Merge remote-tracking branch 'origin/pr/985'

master
Andrew DeMaria 5 years ago
commit 3d0d0482f5
No known key found for this signature in database
GPG Key ID: 0A3F5E91F8364EDF
  1. 41
      airsonic-main/src/main/java/org/airsonic/player/ajax/PlayQueueInfo.java
  2. 124
      airsonic-main/src/main/java/org/airsonic/player/ajax/PlayQueueService.java
  3. 1
      airsonic-main/src/main/java/org/airsonic/player/controller/RandomPlayQueueController.java
  4. 11
      airsonic-main/src/main/java/org/airsonic/player/dao/InternetRadioDao.java
  5. 11
      airsonic-main/src/main/java/org/airsonic/player/domain/InternetRadioSource.java
  6. 45
      airsonic-main/src/main/java/org/airsonic/player/domain/PlayQueue.java
  7. 302
      airsonic-main/src/main/java/org/airsonic/player/service/InternetRadioService.java
  8. 1
      airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_en.properties
  9. 1
      airsonic-main/src/main/resources/org/airsonic/player/i18n/ResourceBundle_fr.properties
  10. 16
      airsonic-main/src/main/webapp/WEB-INF/jsp/left.jsp
  11. 132
      airsonic-main/src/main/webapp/WEB-INF/jsp/playQueue.jsp
  12. 7
      airsonic-main/src/main/webapp/style/default-without-mediaelement.css
  13. 7
      airsonic-main/src/main/webapp/style/groove.css
  14. 7
      airsonic-main/src/main/webapp/style/hiconi.css
  15. 7
      airsonic-main/src/main/webapp/style/midnight.css
  16. 7
      airsonic-main/src/main/webapp/style/monochrome_black.css
  17. 177
      airsonic-main/src/test/java/org/airsonic/player/service/InternetRadioServiceTest.java

