commit
3d0d0482f5
@ -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; } |
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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…
Reference in new issue