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> 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 getInternetRadioSources(InternetRadio radio) { List 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 */ private List 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 */ private List 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 entries = new ArrayList<>(); try { inputPlaylist.toPlaylist().acceptDown(new PlaylistVisitor() { @Override public void beginVisitPlaylist(Playlist playlist) { } @Override public void endVisitPlaylist(Playlist playlist) { } @Override public void beginVisitParallel(Parallel parallel) { } @Override public void endVisitParallel(Parallel parallel) { } @Override public void beginVisitSequence(Sequence sequence) { } @Override public void endVisitSequence(Sequence sequence) { } @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) { } }); } 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; } }