@ -33,17 +33,19 @@ public class PlayQueueInfo {
private final List<Entry> entries;
private final boolean stopEnabled;
private final boolean repeatEnabled;
private final boolean radioEnabled;
private final boolean shuffleRadioEnabled;
private final boolean internetRadioEnabled;
private final boolean sendM3U;
private final float gain;
private int startPlayerAt = -1;
private long startPlayerAtPosition; // millis
public PlayQueueInfo(List<Entry> entries, boolean stopEnabled, boolean repeatEnabled, boolean radioEnabled, boolean sendM3U, float gain) {
public PlayQueueInfo(List<Entry> entries, boolean stopEnabled, boolean repeatEnabled, boolean shuffleRadioEnabled, boolean internetRadioEnabled, boolean sendM3U, float gain) {
this.entries = entries;
this.stopEnabled = stopEnabled;
this.repeatEnabled = repeatEnabled;
this.radioEnabled = radioEnabled;
this.shuffleRadioEnabled = shuffleRadioEnabled;
this.internetRadioEnabled = internetRadioEnabled;
this.sendM3U = sendM3U;
this.gain = gain;
}
@ -74,8 +76,12 @@ public class PlayQueueInfo {
return repeatEnabled;
}
public boolean isRadioEnabled() {
return radioEnabled;
public boolean isShuffleRadioEnabled() {
return shuffleRadioEnabled;
}
public boolean isInternetRadioEnabled() {
return internetRadioEnabled;
}
public float getGain() {
@ -121,9 +127,27 @@ public class PlayQueueInfo {
private final String coverArtUrl;
private final String remoteCoverArtUrl;
public Entry(int id, Integer trackNumber, String title, String artist, String album, String genre, Integer year,
String bitRate, Integer duration, String durationAsString, String format, String contentType, String fileSize,
boolean starred, String albumUrl, String streamUrl, String remoteStreamUrl, String coverArtUrl, String remoteCoverArtUrl) {
public Entry(
int id,
Integer trackNumber,
String title,
String artist,
String album,
String genre,
Integer year,
String bitRate,
Integer duration,
String durationAsString,
String format,
String contentType,
String fileSize,
boolean starred,
String albumUrl,
String streamUrl,
String remoteStreamUrl,
String coverArtUrl,
String remoteCoverArtUrl) {
this.id = id;
this.trackNumber = trackNumber;
this.title = title;
@ -220,5 +244,6 @@ public class PlayQueueInfo {
public String getRemoteCoverArtUrl() {
return remoteCoverArtUrl;
}
}
}

@ -21,6 +21,7 @@ package org.airsonic.player.ajax;
import com.google.common.base.Function;
import com.google.common.collect.Lists;
import org.airsonic.player.dao.InternetRadioDao;
import org.airsonic.player.dao.MediaFileDao;
import org.airsonic.player.dao.PlayQueueDao;
import org.airsonic.player.domain.*;
@ -28,6 +29,8 @@ import org.airsonic.player.service.*;
import org.airsonic.player.service.PlaylistService;
import org.airsonic.player.util.StringUtil;
import org.directwebremoting.WebContextFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.support.RequestContextUtils;
@ -74,7 +77,13 @@ public class PlayQueueService {
@Autowired
private PlayQueueDao playQueueDao;
@Autowired
private InternetRadioDao internetRadioDao;
@Autowired
private JWTSecurityService jwtSecurityService;
@Autowired
private InternetRadioService internetRadioService;
private static final Logger LOG = LoggerFactory.getLogger(PlayQueueService.class);
/**
* Returns the play queue for the player of the current user.
@ -143,6 +152,7 @@ public class PlayQueueService {
String username = securityService.getCurrentUsername(request);
Player player = getCurrentPlayer(request, response);
PlayQueue playQueue = player.getPlayQueue();
playQueue.setInternetRadio(null);
if (playQueue.getRandomSearchCriteria() != null) {
playQueue.addFiles(true, mediaFileService.getRandomSongs(playQueue.getRandomSearchCriteria(), username));
}
@ -232,6 +242,19 @@ public class PlayQueueService {
return doPlay(request, player, songs).setStartPlayerAt(0);
}
/**
* @param index Start playing at this index, or play whole radio playlist if {@code null}.
*/
public PlayQueueInfo playInternetRadio(int id, Integer index) throws Exception {
HttpServletRequest request = WebContextFactory.get().getHttpServletRequest();
InternetRadio radio = internetRadioDao.getInternetRadioById(id);
if (!radio.isEnabled()) { throw new Exception("Radio is not enabled"); }
Player player = resolvePlayer();
return doPlayInternetRadio(request, player, radio).setStartPlayerAt(0);
}
/**
* @param index Start playing at this index, or play whole playlist if {@code null}.
*/
@ -418,6 +441,18 @@ public class PlayQueueService {
}
player.getPlayQueue().addFiles(false, files);
player.getPlayQueue().setRandomSearchCriteria(null);
player.getPlayQueue().setInternetRadio(null);
if (player.isJukebox()) {
jukeboxService.play(player);
}
return convert(request, player, true);
}
private PlayQueueInfo doPlayInternetRadio(HttpServletRequest request, Player player, InternetRadio radio) throws Exception {
internetRadioService.clearInternetRadioSourceCache(radio.getId());
player.getPlayQueue().clear();
player.getPlayQueue().setRandomSearchCriteria(null);
player.getPlayQueue().setInternetRadio(radio);
if (player.isJukebox()) {
jukeboxService.play(player);
}
@ -433,6 +468,7 @@ public class PlayQueueService {
Player player = getCurrentPlayer(request, response);
player.getPlayQueue().addFiles(false, randomFiles);
player.getPlayQueue().setRandomSearchCriteria(null);
player.getPlayQueue().setInternetRadio(null);
return convert(request, player, true).setStartPlayerAt(0);
}
@ -445,6 +481,8 @@ public class PlayQueueService {
List<MediaFile> similarSongs = lastFmService.getSimilarSongs(artist, count, musicFolders);
Player player = getCurrentPlayer(request, response);
player.getPlayQueue().addFiles(false, similarSongs);
player.getPlayQueue().setRandomSearchCriteria(null);
player.getPlayQueue().setInternetRadio(null);
return convert(request, player, true).setStartPlayerAt(0);
}
@ -478,6 +516,7 @@ public class PlayQueueService {
playQueue.addFiles(true, files);
}
playQueue.setRandomSearchCriteria(null);
playQueue.setInternetRadio(null);
return playQueue;
}
@ -589,7 +628,7 @@ public class PlayQueueService {
HttpServletResponse response = WebContextFactory.get().getHttpServletResponse();
Player player = getCurrentPlayer(request, response);
PlayQueue playQueue = player.getPlayQueue();
if (playQueue.isRadioEnabled()) {
if (playQueue.isShuffleRadioEnabled()) {
playQueue.setRandomSearchCriteria(null);
playQueue.setRepeatEnabled(false);
} else {
@ -636,20 +675,43 @@ public class PlayQueueService {
}
private PlayQueueInfo convert(HttpServletRequest request, Player player, boolean serverSidePlaylist, int offset) throws Exception {
String url = NetworkService.getBaseUrl(request);
Locale locale = RequestContextUtils.getLocale(request);
PlayQueue playQueue = player.getPlayQueue();
/* if (serverSidePlaylist && player.isJukebox()) {
updateJukebox(player, offset);
} */
boolean isCurrentPlayer = player.getIpAddress() != null && player.getIpAddress().equals(request.getRemoteAddr());
List<PlayQueueInfo.Entry> entries;
if (playQueue.isInternetRadioEnabled()) {
entries = convertInternetRadio(request, player);
} else {
entries = convertMediaFileList(request, player);
}
boolean isCurrentPlayer = player.getIpAddress() != null && player.getIpAddress().equals(request.getRemoteAddr());
boolean isStopEnabled = playQueue.getStatus() == PlayQueue.Status.PLAYING && !player.isExternalWithPlaylist();
boolean m3uSupported = player.isExternal() || player.isExternalWithPlaylist();
serverSidePlaylist = player.isAutoControlEnabled() && m3uSupported && isCurrentPlayer && serverSidePlaylist;
Locale locale = RequestContextUtils.getLocale(request);
List<PlayQueueInfo.Entry> entries = new ArrayList<PlayQueueInfo.Entry>();
float gain = jukeboxService.getGain(player);
return new PlayQueueInfo(
entries,
isStopEnabled,
playQueue.isRepeatEnabled(),
playQueue.isShuffleRadioEnabled(),
playQueue.isInternetRadioEnabled(),
serverSidePlaylist,
gain
);
}
private List<PlayQueueInfo.Entry> convertMediaFileList(HttpServletRequest request, Player player) {
String url = NetworkService.getBaseUrl(request);
Locale locale = RequestContextUtils.getLocale(request);
PlayQueue playQueue = player.getPlayQueue();
List<PlayQueueInfo.Entry> entries = new ArrayList<>();
for (MediaFile file : playQueue.getFiles()) {
String albumUrl = url + "main.view?id=" + file.getId();
@ -668,12 +730,48 @@ public class PlayQueueService {
formatFileSize(file.getFileSize(), locale), starred, albumUrl, streamUrl, remoteStreamUrl,
coverArtUrl, remoteCoverArtUrl));
}
boolean isStopEnabled = playQueue.getStatus() == PlayQueue.Status.PLAYING && !player.isExternalWithPlaylist();
float gain = 0.0f;
gain = jukeboxService.getGain(player);
return entries;
}
private List<PlayQueueInfo.Entry> convertInternetRadio(HttpServletRequest request, Player player) throws Exception {
return new PlayQueueInfo(entries, isStopEnabled, playQueue.isRepeatEnabled(), playQueue.isRadioEnabled(), serverSidePlaylist, gain);
PlayQueue playQueue = player.getPlayQueue();
InternetRadio radio = playQueue.getInternetRadio();
final String radioHomepageUrl = radio.getHomepageUrl();
final String radioName = radio.getName();
List<PlayQueueInfo.Entry> entries = new ArrayList<>();
for (InternetRadioSource streamSource: internetRadioService.getInternetRadioSources(radio)) {
// Fake entry id so that the source can be selected in the UI
Integer streamId = -(1+entries.size());
Integer streamTrackNumber = entries.size();
String streamUrl = streamSource.getStreamUrl();
entries.add(new PlayQueueInfo.Entry(
streamId, // Entry id
streamTrackNumber, // Track number
streamUrl, // Track title (use radio stream URL for now)
"", // Track artist
radioName, // Album name (use radio name)
"Internet Radio", // Genre
0, // Year
"", // Bit rate
0, // Duration
"", // Duration (as string)
"", // Format
"", // Content Type
"", // File size
false, // Starred
radioHomepageUrl, // Album URL (use radio home page URL)
streamUrl, // Stream URL
streamUrl, // Remote stream URL
null, // Cover art URL
null // Remote cover art URL
));
}
return entries;
}
private String formatFileSize(Long fileSize, Locale locale) {
@ -797,4 +895,8 @@ public class PlayQueueService {
public void setJwtSecurityService(JWTSecurityService jwtSecurityService) {
this.jwtSecurityService = jwtSecurityService;
}
public void setInternetRadioDao(InternetRadioDao internetRadioDao) {
this.internetRadioDao = internetRadioDao;
}
}

@ -239,6 +239,7 @@ public class RandomPlayQueueController {
if (autoRandom != null) {
playQueue.setRandomSearchCriteria(criteria);
playQueue.setInternetRadio(null);
}
// Render the 'reload' view to reload the play queue and the main page

@ -42,6 +42,17 @@ public class InternetRadioDao extends AbstractDao {
private static final String QUERY_COLUMNS = "id, " + INSERT_COLUMNS;
private final InternetRadioRowMapper rowMapper = new InternetRadioRowMapper();
/**
* Returns the internet radio station with the given ID.
*
* @param id The unique internet radio station ID.
* @return The internet radio station with the given ID, or <code>null</code> if no such internet radio exists.
*/
public InternetRadio getInternetRadioById(int id) {
String sql = "select " + QUERY_COLUMNS + " from internet_radio where id=?";
return queryOne(sql, rowMapper, id);
}
/**
* Returns all internet radio stations.
*

@ -0,0 +1,11 @@
package org.airsonic.player.domain;
public class InternetRadioSource {
private String streamUrl;
public InternetRadioSource(String streamUrl) {
this.streamUrl = streamUrl;
}
public String getStreamUrl() { return streamUrl; }
}

@ -35,7 +35,9 @@ public class PlayQueue {
private boolean repeatEnabled;
private String name = "(unnamed)";
private Status status = Status.PLAYING;
private RandomSearchCriteria randomSearchCriteria;
private InternetRadio internetRadio;
/**
* The index of the current song, or -1 is the end of the playlist is reached.
@ -224,6 +226,8 @@ public class PlayQueue {
public synchronized void clear() {
makeBackup();
files.clear();
setRandomSearchCriteria(null);
setInternetRadio(null);
index = 0;
}
@ -363,13 +367,18 @@ public class PlayQueue {
}
/**
* Returns whether the playlist is a shuffle radio
* Returns whether the play queue is in shuffle radio mode.
*
* @return Whether the playlist is a shuffle radio.
* @return Whether the play queue is a shuffle radio mode.
*/
public synchronized boolean isRadioEnabled() {
return this.randomSearchCriteria != null;
}
public synchronized boolean isShuffleRadioEnabled() { return this.randomSearchCriteria != null; }
/**
* Returns whether the play queue is a internet radio mode.
*
* @return Whether the play queue is a internet radio mode.
*/
public synchronized boolean isInternetRadioEnabled() { return this.internetRadio != null; }
/**
* Revert the last operation.
@ -407,22 +416,32 @@ public class PlayQueue {
}
/**
* Returns the criteria used to generate this random playlist.
* Sets the current internet radio
*
* @param internetRadio An internet radio, or <code>null</code> if this is not an internet radio playlist
*/
public void setInternetRadio(InternetRadio internetRadio) { this.internetRadio = internetRadio; }
/**
* Gets the current internet radio
*
* @return The current internet radio, or <code>null</code> if this is not an internet radio playlist
*/
public InternetRadio getInternetRadio() { return internetRadio; }
/**
* Returns the criteria used to generate this random playlist
*
* @return The search criteria, or <code>null</code> if this is not a random playlist.
*/
public synchronized RandomSearchCriteria getRandomSearchCriteria() {
return randomSearchCriteria;
}
public synchronized RandomSearchCriteria getRandomSearchCriteria() { return randomSearchCriteria; }
/**
* Sets the criteria used to generate this random playlist.
* Sets the criteria used to generate this random playlist
*
* @param randomSearchCriteria The search criteria, or <code>null</code> if this is not a random playlist.
*/
public synchronized void setRandomSearchCriteria(RandomSearchCriteria randomSearchCriteria) {
this.randomSearchCriteria = randomSearchCriteria;
}
public synchronized void setRandomSearchCriteria(RandomSearchCriteria randomSearchCriteria) { this.randomSearchCriteria = randomSearchCriteria; }
/**
* Returns the total length in bytes.

@ -0,0 +1,302 @@
package org.airsonic.player.service;
import chameleon.playlist.*;
import org.airsonic.player.domain.InternetRadio;
import org.airsonic.player.domain.InternetRadioSource;
import org.apache.commons.io.input.BoundedInputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.*;
@Service
public class InternetRadioService {
private static final Logger LOG = LoggerFactory.getLogger(InternetRadioService.class);
/**
* The maximum number of source URLs in a remote playlist.
*/
private static final int PLAYLIST_REMOTE_MAX_LENGTH = 250;
/**
* The maximum size, in bytes, for a remote playlist response.
*/
private static final long PLAYLIST_REMOTE_MAX_BYTE_SIZE = 100 * 1024; // 100 kB
/**
* The maximum number of redirects for a remote playlist response.
*/
private static final int PLAYLIST_REMOTE_MAX_REDIRECTS = 20;
/**
* A list of cached source URLs for remote playlists.
*/
private final Map<Integer, List<InternetRadioSource>> cachedSources;
/**
* Generic exception class for playlists.
*/
private class PlaylistException extends Exception {
public PlaylistException(String message) { super(message); }
}
/**
* Exception thrown when the remote playlist is too large to be parsed completely.
*/
private class PlaylistTooLarge extends PlaylistException {
public PlaylistTooLarge(String message) { super(message); }
}
/**
* Exception thrown when the remote playlist format cannot be determined.
*/
private class PlaylistFormatUnsupported extends PlaylistException {
public PlaylistFormatUnsupported(String message) {
super(message);
}
}
/**
* Exception thrown when too many redirects occurred when retrieving a remote playlist.
*/
private class PlaylistHasTooManyRedirects extends PlaylistException {
public PlaylistHasTooManyRedirects(String message) { super(message); }
}
public InternetRadioService() {
this.cachedSources = new HashMap<>();
}
/**
* Clear the radio source cache.
*/
public void clearInternetRadioSourceCache() {
cachedSources.clear();
}
/**
* Clear the radio source cache for the given radio id
* @param internetRadioId a radio id
*/
public void clearInternetRadioSourceCache(Integer internetRadioId) {
if (internetRadioId != null) {
cachedSources.remove(internetRadioId);
}
}
/**
* Retrieve a list of sources for the given internet radio.
*
* This method caches the sources using the InternetRadio.getId
* method as a key, until clearInternetRadioSourceCache is called.
*
* @param radio an internet radio
* @return a list of internet radio sources
*/
public List<InternetRadioSource> getInternetRadioSources(InternetRadio radio) {
List<InternetRadioSource> sources;
if (cachedSources.containsKey(radio.getId())) {
LOG.debug("Got cached sources for internet radio {}!", radio.getStreamUrl());
sources = cachedSources.get(radio.getId());
} else {
LOG.debug("Retrieving sources for internet radio {}...", radio.getStreamUrl());
try {
sources = retrieveInternetRadioSources(radio);
if (sources.isEmpty()) {
LOG.warn("No entries found for internet radio {}.", radio.getStreamUrl());
} else {
LOG.info("Retrieved playlist for internet radio {}, got {} sources.", radio.getStreamUrl(), sources.size());
}
} catch (Exception e) {
LOG.error("Failed to retrieve sources for internet radio {}.", radio.getStreamUrl(), e);
sources = new ArrayList<>();
}
cachedSources.put(radio.getId(), sources);
}
return sources;
}
/**
* Retrieve a list of sources from the given internet radio
*
* This method uses a default maximum limit of PLAYLIST_REMOTE_MAX_LENGTH sources.
*
* @param radio an internet radio
* @return a list of internet radio sources
* @throws Exception
*/
private List<InternetRadioSource> retrieveInternetRadioSources(InternetRadio radio) throws Exception {
return retrieveInternetRadioSources(
radio,
PLAYLIST_REMOTE_MAX_LENGTH,
PLAYLIST_REMOTE_MAX_BYTE_SIZE,
PLAYLIST_REMOTE_MAX_REDIRECTS
);
}
/**
* Retrieve a list of sources from the given internet radio.
*
* @param radio an internet radio
* @param maxCount the maximum number of items to read from the remote playlist, or 0 if unlimited
* @param maxByteSize maximum size of the response, in bytes, or 0 if unlimited
* @param maxRedirects maximum number of redirects, or 0 if unlimited
* @return a list of internet radio sources
* @throws Exception
*/
private List<InternetRadioSource> retrieveInternetRadioSources(InternetRadio radio, int maxCount, long maxByteSize, int maxRedirects) throws Exception {
// Retrieve the remote playlist
String playlistUrl = radio.getStreamUrl();
LOG.debug("Parsing internet radio playlist at {}...", playlistUrl);
SpecificPlaylist inputPlaylist = retrievePlaylist(new URL(playlistUrl), maxByteSize, maxRedirects);
// Retrieve stream URLs
List<InternetRadioSource> entries = new ArrayList<>();
try {
inputPlaylist.toPlaylist().acceptDown(new PlaylistVisitor() {
@Override
public void beginVisitPlaylist(Playlist playlist) throws Exception {
}
@Override
public void endVisitPlaylist(Playlist playlist) throws Exception {
}
@Override
public void beginVisitParallel(Parallel parallel) throws Exception {
}
@Override
public void endVisitParallel(Parallel parallel) throws Exception {
}
@Override
public void beginVisitSequence(Sequence sequence) throws Exception {
}
@Override
public void endVisitSequence(Sequence sequence) throws Exception {
}
@Override
public void beginVisitMedia(Media media) throws Exception {
// Since we're dealing with remote content, we place a hard
// limit on the maximum number of items to load from the playlist,
// in order to avoid parsing erroneous data.
if (maxCount > 0 && entries.size() >= maxCount) {
throw new PlaylistTooLarge("Remote playlist has too many sources (maximum " + maxCount + ")");
}
String streamUrl = media.getSource().getURI().toString();
LOG.debug("Got source media at {}", streamUrl);
entries.add(new InternetRadioSource(streamUrl));
}
@Override
public void endVisitMedia(Media media) throws Exception {
}
});
} catch (PlaylistTooLarge e) {
// Ignore if playlist is too large, but truncate the rest and log a warning.
LOG.warn(e.getMessage());
}
return entries;
}
/**
* Retrieve playlist data from a given URL.
*
* @param url URL to the remote playlist
* @param maxByteSize maximum size of the response, in bytes, or 0 if unlimited
* @param maxRedirects maximum number of redirects, or 0 if unlimited
* @return the remote playlist data
*/
protected SpecificPlaylist retrievePlaylist(URL url, long maxByteSize, int maxRedirects) throws IOException, PlaylistException {
SpecificPlaylist playlist;
HttpURLConnection urlConnection = connectToURLWithRedirects(url, maxRedirects);
try (InputStream in = urlConnection.getInputStream()) {
String contentEncoding = urlConnection.getContentEncoding();
if (maxByteSize > 0) {
playlist = SpecificPlaylistFactory.getInstance().readFrom(new BoundedInputStream(in, maxByteSize), contentEncoding);
} else {
playlist = SpecificPlaylistFactory.getInstance().readFrom(in, contentEncoding);
}
}
finally {
urlConnection.disconnect();
}
if (playlist == null) {
throw new PlaylistFormatUnsupported("Unsupported playlist format " + url.toString());
}
return playlist;
}
/**
* Start a new connection to a remote URL, and follow redirects.
*
* @param url the remote URL
* @param maxRedirects maximum number of redirects, or 0 if unlimited
* @return an open connection
*/
protected HttpURLConnection connectToURLWithRedirects(URL url, int maxRedirects) throws IOException, PlaylistException {
int redirectCount = 0;
URL currentURL = url;
// Start a first connection.
HttpURLConnection connection = connectToURL(currentURL);
// While it redirects, follow redirects in new connections.
while (connection.getResponseCode() == HttpURLConnection.HTTP_MOVED_PERM ||
connection.getResponseCode() == HttpURLConnection.HTTP_MOVED_TEMP ||
connection.getResponseCode() == HttpURLConnection.HTTP_SEE_OTHER) {
// Check if redirect count is not too large.
redirectCount += 1;
if (maxRedirects > 0 && redirectCount > maxRedirects) {
connection.disconnect();
throw new PlaylistHasTooManyRedirects(String.format("Too many redirects (%d) for URL %s", redirectCount, url));
}
// Reconnect to the new URL.
currentURL = new URL(connection.getHeaderField("Location"));
connection.disconnect();
connection = connectToURL(currentURL);
}
// Return the last connection that did not redirect.
return connection;
}
/**
* Start a new connection to a remote URL.
*
* @param url the remote URL
* @return an open connection
*/
protected HttpURLConnection connectToURL(URL url) throws IOException {
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setAllowUserInteraction(false);
urlConnection.setConnectTimeout(10000);
urlConnection.setDoInput(true);
urlConnection.setDoOutput(false);
urlConnection.setReadTimeout(60000);
urlConnection.setUseCaches(true);
urlConnection.connect();
return urlConnection;
}
}

@ -99,6 +99,7 @@ playlist.empty=Play queue empty.
playlist.toast.appendtoplaylist=Playlist updated.
playlist.toast.saveasplaylist=Playlist saved.
playlist.toast.saveplayqueue=Play queue saved.
playlist.toast.radioerror=Sorry, an error occurred while trying to load the internet radio.
playlist.missing=Missing
playlist2.noplaylists=You haven't created any playlists yet.

@ -99,6 +99,7 @@ playlist.empty=Liste de lecture vide
playlist.toast.appendtoplaylist=Playlist mise \u00e0 jour.
playlist.toast.saveasplaylist=Playlist sauvegard\u00e9e.
playlist.toast.saveplayqueue=Liste de lecture sauvegard\u00e9.
playlist.toast.radioerror=D\u00E9sol\u00E9, une erreur s'est produite pendant le chargement de la radio.
playlist.missing=Manquant
playlist2.noplaylists=Vous n'avez pas encore cr\u00e9er de playlist.

@ -20,6 +20,11 @@
top.main.location.href = mainLocation;
}
}
$('.radio-play').on('click', function(evt) {
top.playQueue.onPlayInternetRadio($(this).data("id"), 0);
evt.preventDefault();
});
}
function updatePlaylists() {
@ -118,19 +123,20 @@
<c:if test="${not empty model.radios}">
<h2 class="bgcolor1" style="padding-left: 2px"><fmt:message key="left.radio"/></h2>
<iframe id="radio-playlist-data" style="display:none;"></iframe>
<c:forEach items="${model.radios}" var="radio">
<p class="dense" style="padding-left: 2px">
<a target="hidden" href="${radio.streamUrl}">
<img src="<spring:theme code="playImage"/>" alt="<fmt:message key="common.play"/>" title="<fmt:message key="common.play"/>"></a>
<a target="hidden" href="${radio.streamUrl}" class="radio-play" data-id="${radio.id}">
<img src="<spring:theme code="playImage"/>" alt="<fmt:message key="common.play"/>" title="<fmt:message key="common.play"/>"></a>
<span style="vertical-align: middle">
<c:choose>
<c:when test="${empty radio.homepageUrl}">
<c:when test="${empty radio.homepageUrl}">
${fn:escapeXml(radio.name)}
</c:when>
<c:otherwise>
<a target="main" href="${radio.homepageUrl}">${fn:escapeXml(radio.name)}</a>
<a target="_blank" rel="noopener" href="${radio.homepageUrl}">${fn:escapeXml(radio.name)}</a>
</c:otherwise>
</c:choose>
</c:choose>
</span>
</p>
</c:forEach>

@ -31,13 +31,30 @@
<span id="dummy-animation-target" style="max-width: ${model.autoHide ? 50 : 150}px; display: none"></span>
<script type="text/javascript" language="javascript">
// These variables store the media player state, received from DWR in the
// playQueueCallback function below.
// List of songs (of type PlayQueueInfo.Entry)
var songs = null;
// Stream URL of the media being played
var currentStreamUrl = null;
// Is autorepeat enabled?
var repeatEnabled = false;
var radioEnabled = false;
// Is the "shuffle radio" playing? (More > Shuffle Radio)
var shuffleRadioEnabled = false;
// Is the "internet radio" playing?
var internetRadioEnabled = false;
// Is the play queue visible? (Initially hidden if set to "auto-hide" in the settings)
var isVisible = ${model.autoHide ? 'false' : 'true'};
// Initialize the Cast player (ChromeCast support)
var CastPlayer = new CastPlayer();
var ignore = false;
function init() {
<c:if test="${model.autoHide}">initAutoHide();</c:if>
@ -82,6 +99,40 @@
}
});
/** Toggle between <a> and <span> in order to disable play queue action buttons */
$.fn.toggleLink = function(newState) {
$(this).each(function(ix, elt) {
var node, currentState;
if (elt.tagName.toLowerCase() === "a") currentState = true;
else if (elt.tagName.toLowerCase() === "span") currentState = false;
else return true;
if (typeof newState === 'undefined') newState = !currentState;
if (newState === currentState) return true;
if (newState) node = document.createElement("a");
else node = document.createElement("span");
node.innerHTML = elt.innerHTML;
if (elt.hasAttribute("id")) node.setAttribute("id", elt.getAttribute("id"));
if (elt.hasAttribute("style")) node.setAttribute("style", elt.getAttribute("style"));
if (elt.hasAttribute("class")) node.setAttribute("class", elt.getAttribute("class"));
if (newState) {
if (elt.hasAttribute("data-href")) node.setAttribute("href", elt.getAttribute("data-href"));
node.classList.remove("disabled");
node.removeAttribute("aria-disabled");
} else {
if (elt.hasAttribute("href")) node.setAttribute("data-href", elt.getAttribute("href"));
node.classList.add("disabled");
node.setAttribute("aria-disabled", "true");
}
elt.parentNode.replaceChild(node, elt);
return true;
});
};
getPlayQueue();
}
@ -280,7 +331,7 @@
}
function onNext(wrap) {
var index = parseInt(getCurrentSongIndex()) + 1;
if (radioEnabled && index >= songs.length) {
if (shuffleRadioEnabled && index >= songs.length) {
playQueueService.reloadSearchCriteria(function(playQueue) {
playQueueCallback(playQueue);
onSkip(index);
@ -303,6 +354,9 @@
function onPlayPlaylist(id, index) {
playQueueService.playPlaylist(id, index, playQueueCallback);
}
function onPlayInternetRadio(id, index) {
playQueueService.playInternetRadio(id, index, playQueueCallback);
}
function onPlayTopSong(id, index) {
playQueueService.playTopSong(id, index, playQueueCallback);
}
@ -423,14 +477,22 @@
function playQueueCallback(playQueue) {
songs = playQueue.entries;
repeatEnabled = playQueue.repeatEnabled;
radioEnabled = playQueue.radioEnabled;
shuffleRadioEnabled = playQueue.shuffleRadioEnabled;
internetRadioEnabled = playQueue.internetRadioEnabled;
// If an internet radio has no sources, display a message to the user.
if (internetRadioEnabled && songs.length == 0) {
top.main.$().toastmessage("showErrorToast", "<fmt:message key="playlist.toast.radioerror"/>");
onStop();
}
if ($("#start")) {
$("#start").toggle(!playQueue.stopEnabled);
$("#stop").toggle(playQueue.stopEnabled);
}
if ($("#toggleRepeat")) {
if (radioEnabled) {
if (shuffleRadioEnabled) {
$("#toggleRepeat").html("<fmt:message key="playlist.repeat_radio"/>");
} else if (repeatEnabled) {
$("#toggleRepeat").attr('src', '<spring:theme code="repeatOn"/>');
@ -441,6 +503,24 @@
}
}
// Disable some UI items if internet radio is playing
$("select#moreActions #loadPlayQueue").prop("disabled", internetRadioEnabled);
$("select#moreActions #savePlayQueue").prop("disabled", internetRadioEnabled);
$("select#moreActions #savePlaylist").prop("disabled", internetRadioEnabled);
$("select#moreActions #downloadPlaylist").prop("disabled", internetRadioEnabled);
$("select#moreActions #sharePlaylist").prop("disabled", internetRadioEnabled);
$("select#moreActions #sortByTrack").prop("disabled", internetRadioEnabled);
$("select#moreActions #sortByAlbum").prop("disabled", internetRadioEnabled);
$("select#moreActions #sortByArtist").prop("disabled", internetRadioEnabled);
$("select#moreActions #selectAll").prop("disabled", internetRadioEnabled);
$("select#moreActions #selectNone").prop("disabled", internetRadioEnabled);
$("select#moreActions #removeSelected").prop("disabled", internetRadioEnabled);
$("select#moreActions #download").prop("disabled", internetRadioEnabled);
$("select#moreActions #appendPlaylist").prop("disabled", internetRadioEnabled);
$("#shuffleQueue").toggleLink(!internetRadioEnabled);
$("#repeatQueue").toggleLink(!internetRadioEnabled);
$("#undoQueue").toggleLink(!internetRadioEnabled);
if (songs.length == 0) {
$("#songCountAndDuration").text("");
$("#empty").show();
@ -462,11 +542,26 @@
if ($("#trackNumber" + id)) {
$("#trackNumber" + id).text(song.trackNumber);
}
if (song.starred) {
$("#starSong" + id).attr("src", "<spring:theme code='ratingOnImage'/>");
if (!internetRadioEnabled) {
// Show star/remove buttons in all cases...
$("#starSong" + id).show();
$("#removeSong" + id).show();
$("#songIndex" + id).show();
// Show star rating
if (song.starred) {
$("#starSong" + id).attr("src", "<spring:theme code='ratingOnImage'/>");
} else {
$("#starSong" + id).attr("src", "<spring:theme code='ratingOffImage'/>");
}
} else {
$("#starSong" + id).attr("src", "<spring:theme code='ratingOffImage'/>");
// ...except from when internet radio is playing.
$("#starSong" + id).hide();
$("#removeSong" + id).hide();
$("#songIndex" + id).hide();
}
if ($("#currentImage" + id) && song.streamUrl == currentStreamUrl) {
$("#currentImage" + id).show();
if (isJavaJukeboxPresent()) {
@ -486,6 +581,13 @@
$("#album" + id).text(song.album);
$("#album" + id).attr("title", song.album);
$("#albumUrl" + id).attr("href", song.albumUrl);
// Open external internet radio links in new windows
if (internetRadioEnabled) {
$("#albumUrl" + id).attr({
target: "_blank",
rel: "noopener noreferrer",
});
}
}
if ($("#artist" + id)) {
$("#artist" + id).text(song.artist);
@ -495,7 +597,13 @@
$("#genre" + id).text(song.genre);
}
if ($("#year" + id)) {
$("#year" + id).text(song.year);
// If song.year is not an int, this will return NaN, which
// conveniently returns false in all boolean operations.
if (parseInt(song.year) > 0) {
$("#year" + id).text(song.year);
} else {
$("#year" + id).text("");
}
}
if ($("#bitRate" + id)) {
$("#bitRate" + id).text(song.bitRate);
@ -805,7 +913,7 @@
<td style="white-space:nowrap;">
<span class="header">
<a href="javascript:onShuffle()">
<a href="javascript:onShuffle()" id="shuffleQueue">
<img src="<spring:theme code="shuffleImage"/>" alt="shuffle" style="cursor:pointer; height:18px">
</a>
</span> |</td>
@ -813,7 +921,7 @@
<c:if test="${model.player.web or model.player.jukebox or model.player.external}">
<td style="white-space:nowrap;">
<span class="header">
<a href="javascript:onToggleRepeat()">
<a href="javascript:onToggleRepeat()" id="repeatQueue">
<img id="toggleRepeat" src="<spring:theme code="repeatOn"/>" alt="repeatOn" style="cursor:pointer; height:18px">
</a>
</span> |</td>
@ -821,7 +929,7 @@
<td style="white-space:nowrap;">
<span class="header">
<a href="javascript:onUndo()">
<a href="javascript:onUndo()" id="undoQueue">
<img src="<spring:theme code="undoImage"/>" alt="undo" style="cursor:pointer; height:18px">
</a>
</span> |</td>

@ -62,6 +62,13 @@ a:hover, a:hover * {
color: #DD6900
}
/* Disabled links color and behavior */
a.disabled, a.disabled span, span.disabled {
cursor: default;
text-decoration: none;
color: #bbb;
}
/* Color for warning messages. */
.warning {
color: red;

@ -83,6 +83,13 @@ a:hover, a:hover * {
color: #FFFFFF;
}
/* Disabled links color and behavior */
a.disabled, a.disabled span, span.disabled {
cursor: default;
text-decoration: none;
color: #999;
}
/* Colour for warning messages. */
.warning {
color: #990099;

@ -51,6 +51,13 @@ a:hover, a:hover * {
color: #a3a3a3;
}
/* Disabled links color and behavior */
a.disabled, a.disabled span, span.disabled {
cursor: default;
text-decoration: none;
color: #999;
}
/* Color for warning messages. */
.warning {
color: red;

@ -43,6 +43,13 @@ a:hover, a:hover * {
color: orange;
}
/* Disabled links color and behavior */
a.disabled, a.disabled span, span.disabled {
cursor: default;
text-decoration: none;
color: #999;
}
/* Color for warning messages. */
.warning {
color: orange;

@ -51,6 +51,13 @@ a:hover, a:hover * {
color: #FFFFFF;
}
/* Disabled links color and behavior */
a.disabled, a.disabled span, span.disabled {
cursor: default;
text-decoration: none;
color: #999;
}
/* Colour for warning messages. */
.warning {
color: #990099;

@ -0,0 +1,177 @@
package org.airsonic.player.service;
import org.airsonic.player.domain.InternetRadio;
import org.airsonic.player.domain.InternetRadioSource;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.runners.MockitoJUnitRunner;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Date;
import java.util.List;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.class)
public class InternetRadioServiceTest {
String TEST_RADIO_NAME = "Test Radio";
String TEST_RADIO_HOMEPAGE = "http://example.com";
String TEST_PLAYLIST_URL_MOVE = "http://example.com/stream_move.m3u";
String TEST_PLAYLIST_URL_MOVE_LOOP = "http://example.com/stream_infinity_move.m3u";
String TEST_PLAYLIST_URL_LARGE = "http://example.com/stream_infinity_repeat.m3u";
String TEST_PLAYLIST_URL_LARGE_2 = "http://example.com/stream_infinity_big.m3u";
String TEST_PLAYLIST_URL_1 = "http://example.com/stream1.m3u";
String TEST_PLAYLIST_URL_2 = "http://example.com/stream2.m3u";
String TEST_STREAM_URL_1 = "http://example.com/stream1";
String TEST_STREAM_URL_2 = "http://example.com/stream2";
String TEST_STREAM_URL_3 = "http://example.com/stream3";
String TEST_STREAM_URL_4 = "http://example.com/stream4";
String TEST_STREAM_PLAYLIST_CONTENTS_1 = (
"http://example.com/stream1\n" +
"http://example.com/stream2\n"
);
String TEST_STREAM_PLAYLIST_CONTENTS_2 = (
"#EXTM3U\n" +
"#EXTINF:123, Sample artist - Sample title\n" +
"http://example.com/stream3\n" +
"#EXTINF:321,Example Artist - Example title\n" +
"http://example.com/stream4\n"
);
InternetRadio radio1;
InternetRadio radio2;
InternetRadio radioMove;
InternetRadio radioMoveLoop;
InternetRadio radioLarge;
InternetRadio radioLarge2;
@Spy
InternetRadioService internetRadioService;
@Before
public void setup() throws Exception {
// Prepare a mock InternetRadio object
radio1 = new InternetRadio(1, TEST_RADIO_NAME, TEST_PLAYLIST_URL_1, TEST_RADIO_HOMEPAGE, true, new Date());
radio2 = new InternetRadio(2, TEST_RADIO_NAME, TEST_PLAYLIST_URL_2, TEST_RADIO_HOMEPAGE, true, new Date());
radioMove = new InternetRadio(3, TEST_RADIO_NAME, TEST_PLAYLIST_URL_MOVE, TEST_RADIO_HOMEPAGE, true, new Date());
radioMoveLoop = new InternetRadio(3, TEST_RADIO_NAME, TEST_PLAYLIST_URL_MOVE_LOOP, TEST_RADIO_HOMEPAGE, true, new Date());
radioLarge = new InternetRadio(4, TEST_RADIO_NAME, TEST_PLAYLIST_URL_LARGE, TEST_RADIO_HOMEPAGE, true, new Date());
radioLarge2 = new InternetRadio(5, TEST_RADIO_NAME, TEST_PLAYLIST_URL_LARGE_2, TEST_RADIO_HOMEPAGE, true, new Date());
// Prepare the mocked URL connection for the simple playlist
HttpURLConnection mockURLConnection1 = Mockito.mock(HttpURLConnection.class);
InputStream mockURLInputStream1 = new ByteArrayInputStream(TEST_STREAM_PLAYLIST_CONTENTS_1.getBytes());
doReturn(mockURLInputStream1).when(mockURLConnection1).getInputStream();
doReturn(HttpURLConnection.HTTP_OK).when(mockURLConnection1).getResponseCode();
// Prepare the mocked URL connection for the second simple playlist
HttpURLConnection mockURLConnection2 = Mockito.mock(HttpURLConnection.class);
InputStream mockURLInputStream2 = new ByteArrayInputStream(TEST_STREAM_PLAYLIST_CONTENTS_2.getBytes());
doReturn(mockURLInputStream2).when(mockURLConnection2).getInputStream();
doReturn(HttpURLConnection.HTTP_OK).when(mockURLConnection2).getResponseCode();
// Prepare the mocked URL connection for the redirection to simple playlist
HttpURLConnection mockURLConnectionMove = Mockito.mock(HttpURLConnection.class);
InputStream mockURLInputStreamMove = new ByteArrayInputStream("".getBytes());
doReturn(mockURLInputStreamMove).when(mockURLConnectionMove).getInputStream();
doReturn(HttpURLConnection.HTTP_MOVED_PERM).when(mockURLConnectionMove).getResponseCode();
doReturn(TEST_PLAYLIST_URL_2).when(mockURLConnectionMove).getHeaderField(eq("Location"));
// Prepare the mocked URL connection for the redirection loop
HttpURLConnection mockURLConnectionMoveLoop = Mockito.mock(HttpURLConnection.class);
InputStream mockURLInputStreamMoveLoop = new ByteArrayInputStream("".getBytes());
doReturn(mockURLInputStreamMoveLoop).when(mockURLConnectionMoveLoop).getInputStream();
doReturn(HttpURLConnection.HTTP_MOVED_PERM).when(mockURLConnectionMoveLoop).getResponseCode();
doReturn(TEST_PLAYLIST_URL_MOVE_LOOP).when(mockURLConnectionMoveLoop).getHeaderField(eq("Location"));
// Prepare the mocked URL connection for the 'content too large' test
HttpURLConnection mockURLConnectionLarge = Mockito.mock(HttpURLConnection.class);
InputStream mockURLInputStreamLarge = new InputStream() {
private long pos = 0;
@Override
public int read() throws IOException {
return TEST_STREAM_PLAYLIST_CONTENTS_2.charAt((int)(pos++ % TEST_STREAM_PLAYLIST_CONTENTS_2.length()));
}
};
doReturn(mockURLInputStreamLarge).when(mockURLConnectionLarge).getInputStream();
doReturn(HttpURLConnection.HTTP_OK).when(mockURLConnectionLarge).getResponseCode();
// Prepare the mocked URL connection for the 'content too large' test
// (return a single entry with 'aaaa...' running infinitely long).
HttpURLConnection mockURLConnectionLarge2 = Mockito.mock(HttpURLConnection.class);
InputStream mockURLInputStreamLarge2 = new InputStream() {
private long pos = 0;
@Override
public int read() throws IOException {
return 0x41;
}
};
doReturn(mockURLInputStreamLarge2).when(mockURLConnectionLarge2).getInputStream();
doReturn(HttpURLConnection.HTTP_OK).when(mockURLConnectionLarge2).getResponseCode();
// Prepare the mock 'connectToURL' method
doReturn(mockURLConnection1).when(internetRadioService).connectToURL(eq(new URL(TEST_PLAYLIST_URL_1)));
doReturn(mockURLConnection2).when(internetRadioService).connectToURL(eq(new URL(TEST_PLAYLIST_URL_2)));
doReturn(mockURLConnectionMove).when(internetRadioService).connectToURL(eq(new URL(TEST_PLAYLIST_URL_MOVE)));
doReturn(mockURLConnectionMoveLoop).when(internetRadioService).connectToURL(eq(new URL(TEST_PLAYLIST_URL_MOVE_LOOP)));
doReturn(mockURLConnectionLarge).when(internetRadioService).connectToURL(eq(new URL(TEST_PLAYLIST_URL_LARGE)));
doReturn(mockURLConnectionLarge2).when(internetRadioService).connectToURL(eq(new URL(TEST_PLAYLIST_URL_LARGE_2)));
}
@Test
public void testParseSimplePlaylist() throws Exception {
List<InternetRadioSource> radioSources = internetRadioService.getInternetRadioSources(radio1);
Assert.assertEquals(2, radioSources.size());
Assert.assertEquals(TEST_STREAM_URL_1, radioSources.get(0).getStreamUrl());
Assert.assertEquals(TEST_STREAM_URL_2, radioSources.get(1).getStreamUrl());
}
@Test
public void testRedirects() throws Exception {
List<InternetRadioSource> radioSources = internetRadioService.getInternetRadioSources(radioMove);
Assert.assertEquals(2, radioSources.size());
Assert.assertEquals(TEST_STREAM_URL_3, radioSources.get(0).getStreamUrl());
Assert.assertEquals(TEST_STREAM_URL_4, radioSources.get(1).getStreamUrl());
}
@Test
public void testLargeInput() throws Exception {
List<InternetRadioSource> radioSources = internetRadioService.getInternetRadioSources(radioLarge);
// A PlaylistTooLarge exception is thrown internally, and the
// `getInternetRadioSources` method logs it and returns a
// limited number of sources.
Assert.assertEquals(250, radioSources.size());
}
@Test
public void testLargeInputURL() throws Exception {
List<InternetRadioSource> radioSources = internetRadioService.getInternetRadioSources(radioLarge2);
// A PlaylistTooLarge exception is thrown internally, and the
// `getInternetRadioSources` method logs it and returns a
// limited number of bytes from the input.
Assert.assertEquals(1, radioSources.size());
}
@Test
public void testRedirectLoop() throws Exception {
List<InternetRadioSource> radioSources = internetRadioService.getInternetRadioSources(radioMoveLoop);
// A PlaylistHasTooManyRedirects exception is thrown internally,
// and the `getInternetRadioSources` method logs it and returns 0 sources.
Assert.assertEquals(0, radioSources.size());
}
}
Loading…
Cancel
Save