diff options
Diffstat (limited to 'subsonic-main/src/main/java/net/sourceforge/subsonic/service')
27 files changed, 8009 insertions, 0 deletions
diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/AdService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/AdService.java new file mode 100644 index 00000000..9ae4d765 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/AdService.java @@ -0,0 +1,71 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +/** + * Provides services for generating ads. + * + * @author Sindre Mehus + */ +public class AdService { + + private final String[] ads = { + + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=40&l=ur1&category=computers_accesories&banner=1CH7VNNWF908JYQPHX82&f=ifr' width='120' height='60' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=21&l=ur1&category=game_downloads&banner=13PTQH69Q2290VF8SR82&f=ifr' width='125' height='125' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='ad/omakasa.html' width='120' height='240' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=29&l=ur1&category=homeaudiohometheater&banner=0T4YJ6YBNCMJM9GGAK02&f=ifr' width='120' height='600' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=21&l=ur1&category=50mp3albums5each&banner=19QT8FZHDHFZDN87C482&f=ifr' width='125' height='125' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=21&l=ur1&category=computers_accesories&banner=0Q1FJ9TBD13SA09DSMR2&f=ifr' width='125' height='125' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=40&l=ur1&category=mp3&banner=0TBQHNYNA4B47J02NFG2&f=ifr' width='120' height='60' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<OBJECT classid='clsid:D27CDB6E-AE6D-11cf-96B8-444553540000' codebase='http://fpdownload.macromedia.com/get/flashplayer/current/swflash.cab' id='Player_3d36fdd7-b2fa-4dfd-b517-c5efe035a14d' WIDTH='120px' HEIGHT='500px'> <PARAM NAME='movie' VALUE='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8010%2F3d36fdd7-b2fa-4dfd-b517-c5efe035a14d&Operation=GetDisplayTemplate'><PARAM NAME='quality' VALUE='high'><PARAM NAME='bgcolor' VALUE='#FFFFFF'><PARAM NAME='allowscriptaccess' VALUE='always'><embed src='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8010%2F3d36fdd7-b2fa-4dfd-b517-c5efe035a14d&Operation=GetDisplayTemplate' id='Player_3d36fdd7-b2fa-4dfd-b517-c5efe035a14d' quality='high' bgcolor='#ffffff' name='Player_3d36fdd7-b2fa-4dfd-b517-c5efe035a14d' allowscriptaccess='always' type='application/x-shockwave-flash' align='middle' height='500px' width='120px'/> </OBJECT> <NOSCRIPT><A HREF='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8010%2F3d36fdd7-b2fa-4dfd-b517-c5efe035a14d&Operation=NoScript'>Amazon.com Widgets</A></NOSCRIPT>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=40&l=ur1&category=kindle&banner=19NTJJCKSX6TY1C567G2&f=ifr' width='120' height='60' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='ad/omakasa.html' width='120' height='240' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=14&l=ur1&category=electronicsrot&f=ifr' width='160' height='600' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<OBJECT classid='clsid:D27CDB6E-AE6D-11cf-96B8-444553540000' codebase='http://fpdownload.macromedia.com/get/flashplayer/current/swflash.cab' id='Player_3fde1609-804d-46de-8802-2a16321cf533' WIDTH='160px' HEIGHT='400px'> <PARAM NAME='movie' VALUE='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8009%2F3fde1609-804d-46de-8802-2a16321cf533&Operation=GetDisplayTemplate'><PARAM NAME='quality' VALUE='high'><PARAM NAME='bgcolor' VALUE='#FFFFFF'><PARAM NAME='allowscriptaccess' VALUE='always'><embed src='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8009%2F3fde1609-804d-46de-8802-2a16321cf533&Operation=GetDisplayTemplate' id='Player_3fde1609-804d-46de-8802-2a16321cf533' quality='high' bgcolor='#ffffff' name='Player_3fde1609-804d-46de-8802-2a16321cf533' allowscriptaccess='always' type='application/x-shockwave-flash' align='middle' height='400px' width='160px'/> </OBJECT> <NOSCRIPT><A HREF='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8009%2F3fde1609-804d-46de-8802-2a16321cf533&Operation=NoScript'>Amazon.com Widgets</A></NOSCRIPT>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=40&l=ur1&category=unboxdigital&banner=10NVPFMW8ACPNX4T4E82&f=ifr' width='120' height='60' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<OBJECT classid='clsid:D27CDB6E-AE6D-11cf-96B8-444553540000' codebase='http://fpdownload.macromedia.com/get/flashplayer/current/swflash.cab' id='Player_2e2141ca-ec13-4dc9-88f2-08be95e47e6d' WIDTH='160px' HEIGHT='300px'> <PARAM NAME='movie' VALUE='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8014%2F2e2141ca-ec13-4dc9-88f2-08be95e47e6d&Operation=GetDisplayTemplate'><PARAM NAME='quality' VALUE='high'><PARAM NAME='bgcolor' VALUE='#FFFFFF'><PARAM NAME='allowscriptaccess' VALUE='always'><embed src='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8014%2F2e2141ca-ec13-4dc9-88f2-08be95e47e6d&Operation=GetDisplayTemplate' id='Player_2e2141ca-ec13-4dc9-88f2-08be95e47e6d' quality='high' bgcolor='#ffffff' name='Player_2e2141ca-ec13-4dc9-88f2-08be95e47e6d' allowscriptaccess='always' type='application/x-shockwave-flash' align='middle' height='300px' width='160px'></embed></OBJECT> <NOSCRIPT><A HREF='http://ws.amazon.com/widgets/q?ServiceVersion=20070822&MarketPlace=US&ID=V20070822%2FUS%2Fsubsonic-20%2F8014%2F2e2141ca-ec13-4dc9-88f2-08be95e47e6d&Operation=NoScript'>Amazon.com Widgets</A></NOSCRIPT>", + "<iframe src='http://rcm.amazon.com/e/cm?t=subsonic-20&o=1&p=14&l=ur1&category=musicandentertainmentrot&f=ifr' width='160' height='600' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://www.subsonic.org/pages/subsonic-ad.jsp' width='180' height='400' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>", + "<iframe src='http://www.subsonic.org/pages/zazeen-ad.jsp' width='120' height='600' scrolling='no' border='0' marginwidth='0' style='border:none;' frameborder='0'></iframe>" + }; + private int adInterval; + private int pageCount; + private int adIndex; + + /** + * Returns an ad or <code>null</code> if no ad should be displayed. + */ + public String getAd() { + if (pageCount++ % adInterval == 0) { + + adIndex = (adIndex + 1) % ads.length; + return ads[adIndex]; + } + + return null; + } + + /** + * Set by Spring. + */ + public void setAdInterval(int adInterval) { + this.adInterval = adInterval; + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/AudioScrobblerService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/AudioScrobblerService.java new file mode 100644 index 00000000..9ca402b8 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/AudioScrobblerService.java @@ -0,0 +1,331 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; + +import net.sourceforge.subsonic.domain.MediaFile; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.http.NameValuePair; +import org.apache.http.client.HttpClient; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.BasicResponseHandler; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.params.HttpConnectionParams; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.domain.UserSettings; +import net.sourceforge.subsonic.util.StringUtil; + +/** + * Provides services for "audioscrobbling", which is the process of + * registering what songs are played at www.last.fm. + * <p/> + * See http://www.last.fm/api/submissions + * + * @author Sindre Mehus + */ +public class AudioScrobblerService { + + private static final Logger LOG = Logger.getLogger(AudioScrobblerService.class); + private static final int MAX_PENDING_REGISTRATION = 2000; + private static final long MIN_REGISTRATION_INTERVAL = 30000L; + + private RegistrationThread thread; + private final Map<String, Long> lastRegistrationTimes = new HashMap<String, Long>(); + private final LinkedBlockingQueue<RegistrationData> queue = new LinkedBlockingQueue<RegistrationData>(); + + private SettingsService settingsService; + + /** + * Registers the given media file at www.last.fm. This method returns immediately, the actual registration is done + * by a separate thread. + * + * @param mediaFile The media file to register. + * @param username The user which played the music file. + * @param submission Whether this is a submission or a now playing notification. + */ + public synchronized void register(MediaFile mediaFile, String username, boolean submission) { + + if (thread == null) { + thread = new RegistrationThread(); + thread.start(); + } + + if (queue.size() >= MAX_PENDING_REGISTRATION) { + LOG.warn("Last.fm scrobbler queue is full. Ignoring " + mediaFile); + return; + } + + RegistrationData registrationData = createRegistrationData(mediaFile, username, submission); + if (registrationData == null) { + return; + } + + try { + queue.put(registrationData); + } catch (InterruptedException x) { + LOG.warn("Interrupted while queuing Last.fm scrobble.", x); + } + } + + /** + * Returns registration details, or <code>null</code> if not eligible for registration. + */ + private RegistrationData createRegistrationData(MediaFile mediaFile, String username, boolean submission) { + + if (mediaFile == null || mediaFile.isVideo()) { + return null; + } + + UserSettings userSettings = settingsService.getUserSettings(username); + if (!userSettings.isLastFmEnabled() || userSettings.getLastFmUsername() == null || userSettings.getLastFmPassword() == null) { + return null; + } + + long now = System.currentTimeMillis(); + + // Don't register submissions more often than every 30 seconds. + if (submission) { + Long lastRegistrationTime = lastRegistrationTimes.get(username); + if (lastRegistrationTime != null && now - lastRegistrationTime < MIN_REGISTRATION_INTERVAL) { + return null; + } + lastRegistrationTimes.put(username, now); + } + + RegistrationData reg = new RegistrationData(); + reg.username = userSettings.getLastFmUsername(); + reg.password = userSettings.getLastFmPassword(); + reg.artist = mediaFile.getArtist(); + reg.album = mediaFile.getAlbumName(); + reg.title = mediaFile.getTitle(); + reg.duration = mediaFile.getDurationSeconds() == null ? 0 : mediaFile.getDurationSeconds(); + reg.time = new Date(now); + reg.submission = submission; + + return reg; + } + + /** + * Scrobbles the given song data at last.fm, using the protocol defined at http://www.last.fm/api/submissions. + * + * @param registrationData Registration data for the song. + */ + private void scrobble(RegistrationData registrationData) throws Exception { + if (registrationData == null) { + return; + } + + String[] lines = authenticate(registrationData); + if (lines == null) { + return; + } + + String sessionId = lines[1]; + String nowPlayingUrl = lines[2]; + String submissionUrl = lines[3]; + + if (registrationData.submission) { + lines = registerSubmission(registrationData, sessionId, submissionUrl); + } else { + lines = registerNowPlaying(registrationData, sessionId, nowPlayingUrl); + } + + if (lines[0].startsWith("FAILED")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm: " + lines[0]); + } else if (lines[0].startsWith("BADSESSION")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm. Invalid session."); + } else if (lines[0].startsWith("OK")) { + LOG.debug("Successfully registered " + (registrationData.submission ? "submission" : "now playing") + + " for song '" + registrationData.title + "' for user " + registrationData.username + " at Last.fm."); + } + } + + /** + * Returns the following lines if authentication succeeds: + * <p/> + * Line 0: Always "OK" + * Line 1: Session ID, e.g., "17E61E13454CDD8B68E8D7DEEEDF6170" + * Line 2: URL to use for now playing, e.g., "http://post.audioscrobbler.com:80/np_1.2" + * Line 3: URL to use for submissions, e.g., "http://post2.audioscrobbler.com:80/protocol_1.2" + * <p/> + * If authentication fails, <code>null</code> is returned. + */ + private String[] authenticate(RegistrationData registrationData) throws Exception { + String clientId = "sub"; + String clientVersion = "0.1"; + long timestamp = System.currentTimeMillis() / 1000L; + String authToken = calculateAuthenticationToken(registrationData.password, timestamp); + String[] lines = executeGetRequest("http://post.audioscrobbler.com/?hs=true&p=1.2.1&c=" + clientId + "&v=" + + clientVersion + "&u=" + registrationData.username + "&t=" + timestamp + "&a=" + authToken); + + if (lines[0].startsWith("BANNED")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm. Client version is banned."); + return null; + } + + if (lines[0].startsWith("BADAUTH")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm. Wrong username or password."); + return null; + } + + if (lines[0].startsWith("BADTIME")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm. Bad timestamp, please check local clock."); + return null; + } + + if (lines[0].startsWith("FAILED")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm: " + lines[0]); + return null; + } + + if (!lines[0].startsWith("OK")) { + LOG.warn("Failed to scrobble song '" + registrationData.title + "' at Last.fm. Unknown response: " + lines[0]); + return null; + } + + return lines; + } + + private String[] registerSubmission(RegistrationData registrationData, String sessionId, String url) throws IOException { + Map<String, String> params = new HashMap<String, String>(); + params.put("s", sessionId); + params.put("a[0]", registrationData.artist); + params.put("t[0]", registrationData.title); + params.put("i[0]", String.valueOf(registrationData.time.getTime() / 1000L)); + params.put("o[0]", "P"); + params.put("r[0]", ""); + params.put("l[0]", String.valueOf(registrationData.duration)); + params.put("b[0]", registrationData.album); + params.put("n[0]", ""); + params.put("m[0]", ""); + return executePostRequest(url, params); + } + + private String[] registerNowPlaying(RegistrationData registrationData, String sessionId, String url) throws IOException { + Map<String, String> params = new HashMap<String, String>(); + params.put("s", sessionId); + params.put("a", registrationData.artist); + params.put("t", registrationData.title); + params.put("b", registrationData.album); + params.put("l", String.valueOf(registrationData.duration)); + params.put("n", ""); + params.put("m", ""); + return executePostRequest(url, params); + } + + private String calculateAuthenticationToken(String password, long timestamp) { + return DigestUtils.md5Hex(DigestUtils.md5Hex(password) + timestamp); + } + + private String[] executeGetRequest(String url) throws IOException { + return executeRequest(new HttpGet(url)); + } + + private String[] executePostRequest(String url, Map<String, String> parameters) throws IOException { + List<NameValuePair> params = new ArrayList<NameValuePair>(); + for (Map.Entry<String, String> entry : parameters.entrySet()) { + params.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); + } + + HttpPost request = new HttpPost(url); + request.setEntity(new UrlEncodedFormEntity(params, StringUtil.ENCODING_UTF8)); + + return executeRequest(request); + } + + private String[] executeRequest(HttpUriRequest request) throws IOException { + HttpClient client = new DefaultHttpClient(); + HttpConnectionParams.setConnectionTimeout(client.getParams(), 15000); + HttpConnectionParams.setSoTimeout(client.getParams(), 15000); + + try { + ResponseHandler<String> responseHandler = new BasicResponseHandler(); + String response = client.execute(request, responseHandler); + return response.split("\\n"); + + } finally { + client.getConnectionManager().shutdown(); + } + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + private class RegistrationThread extends Thread { + private RegistrationThread() { + super("AudioScrobbler Registration"); + } + + @Override + public void run() { + while (true) { + RegistrationData registrationData = null; + try { + registrationData = queue.take(); + scrobble(registrationData); + } catch (IOException x) { + handleNetworkError(registrationData, x); + } catch (Exception x) { + LOG.warn("Error in Last.fm registration.", x); + } + } + } + + private void handleNetworkError(RegistrationData registrationData, IOException x) { + try { + queue.put(registrationData); + LOG.info("Last.fm registration for " + registrationData.title + + " encountered network error. Will try again later. In queue: " + queue.size(), x); + } catch (InterruptedException e) { + LOG.error("Failed to reschedule Last.fm registration for " + registrationData.title, e); + } + try { + sleep(15L * 60L * 1000L); // Wait 15 minutes. + } catch (InterruptedException e) { + LOG.error("Failed to sleep after Last.fm registration failure for " + registrationData.title, e); + } + } + } + + private static class RegistrationData { + private String username; + private String password; + private String artist; + private String album; + private String title; + private int duration; + private Date time; + public boolean submission; + } + +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/JukeboxService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/JukeboxService.java new file mode 100644 index 00000000..9f2eff22 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/JukeboxService.java @@ -0,0 +1,206 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.Player; +import net.sourceforge.subsonic.domain.PlayQueue; +import net.sourceforge.subsonic.domain.Transcoding; +import net.sourceforge.subsonic.domain.TransferStatus; +import net.sourceforge.subsonic.domain.User; +import net.sourceforge.subsonic.domain.VideoTranscodingSettings; +import net.sourceforge.subsonic.service.jukebox.AudioPlayer; +import net.sourceforge.subsonic.util.FileUtil; +import org.apache.commons.io.IOUtils; + +import java.io.InputStream; + +import static net.sourceforge.subsonic.service.jukebox.AudioPlayer.State.EOM; + +/** + * Plays music on the local audio device. + * + * @author Sindre Mehus + */ +public class JukeboxService implements AudioPlayer.Listener { + + private static final Logger LOG = Logger.getLogger(JukeboxService.class); + + private AudioPlayer audioPlayer; + private TranscodingService transcodingService; + private AudioScrobblerService audioScrobblerService; + private StatusService statusService; + private SettingsService settingsService; + private SecurityService securityService; + + private Player player; + private TransferStatus status; + private MediaFile currentPlayingFile; + private float gain = 0.5f; + private int offset; + private MediaFileService mediaFileService; + + /** + * Updates the jukebox by starting or pausing playback on the local audio device. + * + * @param player The player in question. + * @param offset Start playing after this many seconds into the track. + */ + public synchronized void updateJukebox(Player player, int offset) throws Exception { + User user = securityService.getUserByName(player.getUsername()); + if (!user.isJukeboxRole()) { + LOG.warn(user.getUsername() + " is not authorized for jukebox playback."); + return; + } + + if (player.getPlayQueue().getStatus() == PlayQueue.Status.PLAYING) { + this.player = player; + MediaFile result; + synchronized (player.getPlayQueue()) { + result = player.getPlayQueue().getCurrentFile(); + } + play(result, offset); + } else { + if (audioPlayer != null) { + audioPlayer.pause(); + } + } + } + + private synchronized void play(MediaFile file, int offset) { + InputStream in = null; + try { + + // Resume if possible. + boolean sameFile = file != null && file.equals(currentPlayingFile); + boolean paused = audioPlayer != null && audioPlayer.getState() == AudioPlayer.State.PAUSED; + if (sameFile && paused && offset == 0) { + audioPlayer.play(); + } else { + this.offset = offset; + if (audioPlayer != null) { + audioPlayer.close(); + if (currentPlayingFile != null) { + onSongEnd(currentPlayingFile); + } + } + + if (file != null) { + TranscodingService.Parameters parameters = new TranscodingService.Parameters(file, new VideoTranscodingSettings(0, 0, offset)); + String command = settingsService.getJukeboxCommand(); + parameters.setTranscoding(new Transcoding(null, null, null, null, command, null, null, false)); + in = transcodingService.getTranscodedInputStream(parameters); + audioPlayer = new AudioPlayer(in, this); + audioPlayer.setGain(gain); + audioPlayer.play(); + onSongStart(file); + } + } + + currentPlayingFile = file; + + } catch (Exception x) { + LOG.error("Error in jukebox: " + x, x); + IOUtils.closeQuietly(in); + } + } + + public synchronized void stateChanged(AudioPlayer audioPlayer, AudioPlayer.State state) { + if (state == EOM) { + player.getPlayQueue().next(); + MediaFile result; + synchronized (player.getPlayQueue()) { + result = player.getPlayQueue().getCurrentFile(); + } + play(result, 0); + } + } + + public synchronized float getGain() { + return gain; + } + + public synchronized int getPosition() { + return audioPlayer == null ? 0 : offset + audioPlayer.getPosition(); + } + + /** + * Returns the player which currently uses the jukebox. + * + * @return The player, may be {@code null}. + */ + public Player getPlayer() { + return player; + } + + private void onSongStart(MediaFile file) { + LOG.info(player.getUsername() + " starting jukebox for \"" + FileUtil.getShortPath(file.getFile()) + "\""); + status = statusService.createStreamStatus(player); + status.setFile(file.getFile()); + status.addBytesTransfered(file.getFileSize()); + mediaFileService.incrementPlayCount(file); + scrobble(file, false); + } + + private void onSongEnd(MediaFile file) { + LOG.info(player.getUsername() + " stopping jukebox for \"" + FileUtil.getShortPath(file.getFile()) + "\""); + if (status != null) { + statusService.removeStreamStatus(status); + } + scrobble(file, true); + } + + private void scrobble(MediaFile file, boolean submission) { + if (player.getClientId() == null) { // Don't scrobble REST players. + audioScrobblerService.register(file, player.getUsername(), submission); + } + } + + public synchronized void setGain(float gain) { + this.gain = gain; + if (audioPlayer != null) { + audioPlayer.setGain(gain); + } + } + + public void setTranscodingService(TranscodingService transcodingService) { + this.transcodingService = transcodingService; + } + + public void setAudioScrobblerService(AudioScrobblerService audioScrobblerService) { + this.audioScrobblerService = audioScrobblerService; + } + + public void setStatusService(StatusService statusService) { + this.statusService = statusService; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaFileService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaFileService.java new file mode 100644 index 00000000..bc575714 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaFileService.java @@ -0,0 +1,614 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sf.ehcache.Ehcache; +import net.sf.ehcache.Element; +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.AlbumDao; +import net.sourceforge.subsonic.dao.MediaFileDao; +import net.sourceforge.subsonic.domain.Album; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.MediaFileComparator; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.service.metadata.JaudiotaggerParser; +import net.sourceforge.subsonic.service.metadata.MetaData; +import net.sourceforge.subsonic.service.metadata.MetaDataParser; +import net.sourceforge.subsonic.service.metadata.MetaDataParserFactory; +import net.sourceforge.subsonic.util.FileUtil; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static net.sourceforge.subsonic.domain.MediaFile.MediaType.*; + +/** + * Provides services for instantiating and caching media files and cover art. + * + * @author Sindre Mehus + */ +public class MediaFileService { + + private static final Logger LOG = Logger.getLogger(MediaFileService.class); + + private Ehcache mediaFileMemoryCache; + + private SecurityService securityService; + private SettingsService settingsService; + private MediaFileDao mediaFileDao; + private AlbumDao albumDao; + private MetaDataParserFactory metaDataParserFactory; + + /** + * Returns a media file instance for the given file. If possible, a cached value is returned. + * + * @param file A file on the local file system. + * @return A media file instance, or null if not found. + * @throws SecurityException If access is denied to the given file. + */ + public MediaFile getMediaFile(File file) { + return getMediaFile(file, settingsService.isFastCacheEnabled()); + } + + /** + * Returns a media file instance for the given file. If possible, a cached value is returned. + * + * @param file A file on the local file system. + * @return A media file instance, or null if not found. + * @throws SecurityException If access is denied to the given file. + */ + public MediaFile getMediaFile(File file, boolean useFastCache) { + + // Look in fast memory cache first. + Element element = mediaFileMemoryCache.get(file); + MediaFile result = element == null ? null : (MediaFile) element.getObjectValue(); + if (result != null) { + return result; + } + + if (!securityService.isReadAllowed(file)) { + throw new SecurityException("Access denied to file " + file); + } + + // Secondly, look in database. + result = mediaFileDao.getMediaFile(file.getPath()); + if (result != null) { + result = checkLastModified(result, useFastCache); + mediaFileMemoryCache.put(new Element(file, result)); + return result; + } + + if (!FileUtil.exists(file)) { + return null; + } + // Not found in database, must read from disk. + result = createMediaFile(file); + + // Put in cache and database. + mediaFileMemoryCache.put(new Element(file, result)); + mediaFileDao.createOrUpdateMediaFile(result); + + return result; + } + + private MediaFile checkLastModified(MediaFile mediaFile, boolean useFastCache) { + if (useFastCache || mediaFile.getChanged().getTime() >= FileUtil.lastModified(mediaFile.getFile())) { + return mediaFile; + } + mediaFile = createMediaFile(mediaFile.getFile()); + mediaFileDao.createOrUpdateMediaFile(mediaFile); + return mediaFile; + } + + /** + * Returns a media file instance for the given path name. If possible, a cached value is returned. + * + * @param pathName A path name for a file on the local file system. + * @return A media file instance. + * @throws SecurityException If access is denied to the given file. + */ + public MediaFile getMediaFile(String pathName) { + return getMediaFile(new File(pathName)); + } + + // TODO: Optimize with memory caching. + public MediaFile getMediaFile(int id) { + MediaFile mediaFile = mediaFileDao.getMediaFile(id); + if (mediaFile == null) { + return null; + } + + if (!securityService.isReadAllowed(mediaFile.getFile())) { + throw new SecurityException("Access denied to file " + mediaFile); + } + + return checkLastModified(mediaFile, settingsService.isFastCacheEnabled()); + } + + public MediaFile getParentOf(MediaFile mediaFile) { + if (mediaFile.getParentPath() == null) { + return null; + } + return getMediaFile(mediaFile.getParentPath()); + } + + public List<MediaFile> getChildrenOf(String parentPath, boolean includeFiles, boolean includeDirectories, boolean sort) { + return getChildrenOf(new File(parentPath), includeFiles, includeDirectories, sort); + } + + public List<MediaFile> getChildrenOf(File parent, boolean includeFiles, boolean includeDirectories, boolean sort) { + return getChildrenOf(getMediaFile(parent), includeFiles, includeDirectories, sort); + } + + /** + * Returns all media files that are children of a given media file. + * + * @param includeFiles Whether files should be included in the result. + * @param includeDirectories Whether directories should be included in the result. + * @param sort Whether to sort files in the same directory. + * @return All children media files. + */ + public List<MediaFile> getChildrenOf(MediaFile parent, boolean includeFiles, boolean includeDirectories, boolean sort) { + return getChildrenOf(parent, includeFiles, includeDirectories, sort, settingsService.isFastCacheEnabled()); + } + + /** + * Returns all media files that are children of a given media file. + * + * @param includeFiles Whether files should be included in the result. + * @param includeDirectories Whether directories should be included in the result. + * @param sort Whether to sort files in the same directory. + * @return All children media files. + */ + public List<MediaFile> getChildrenOf(MediaFile parent, boolean includeFiles, boolean includeDirectories, boolean sort, boolean useFastCache) { + + if (!parent.isDirectory()) { + return Collections.emptyList(); + } + + // Make sure children are stored and up-to-date in the database. + if (!useFastCache) { + updateChildren(parent); + } + + List<MediaFile> result = new ArrayList<MediaFile>(); + for (MediaFile child : mediaFileDao.getChildrenOf(parent.getPath())) { + child = checkLastModified(child, useFastCache); + if (child.isDirectory() && includeDirectories) { + result.add(child); + } + if (child.isFile() && includeFiles) { + result.add(child); + } + } + + if (sort) { + Comparator<MediaFile> comparator = new MediaFileComparator(settingsService.isSortAlbumsByYear()); + // Note: Intentionally not using Collections.sort() since it can be problematic on Java 7. + // http://www.oracle.com/technetwork/java/javase/compatibility-417013.html#jdk7 + Set<MediaFile> set = new TreeSet<MediaFile>(comparator); + set.addAll(result); + result = new ArrayList<MediaFile>(set); + } + + return result; + } + + /** + * Returns whether the given file is the root of a media folder. + * + * @see MusicFolder + */ + public boolean isRoot(MediaFile mediaFile) { + for (MusicFolder musicFolder : settingsService.getAllMusicFolders(false, true)) { + if (mediaFile.getPath().equals(musicFolder.getPath().getPath())) { + return true; + } + } + return false; + } + + /** + * Returns all genres in the music collection. + * + * @return Sorted list of genres. + */ + public List<String> getGenres() { + return mediaFileDao.getGenres(); + } + + /** + * Returns the most frequently played albums. + * + * @param offset Number of albums to skip. + * @param count Maximum number of albums to return. + * @return The most frequently played albums. + */ + public List<MediaFile> getMostFrequentlyPlayedAlbums(int offset, int count) { + return mediaFileDao.getMostFrequentlyPlayedAlbums(offset, count); + } + + /** + * Returns the most recently played albums. + * + * @param offset Number of albums to skip. + * @param count Maximum number of albums to return. + * @return The most recently played albums. + */ + public List<MediaFile> getMostRecentlyPlayedAlbums(int offset, int count) { + return mediaFileDao.getMostRecentlyPlayedAlbums(offset, count); + } + + /** + * Returns the most recently added albums. + * + * @param offset Number of albums to skip. + * @param count Maximum number of albums to return. + * @return The most recently added albums. + */ + public List<MediaFile> getNewestAlbums(int offset, int count) { + return mediaFileDao.getNewestAlbums(offset, count); + } + + /** + * Returns the most recently starred albums. + * + * @param offset Number of albums to skip. + * @param count Maximum number of albums to return. + * @param username Returns albums starred by this user. + * @return The most recently starred albums for this user. + */ + public List<MediaFile> getStarredAlbums(int offset, int count, String username) { + return mediaFileDao.getStarredAlbums(offset, count, username); + } + + /** + * Returns albums in alphabetial order. + * + * + * @param offset Number of albums to skip. + * @param count Maximum number of albums to return. + * @param byArtist Whether to sort by artist name + * @return Albums in alphabetical order. + */ + public List<MediaFile> getAlphabetialAlbums(int offset, int count, boolean byArtist) { + return mediaFileDao.getAlphabetialAlbums(offset, count, byArtist); + } + + public Date getMediaFileStarredDate(int id, String username) { + return mediaFileDao.getMediaFileStarredDate(id, username); + } + + public void populateStarredDate(List<MediaFile> mediaFiles, String username) { + for (MediaFile mediaFile : mediaFiles) { + populateStarredDate(mediaFile, username); + } + } + + public void populateStarredDate(MediaFile mediaFile, String username) { + Date starredDate = mediaFileDao.getMediaFileStarredDate(mediaFile.getId(), username); + mediaFile.setStarredDate(starredDate); + } + + private void updateChildren(MediaFile parent) { + + // Check timestamps. + if (parent.getChildrenLastUpdated().getTime() >= parent.getChanged().getTime()) { + return; + } + + List<MediaFile> storedChildren = mediaFileDao.getChildrenOf(parent.getPath()); + Map<String, MediaFile> storedChildrenMap = new HashMap<String, MediaFile>(); + for (MediaFile child : storedChildren) { + storedChildrenMap.put(child.getPath(), child); + } + + List<File> children = filterMediaFiles(FileUtil.listFiles(parent.getFile())); + for (File child : children) { + if (storedChildrenMap.remove(child.getPath()) == null) { + // Add children that are not already stored. + mediaFileDao.createOrUpdateMediaFile(createMediaFile(child)); + } + } + + // Delete children that no longer exist on disk. + for (String path : storedChildrenMap.keySet()) { + mediaFileDao.deleteMediaFile(path); + } + + // Update timestamp in parent. + parent.setChildrenLastUpdated(parent.getChanged()); + parent.setPresent(true); + mediaFileDao.createOrUpdateMediaFile(parent); + } + + private List<File> filterMediaFiles(File[] candidates) { + List<File> result = new ArrayList<File>(); + for (File candidate : candidates) { + String suffix = FilenameUtils.getExtension(candidate.getName()).toLowerCase(); + if (!isExcluded(candidate) && (FileUtil.isDirectory(candidate) || isAudioFile(suffix) || isVideoFile(suffix))) { + result.add(candidate); + } + } + return result; + } + + private boolean isAudioFile(String suffix) { + for (String s : settingsService.getMusicFileTypesAsArray()) { + if (suffix.equals(s.toLowerCase())) { + return true; + } + } + return false; + } + + private boolean isVideoFile(String suffix) { + for (String s : settingsService.getVideoFileTypesAsArray()) { + if (suffix.equals(s.toLowerCase())) { + return true; + } + } + return false; + } + + /** + * Returns whether the given file is excluded. + * + * @param file The child file in question. + * @return Whether the child file is excluded. + */ + private boolean isExcluded(File file) { + + // Exclude all hidden files starting with a "." or "@eaDir" (thumbnail dir created on Synology devices). + String name = file.getName(); + return name.startsWith(".") || name.startsWith("@eaDir") || name.equals("Thumbs.db"); + } + + private MediaFile createMediaFile(File file) { + MediaFile mediaFile = new MediaFile(); + Date lastModified = new Date(FileUtil.lastModified(file)); + mediaFile.setPath(file.getPath()); + mediaFile.setFolder(securityService.getRootFolderForFile(file)); + mediaFile.setParentPath(file.getParent()); + mediaFile.setChanged(lastModified); + mediaFile.setLastScanned(new Date()); + mediaFile.setPlayCount(0); + mediaFile.setChildrenLastUpdated(new Date(0)); + mediaFile.setCreated(lastModified); + mediaFile.setMediaType(DIRECTORY); + mediaFile.setPresent(true); + + if (file.isFile()) { + + MetaDataParser parser = metaDataParserFactory.getParser(file); + if (parser != null) { + MetaData metaData = parser.getMetaData(file); + mediaFile.setArtist(metaData.getArtist()); + mediaFile.setAlbumArtist(metaData.getArtist()); + mediaFile.setAlbumName(metaData.getAlbumName()); + mediaFile.setTitle(metaData.getTitle()); + mediaFile.setDiscNumber(metaData.getDiscNumber()); + mediaFile.setTrackNumber(metaData.getTrackNumber()); + mediaFile.setGenre(metaData.getGenre()); + mediaFile.setYear(metaData.getYear()); + mediaFile.setDurationSeconds(metaData.getDurationSeconds()); + mediaFile.setBitRate(metaData.getBitRate()); + mediaFile.setVariableBitRate(metaData.getVariableBitRate()); + mediaFile.setHeight(metaData.getHeight()); + mediaFile.setWidth(metaData.getWidth()); + } + String format = StringUtils.trimToNull(StringUtils.lowerCase(FilenameUtils.getExtension(mediaFile.getPath()))); + mediaFile.setFormat(format); + mediaFile.setFileSize(FileUtil.length(file)); + mediaFile.setMediaType(getMediaType(mediaFile)); + + } else { + + // Is this an album? + if (!isRoot(mediaFile)) { + File[] children = FileUtil.listFiles(file); + File firstChild = null; + for (File child : filterMediaFiles(children)) { + if (FileUtil.isFile(child)) { + firstChild = child; + break; + } + } + + if (firstChild != null) { + mediaFile.setMediaType(ALBUM); + + // Guess artist/album name and year. + MetaDataParser parser = metaDataParserFactory.getParser(firstChild); + if (parser != null) { + MetaData metaData = parser.getMetaData(firstChild); + mediaFile.setArtist(metaData.getArtist()); + mediaFile.setAlbumName(metaData.getAlbumName()); + mediaFile.setYear(metaData.getYear()); + } + + // Look for cover art. + try { + File coverArt = findCoverArt(children); + if (coverArt != null) { + mediaFile.setCoverArtPath(coverArt.getPath()); + } + } catch (IOException x) { + LOG.error("Failed to find cover art.", x); + } + + } else { + mediaFile.setArtist(file.getName()); + } + } + } + + return mediaFile; + } + + private MediaFile.MediaType getMediaType(MediaFile mediaFile) { + if (isVideoFile(mediaFile.getFormat())) { + return VIDEO; + } + String path = mediaFile.getPath().toLowerCase(); + String genre = StringUtils.trimToEmpty(mediaFile.getGenre()).toLowerCase(); + if (path.contains("podcast") || genre.contains("podcast")) { + return PODCAST; + } + if (path.contains("audiobook") || genre.contains("audiobook") || path.contains("audio book") || genre.contains("audio book")) { + return AUDIOBOOK; + } + return MUSIC; + } + + public void refreshMediaFile(MediaFile mediaFile) { + mediaFile = createMediaFile(mediaFile.getFile()); + mediaFileDao.createOrUpdateMediaFile(mediaFile); + mediaFileMemoryCache.remove(mediaFile.getFile()); + } + + /** + * Returns a cover art image for the given media file. + */ + public File getCoverArt(MediaFile mediaFile) { + if (mediaFile.getCoverArtFile() != null) { + return mediaFile.getCoverArtFile(); + } + MediaFile parent = getParentOf(mediaFile); + return parent == null ? null : parent.getCoverArtFile(); + } + + /** + * Finds a cover art image for the given directory, by looking for it on the disk. + */ + private File findCoverArt(File[] candidates) throws IOException { + for (String mask : settingsService.getCoverArtFileTypesAsArray()) { + for (File candidate : candidates) { + if (candidate.isFile() && candidate.getName().toUpperCase().endsWith(mask.toUpperCase()) && !candidate.getName().startsWith(".")) { + return candidate; + } + } + } + + // Look for embedded images in audiofiles. (Only check first audio file encountered). + JaudiotaggerParser parser = new JaudiotaggerParser(); + for (File candidate : candidates) { + if (parser.isApplicable(candidate)) { + if (parser.isImageAvailable(getMediaFile(candidate))) { + return candidate; + } else { + return null; + } + } + } + return null; + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setMediaFileMemoryCache(Ehcache mediaFileMemoryCache) { + this.mediaFileMemoryCache = mediaFileMemoryCache; + } + + public void setMediaFileDao(MediaFileDao mediaFileDao) { + this.mediaFileDao = mediaFileDao; + } + + /** + * Returns all media files that are children, grand-children etc of a given media file. + * Directories are not included in the result. + * + * @param sort Whether to sort files in the same directory. + * @return All descendant music files. + */ + public List<MediaFile> getDescendantsOf(MediaFile ancestor, boolean sort) { + + if (ancestor.isFile()) { + return Arrays.asList(ancestor); + } + + List<MediaFile> result = new ArrayList<MediaFile>(); + + for (MediaFile child : getChildrenOf(ancestor, true, true, sort)) { + if (child.isDirectory()) { + result.addAll(getDescendantsOf(child, sort)); + } else { + result.add(child); + } + } + return result; + } + + public void setMetaDataParserFactory(MetaDataParserFactory metaDataParserFactory) { + this.metaDataParserFactory = metaDataParserFactory; + } + + public void updateMediaFile(MediaFile mediaFile) { + mediaFileDao.createOrUpdateMediaFile(mediaFile); + } + + /** + * Increments the play count and last played date for the given media file and its + * directory and album. + */ + public void incrementPlayCount(MediaFile file) { + Date now = new Date(); + file.setLastPlayed(now); + file.setPlayCount(file.getPlayCount() + 1); + updateMediaFile(file); + + MediaFile parent = getParentOf(file); + if (!isRoot(parent)) { + parent.setLastPlayed(now); + parent.setPlayCount(parent.getPlayCount() + 1); + updateMediaFile(parent); + } + + Album album = albumDao.getAlbum(file.getAlbumArtist(), file.getAlbumName()); + if (album != null) { + album.setLastPlayed(now); + album.setPlayCount(album.getPlayCount() + 1); + albumDao.createOrUpdateAlbum(album); + } + } + + public void setAlbumDao(AlbumDao albumDao) { + this.albumDao = albumDao; + } + +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaScannerService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaScannerService.java new file mode 100644 index 00000000..84f2d31c --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaScannerService.java @@ -0,0 +1,354 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.File; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.AlbumDao; +import net.sourceforge.subsonic.dao.ArtistDao; +import net.sourceforge.subsonic.dao.MediaFileDao; +import net.sourceforge.subsonic.domain.Album; +import net.sourceforge.subsonic.domain.Artist; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.MediaLibraryStatistics; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.util.FileUtil; +import org.apache.commons.lang.ObjectUtils; + +/** + * Provides services for scanning the music library. + * + * @author Sindre Mehus + */ +public class MediaScannerService { + + private static final int INDEX_VERSION = 15; + private static final Logger LOG = Logger.getLogger(MediaScannerService.class); + + private MediaLibraryStatistics statistics; + + private boolean scanning; + private Timer timer; + private SettingsService settingsService; + private SearchService searchService; + private MediaFileService mediaFileService; + private MediaFileDao mediaFileDao; + private ArtistDao artistDao; + private AlbumDao albumDao; + private int scanCount; + + public void init() { + deleteOldIndexFiles(); + statistics = mediaFileDao.getStatistics(); + schedule(); + } + + /** + * Schedule background execution of media library scanning. + */ + public synchronized void schedule() { + if (timer != null) { + timer.cancel(); + } + timer = new Timer(true); + + TimerTask task = new TimerTask() { + @Override + public void run() { + scanLibrary(); + } + }; + + long daysBetween = settingsService.getIndexCreationInterval(); + int hour = settingsService.getIndexCreationHour(); + + if (daysBetween == -1) { + LOG.info("Automatic media scanning disabled."); + return; + } + + Date now = new Date(); + Calendar cal = Calendar.getInstance(); + cal.setTime(now); + cal.set(Calendar.HOUR_OF_DAY, hour); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + + if (cal.getTime().before(now)) { + cal.add(Calendar.DATE, 1); + } + + Date firstTime = cal.getTime(); + long period = daysBetween * 24L * 3600L * 1000L; + timer.schedule(task, firstTime, period); + + LOG.info("Automatic media library scanning scheduled to run every " + daysBetween + " day(s), starting at " + firstTime); + + // In addition, create index immediately if it doesn't exist on disk. + if (settingsService.getLastScanned() == null) { + LOG.info("Media library never scanned. Doing it now."); + scanLibrary(); + } + } + + /** + * Returns whether the media library is currently being scanned. + */ + public synchronized boolean isScanning() { + return scanning; + } + + /** + * Returns the number of files scanned so far. + */ + public int getScanCount() { + return scanCount; + } + + /** + * Scans the media library. + * The scanning is done asynchronously, i.e., this method returns immediately. + */ + public synchronized void scanLibrary() { + if (isScanning()) { + return; + } + scanning = true; + + Thread thread = new Thread("MediaLibraryScanner") { + @Override + public void run() { + doScanLibrary(); + } + }; + + thread.setPriority(Thread.MIN_PRIORITY); + thread.start(); + } + + private void doScanLibrary() { + LOG.info("Starting to scan media library."); + + try { + Date lastScanned = new Date(); + Map<String, Integer> albumCount = new HashMap<String, Integer>(); + scanCount = 0; + + searchService.startIndexing(); + + // Recurse through all files on disk. + for (MusicFolder musicFolder : settingsService.getAllMusicFolders()) { + MediaFile root = mediaFileService.getMediaFile(musicFolder.getPath(), false); + scanFile(root, musicFolder, lastScanned, albumCount); + } + mediaFileDao.markNonPresent(lastScanned); + artistDao.markNonPresent(lastScanned); + albumDao.markNonPresent(lastScanned); + + // Update statistics + statistics = mediaFileDao.getStatistics(); + + settingsService.setLastScanned(lastScanned); + settingsService.save(false); + LOG.info("Scanned media library with " + scanCount + " entries."); + + } catch (Throwable x) { + LOG.error("Failed to scan media library.", x); + } finally { + scanning = false; + searchService.stopIndexing(); + } + } + + private void scanFile(MediaFile file, MusicFolder musicFolder, Date lastScanned, Map<String, Integer> albumCount) { + scanCount++; + if (scanCount % 250 == 0) { + LOG.info("Scanned media library with " + scanCount + " entries."); + } + + searchService.index(file); + + // Update the root folder if it has changed. + if (!musicFolder.getPath().getPath().equals(file.getFolder())) { + file.setFolder(musicFolder.getPath().getPath()); + mediaFileDao.createOrUpdateMediaFile(file); + } + + if (file.isDirectory()) { + for (MediaFile child : mediaFileService.getChildrenOf(file, true, false, false, false)) { + scanFile(child, musicFolder, lastScanned, albumCount); + } + for (MediaFile child : mediaFileService.getChildrenOf(file, false, true, false, false)) { + scanFile(child, musicFolder, lastScanned, albumCount); + } + } else { + updateAlbum(file, lastScanned, albumCount); + updateArtist(file, lastScanned, albumCount); + } + + mediaFileDao.markPresent(file.getPath(), lastScanned); + artistDao.markPresent(file.getArtist(), lastScanned); + } + + private void updateAlbum(MediaFile file, Date lastScanned, Map<String, Integer> albumCount) { + if (file.getAlbumName() == null || file.getArtist() == null || file.getParentPath() == null || !file.isAudio()) { + return; + } + + Album album = albumDao.getAlbumForFile(file); + if (album == null) { + album = new Album(); + album.setPath(file.getParentPath()); + album.setName(file.getAlbumName()); + album.setArtist(file.getArtist()); + album.setCreated(file.getChanged()); + } + if (album.getCoverArtPath() == null) { + MediaFile parent = mediaFileService.getParentOf(file); + if (parent != null) { + album.setCoverArtPath(parent.getCoverArtPath()); + } + } + boolean firstEncounter = !lastScanned.equals(album.getLastScanned()); + if (firstEncounter) { + album.setDurationSeconds(0); + album.setSongCount(0); + Integer n = albumCount.get(file.getArtist()); + albumCount.put(file.getArtist(), n == null ? 1 : n + 1); + } + if (file.getDurationSeconds() != null) { + album.setDurationSeconds(album.getDurationSeconds() + file.getDurationSeconds()); + } + if (file.isAudio()) { + album.setSongCount(album.getSongCount() + 1); + } + + album.setLastScanned(lastScanned); + album.setPresent(true); + albumDao.createOrUpdateAlbum(album); + if (firstEncounter) { + searchService.index(album); + } + + // Update the file's album artist, if necessary. + if (!ObjectUtils.equals(album.getArtist(), file.getAlbumArtist())) { + file.setAlbumArtist(album.getArtist()); + mediaFileDao.createOrUpdateMediaFile(file); + } + } + + private void updateArtist(MediaFile file, Date lastScanned, Map<String, Integer> albumCount) { + if (file.getArtist() == null || !file.isAudio()) { + return; + } + + Artist artist = artistDao.getArtist(file.getArtist()); + if (artist == null) { + artist = new Artist(); + artist.setName(file.getArtist()); + } + if (artist.getCoverArtPath() == null) { + MediaFile parent = mediaFileService.getParentOf(file); + if (parent != null) { + artist.setCoverArtPath(parent.getCoverArtPath()); + } + } + boolean firstEncounter = !lastScanned.equals(artist.getLastScanned()); + + Integer n = albumCount.get(artist.getName()); + artist.setAlbumCount(n == null ? 0 : n); + + artist.setLastScanned(lastScanned); + artist.setPresent(true); + artistDao.createOrUpdateArtist(artist); + + if (firstEncounter) { + searchService.index(artist); + } + } + + /** + * Returns media library statistics, including the number of artists, albums and songs. + * + * @return Media library statistics. + */ + public MediaLibraryStatistics getStatistics() { + return statistics; + } + + /** + * Deletes old versions of the index file. + */ + private void deleteOldIndexFiles() { + for (int i = 2; i < INDEX_VERSION; i++) { + File file = getIndexFile(i); + try { + if (FileUtil.exists(file)) { + if (file.delete()) { + LOG.info("Deleted old index file: " + file.getPath()); + } + } + } catch (Exception x) { + LOG.warn("Failed to delete old index file: " + file.getPath(), x); + } + } + } + + /** + * Returns the index file for the given index version. + * + * @param version The index version. + * @return The index file for the given index version. + */ + private File getIndexFile(int version) { + File home = SettingsService.getSubsonicHome(); + return new File(home, "subsonic" + version + ".index"); + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setSearchService(SearchService searchService) { + this.searchService = searchService; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } + + public void setMediaFileDao(MediaFileDao mediaFileDao) { + this.mediaFileDao = mediaFileDao; + } + + public void setArtistDao(ArtistDao artistDao) { + this.artistDao = artistDao; + } + + public void setAlbumDao(AlbumDao albumDao) { + this.albumDao = albumDao; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MusicIndexService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MusicIndexService.java new file mode 100644 index 00000000..b6ee682e --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/MusicIndexService.java @@ -0,0 +1,250 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.StringTokenizer; +import java.util.TreeMap; +import java.util.TreeSet; + +import net.sourceforge.subsonic.dao.MediaFileDao; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.domain.MusicIndex; +import net.sourceforge.subsonic.domain.MusicIndex.Artist; + +/** + * Provides services for grouping artists by index. + * + * @author Sindre Mehus + */ +public class MusicIndexService { + + private SettingsService settingsService; + private MediaFileService mediaFileService; + private MediaFileDao mediaFileDao; + + /** + * Returns a map from music indexes to sets of artists that are direct children of the given music folders. + * + * @param folders The music folders. + * @return A map from music indexes to sets of artists that are direct children of this music file. + * @throws IOException If an I/O error occurs. + */ + public SortedMap<MusicIndex, SortedSet<Artist>> getIndexedArtists(List<MusicFolder> folders) throws IOException { + + String[] ignoredArticles = settingsService.getIgnoredArticlesAsArray(); + String[] shortcuts = settingsService.getShortcutsAsArray(); + final List<MusicIndex> indexes = createIndexesFromExpression(settingsService.getIndexString()); + + Comparator<MusicIndex> indexComparator = new MusicIndexComparator(indexes); + SortedSet<Artist> artists = createArtists(folders, ignoredArticles, shortcuts); + SortedMap<MusicIndex, SortedSet<Artist>> result = new TreeMap<MusicIndex, SortedSet<Artist>>(indexComparator); + + for (Artist artist : artists) { + MusicIndex index = getIndex(artist, indexes); + SortedSet<Artist> artistSet = result.get(index); + if (artistSet == null) { + artistSet = new TreeSet<Artist>(); + result.put(index, artistSet); + } + artistSet.add(artist); + } + + return result; + } + + /** + * Creates a new instance by parsing the given expression. The expression consists of an index name, followed by + * an optional list of one-character prefixes. For example:<p/> + * <p/> + * The expression <em>"A"</em> will create the index <em>"A" -> ["A"]</em><br/> + * The expression <em>"The"</em> will create the index <em>"The" -> ["The"]</em><br/> + * The expression <em>"A(AÅÆ)"</em> will create the index <em>"A" -> ["A", "Å", "Æ"]</em><br/> + * The expression <em>"X-Z(XYZ)"</em> will create the index <em>"X-Z" -> ["X", "Y", "Z"]</em> + * + * @param expr The expression to parse. + * @return A new instance. + */ + protected MusicIndex createIndexFromExpression(String expr) { + int separatorIndex = expr.indexOf('('); + if (separatorIndex == -1) { + + MusicIndex index = new MusicIndex(expr); + index.addPrefix(expr); + return index; + } + + MusicIndex index = new MusicIndex(expr.substring(0, separatorIndex)); + String prefixString = expr.substring(separatorIndex + 1, expr.length() - 1); + for (int i = 0; i < prefixString.length(); i++) { + index.addPrefix(prefixString.substring(i, i + 1)); + } + return index; + } + + /** + * Creates a list of music indexes by parsing the given expression. The expression is a space-separated list of + * sub-expressions, for which the rules described in {@link #createIndexFromExpression} apply. + * + * @param expr The expression to parse. + * @return A list of music indexes. + */ + protected List<MusicIndex> createIndexesFromExpression(String expr) { + List<MusicIndex> result = new ArrayList<MusicIndex>(); + + StringTokenizer tokenizer = new StringTokenizer(expr, " "); + while (tokenizer.hasMoreTokens()) { + MusicIndex index = createIndexFromExpression(tokenizer.nextToken()); + result.add(index); + } + + return result; + } + + private SortedSet<Artist> createArtists(List<MusicFolder> folders, String[] ignoredArticles, String[] shortcuts) throws IOException { + return settingsService.isOrganizeByFolderStructure() ? + createArtistsByFolderStructure(folders, ignoredArticles, shortcuts) : + createArtistsByTagStructure(folders, ignoredArticles, shortcuts); + } + + private SortedSet<Artist> createArtistsByFolderStructure(List<MusicFolder> folders, String[] ignoredArticles, String[] shortcuts) { + SortedMap<String, Artist> artistMap = new TreeMap<String, Artist>(); + Set<String> shortcutSet = new HashSet<String>(Arrays.asList(shortcuts)); + + for (MusicFolder folder : folders) { + + MediaFile root = mediaFileService.getMediaFile(folder.getPath(), true); + List<MediaFile> children = mediaFileService.getChildrenOf(root, false, true, true, true); + for (MediaFile child : children) { + if (shortcutSet.contains(child.getName())) { + continue; + } + + String sortableName = createSortableName(child.getName(), ignoredArticles); + Artist artist = artistMap.get(sortableName); + if (artist == null) { + artist = new Artist(child.getName(), sortableName); + artistMap.put(sortableName, artist); + } + artist.addMediaFile(child); + } + } + + return new TreeSet<Artist>(artistMap.values()); + } + + private SortedSet<Artist> createArtistsByTagStructure(List<MusicFolder> folders, String[] ignoredArticles, String[] shortcuts) { + Set<String> shortcutSet = new HashSet<String>(Arrays.asList(shortcuts)); + SortedSet<Artist> artists = new TreeSet<Artist>(); + + // TODO: Filter by folder + for (String artistName : mediaFileDao.getArtists()) { + + if (shortcutSet.contains(artistName)) { + continue; + } + + String sortableName = createSortableName(artistName, ignoredArticles); + Artist artist = new Artist(artistName, sortableName); + artists.add(artist); + } + + return artists; + } + + private String createSortableName(String name, String[] ignoredArticles) { + String uppercaseName = name.toUpperCase(); + for (String article : ignoredArticles) { + if (uppercaseName.startsWith(article.toUpperCase() + " ")) { + return name.substring(article.length() + 1) + ", " + article; + } + } + return name; + } + + /** + * Returns the music index to which the given artist belongs. + * + * @param artist The artist in question. + * @param indexes List of available indexes. + * @return The music index to which this music file belongs, or {@link MusicIndex#OTHER} if no index applies. + */ + private MusicIndex getIndex(Artist artist, List<MusicIndex> indexes) { + String sortableName = artist.getSortableName().toUpperCase(); + for (MusicIndex index : indexes) { + for (String prefix : index.getPrefixes()) { + if (sortableName.startsWith(prefix.toUpperCase())) { + return index; + } + } + } + return MusicIndex.OTHER; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } + + public void setMediaFileDao(MediaFileDao mediaFileDao) { + this.mediaFileDao = mediaFileDao; + } + + private static class MusicIndexComparator implements Comparator<MusicIndex>, Serializable { + + private List<MusicIndex> indexes; + + public MusicIndexComparator(List<MusicIndex> indexes) { + this.indexes = indexes; + } + + public int compare(MusicIndex a, MusicIndex b) { + int indexA = indexes.indexOf(a); + int indexB = indexes.indexOf(b); + + if (indexA == -1) { + indexA = Integer.MAX_VALUE; + } + if (indexB == -1) { + indexB = Integer.MAX_VALUE; + } + + if (indexA < indexB) { + return -1; + } + if (indexA > indexB) { + return 1; + } + return 0; + } + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/NetworkService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/NetworkService.java new file mode 100644 index 00000000..b54026a0 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/NetworkService.java @@ -0,0 +1,336 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.BasicResponseHandler; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.util.EntityUtils; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.domain.NATPMPRouter; +import net.sourceforge.subsonic.domain.Router; +import net.sourceforge.subsonic.domain.SBBIRouter; +import net.sourceforge.subsonic.domain.WeUPnPRouter; +import net.sourceforge.subsonic.util.StringUtil; +import net.sourceforge.subsonic.util.Util; + +/** + * Provides network-related services, including port forwarding on UPnP routers and + * URL redirection from http://xxxx.subsonic.org. + * + * @author Sindre Mehus + */ +public class NetworkService { + + private static final Logger LOG = Logger.getLogger(NetworkService.class); + private static final long PORT_FORWARDING_DELAY = 3600L; + private static final long URL_REDIRECTION_DELAY = 2 * 3600L; + + private static final String URL_REDIRECTION_REGISTER_URL = getBackendUrl() + "/backend/redirect/register.view"; + private static final String URL_REDIRECTION_UNREGISTER_URL = getBackendUrl() + "/backend/redirect/unregister.view"; + private static final String URL_REDIRECTION_TEST_URL = getBackendUrl() + "/backend/redirect/test.view"; + + private SettingsService settingsService; + private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(4); + private final PortForwardingTask portForwardingTask = new PortForwardingTask(); + private final URLRedirectionTask urlRedirectionTask = new URLRedirectionTask(); + private Future<?> portForwardingFuture; + private Future<?> urlRedirectionFuture; + + private final Status portForwardingStatus = new Status(); + private final Status urlRedirectionStatus = new Status(); + private boolean testUrlRedirection; + + public void init() { + initPortForwarding(); + initUrlRedirection(false); + } + + /** + * Configures UPnP port forwarding. + */ + public synchronized void initPortForwarding() { + portForwardingStatus.setText("Idle"); + if (portForwardingFuture != null) { + portForwardingFuture.cancel(true); + } + portForwardingFuture = executor.scheduleWithFixedDelay(portForwardingTask, 0L, PORT_FORWARDING_DELAY, TimeUnit.SECONDS); + } + + /** + * Configures URL redirection. + * + * @param test Whether to test that the redirection works. + */ + public synchronized void initUrlRedirection(boolean test) { + urlRedirectionStatus.setText("Idle"); + if (urlRedirectionFuture != null) { + urlRedirectionFuture.cancel(true); + } + testUrlRedirection = test; + urlRedirectionFuture = executor.scheduleWithFixedDelay(urlRedirectionTask, 0L, URL_REDIRECTION_DELAY, TimeUnit.SECONDS); + } + + public Status getPortForwardingStatus() { + return portForwardingStatus; + } + + public Status getURLRedirecionStatus() { + return urlRedirectionStatus; + } + + public static String getBackendUrl() { + return "true".equals(System.getProperty("subsonic.test")) ? "http://localhost:8181" : "http://subsonic.org"; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + private class PortForwardingTask extends Task { + + @Override + protected void execute() { + + boolean enabled = settingsService.isPortForwardingEnabled(); + portForwardingStatus.setText("Looking for router..."); + Router router = findRouter(); + if (router == null) { + LOG.warn("No UPnP router found."); + portForwardingStatus.setText("No router found."); + } else { + + portForwardingStatus.setText("Router found."); + + int port = settingsService.getPort(); + int httpsPort = settingsService.getHttpsPort(); + + // Create new NAT entry. + if (enabled) { + try { + router.addPortMapping(port, port, 0); + String message = "Successfully forwarding port " + port; + + if (httpsPort != 0 && httpsPort != port) { + router.addPortMapping(httpsPort, httpsPort, 0); + message += " and port " + httpsPort; + } + message += "."; + + LOG.info(message); + portForwardingStatus.setText(message); + } catch (Throwable x) { + String message = "Failed to create port forwarding."; + LOG.warn(message, x); + portForwardingStatus.setText(message + " See log for details."); + } + } + + // Delete NAT entry. + else { + try { + router.deletePortMapping(port, port); + LOG.info("Deleted port mapping for port " + port); + if (httpsPort != 0 && httpsPort != port) { + router.deletePortMapping(httpsPort, httpsPort); + LOG.info("Deleted port mapping for port " + httpsPort); + } + } catch (Throwable x) { + LOG.warn("Failed to delete port mapping.", x); + } + portForwardingStatus.setText("Port forwarding disabled."); + } + } + + // Don't do it again if disabled. + if (!enabled && portForwardingFuture != null) { + portForwardingFuture.cancel(false); + } + } + + private Router findRouter() { + try { + Router router = SBBIRouter.findRouter(); + if (router != null) { + return router; + } + } catch (Throwable x) { + LOG.warn("Failed to find UPnP router using SBBI library.", x); + } + + try { + Router router = WeUPnPRouter.findRouter(); + if (router != null) { + return router; + } + } catch (Throwable x) { + LOG.warn("Failed to find UPnP router using WeUPnP library.", x); + } + + try { + Router router = NATPMPRouter.findRouter(); + if (router != null) { + return router; + } + } catch (Throwable x) { + LOG.warn("Failed to find NAT-PMP router.", x); + } + + return null; + } + } + + private class URLRedirectionTask extends Task { + + @Override + protected void execute() { + + boolean enable = settingsService.isUrlRedirectionEnabled(); + HttpPost request = new HttpPost(enable ? URL_REDIRECTION_REGISTER_URL : URL_REDIRECTION_UNREGISTER_URL); + + int port = settingsService.getPort(); + boolean trial = !settingsService.isLicenseValid(); + Date trialExpires = settingsService.getUrlRedirectTrialExpires(); + + List<NameValuePair> params = new ArrayList<NameValuePair>(); + params.add(new BasicNameValuePair("serverId", settingsService.getServerId())); + params.add(new BasicNameValuePair("redirectFrom", settingsService.getUrlRedirectFrom())); + params.add(new BasicNameValuePair("port", String.valueOf(port))); + params.add(new BasicNameValuePair("localIp", Util.getLocalIpAddress())); + params.add(new BasicNameValuePair("localPort", String.valueOf(port))); + params.add(new BasicNameValuePair("contextPath", settingsService.getUrlRedirectContextPath())); + params.add(new BasicNameValuePair("trial", String.valueOf(trial))); + if (trial && trialExpires != null) { + params.add(new BasicNameValuePair("trialExpires", String.valueOf(trialExpires.getTime()))); + } else { + params.add(new BasicNameValuePair("licenseHolder", settingsService.getLicenseEmail())); + } + + HttpClient client = new DefaultHttpClient(); + + try { + urlRedirectionStatus.setText(enable ? "Registering web address..." : "Unregistering web address..."); + request.setEntity(new UrlEncodedFormEntity(params, StringUtil.ENCODING_UTF8)); + + HttpResponse response = client.execute(request); + StatusLine status = response.getStatusLine(); + + switch (status.getStatusCode()) { + case HttpStatus.SC_BAD_REQUEST: + urlRedirectionStatus.setText(EntityUtils.toString(response.getEntity())); + break; + case HttpStatus.SC_OK: + urlRedirectionStatus.setText(enable ? "Successfully registered web address." : "Web address disabled."); + break; + default: + throw new IOException(status.getStatusCode() + " " + status.getReasonPhrase()); + } + + } catch (Throwable x) { + LOG.warn(enable ? "Failed to register web address." : "Failed to unregister web address.", x); + urlRedirectionStatus.setText(enable ? ("Failed to register web address. " + x.getMessage() + + " (" + x.getClass().getSimpleName() + ")") : "Web address disabled."); + } finally { + client.getConnectionManager().shutdown(); + } + + // Test redirection, but only once. + if (testUrlRedirection) { + testUrlRedirection = false; + testUrlRedirection(); + } + + // Don't do it again if disabled. + if (!enable && urlRedirectionFuture != null) { + urlRedirectionFuture.cancel(false); + } + } + + private void testUrlRedirection() { + + HttpGet request = new HttpGet(URL_REDIRECTION_TEST_URL + "?redirectFrom=" + settingsService.getUrlRedirectFrom()); + HttpClient client = new DefaultHttpClient(); + HttpConnectionParams.setConnectionTimeout(client.getParams(), 10000); + HttpConnectionParams.setSoTimeout(client.getParams(), 30000); + + try { + urlRedirectionStatus.setText("Testing web address " + settingsService.getUrlRedirectFrom() + ".subsonic.org. Please wait..."); + String response = client.execute(request, new BasicResponseHandler()); + urlRedirectionStatus.setText(response); + + } catch (Throwable x) { + LOG.warn("Failed to test web address.", x); + urlRedirectionStatus.setText("Failed to test web address. " + x.getMessage() + " (" + x.getClass().getSimpleName() + ")"); + } finally { + client.getConnectionManager().shutdown(); + } + } + } + + private abstract class Task implements Runnable { + public void run() { + String name = getClass().getSimpleName(); + try { + execute(); + } catch (Throwable x) { + LOG.error("Error executing " + name + ": " + x.getMessage(), x); + } + } + + protected abstract void execute(); + } + + public static class Status { + + private String text; + private Date date; + + public void setText(String text) { + this.text = text; + date = new Date(); + } + + public String getText() { + return text; + } + + public Date getDate() { + return date; + } + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlayerService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlayerService.java new file mode 100644 index 00000000..0f24b2b8 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlayerService.java @@ -0,0 +1,317 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.dao.PlayerDao; +import net.sourceforge.subsonic.domain.Player; +import net.sourceforge.subsonic.domain.Transcoding; +import net.sourceforge.subsonic.domain.TransferStatus; +import net.sourceforge.subsonic.util.StringUtil; +import org.apache.commons.lang.StringUtils; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Provides services for maintaining the set of players. + * + * @author Sindre Mehus + * @see Player + */ +public class PlayerService { + + private static final String COOKIE_NAME = "player"; + private static final int COOKIE_EXPIRY = 365 * 24 * 3600; // One year + + private PlayerDao playerDao; + private StatusService statusService; + private SecurityService securityService; + private TranscodingService transcodingService; + + public void init() { + playerDao.deleteOldPlayers(60); + } + + /** + * Equivalent to <code>getPlayer(request, response, true)</code> . + */ + public Player getPlayer(HttpServletRequest request, HttpServletResponse response) { + return getPlayer(request, response, true, false); + } + + /** + * Returns the player associated with the given HTTP request. If no such player exists, a new + * one is created. + * + * @param request The HTTP request. + * @param response The HTTP response. + * @param remoteControlEnabled Whether this method should return a remote-controlled player. + * @param isStreamRequest Whether the HTTP request is a request for streaming data. + * @return The player associated with the given HTTP request. + */ + public synchronized Player getPlayer(HttpServletRequest request, HttpServletResponse response, + boolean remoteControlEnabled, boolean isStreamRequest) { + + // Find by 'player' request parameter. + Player player = getPlayerById(request.getParameter("player")); + + // Find in session context. + if (player == null && remoteControlEnabled) { + String playerId = (String) request.getSession().getAttribute("player"); + if (playerId != null) { + player = getPlayerById(playerId); + } + } + + // Find by cookie. + String username = securityService.getCurrentUsername(request); + if (player == null && remoteControlEnabled) { + player = getPlayerById(getPlayerIdFromCookie(request, username)); + } + + // Make sure we're not hijacking the player of another user. + if (player != null && player.getUsername() != null && username != null && !player.getUsername().equals(username)) { + player = null; + } + + // Look for player with same IP address and user name. + if (player == null) { + player = getPlayerByIpAddressAndUsername(request.getRemoteAddr(), username); + + // Don't use this player if it's used by REST API. + if (player != null && player.getClientId() != null) { + player = null; + } + } + + // If no player was found, create it. + if (player == null) { + player = new Player(); + createPlayer(player); +// LOG.debug("Created player " + player.getId() + " (remoteControlEnabled: " + remoteControlEnabled + +// ", isStreamRequest: " + isStreamRequest + ", username: " + username + +// ", ip: " + request.getRemoteAddr() + ")."); + } + + // Update player data. + boolean isUpdate = false; + if (username != null && player.getUsername() == null) { + player.setUsername(username); + isUpdate = true; + } + if (player.getIpAddress() == null || isStreamRequest || + (!isPlayerConnected(player) && player.isDynamicIp() && !request.getRemoteAddr().equals(player.getIpAddress()))) { + player.setIpAddress(request.getRemoteAddr()); + isUpdate = true; + } + String userAgent = request.getHeader("user-agent"); + if (isStreamRequest) { + player.setType(userAgent); + player.setLastSeen(new Date()); + isUpdate = true; + } + + if (isUpdate) { + updatePlayer(player); + } + + // Set cookie in response. + if (response != null) { + String cookieName = COOKIE_NAME + "-" + StringUtil.utf8HexEncode(username); + Cookie cookie = new Cookie(cookieName, player.getId()); + cookie.setMaxAge(COOKIE_EXPIRY); + String path = request.getContextPath(); + if (StringUtils.isEmpty(path)) { + path = "/"; + } + cookie.setPath(path); + response.addCookie(cookie); + } + + // Save player in session context. + if (remoteControlEnabled) { + request.getSession().setAttribute("player", player.getId()); + } + + return player; + } + + /** + * Updates the given player. + * + * @param player The player to update. + */ + public void updatePlayer(Player player) { + playerDao.updatePlayer(player); + } + + /** + * Returns the player with the given ID. + * + * @param id The unique player ID. + * @return The player with the given ID, or <code>null</code> if no such player exists. + */ + public Player getPlayerById(String id) { + return playerDao.getPlayerById(id); + } + + /** + * Returns whether the given player is connected. + * + * @param player The player in question. + * @return Whether the player is connected. + */ + private boolean isPlayerConnected(Player player) { + for (TransferStatus status : statusService.getStreamStatusesForPlayer(player)) { + if (status.isActive()) { + return true; + } + } + return false; + } + + /** + * Returns the player with the given IP address and username. If no username is given, only IP address is + * used as search criteria. + * + * @param ipAddress The IP address. + * @param username The remote user. + * @return The player with the given IP address, or <code>null</code> if no such player exists. + */ + private Player getPlayerByIpAddressAndUsername(final String ipAddress, final String username) { + if (ipAddress == null) { + return null; + } + for (Player player : getAllPlayers()) { + boolean ipMatches = ipAddress.equals(player.getIpAddress()); + boolean userMatches = username == null || username.equals(player.getUsername()); + if (ipMatches && userMatches) { + return player; + } + } + return null; + } + + /** + * Reads the player ID from the cookie in the HTTP request. + * + * @param request The HTTP request. + * @param username The name of the current user. + * @return The player ID embedded in the cookie, or <code>null</code> if cookie is not present. + */ + private String getPlayerIdFromCookie(HttpServletRequest request, String username) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + String cookieName = COOKIE_NAME + "-" + StringUtil.utf8HexEncode(username); + for (Cookie cookie : cookies) { + if (cookieName.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + /** + * Returns all players owned by the given username and client ID. + * + * @param username The name of the user. + * @param clientId The third-party client ID (used if this player is managed over the + * Subsonic REST API). May be <code>null</code>. + * @return All relevant players. + */ + public List<Player> getPlayersForUserAndClientId(String username, String clientId) { + return playerDao.getPlayersForUserAndClientId(username, clientId); + } + + /** + * Returns all currently registered players. + * + * @return All currently registered players. + */ + public List<Player> getAllPlayers() { + return playerDao.getAllPlayers(); + } + + /** + * Removes the player with the given ID. + * + * @param id The unique player ID. + */ + public synchronized void removePlayerById(String id) { + playerDao.deletePlayer(id); + } + + /** + * Creates and returns a clone of the given player. + * + * @param playerId The ID of the player to clone. + * @return The cloned player. + */ + public Player clonePlayer(String playerId) { + Player player = getPlayerById(playerId); + if (player.getName() != null) { + player.setName(player.getName() + " (copy)"); + } + + createPlayer(player); + return player; + } + + /** + * Creates the given player, and activates all transcodings. + * + * @param player The player to create. + */ + public void createPlayer(Player player) { + playerDao.createPlayer(player); + + List<Transcoding> transcodings = transcodingService.getAllTranscodings(); + List<Transcoding> defaultActiveTranscodings = new ArrayList<Transcoding>(); + for (Transcoding transcoding : transcodings) { + if (transcoding.isDefaultActive()) { + defaultActiveTranscodings.add(transcoding); + } + } + + transcodingService.setTranscodingsForPlayer(player, defaultActiveTranscodings); + } + + public void setStatusService(StatusService statusService) { + this.statusService = statusService; + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setPlayerDao(PlayerDao playerDao) { + this.playerDao = playerDao; + } + + public void setTranscodingService(TranscodingService transcodingService) { + this.transcodingService = transcodingService; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlaylistService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlaylistService.java new file mode 100644 index 00000000..6208c3dc --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlaylistService.java @@ -0,0 +1,426 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import net.sourceforge.subsonic.domain.User; +import net.sourceforge.subsonic.util.Pair; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringEscapeUtils; +import org.jdom.Document; +import org.jdom.Element; +import org.jdom.JDOMException; +import org.jdom.Namespace; +import org.jdom.input.SAXBuilder; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.MediaFileDao; +import net.sourceforge.subsonic.dao.PlaylistDao; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.Playlist; +import net.sourceforge.subsonic.util.StringUtil; + +/** + * Provides services for loading and saving playlists to and from persistent storage. + * + * @author Sindre Mehus + * @see net.sourceforge.subsonic.domain.PlayQueue + */ +public class PlaylistService { + + private static final Logger LOG = Logger.getLogger(PlaylistService.class); + private MediaFileService mediaFileService; + private MediaFileDao mediaFileDao; + private PlaylistDao playlistDao; + private SecurityService securityService; + private SettingsService settingsService; + + public void init() { + try { + importPlaylists(); + } catch (Throwable x) { + LOG.warn("Failed to import playlists: " + x, x); + } + } + + public List<Playlist> getReadablePlaylistsForUser(String username) { + return playlistDao.getReadablePlaylistsForUser(username); + } + + public List<Playlist> getWritablePlaylistsForUser(String username) { + + // Admin users are allowed to modify all playlists that are visible to them. + if (securityService.isAdmin(username)) { + return getReadablePlaylistsForUser(username); + } + + return playlistDao.getWritablePlaylistsForUser(username); + } + + public Playlist getPlaylist(int id) { + return playlistDao.getPlaylist(id); + } + + public List<String> getPlaylistUsers(int playlistId) { + return playlistDao.getPlaylistUsers(playlistId); + } + + public List<MediaFile> getFilesInPlaylist(int id) { + return mediaFileDao.getFilesInPlaylist(id); + } + + public void setFilesInPlaylist(int id, List<MediaFile> files) { + playlistDao.setFilesInPlaylist(id, files); + } + + public void createPlaylist(Playlist playlist) { + playlistDao.createPlaylist(playlist); + } + + public void addPlaylistUser(int playlistId, String username) { + playlistDao.addPlaylistUser(playlistId, username); + } + + public void deletePlaylistUser(int playlistId, String username) { + playlistDao.deletePlaylistUser(playlistId, username); + } + + public boolean isReadAllowed(Playlist playlist, String username) { + if (username == null) { + return false; + } + if (username.equals(playlist.getUsername()) || playlist.isPublic()) { + return true; + } + return playlistDao.getPlaylistUsers(playlist.getId()).contains(username); + } + + public boolean isWriteAllowed(Playlist playlist, String username) { + return username != null && username.equals(playlist.getUsername()); + } + + public void deletePlaylist(int id) { + playlistDao.deletePlaylist(id); + } + + public void updatePlaylist(Playlist playlist) { + playlistDao.updatePlaylist(playlist); + } + + public Playlist importPlaylist(String username, String playlistName, String fileName, String format, InputStream inputStream) throws Exception { + PlaylistFormat playlistFormat = PlaylistFormat.getPlaylistFormat(format); + if (playlistFormat == null) { + throw new Exception("Unsupported playlist format: " + format); + } + + Pair<List<MediaFile>, List<String>> result = parseFiles(IOUtils.toByteArray(inputStream), playlistFormat); + if (result.getFirst().isEmpty() && !result.getSecond().isEmpty()) { + throw new Exception("No songs in the playlist were found."); + } + + for (String error : result.getSecond()) { + LOG.warn("File in playlist '" + fileName + "' not found: " + error); + } + + Date now = new Date(); + Playlist playlist = new Playlist(); + playlist.setUsername(username); + playlist.setCreated(now); + playlist.setChanged(now); + playlist.setPublic(true); + playlist.setName(playlistName); + playlist.setImportedFrom(fileName); + + createPlaylist(playlist); + setFilesInPlaylist(playlist.getId(), result.getFirst()); + + return playlist; + } + + private Pair<List<MediaFile>, List<String>> parseFiles(byte[] playlist, PlaylistFormat playlistFormat) throws IOException { + Pair<List<MediaFile>, List<String>> result = null; + + // Try with multiple encodings; use the one that finds the most files. + String[] encodings = {StringUtil.ENCODING_LATIN, StringUtil.ENCODING_UTF8, Charset.defaultCharset().name()}; + for (String encoding : encodings) { + Pair<List<MediaFile>, List<String>> files = parseFilesWithEncoding(playlist, playlistFormat, encoding); + if (result == null || result.getFirst().size() < files.getFirst().size()) { + result = files; + } + } + return result; + } + + private Pair<List<MediaFile>, List<String>> parseFilesWithEncoding(byte[] playlist, PlaylistFormat playlistFormat, String encoding) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(playlist), encoding)); + return playlistFormat.parse(reader, mediaFileService); + } + + public void exportPlaylist(int id, OutputStream out) throws Exception { + PrintWriter writer = new PrintWriter(new OutputStreamWriter(out, StringUtil.ENCODING_UTF8)); + new M3UFormat().format(getFilesInPlaylist(id), writer); + } + + /** + * Implementation of M3U playlist format. + */ + private void importPlaylists() throws Exception { + String playlistFolderPath = settingsService.getPlaylistFolder(); + if (playlistFolderPath == null) { + return; + } + File playlistFolder = new File(playlistFolderPath); + if (!playlistFolder.exists()) { + return; + } + + List<Playlist> allPlaylists = playlistDao.getAllPlaylists(); + for (File file : playlistFolder.listFiles()) { + try { + importPlaylistIfNotExisting(file, allPlaylists); + } catch (Exception x) { + LOG.warn("Failed to auto-import playlist " + file + ". " + x.getMessage()); + } + } + } + + private void importPlaylistIfNotExisting(File file, List<Playlist> allPlaylists) throws Exception { + String format = FilenameUtils.getExtension(file.getPath()); + if (PlaylistFormat.getPlaylistFormat(format) == null) { + return; + } + + String fileName = file.getName(); + for (Playlist playlist : allPlaylists) { + if (fileName.equals(playlist.getImportedFrom())) { + return; // Already imported. + } + } + InputStream in = new FileInputStream(file); + try { + importPlaylist(User.USERNAME_ADMIN, FilenameUtils.getBaseName(fileName), fileName, format, in); + LOG.info("Auto-imported playlist " + file); + } finally { + IOUtils.closeQuietly(in); + } + } + + public void setPlaylistDao(PlaylistDao playlistDao) { + this.playlistDao = playlistDao; + } + + public void setMediaFileDao(MediaFileDao mediaFileDao) { + this.mediaFileDao = mediaFileDao; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + /** + * Abstract superclass for playlist formats. + */ + + private abstract static class PlaylistFormat { + public abstract Pair<List<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException; + + public abstract void format(List<MediaFile> files, PrintWriter writer) throws IOException; + + public static PlaylistFormat getPlaylistFormat(String format) { + if (format == null) { + return null; + } + if (format.equalsIgnoreCase("m3u") || format.equalsIgnoreCase("m3u8")) { + return new M3UFormat(); + } + if (format.equalsIgnoreCase("pls")) { + return new PLSFormat(); + } + if (format.equalsIgnoreCase("xspf")) { + return new XSPFFormat(); + } + return null; + } + + protected MediaFile getMediaFile(MediaFileService mediaFileService, String path) { + try { + MediaFile file = mediaFileService.getMediaFile(path); + if (file != null && file.exists()) { + return file; + } + } catch (SecurityException x) { + // Ignored + } + return null; + } + } + + private static class M3UFormat extends PlaylistFormat { + public Pair<List<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException { + List<MediaFile> ok = new ArrayList<MediaFile>(); + List<String> error = new ArrayList<String>(); + String line = reader.readLine(); + while (line != null) { + if (!line.startsWith("#")) { + MediaFile file = getMediaFile(mediaFileService, line); + if (file != null) { + ok.add(file); + } else { + error.add(line); + } + } + line = reader.readLine(); + } + return new Pair<List<MediaFile>, List<String>>(ok, error); + } + + public void format(List<MediaFile> files, PrintWriter writer) throws IOException { + writer.println("#EXTM3U"); + for (MediaFile file : files) { + writer.println(file.getPath()); + } + if (writer.checkError()) { + throw new IOException("Error when writing playlist"); + } + } + } + + /** + * Implementation of PLS playlist format. + */ + private static class PLSFormat extends PlaylistFormat { + public Pair<List<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException { + List<MediaFile> ok = new ArrayList<MediaFile>(); + List<String> error = new ArrayList<String>(); + + Pattern pattern = Pattern.compile("^File\\d+=(.*)$"); + String line = reader.readLine(); + while (line != null) { + + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + String path = matcher.group(1); + MediaFile file = getMediaFile(mediaFileService, path); + if (file != null) { + ok.add(file); + } else { + error.add(path); + } + } + line = reader.readLine(); + } + return new Pair<List<MediaFile>, List<String>>(ok, error); + } + + public void format(List<MediaFile> files, PrintWriter writer) throws IOException { + writer.println("[playlist]"); + int counter = 0; + + for (MediaFile file : files) { + counter++; + writer.println("File" + counter + '=' + file.getPath()); + } + writer.println("NumberOfEntries=" + counter); + writer.println("Version=2"); + + if (writer.checkError()) { + throw new IOException("Error when writing playlist."); + } + } + } + + /** + * Implementation of XSPF (http://www.xspf.org/) playlist format. + */ + private static class XSPFFormat extends PlaylistFormat { + public Pair<List<MediaFile>, List<String>> parse(BufferedReader reader, MediaFileService mediaFileService) throws IOException { + List<MediaFile> ok = new ArrayList<MediaFile>(); + List<String> error = new ArrayList<String>(); + + SAXBuilder builder = new SAXBuilder(); + Document document; + try { + document = builder.build(reader); + } catch (JDOMException x) { + LOG.warn("Failed to parse XSPF playlist.", x); + throw new IOException("Failed to parse XSPF playlist."); + } + + Element root = document.getRootElement(); + Namespace ns = root.getNamespace(); + Element trackList = root.getChild("trackList", ns); + List<?> tracks = trackList.getChildren("track", ns); + + for (Object obj : tracks) { + Element track = (Element) obj; + String location = track.getChildText("location", ns); + if (location != null && location.startsWith("file://")) { + location = location.replaceFirst("file://", ""); + MediaFile file = getMediaFile(mediaFileService, location); + if (file != null) { + ok.add(file); + } else { + error.add(location); + } + } + } + return new Pair<List<MediaFile>, List<String>>(ok, error); + } + + public void format(List<MediaFile> files, PrintWriter writer) throws IOException { + writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); + writer.println("<playlist version=\"1\" xmlns=\"http://xspf.org/ns/0/\">"); + writer.println(" <trackList>"); + + for (MediaFile file : files) { + writer.println(" <track><location>file://" + StringEscapeUtils.escapeXml(file.getPath()) + "</location></track>"); + } + writer.println(" </trackList>"); + writer.println("</playlist>"); + + if (writer.checkError()) { + throw new IOException("Error when writing playlist."); + } + } + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java new file mode 100644 index 00000000..09184df6 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java @@ -0,0 +1,599 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.PodcastDao; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.PodcastChannel; +import net.sourceforge.subsonic.domain.PodcastEpisode; +import net.sourceforge.subsonic.domain.PodcastStatus; +import net.sourceforge.subsonic.util.StringUtil; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.params.HttpConnectionParams; +import org.jdom.Document; +import org.jdom.Element; +import org.jdom.Namespace; +import org.jdom.input.SAXBuilder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Provides services for Podcast reception. + * + * @author Sindre Mehus + */ +public class PodcastService { + + private static final Logger LOG = Logger.getLogger(PodcastService.class); + private static final DateFormat[] RSS_DATE_FORMATS = {new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US), + new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z", Locale.US)}; + + private static final Namespace[] ITUNES_NAMESPACES = {Namespace.getNamespace("http://www.itunes.com/DTDs/Podcast-1.0.dtd"), + Namespace.getNamespace("http://www.itunes.com/dtds/podcast-1.0.dtd")}; + + private final ExecutorService refreshExecutor; + private final ExecutorService downloadExecutor; + private final ScheduledExecutorService scheduledExecutor; + private ScheduledFuture<?> scheduledRefresh; + private PodcastDao podcastDao; + private SettingsService settingsService; + private SecurityService securityService; + private MediaFileService mediaFileService; + + public PodcastService() { + ThreadFactory threadFactory = new ThreadFactory() { + public Thread newThread(Runnable r) { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + return t; + } + }; + refreshExecutor = Executors.newFixedThreadPool(5, threadFactory); + downloadExecutor = Executors.newFixedThreadPool(3, threadFactory); + scheduledExecutor = Executors.newSingleThreadScheduledExecutor(threadFactory); + } + + public synchronized void init() { + // Clean up partial downloads. + for (PodcastChannel channel : getAllChannels()) { + for (PodcastEpisode episode : getEpisodes(channel.getId(), false)) { + if (episode.getStatus() == PodcastStatus.DOWNLOADING) { + deleteEpisode(episode.getId(), false); + LOG.info("Deleted Podcast episode '" + episode.getTitle() + "' since download was interrupted."); + } + } + } + + schedule(); + } + + public synchronized void schedule() { + Runnable task = new Runnable() { + public void run() { + LOG.info("Starting scheduled Podcast refresh."); + refreshAllChannels(true); + LOG.info("Completed scheduled Podcast refresh."); + } + }; + + if (scheduledRefresh != null) { + scheduledRefresh.cancel(true); + } + + int hoursBetween = settingsService.getPodcastUpdateInterval(); + + if (hoursBetween == -1) { + LOG.info("Automatic Podcast update disabled."); + return; + } + + long periodMillis = hoursBetween * 60L * 60L * 1000L; + long initialDelayMillis = 5L * 60L * 1000L; + + scheduledRefresh = scheduledExecutor.scheduleAtFixedRate(task, initialDelayMillis, periodMillis, TimeUnit.MILLISECONDS); + Date firstTime = new Date(System.currentTimeMillis() + initialDelayMillis); + LOG.info("Automatic Podcast update scheduled to run every " + hoursBetween + " hour(s), starting at " + firstTime); + } + + /** + * Creates a new Podcast channel. + * + * @param url The URL of the Podcast channel. + */ + public void createChannel(String url) { + url = sanitizeUrl(url); + PodcastChannel channel = new PodcastChannel(url); + int channelId = podcastDao.createChannel(channel); + + refreshChannels(Arrays.asList(getChannel(channelId)), true); + } + + private String sanitizeUrl(String url) { + return url.replace(" ", "%20"); + } + + private PodcastChannel getChannel(int channelId) { + for (PodcastChannel channel : getAllChannels()) { + if (channelId == channel.getId()) { + return channel; + } + } + return null; + } + + /** + * Returns all Podcast channels. + * + * @return Possibly empty list of all Podcast channels. + */ + public List<PodcastChannel> getAllChannels() { + return podcastDao.getAllChannels(); + } + + /** + * Returns all Podcast episodes for a given channel. + * + * @param channelId The Podcast channel ID. + * @param includeDeleted Whether to include logically deleted episodes in the result. + * @return Possibly empty list of all Podcast episodes for the given channel, sorted in + * reverse chronological order (newest episode first). + */ + public List<PodcastEpisode> getEpisodes(int channelId, boolean includeDeleted) { + List<PodcastEpisode> all = podcastDao.getEpisodes(channelId); + addMediaFileIdToEpisodes(all); + if (includeDeleted) { + return all; + } + + List<PodcastEpisode> filtered = new ArrayList<PodcastEpisode>(); + for (PodcastEpisode episode : all) { + if (episode.getStatus() != PodcastStatus.DELETED) { + filtered.add(episode); + } + } + return filtered; + } + + public PodcastEpisode getEpisode(int episodeId, boolean includeDeleted) { + PodcastEpisode episode = podcastDao.getEpisode(episodeId); + if (episode == null) { + return null; + } + if (episode.getStatus() == PodcastStatus.DELETED && !includeDeleted) { + return null; + } + addMediaFileIdToEpisodes(Arrays.asList(episode)); + return episode; + } + + private void addMediaFileIdToEpisodes(List<PodcastEpisode> episodes) { + for (PodcastEpisode episode : episodes) { + if (episode.getPath() != null) { + MediaFile mediaFile = mediaFileService.getMediaFile(episode.getPath()); + if (mediaFile != null) { + episode.setMediaFileId(mediaFile.getId()); + } + } + } + } + + private PodcastEpisode getEpisode(int channelId, String url) { + if (url == null) { + return null; + } + + for (PodcastEpisode episode : getEpisodes(channelId, true)) { + if (url.equals(episode.getUrl())) { + return episode; + } + } + return null; + } + + public void refreshAllChannels(boolean downloadEpisodes) { + refreshChannels(getAllChannels(), downloadEpisodes); + } + + private void refreshChannels(final List<PodcastChannel> channels, final boolean downloadEpisodes) { + for (final PodcastChannel channel : channels) { + Runnable task = new Runnable() { + public void run() { + doRefreshChannel(channel, downloadEpisodes); + } + }; + refreshExecutor.submit(task); + } + } + + @SuppressWarnings({"unchecked"}) + private void doRefreshChannel(PodcastChannel channel, boolean downloadEpisodes) { + InputStream in = null; + HttpClient client = new DefaultHttpClient(); + + try { + channel.setStatus(PodcastStatus.DOWNLOADING); + channel.setErrorMessage(null); + podcastDao.updateChannel(channel); + + HttpConnectionParams.setConnectionTimeout(client.getParams(), 2 * 60 * 1000); // 2 minutes + HttpConnectionParams.setSoTimeout(client.getParams(), 10 * 60 * 1000); // 10 minutes + HttpGet method = new HttpGet(channel.getUrl()); + + HttpResponse response = client.execute(method); + in = response.getEntity().getContent(); + + Document document = new SAXBuilder().build(in); + Element channelElement = document.getRootElement().getChild("channel"); + + channel.setTitle(channelElement.getChildTextTrim("title")); + channel.setDescription(channelElement.getChildTextTrim("description")); + channel.setStatus(PodcastStatus.COMPLETED); + channel.setErrorMessage(null); + podcastDao.updateChannel(channel); + + refreshEpisodes(channel, channelElement.getChildren("item")); + + } catch (Exception x) { + LOG.warn("Failed to get/parse RSS file for Podcast channel " + channel.getUrl(), x); + channel.setStatus(PodcastStatus.ERROR); + channel.setErrorMessage(x.toString()); + podcastDao.updateChannel(channel); + } finally { + IOUtils.closeQuietly(in); + client.getConnectionManager().shutdown(); + } + + if (downloadEpisodes) { + for (final PodcastEpisode episode : getEpisodes(channel.getId(), false)) { + if (episode.getStatus() == PodcastStatus.NEW && episode.getUrl() != null) { + downloadEpisode(episode); + } + } + } + } + + public void downloadEpisode(final PodcastEpisode episode) { + Runnable task = new Runnable() { + public void run() { + doDownloadEpisode(episode); + } + }; + downloadExecutor.submit(task); + } + + private void refreshEpisodes(PodcastChannel channel, List<Element> episodeElements) { + + List<PodcastEpisode> episodes = new ArrayList<PodcastEpisode>(); + + for (Element episodeElement : episodeElements) { + + String title = episodeElement.getChildTextTrim("title"); + String duration = getITunesElement(episodeElement, "duration"); + String description = episodeElement.getChildTextTrim("description"); + if (StringUtils.isBlank(description)) { + description = getITunesElement(episodeElement, "summary"); + } + + Element enclosure = episodeElement.getChild("enclosure"); + if (enclosure == null) { + LOG.debug("No enclosure found for episode " + title); + continue; + } + + String url = enclosure.getAttributeValue("url"); + url = sanitizeUrl(url); + if (url == null) { + LOG.debug("No enclosure URL found for episode " + title); + continue; + } + + if (getEpisode(channel.getId(), url) == null) { + Long length = null; + try { + length = new Long(enclosure.getAttributeValue("length")); + } catch (Exception x) { + LOG.warn("Failed to parse enclosure length.", x); + } + + Date date = parseDate(episodeElement.getChildTextTrim("pubDate")); + PodcastEpisode episode = new PodcastEpisode(null, channel.getId(), url, null, title, description, date, + duration, length, 0L, PodcastStatus.NEW, null); + episodes.add(episode); + LOG.info("Created Podcast episode " + title); + } + } + + // Sort episode in reverse chronological order (newest first) + Collections.sort(episodes, new Comparator<PodcastEpisode>() { + public int compare(PodcastEpisode a, PodcastEpisode b) { + long timeA = a.getPublishDate() == null ? 0L : a.getPublishDate().getTime(); + long timeB = b.getPublishDate() == null ? 0L : b.getPublishDate().getTime(); + + if (timeA < timeB) { + return 1; + } + if (timeA > timeB) { + return -1; + } + return 0; + } + }); + + // Create episodes in database, skipping the proper number of episodes. + int downloadCount = settingsService.getPodcastEpisodeDownloadCount(); + if (downloadCount == -1) { + downloadCount = Integer.MAX_VALUE; + } + + for (int i = 0; i < episodes.size(); i++) { + PodcastEpisode episode = episodes.get(i); + if (i >= downloadCount) { + episode.setStatus(PodcastStatus.SKIPPED); + } + podcastDao.createEpisode(episode); + } + } + + private Date parseDate(String s) { + for (DateFormat dateFormat : RSS_DATE_FORMATS) { + try { + return dateFormat.parse(s); + } catch (Exception x) { + // Ignored. + } + } + LOG.warn("Failed to parse publish date: '" + s + "'."); + return null; + } + + private String getITunesElement(Element element, String childName) { + for (Namespace ns : ITUNES_NAMESPACES) { + String value = element.getChildTextTrim(childName, ns); + if (value != null) { + return value; + } + } + return null; + } + + private void doDownloadEpisode(PodcastEpisode episode) { + InputStream in = null; + OutputStream out = null; + + if (getEpisode(episode.getId(), false) == null) { + LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download."); + return; + } + + LOG.info("Starting to download Podcast from " + episode.getUrl()); + + HttpClient client = new DefaultHttpClient(); + try { + PodcastChannel channel = getChannel(episode.getChannelId()); + + HttpConnectionParams.setConnectionTimeout(client.getParams(), 2 * 60 * 1000); // 2 minutes + HttpConnectionParams.setSoTimeout(client.getParams(), 10 * 60 * 1000); // 10 minutes + HttpGet method = new HttpGet(episode.getUrl()); + + HttpResponse response = client.execute(method); + in = response.getEntity().getContent(); + + File file = getFile(channel, episode); + out = new FileOutputStream(file); + + episode.setStatus(PodcastStatus.DOWNLOADING); + episode.setBytesDownloaded(0L); + episode.setErrorMessage(null); + episode.setPath(file.getPath()); + podcastDao.updateEpisode(episode); + + byte[] buffer = new byte[4096]; + long bytesDownloaded = 0; + int n; + long nextLogCount = 30000L; + + while ((n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + bytesDownloaded += n; + + if (bytesDownloaded > nextLogCount) { + episode.setBytesDownloaded(bytesDownloaded); + nextLogCount += 30000L; + if (getEpisode(episode.getId(), false) == null) { + break; + } + podcastDao.updateEpisode(episode); + } + } + + if (getEpisode(episode.getId(), false) == null) { + LOG.info("Podcast " + episode.getUrl() + " was deleted. Aborting download."); + IOUtils.closeQuietly(out); + file.delete(); + } else { + episode.setBytesDownloaded(bytesDownloaded); + podcastDao.updateEpisode(episode); + LOG.info("Downloaded " + bytesDownloaded + " bytes from Podcast " + episode.getUrl()); + IOUtils.closeQuietly(out); + episode.setStatus(PodcastStatus.COMPLETED); + podcastDao.updateEpisode(episode); + deleteObsoleteEpisodes(channel); + } + + } catch (Exception x) { + LOG.warn("Failed to download Podcast from " + episode.getUrl(), x); + episode.setStatus(PodcastStatus.ERROR); + episode.setErrorMessage(x.toString()); + podcastDao.updateEpisode(episode); + } finally { + IOUtils.closeQuietly(in); + IOUtils.closeQuietly(out); + client.getConnectionManager().shutdown(); + } + } + + private synchronized void deleteObsoleteEpisodes(PodcastChannel channel) { + int episodeCount = settingsService.getPodcastEpisodeRetentionCount(); + if (episodeCount == -1) { + return; + } + + List<PodcastEpisode> episodes = getEpisodes(channel.getId(), false); + + // Don't do anything if other episodes of the same channel is currently downloading. + for (PodcastEpisode episode : episodes) { + if (episode.getStatus() == PodcastStatus.DOWNLOADING) { + return; + } + } + + // Reverse array to get chronological order (oldest episodes first). + Collections.reverse(episodes); + + int episodesToDelete = Math.max(0, episodes.size() - episodeCount); + for (int i = 0; i < episodesToDelete; i++) { + deleteEpisode(episodes.get(i).getId(), true); + LOG.info("Deleted old Podcast episode " + episodes.get(i).getUrl()); + } + } + + private synchronized File getFile(PodcastChannel channel, PodcastEpisode episode) { + + File podcastDir = new File(settingsService.getPodcastFolder()); + File channelDir = new File(podcastDir, StringUtil.fileSystemSafe(channel.getTitle())); + + if (!channelDir.exists()) { + boolean ok = channelDir.mkdirs(); + if (!ok) { + throw new RuntimeException("Failed to create directory " + channelDir); + } + + MediaFile mediaFile = mediaFileService.getMediaFile(channelDir); + mediaFile.setComment(channel.getDescription()); + mediaFileService.updateMediaFile(mediaFile); + } + + String filename = StringUtil.getUrlFile(episode.getUrl()); + if (filename == null) { + filename = episode.getTitle(); + } + filename = StringUtil.fileSystemSafe(filename); + String extension = FilenameUtils.getExtension(filename); + filename = FilenameUtils.removeExtension(filename); + if (StringUtils.isBlank(extension)) { + extension = "mp3"; + } + + File file = new File(channelDir, filename + "." + extension); + for (int i = 0; file.exists(); i++) { + file = new File(channelDir, filename + i + "." + extension); + } + + if (!securityService.isWriteAllowed(file)) { + throw new SecurityException("Access denied to file " + file); + } + return file; + } + + /** + * Deletes the Podcast channel with the given ID. + * + * @param channelId The Podcast channel ID. + */ + public void deleteChannel(int channelId) { + // Delete all associated episodes (in case they have files that need to be deleted). + List<PodcastEpisode> episodes = getEpisodes(channelId, false); + for (PodcastEpisode episode : episodes) { + deleteEpisode(episode.getId(), false); + } + podcastDao.deleteChannel(channelId); + } + + /** + * Deletes the Podcast episode with the given ID. + * + * @param episodeId The Podcast episode ID. + * @param logicalDelete Whether to perform a logical delete by setting the + * episode status to {@link PodcastStatus#DELETED}. + */ + public void deleteEpisode(int episodeId, boolean logicalDelete) { + PodcastEpisode episode = podcastDao.getEpisode(episodeId); + if (episode == null) { + return; + } + + // Delete file. + if (episode.getPath() != null) { + File file = new File(episode.getPath()); + if (file.exists()) { + file.delete(); + // TODO: Delete directory if empty? + } + } + + if (logicalDelete) { + episode.setStatus(PodcastStatus.DELETED); + episode.setErrorMessage(null); + podcastDao.updateEpisode(episode); + } else { + podcastDao.deleteEpisode(episodeId); + } + } + + public void setPodcastDao(PodcastDao podcastDao) { + this.podcastDao = podcastDao; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/RatingService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/RatingService.java new file mode 100644 index 00000000..6208faf2 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/RatingService.java @@ -0,0 +1,101 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.dao.*; +import net.sourceforge.subsonic.domain.*; +import net.sourceforge.subsonic.util.FileUtil; + +import java.util.*; +import java.io.File; + +/** + * Provides services for user ratings. + * + * @author Sindre Mehus + */ +public class RatingService { + + private RatingDao ratingDao; + private SecurityService securityService; + private MediaFileService mediaFileService; + + /** + * Returns the highest rated music files. + * + * @param offset Number of files to skip. + * @param count Maximum number of files to return. + * @return The highest rated music files. + */ + public List<MediaFile> getHighestRated(int offset, int count) { + List<String> highestRated = ratingDao.getHighestRated(offset, count); + List<MediaFile> result = new ArrayList<MediaFile>(); + for (String path : highestRated) { + File file = new File(path); + if (FileUtil.exists(file) && securityService.isReadAllowed(file)) { + result.add(mediaFileService.getMediaFile(path)); + } + } + return result; + } + + /** + * Sets the rating for a music file and a given user. + * + * @param username The user name. + * @param mediaFile The music file. + * @param rating The rating between 1 and 5, or <code>null</code> to remove the rating. + */ + public void setRatingForUser(String username, MediaFile mediaFile, Integer rating) { + ratingDao.setRatingForUser(username, mediaFile, rating); + } + + /** + * Returns the average rating for the given music file. + * + * @param mediaFile The music file. + * @return The average rating, or <code>null</code> if no ratings are set. + */ + public Double getAverageRating(MediaFile mediaFile) { + return ratingDao.getAverageRating(mediaFile); + } + + /** + * Returns the rating for the given user and music file. + * + * @param username The user name. + * @param mediaFile The music file. + * @return The rating, or <code>null</code> if no rating is set. + */ + public Integer getRatingForUser(String username, MediaFile mediaFile) { + return ratingDao.getRatingForUser(username, mediaFile); + } + + public void setRatingDao(RatingDao ratingDao) { + this.ratingDao = ratingDao; + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SearchService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SearchService.java new file mode 100644 index 00000000..2698bbd6 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SearchService.java @@ -0,0 +1,567 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.AlbumDao; +import net.sourceforge.subsonic.dao.ArtistDao; +import net.sourceforge.subsonic.domain.Album; +import net.sourceforge.subsonic.domain.Artist; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.domain.RandomSearchCriteria; +import net.sourceforge.subsonic.domain.SearchCriteria; +import net.sourceforge.subsonic.domain.SearchResult; +import net.sourceforge.subsonic.util.FileUtil; +import org.apache.lucene.analysis.ASCIIFoldingFilter; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.LowerCaseFilter; +import org.apache.lucene.analysis.StopFilter; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.analysis.standard.StandardFilter; +import org.apache.lucene.analysis.standard.StandardTokenizer; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.NumericField; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.Term; +import org.apache.lucene.queryParser.MultiFieldQueryParser; +import org.apache.lucene.search.BooleanClause; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.NumericRangeQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.Searcher; +import org.apache.lucene.search.TermQuery; +import org.apache.lucene.search.TopDocs; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; +import org.apache.lucene.util.Version; + +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static net.sourceforge.subsonic.service.SearchService.IndexType.*; +import static net.sourceforge.subsonic.service.SearchService.IndexType.SONG; + +/** + * Performs Lucene-based searching and indexing. + * + * @author Sindre Mehus + * @version $Id$ + * @see MediaScannerService + */ +public class SearchService { + + private static final Logger LOG = Logger.getLogger(SearchService.class); + + private static final String FIELD_ID = "id"; + private static final String FIELD_TITLE = "title"; + private static final String FIELD_ALBUM = "album"; + private static final String FIELD_ARTIST = "artist"; + private static final String FIELD_GENRE = "genre"; + private static final String FIELD_YEAR = "year"; + private static final String FIELD_MEDIA_TYPE = "mediaType"; + private static final String FIELD_FOLDER = "folder"; + + private static final Version LUCENE_VERSION = Version.LUCENE_30; + + private MediaFileService mediaFileService; + private SettingsService settingsService; + private ArtistDao artistDao; + private AlbumDao albumDao; + + private IndexWriter artistWriter; + private IndexWriter artistId3Writer; + private IndexWriter albumWriter; + private IndexWriter albumId3Writer; + private IndexWriter songWriter; + + public SearchService() { + removeLocks(); + } + + + public void startIndexing() { + try { + artistWriter = createIndexWriter(ARTIST); + artistId3Writer = createIndexWriter(ARTIST_ID3); + albumWriter = createIndexWriter(ALBUM); + albumId3Writer = createIndexWriter(ALBUM_ID3); + songWriter = createIndexWriter(SONG); + } catch (Exception x) { + LOG.error("Failed to create search index.", x); + } + } + + public void index(MediaFile mediaFile) { + try { + if (mediaFile.isFile()) { + songWriter.addDocument(SONG.createDocument(mediaFile)); + } else if (mediaFile.isAlbum()) { + albumWriter.addDocument(ALBUM.createDocument(mediaFile)); + } else { + artistWriter.addDocument(ARTIST.createDocument(mediaFile)); + } + } catch (Exception x) { + LOG.error("Failed to create search index for " + mediaFile, x); + } + } + + public void index(Artist artist) { + try { + artistId3Writer.addDocument(ARTIST_ID3.createDocument(artist)); + } catch (Exception x) { + LOG.error("Failed to create search index for " + artist, x); + } + } + + public void index(Album album) { + try { + albumId3Writer.addDocument(ALBUM_ID3.createDocument(album)); + } catch (Exception x) { + LOG.error("Failed to create search index for " + album, x); + } + } + + public void stopIndexing() { + try { + artistWriter.optimize(); + artistId3Writer.optimize(); + albumWriter.optimize(); + albumId3Writer.optimize(); + songWriter.optimize(); + } catch (Exception x) { + LOG.error("Failed to create search index.", x); + } finally { + FileUtil.closeQuietly(artistId3Writer); + FileUtil.closeQuietly(artistWriter); + FileUtil.closeQuietly(albumWriter); + FileUtil.closeQuietly(albumId3Writer); + FileUtil.closeQuietly(songWriter); + } + } + + public SearchResult search(SearchCriteria criteria, IndexType indexType) { + SearchResult result = new SearchResult(); + int offset = criteria.getOffset(); + int count = criteria.getCount(); + result.setOffset(offset); + + IndexReader reader = null; + try { + reader = createIndexReader(indexType); + Searcher searcher = new IndexSearcher(reader); + Analyzer analyzer = new SubsonicAnalyzer(); + + MultiFieldQueryParser queryParser = new MultiFieldQueryParser(LUCENE_VERSION, indexType.getFields(), analyzer, indexType.getBoosts()); + Query query = queryParser.parse(criteria.getQuery()); + + TopDocs topDocs = searcher.search(query, null, offset + count); + result.setTotalHits(topDocs.totalHits); + + int start = Math.min(offset, topDocs.totalHits); + int end = Math.min(start + count, topDocs.totalHits); + for (int i = start; i < end; i++) { + Document doc = searcher.doc(topDocs.scoreDocs[i].doc); + switch (indexType) { + case SONG: + case ARTIST: + case ALBUM: + MediaFile mediaFile = mediaFileService.getMediaFile(Integer.valueOf(doc.get(FIELD_ID))); + addIfNotNull(mediaFile, result.getMediaFiles()); + break; + case ARTIST_ID3: + Artist artist = artistDao.getArtist(Integer.valueOf(doc.get(FIELD_ID))); + addIfNotNull(artist, result.getArtists()); + break; + case ALBUM_ID3: + Album album = albumDao.getAlbum(Integer.valueOf(doc.get(FIELD_ID))); + addIfNotNull(album, result.getAlbums()); + break; + default: + break; + } + } + + } catch (Throwable x) { + LOG.error("Failed to execute Lucene search.", x); + } finally { + FileUtil.closeQuietly(reader); + } + return result; + } + + /** + * Returns a number of random songs. + * + * @param criteria Search criteria. + * @return List of random songs. + */ + public List<MediaFile> getRandomSongs(RandomSearchCriteria criteria) { + List<MediaFile> result = new ArrayList<MediaFile>(); + + String musicFolderPath = null; + if (criteria.getMusicFolderId() != null) { + MusicFolder musicFolder = settingsService.getMusicFolderById(criteria.getMusicFolderId()); + musicFolderPath = musicFolder.getPath().getPath(); + } + + IndexReader reader = null; + try { + reader = createIndexReader(SONG); + Searcher searcher = new IndexSearcher(reader); + + BooleanQuery query = new BooleanQuery(); + query.add(new TermQuery(new Term(FIELD_MEDIA_TYPE, MediaFile.MediaType.MUSIC.name().toLowerCase())), BooleanClause.Occur.MUST); + if (criteria.getGenre() != null) { + String genre = normalizeGenre(criteria.getGenre()); + query.add(new TermQuery(new Term(FIELD_GENRE, genre)), BooleanClause.Occur.MUST); + } + if (criteria.getFromYear() != null || criteria.getToYear() != null) { + NumericRangeQuery<Integer> rangeQuery = NumericRangeQuery.newIntRange(FIELD_YEAR, criteria.getFromYear(), criteria.getToYear(), true, true); + query.add(rangeQuery, BooleanClause.Occur.MUST); + } + if (musicFolderPath != null) { + query.add(new TermQuery(new Term(FIELD_FOLDER, musicFolderPath)), BooleanClause.Occur.MUST); + } + + TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE); + Random random = new Random(System.currentTimeMillis()); + + for (int i = 0; i < Math.min(criteria.getCount(), topDocs.totalHits); i++) { + int index = random.nextInt(topDocs.totalHits); + Document doc = searcher.doc(topDocs.scoreDocs[index].doc); + int id = Integer.valueOf(doc.get(FIELD_ID)); + try { + result.add(mediaFileService.getMediaFile(id)); + } catch (Exception x) { + LOG.warn("Failed to get media file " + id); + } + } + + } catch (Throwable x) { + LOG.error("Failed to search or random songs.", x); + } finally { + FileUtil.closeQuietly(reader); + } + return result; + } + + private static String normalizeGenre(String genre) { + return genre.toLowerCase().replace(" ", ""); + } + + /** + * Returns a number of random albums. + * + * @param count Number of albums to return. + * @return List of random albums. + */ + public List<MediaFile> getRandomAlbums(int count) { + List<MediaFile> result = new ArrayList<MediaFile>(); + + IndexReader reader = null; + try { + reader = createIndexReader(ALBUM); + Searcher searcher = new IndexSearcher(reader); + + Query query = new MatchAllDocsQuery(); + TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE); + Random random = new Random(System.currentTimeMillis()); + + for (int i = 0; i < Math.min(count, topDocs.totalHits); i++) { + int index = random.nextInt(topDocs.totalHits); + Document doc = searcher.doc(topDocs.scoreDocs[index].doc); + int id = Integer.valueOf(doc.get(FIELD_ID)); + try { + addIfNotNull(mediaFileService.getMediaFile(id), result); + } catch (Exception x) { + LOG.warn("Failed to get media file " + id, x); + } + } + + } catch (Throwable x) { + LOG.error("Failed to search for random albums.", x); + } finally { + FileUtil.closeQuietly(reader); + } + return result; + } + + /** + * Returns a number of random albums, using ID3 tag. + * + * @param count Number of albums to return. + * @return List of random albums. + */ + public List<Album> getRandomAlbumsId3(int count) { + List<Album> result = new ArrayList<Album>(); + + IndexReader reader = null; + try { + reader = createIndexReader(ALBUM_ID3); + Searcher searcher = new IndexSearcher(reader); + + Query query = new MatchAllDocsQuery(); + TopDocs topDocs = searcher.search(query, null, Integer.MAX_VALUE); + Random random = new Random(System.currentTimeMillis()); + + for (int i = 0; i < Math.min(count, topDocs.totalHits); i++) { + int index = random.nextInt(topDocs.totalHits); + Document doc = searcher.doc(topDocs.scoreDocs[index].doc); + int id = Integer.valueOf(doc.get(FIELD_ID)); + try { + addIfNotNull(albumDao.getAlbum(id), result); + } catch (Exception x) { + LOG.warn("Failed to get album file " + id, x); + } + } + + } catch (Throwable x) { + LOG.error("Failed to search for random albums.", x); + } finally { + FileUtil.closeQuietly(reader); + } + return result; + } + + private <T> void addIfNotNull(T value, List<T> list) { + if (value != null) { + list.add(value); + } + } + private IndexWriter createIndexWriter(IndexType indexType) throws IOException { + File dir = getIndexDirectory(indexType); + return new IndexWriter(FSDirectory.open(dir), new SubsonicAnalyzer(), true, new IndexWriter.MaxFieldLength(10)); + } + + private IndexReader createIndexReader(IndexType indexType) throws IOException { + File dir = getIndexDirectory(indexType); + return IndexReader.open(FSDirectory.open(dir), true); + } + + private File getIndexRootDirectory() { + return new File(SettingsService.getSubsonicHome(), "lucene2"); + } + + private File getIndexDirectory(IndexType indexType) { + return new File(getIndexRootDirectory(), indexType.toString().toLowerCase()); + } + + private void removeLocks() { + for (IndexType indexType : IndexType.values()) { + Directory dir = null; + try { + dir = FSDirectory.open(getIndexDirectory(indexType)); + if (IndexWriter.isLocked(dir)) { + IndexWriter.unlock(dir); + LOG.info("Removed Lucene lock file in " + dir); + } + } catch (Exception x) { + LOG.warn("Failed to remove Lucene lock file in " + dir, x); + } finally { + FileUtil.closeQuietly(dir); + } + } + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setArtistDao(ArtistDao artistDao) { + this.artistDao = artistDao; + } + + public void setAlbumDao(AlbumDao albumDao) { + this.albumDao = albumDao; + } + + public static enum IndexType { + + SONG(new String[]{FIELD_TITLE, FIELD_ARTIST}, FIELD_TITLE) { + @Override + public Document createDocument(MediaFile mediaFile) { + Document doc = new Document(); + doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId())); + doc.add(new Field(FIELD_MEDIA_TYPE, mediaFile.getMediaType().name(), Field.Store.NO, Field.Index.ANALYZED_NO_NORMS)); + + if (mediaFile.getTitle() != null) { + doc.add(new Field(FIELD_TITLE, mediaFile.getTitle(), Field.Store.YES, Field.Index.ANALYZED)); + } + if (mediaFile.getArtist() != null) { + doc.add(new Field(FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); + } + if (mediaFile.getGenre() != null) { + doc.add(new Field(FIELD_GENRE, normalizeGenre(mediaFile.getGenre()), Field.Store.NO, Field.Index.ANALYZED)); + } + if (mediaFile.getYear() != null) { + doc.add(new NumericField(FIELD_YEAR, Field.Store.NO, true).setIntValue(mediaFile.getYear())); + } + if (mediaFile.getFolder() != null) { + doc.add(new Field(FIELD_FOLDER, mediaFile.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS)); + } + + return doc; + } + }, + + ALBUM(new String[]{FIELD_ALBUM, FIELD_ARTIST}, FIELD_ALBUM) { + @Override + public Document createDocument(MediaFile mediaFile) { + Document doc = new Document(); + doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId())); + + if (mediaFile.getArtist() != null) { + doc.add(new Field(FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); + } + if (mediaFile.getAlbumName() != null) { + doc.add(new Field(FIELD_ALBUM, mediaFile.getAlbumName(), Field.Store.YES, Field.Index.ANALYZED)); + } + + return doc; + } + }, + + ALBUM_ID3(new String[]{FIELD_ALBUM, FIELD_ARTIST}, FIELD_ALBUM) { + @Override + public Document createDocument(Album album) { + Document doc = new Document(); + doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(album.getId())); + + if (album.getArtist() != null) { + doc.add(new Field(FIELD_ARTIST, album.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); + } + if (album.getName() != null) { + doc.add(new Field(FIELD_ALBUM, album.getName(), Field.Store.YES, Field.Index.ANALYZED)); + } + + return doc; + } + }, + + ARTIST(new String[]{FIELD_ARTIST}, null) { + @Override + public Document createDocument(MediaFile mediaFile) { + Document doc = new Document(); + doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaFile.getId())); + + if (mediaFile.getArtist() != null) { + doc.add(new Field(FIELD_ARTIST, mediaFile.getArtist(), Field.Store.YES, Field.Index.ANALYZED)); + } + + return doc; + } + }, + + ARTIST_ID3(new String[]{FIELD_ARTIST}, null) { + @Override + public Document createDocument(Artist artist) { + Document doc = new Document(); + doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(artist.getId())); + doc.add(new Field(FIELD_ARTIST, artist.getName(), Field.Store.YES, Field.Index.ANALYZED)); + + return doc; + } + }; + + private final String[] fields; + private final Map<String, Float> boosts; + + private IndexType(String[] fields, String boostedField) { + this.fields = fields; + boosts = new HashMap<String, Float>(); + if (boostedField != null) { + boosts.put(boostedField, 2.0F); + } + } + + public String[] getFields() { + return fields; + } + + protected Document createDocument(MediaFile mediaFile) { + throw new UnsupportedOperationException(); + } + + protected Document createDocument(Artist artist) { + throw new UnsupportedOperationException(); + } + + protected Document createDocument(Album album) { + throw new UnsupportedOperationException(); + } + + public Map<String, Float> getBoosts() { + return boosts; + } + } + + private class SubsonicAnalyzer extends StandardAnalyzer { + private SubsonicAnalyzer() { + super(LUCENE_VERSION); + } + + @Override + public TokenStream tokenStream(String fieldName, Reader reader) { + TokenStream result = super.tokenStream(fieldName, reader); + return new ASCIIFoldingFilter(result); + } + + @Override + public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException { + class SavedStreams { + StandardTokenizer tokenStream; + TokenStream filteredTokenStream; + } + + SavedStreams streams = (SavedStreams) getPreviousTokenStream(); + if (streams == null) { + streams = new SavedStreams(); + setPreviousTokenStream(streams); + streams.tokenStream = new StandardTokenizer(LUCENE_VERSION, reader); + streams.filteredTokenStream = new StandardFilter(streams.tokenStream); + streams.filteredTokenStream = new LowerCaseFilter(streams.filteredTokenStream); + streams.filteredTokenStream = new StopFilter(true, streams.filteredTokenStream, STOP_WORDS_SET); + streams.filteredTokenStream = new ASCIIFoldingFilter(streams.filteredTokenStream); + } else { + streams.tokenStream.reset(reader); + } + streams.tokenStream.setMaxTokenLength(DEFAULT_MAX_TOKEN_LENGTH); + + return streams.filteredTokenStream; + } + } +} + + diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SecurityService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SecurityService.java new file mode 100644 index 00000000..d6ca871d --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SecurityService.java @@ -0,0 +1,303 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.File; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import org.acegisecurity.GrantedAuthority; +import org.acegisecurity.GrantedAuthorityImpl; +import org.acegisecurity.providers.dao.DaoAuthenticationProvider; +import org.acegisecurity.userdetails.UserDetails; +import org.acegisecurity.userdetails.UserDetailsService; +import org.acegisecurity.userdetails.UsernameNotFoundException; +import org.acegisecurity.wrapper.SecurityContextHolderAwareRequestWrapper; +import org.springframework.dao.DataAccessException; + +import net.sf.ehcache.Ehcache; +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.UserDao; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.domain.User; +import net.sourceforge.subsonic.util.FileUtil; + +/** + * Provides security-related services for authentication and authorization. + * + * @author Sindre Mehus + */ +public class SecurityService implements UserDetailsService { + + private static final Logger LOG = Logger.getLogger(SecurityService.class); + + private UserDao userDao; + private SettingsService settingsService; + private Ehcache userCache; + + /** + * Locates the user based on the username. + * + * @param username The username presented to the {@link DaoAuthenticationProvider} + * @return A fully populated user record (never <code>null</code>) + * @throws UsernameNotFoundException if the user could not be found or the user has no GrantedAuthority. + * @throws DataAccessException If user could not be found for a repository-specific reason. + */ + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { + User user = getUserByName(username); + if (user == null) { + throw new UsernameNotFoundException("User \"" + username + "\" was not found."); + } + + String[] roles = userDao.getRolesForUser(username); + GrantedAuthority[] authorities = new GrantedAuthority[roles.length]; + for (int i = 0; i < roles.length; i++) { + authorities[i] = new GrantedAuthorityImpl("ROLE_" + roles[i].toUpperCase()); + } + + // If user is LDAP authenticated, disable user. The proper authentication should in that case + // be done by SubsonicLdapBindAuthenticator. + boolean enabled = !user.isLdapAuthenticated(); + + return new org.acegisecurity.userdetails.User(username, user.getPassword(), enabled, true, true, true, authorities); + } + + /** + * Returns the currently logged-in user for the given HTTP request. + * + * @param request The HTTP request. + * @return The logged-in user, or <code>null</code>. + */ + public User getCurrentUser(HttpServletRequest request) { + String username = getCurrentUsername(request); + return username == null ? null : userDao.getUserByName(username); + } + + /** + * Returns the name of the currently logged-in user. + * + * @param request The HTTP request. + * @return The name of the logged-in user, or <code>null</code>. + */ + public String getCurrentUsername(HttpServletRequest request) { + return new SecurityContextHolderAwareRequestWrapper(request, null).getRemoteUser(); + } + + /** + * Returns the user with the given username. + * + * @param username The username used when logging in. + * @return The user, or <code>null</code> if not found. + */ + public User getUserByName(String username) { + return userDao.getUserByName(username); + } + + /** + * Returns the user with the given email address. + * + * @param email The email address. + * @return The user, or <code>null</code> if not found. + */ + public User getUserByEmail(String email) { + return userDao.getUserByEmail(email); + } + + /** + * Returns all users. + * + * @return Possibly empty array of all users. + */ + public List<User> getAllUsers() { + return userDao.getAllUsers(); + } + + /** + * Returns whether the given user has administrative rights. + */ + public boolean isAdmin(String username) { + if (User.USERNAME_ADMIN.equals(username)) { + return true; + } + User user = getUserByName(username); + return user != null && user.isAdminRole(); + } + + /** + * Creates a new user. + * + * @param user The user to create. + */ + public void createUser(User user) { + userDao.createUser(user); + LOG.info("Created user " + user.getUsername()); + } + + /** + * Deletes the user with the given username. + * + * @param username The username. + */ + public void deleteUser(String username) { + userDao.deleteUser(username); + LOG.info("Deleted user " + username); + userCache.remove(username); + } + + /** + * Updates the given user. + * + * @param user The user to update. + */ + public void updateUser(User user) { + userDao.updateUser(user); + userCache.remove(user.getUsername()); + } + + /** + * Updates the byte counts for given user. + * + * @param user The user to update, may be <code>null</code>. + * @param bytesStreamedDelta Increment bytes streamed count with this value. + * @param bytesDownloadedDelta Increment bytes downloaded count with this value. + * @param bytesUploadedDelta Increment bytes uploaded count with this value. + */ + public void updateUserByteCounts(User user, long bytesStreamedDelta, long bytesDownloadedDelta, long bytesUploadedDelta) { + if (user == null) { + return; + } + + user.setBytesStreamed(user.getBytesStreamed() + bytesStreamedDelta); + user.setBytesDownloaded(user.getBytesDownloaded() + bytesDownloadedDelta); + user.setBytesUploaded(user.getBytesUploaded() + bytesUploadedDelta); + + userDao.updateUser(user); + } + + /** + * Returns whether the given file may be read. + * + * @return Whether the given file may be read. + */ + public boolean isReadAllowed(File file) { + // Allowed to read from both music folder and podcast folder. + return isInMusicFolder(file) || isInPodcastFolder(file); + } + + /** + * Returns whether the given file may be written, created or deleted. + * + * @return Whether the given file may be written, created or deleted. + */ + public boolean isWriteAllowed(File file) { + // Only allowed to write podcasts or cover art. + boolean isPodcast = isInPodcastFolder(file); + boolean isCoverArt = isInMusicFolder(file) && file.getName().startsWith("cover."); + + return isPodcast || isCoverArt; + } + + /** + * Returns whether the given file may be uploaded. + * + * @return Whether the given file may be uploaded. + */ + public boolean isUploadAllowed(File file) { + return isInMusicFolder(file) && !FileUtil.exists(file); + } + + /** + * Returns whether the given file is located in one of the music folders (or any of their sub-folders). + * + * @param file The file in question. + * @return Whether the given file is located in one of the music folders. + */ + private boolean isInMusicFolder(File file) { + return getMusicFolderForFile(file) != null; + } + + private MusicFolder getMusicFolderForFile(File file) { + List<MusicFolder> folders = settingsService.getAllMusicFolders(false, true); + String path = file.getPath(); + for (MusicFolder folder : folders) { + if (isFileInFolder(path, folder.getPath().getPath())) { + return folder; + } + } + return null; + } + + /** + * Returns whether the given file is located in the Podcast folder (or any of its sub-folders). + * + * @param file The file in question. + * @return Whether the given file is located in the Podcast folder. + */ + private boolean isInPodcastFolder(File file) { + String podcastFolder = settingsService.getPodcastFolder(); + return isFileInFolder(file.getPath(), podcastFolder); + } + + public String getRootFolderForFile(File file) { + MusicFolder folder = getMusicFolderForFile(file); + if (folder != null) { + return folder.getPath().getPath(); + } + + if (isInPodcastFolder(file)) { + return settingsService.getPodcastFolder(); + } + return null; + } + + /** + * Returns whether the given file is located in the given folder (or any of its sub-folders). + * If the given file contains the expression ".." (indicating a reference to the parent directory), + * this method will return <code>false</code>. + * + * @param file The file in question. + * @param folder The folder in question. + * @return Whether the given file is located in the given folder. + */ + protected boolean isFileInFolder(String file, String folder) { + // Deny access if file contains ".." surrounded by slashes (or end of line). + if (file.matches(".*(/|\\\\)\\.\\.(/|\\\\|$).*")) { + return false; + } + + // Convert slashes. + file = file.replace('\\', '/'); + folder = folder.replace('\\', '/'); + + return file.toUpperCase().startsWith(folder.toUpperCase()); + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setUserDao(UserDao userDao) { + this.userDao = userDao; + } + + public void setUserCache(Ehcache userCache) { + this.userCache = userCache; + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/ServiceLocator.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/ServiceLocator.java new file mode 100644 index 00000000..4a7f95b4 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/ServiceLocator.java @@ -0,0 +1,46 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import javax.xml.parsers.SAXParser; + +import net.sourceforge.subsonic.service.metadata.MetaDataParserFactory; + +/** + * Locates services for objects that are not part of the Spring context. + * + * @author Sindre Mehus + */ +@Deprecated +public class ServiceLocator { + + private static SettingsService settingsService; + + private ServiceLocator() { + } + + public static SettingsService getSettingsService() { + return settingsService; + } + + public static void setSettingsService(SettingsService settingsService) { + ServiceLocator.settingsService = settingsService; + } +} + diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SettingsService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SettingsService.java new file mode 100644 index 00000000..afb5cdc7 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/SettingsService.java @@ -0,0 +1,1254 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.StringTokenizer; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.http.client.HttpClient; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.BasicResponseHandler; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.params.HttpConnectionParams; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.AvatarDao; +import net.sourceforge.subsonic.dao.InternetRadioDao; +import net.sourceforge.subsonic.dao.MusicFolderDao; +import net.sourceforge.subsonic.dao.UserDao; +import net.sourceforge.subsonic.domain.Avatar; +import net.sourceforge.subsonic.domain.InternetRadio; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.domain.Theme; +import net.sourceforge.subsonic.domain.UserSettings; +import net.sourceforge.subsonic.util.FileUtil; +import net.sourceforge.subsonic.util.StringUtil; +import net.sourceforge.subsonic.util.Util; + +/** + * Provides persistent storage of application settings and preferences. + * + * @author Sindre Mehus + */ +public class SettingsService { + + // Subsonic home directory. + private static final File SUBSONIC_HOME_WINDOWS = new File("c:/subsonic"); + private static final File SUBSONIC_HOME_OTHER = new File("/var/subsonic"); + + // Global settings. + private static final String KEY_INDEX_STRING = "IndexString"; + private static final String KEY_IGNORED_ARTICLES = "IgnoredArticles"; + private static final String KEY_SHORTCUTS = "Shortcuts"; + private static final String KEY_PLAYLIST_FOLDER = "PlaylistFolder"; + private static final String KEY_MUSIC_FILE_TYPES = "MusicFileTypes"; + private static final String KEY_VIDEO_FILE_TYPES = "VideoFileTypes"; + private static final String KEY_COVER_ART_FILE_TYPES = "CoverArtFileTypes"; + private static final String KEY_COVER_ART_LIMIT = "CoverArtLimit"; + private static final String KEY_WELCOME_TITLE = "WelcomeTitle"; + private static final String KEY_WELCOME_SUBTITLE = "WelcomeSubtitle"; + private static final String KEY_WELCOME_MESSAGE = "WelcomeMessage2"; + private static final String KEY_LOGIN_MESSAGE = "LoginMessage"; + private static final String KEY_LOCALE_LANGUAGE = "LocaleLanguage"; + private static final String KEY_LOCALE_COUNTRY = "LocaleCountry"; + private static final String KEY_LOCALE_VARIANT = "LocaleVariant"; + private static final String KEY_THEME_ID = "Theme"; + private static final String KEY_INDEX_CREATION_INTERVAL = "IndexCreationInterval"; + private static final String KEY_INDEX_CREATION_HOUR = "IndexCreationHour"; + private static final String KEY_FAST_CACHE_ENABLED = "FastCacheEnabled"; + private static final String KEY_PODCAST_UPDATE_INTERVAL = "PodcastUpdateInterval"; + private static final String KEY_PODCAST_FOLDER = "PodcastFolder"; + private static final String KEY_PODCAST_EPISODE_RETENTION_COUNT = "PodcastEpisodeRetentionCount"; + private static final String KEY_PODCAST_EPISODE_DOWNLOAD_COUNT = "PodcastEpisodeDownloadCount"; + private static final String KEY_DOWNLOAD_BITRATE_LIMIT = "DownloadBitrateLimit"; + private static final String KEY_UPLOAD_BITRATE_LIMIT = "UploadBitrateLimit"; + private static final String KEY_STREAM_PORT = "StreamPort"; + private static final String KEY_LICENSE_EMAIL = "LicenseEmail"; + private static final String KEY_LICENSE_CODE = "LicenseCode"; + private static final String KEY_LICENSE_DATE = "LicenseDate"; + private static final String KEY_DOWNSAMPLING_COMMAND = "DownsamplingCommand3"; + private static final String KEY_JUKEBOX_COMMAND = "JukeboxCommand"; + private static final String KEY_REWRITE_URL = "RewriteUrl"; + private static final String KEY_LDAP_ENABLED = "LdapEnabled"; + private static final String KEY_LDAP_URL = "LdapUrl"; + private static final String KEY_LDAP_MANAGER_DN = "LdapManagerDn"; + private static final String KEY_LDAP_MANAGER_PASSWORD = "LdapManagerPassword"; + private static final String KEY_LDAP_SEARCH_FILTER = "LdapSearchFilter"; + private static final String KEY_LDAP_AUTO_SHADOWING = "LdapAutoShadowing"; + private static final String KEY_GETTING_STARTED_ENABLED = "GettingStartedEnabled"; + private static final String KEY_PORT_FORWARDING_ENABLED = "PortForwardingEnabled"; + private static final String KEY_PORT = "Port"; + private static final String KEY_HTTPS_PORT = "HttpsPort"; + private static final String KEY_URL_REDIRECTION_ENABLED = "UrlRedirectionEnabled"; + private static final String KEY_URL_REDIRECT_FROM = "UrlRedirectFrom"; + private static final String KEY_URL_REDIRECT_TRIAL_EXPIRES = "UrlRedirectTrialExpires"; + private static final String KEY_URL_REDIRECT_CONTEXT_PATH = "UrlRedirectContextPath"; + private static final String KEY_REST_TRIAL_EXPIRES = "RestTrialExpires-"; + private static final String KEY_VIDEO_TRIAL_EXPIRES = "VideoTrialExpires"; + private static final String KEY_SERVER_ID = "ServerId"; + private static final String KEY_SETTINGS_CHANGED = "SettingsChanged"; + private static final String KEY_LAST_SCANNED = "LastScanned"; + private static final String KEY_ORGANIZE_BY_FOLDER_STRUCTURE = "OrganizeByFolderStructure"; + private static final String KEY_SORT_ALBUMS_BY_YEAR = "SortAlbumsByYear"; + + // Default values. + private static final String DEFAULT_INDEX_STRING = "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ)"; + private static final String DEFAULT_IGNORED_ARTICLES = "The El La Los Las Le Les"; + private static final String DEFAULT_SHORTCUTS = "New Incoming Podcast"; + private static final String DEFAULT_PLAYLIST_FOLDER = Util.getDefaultPlaylistFolder(); + private static final String DEFAULT_MUSIC_FILE_TYPES = "mp3 ogg oga aac m4a flac wav wma aif aiff ape mpc shn"; + private static final String DEFAULT_VIDEO_FILE_TYPES = "flv avi mpg mpeg mp4 m4v mkv mov wmv ogv divx m2ts"; + private static final String DEFAULT_COVER_ART_FILE_TYPES = "cover.jpg folder.jpg jpg jpeg gif png"; + private static final int DEFAULT_COVER_ART_LIMIT = 30; + private static final String DEFAULT_WELCOME_TITLE = "Welcome to Subsonic!"; + private static final String DEFAULT_WELCOME_SUBTITLE = null; + private static final String DEFAULT_WELCOME_MESSAGE = "__Welcome to Subsonic!__\n" + + "\\\\ \\\\\n" + + "Subsonic is a free, web-based media streamer, providing ubiquitous access to your music. \n" + + "\\\\ \\\\\n" + + "Use it to share your music with friends, or to listen to your own music while at work. You can stream to multiple " + + "players simultaneously, for instance to one player in your kitchen and another in your living room.\n" + + "\\\\ \\\\\n" + + "To change or remove this message, log in with administrator rights and go to {link:Settings > General|generalSettings.view}."; + private static final String DEFAULT_LOGIN_MESSAGE = null; + private static final String DEFAULT_LOCALE_LANGUAGE = "en"; + private static final String DEFAULT_LOCALE_COUNTRY = ""; + private static final String DEFAULT_LOCALE_VARIANT = ""; + private static final String DEFAULT_THEME_ID = "default"; + private static final int DEFAULT_INDEX_CREATION_INTERVAL = 1; + private static final int DEFAULT_INDEX_CREATION_HOUR = 3; + private static final boolean DEFAULT_FAST_CACHE_ENABLED = false; + private static final int DEFAULT_PODCAST_UPDATE_INTERVAL = 24; + private static final String DEFAULT_PODCAST_FOLDER = Util.getDefaultPodcastFolder(); + private static final int DEFAULT_PODCAST_EPISODE_RETENTION_COUNT = 10; + private static final int DEFAULT_PODCAST_EPISODE_DOWNLOAD_COUNT = 1; + private static final long DEFAULT_DOWNLOAD_BITRATE_LIMIT = 0; + private static final long DEFAULT_UPLOAD_BITRATE_LIMIT = 0; + private static final long DEFAULT_STREAM_PORT = 0; + private static final String DEFAULT_LICENSE_EMAIL = null; + private static final String DEFAULT_LICENSE_CODE = null; + private static final String DEFAULT_LICENSE_DATE = null; + private static final String DEFAULT_DOWNSAMPLING_COMMAND = "ffmpeg -i %s -ab %bk -v 0 -f mp3 -"; + private static final String DEFAULT_JUKEBOX_COMMAND = "ffmpeg -ss %o -i %s -v 0 -f au -"; + private static final boolean DEFAULT_REWRITE_URL = true; + private static final boolean DEFAULT_LDAP_ENABLED = false; + private static final String DEFAULT_LDAP_URL = "ldap://host.domain.com:389/cn=Users,dc=domain,dc=com"; + private static final String DEFAULT_LDAP_MANAGER_DN = null; + private static final String DEFAULT_LDAP_MANAGER_PASSWORD = null; + private static final String DEFAULT_LDAP_SEARCH_FILTER = "(sAMAccountName={0})"; + private static final boolean DEFAULT_LDAP_AUTO_SHADOWING = false; + private static final boolean DEFAULT_PORT_FORWARDING_ENABLED = false; + private static final boolean DEFAULT_GETTING_STARTED_ENABLED = true; + private static final int DEFAULT_PORT = 80; + private static final int DEFAULT_HTTPS_PORT = 0; + private static final boolean DEFAULT_URL_REDIRECTION_ENABLED = false; + private static final String DEFAULT_URL_REDIRECT_FROM = "yourname"; + private static final String DEFAULT_URL_REDIRECT_TRIAL_EXPIRES = null; + private static final String DEFAULT_URL_REDIRECT_CONTEXT_PATH = null; + private static final String DEFAULT_REST_TRIAL_EXPIRES = null; + private static final String DEFAULT_VIDEO_TRIAL_EXPIRES = null; + private static final String DEFAULT_SERVER_ID = null; + private static final long DEFAULT_SETTINGS_CHANGED = 0L; + private static final boolean DEFAULT_ORGANIZE_BY_FOLDER_STRUCTURE = true; + private static final boolean DEFAULT_SORT_ALBUMS_BY_YEAR = true; + + // Array of obsolete keys. Used to clean property file. + private static final List<String> OBSOLETE_KEYS = Arrays.asList("PortForwardingPublicPort", "PortForwardingLocalPort", + "DownsamplingCommand", "DownsamplingCommand2", "AutoCoverBatch", "MusicMask", "VideoMask", "CoverArtMask"); + + private static final String LOCALES_FILE = "/net/sourceforge/subsonic/i18n/locales.txt"; + private static final String THEMES_FILE = "/net/sourceforge/subsonic/theme/themes.txt"; + + private static final Logger LOG = Logger.getLogger(SettingsService.class); + + private Properties properties = new Properties(); + private List<Theme> themes; + private List<Locale> locales; + private InternetRadioDao internetRadioDao; + private MusicFolderDao musicFolderDao; + private UserDao userDao; + private AvatarDao avatarDao; + private VersionService versionService; + + private String[] cachedCoverArtFileTypesArray; + private String[] cachedMusicFileTypesArray; + private String[] cachedVideoFileTypesArray; + private List<MusicFolder> cachedMusicFolders; + + private static File subsonicHome; + + private boolean licenseValidated = true; + + public SettingsService() { + File propertyFile = getPropertyFile(); + + if (propertyFile.exists()) { + FileInputStream in = null; + try { + in = new FileInputStream(propertyFile); + properties.load(in); + } catch (Exception x) { + LOG.error("Unable to read from property file.", x); + } finally { + IOUtils.closeQuietly(in); + } + + // Remove obsolete properties. + for (Iterator<Object> iterator = properties.keySet().iterator(); iterator.hasNext();) { + String key = (String) iterator.next(); + if (OBSOLETE_KEYS.contains(key)) { + LOG.debug("Removing obsolete property [" + key + ']'); + iterator.remove(); + } + } + } + + save(false); + } + + /** + * Register in service locator so that non-Spring objects can access me. + * This method is invoked automatically by Spring. + */ + public void init() { + ServiceLocator.setSettingsService(this); + validateLicenseAsync(); + } + + public void save() { + save(true); + } + + public void save(boolean updateChangedDate) { + if (updateChangedDate) { + setProperty(KEY_SETTINGS_CHANGED, String.valueOf(System.currentTimeMillis())); + } + + OutputStream out = null; + try { + out = new FileOutputStream(getPropertyFile()); + properties.store(out, "Subsonic preferences. NOTE: This file is automatically generated."); + } catch (Exception x) { + LOG.error("Unable to write to property file.", x); + } finally { + IOUtils.closeQuietly(out); + } + } + + private File getPropertyFile() { + return new File(getSubsonicHome(), "subsonic.properties"); + } + + /** + * Returns the Subsonic home directory. + * + * @return The Subsonic home directory, if it exists. + * @throws RuntimeException If directory doesn't exist. + */ + public static synchronized File getSubsonicHome() { + + if (subsonicHome != null) { + return subsonicHome; + } + + File home; + + String overrideHome = System.getProperty("subsonic.home"); + if (overrideHome != null) { + home = new File(overrideHome); + } else { + boolean isWindows = System.getProperty("os.name", "Windows").toLowerCase().startsWith("windows"); + home = isWindows ? SUBSONIC_HOME_WINDOWS : SUBSONIC_HOME_OTHER; + } + + // Attempt to create home directory if it doesn't exist. + if (!home.exists() || !home.isDirectory()) { + boolean success = home.mkdirs(); + if (success) { + subsonicHome = home; + } else { + String message = "The directory " + home + " does not exist. Please create it and make it writable. " + + "(You can override the directory location by specifying -Dsubsonic.home=... when " + + "starting the servlet container.)"; + System.err.println("ERROR: " + message); + } + } else { + subsonicHome = home; + } + + return home; + } + + private boolean getBoolean(String key, boolean defaultValue) { + return Boolean.valueOf(properties.getProperty(key, String.valueOf(defaultValue))); + } + + private void setBoolean(String key, boolean value) { + setProperty(key, String.valueOf(value)); + } + + public String getIndexString() { + return properties.getProperty(KEY_INDEX_STRING, DEFAULT_INDEX_STRING); + } + + public void setIndexString(String indexString) { + setProperty(KEY_INDEX_STRING, indexString); + } + + public String getIgnoredArticles() { + return properties.getProperty(KEY_IGNORED_ARTICLES, DEFAULT_IGNORED_ARTICLES); + } + + public String[] getIgnoredArticlesAsArray() { + return getIgnoredArticles().split("\\s+"); + } + + public void setIgnoredArticles(String ignoredArticles) { + setProperty(KEY_IGNORED_ARTICLES, ignoredArticles); + } + + public String getShortcuts() { + return properties.getProperty(KEY_SHORTCUTS, DEFAULT_SHORTCUTS); + } + + public String[] getShortcutsAsArray() { + return StringUtil.split(getShortcuts()); + } + + public void setShortcuts(String shortcuts) { + setProperty(KEY_SHORTCUTS, shortcuts); + } + + public String getPlaylistFolder() { + return properties.getProperty(KEY_PLAYLIST_FOLDER, DEFAULT_PLAYLIST_FOLDER); + } + + public String getMusicFileTypes() { + return properties.getProperty(KEY_MUSIC_FILE_TYPES, DEFAULT_MUSIC_FILE_TYPES); + } + + public synchronized void setMusicFileTypes(String fileTypes) { + setProperty(KEY_MUSIC_FILE_TYPES, fileTypes); + cachedMusicFileTypesArray = null; + } + + public synchronized String[] getMusicFileTypesAsArray() { + if (cachedMusicFileTypesArray == null) { + cachedMusicFileTypesArray = toStringArray(getMusicFileTypes()); + } + return cachedMusicFileTypesArray; + } + + public String getVideoFileTypes() { + return properties.getProperty(KEY_VIDEO_FILE_TYPES, DEFAULT_VIDEO_FILE_TYPES); + } + + public synchronized void setVideoFileTypes(String fileTypes) { + setProperty(KEY_VIDEO_FILE_TYPES, fileTypes); + cachedVideoFileTypesArray = null; + } + + public synchronized String[] getVideoFileTypesAsArray() { + if (cachedVideoFileTypesArray == null) { + cachedVideoFileTypesArray = toStringArray(getVideoFileTypes()); + } + return cachedVideoFileTypesArray; + } + + public String getCoverArtFileTypes() { + return properties.getProperty(KEY_COVER_ART_FILE_TYPES, DEFAULT_COVER_ART_FILE_TYPES); + } + + public synchronized void setCoverArtFileTypes(String fileTypes) { + setProperty(KEY_COVER_ART_FILE_TYPES, fileTypes); + cachedCoverArtFileTypesArray = null; + } + + public synchronized String[] getCoverArtFileTypesAsArray() { + if (cachedCoverArtFileTypesArray == null) { + cachedCoverArtFileTypesArray = toStringArray(getCoverArtFileTypes()); + } + return cachedCoverArtFileTypesArray; + } + + public int getCoverArtLimit() { + return Integer.parseInt(properties.getProperty(KEY_COVER_ART_LIMIT, "" + DEFAULT_COVER_ART_LIMIT)); + } + + public void setCoverArtLimit(int limit) { + setProperty(KEY_COVER_ART_LIMIT, "" + limit); + } + + public String getWelcomeTitle() { + return StringUtils.trimToNull(properties.getProperty(KEY_WELCOME_TITLE, DEFAULT_WELCOME_TITLE)); + } + + public void setWelcomeTitle(String title) { + setProperty(KEY_WELCOME_TITLE, title); + } + + public String getWelcomeSubtitle() { + return StringUtils.trimToNull(properties.getProperty(KEY_WELCOME_SUBTITLE, DEFAULT_WELCOME_SUBTITLE)); + } + + public void setWelcomeSubtitle(String subtitle) { + setProperty(KEY_WELCOME_SUBTITLE, subtitle); + } + + public String getWelcomeMessage() { + return StringUtils.trimToNull(properties.getProperty(KEY_WELCOME_MESSAGE, DEFAULT_WELCOME_MESSAGE)); + } + + public void setWelcomeMessage(String message) { + setProperty(KEY_WELCOME_MESSAGE, message); + } + + public String getLoginMessage() { + return StringUtils.trimToNull(properties.getProperty(KEY_LOGIN_MESSAGE, DEFAULT_LOGIN_MESSAGE)); + } + + public void setLoginMessage(String message) { + setProperty(KEY_LOGIN_MESSAGE, message); + } + + /** + * Returns the number of days between automatic index creation, of -1 if automatic index + * creation is disabled. + */ + public int getIndexCreationInterval() { + return Integer.parseInt(properties.getProperty(KEY_INDEX_CREATION_INTERVAL, "" + DEFAULT_INDEX_CREATION_INTERVAL)); + } + + /** + * Sets the number of days between automatic index creation, of -1 if automatic index + * creation is disabled. + */ + public void setIndexCreationInterval(int days) { + setProperty(KEY_INDEX_CREATION_INTERVAL, String.valueOf(days)); + } + + /** + * Returns the hour of day (0 - 23) when automatic index creation should run. + */ + public int getIndexCreationHour() { + return Integer.parseInt(properties.getProperty(KEY_INDEX_CREATION_HOUR, String.valueOf(DEFAULT_INDEX_CREATION_HOUR))); + } + + /** + * Sets the hour of day (0 - 23) when automatic index creation should run. + */ + public void setIndexCreationHour(int hour) { + setProperty(KEY_INDEX_CREATION_HOUR, String.valueOf(hour)); + } + + public boolean isFastCacheEnabled() { + return getBoolean(KEY_FAST_CACHE_ENABLED, DEFAULT_FAST_CACHE_ENABLED); + } + + public void setFastCacheEnabled(boolean enabled) { + setBoolean(KEY_FAST_CACHE_ENABLED, enabled); + } + + /** + * Returns the number of hours between Podcast updates, of -1 if automatic updates + * are disabled. + */ + public int getPodcastUpdateInterval() { + return Integer.parseInt(properties.getProperty(KEY_PODCAST_UPDATE_INTERVAL, String.valueOf(DEFAULT_PODCAST_UPDATE_INTERVAL))); + } + + /** + * Sets the number of hours between Podcast updates, of -1 if automatic updates + * are disabled. + */ + public void setPodcastUpdateInterval(int hours) { + setProperty(KEY_PODCAST_UPDATE_INTERVAL, String.valueOf(hours)); + } + + /** + * Returns the number of Podcast episodes to keep (-1 to keep all). + */ + public int getPodcastEpisodeRetentionCount() { + return Integer.parseInt(properties.getProperty(KEY_PODCAST_EPISODE_RETENTION_COUNT, String.valueOf(DEFAULT_PODCAST_EPISODE_RETENTION_COUNT))); + } + + /** + * Sets the number of Podcast episodes to keep (-1 to keep all). + */ + public void setPodcastEpisodeRetentionCount(int count) { + setProperty(KEY_PODCAST_EPISODE_RETENTION_COUNT, String.valueOf(count)); + } + + /** + * Returns the number of Podcast episodes to download (-1 to download all). + */ + public int getPodcastEpisodeDownloadCount() { + return Integer.parseInt(properties.getProperty(KEY_PODCAST_EPISODE_DOWNLOAD_COUNT, String.valueOf(DEFAULT_PODCAST_EPISODE_DOWNLOAD_COUNT))); + } + + /** + * Sets the number of Podcast episodes to download (-1 to download all). + */ + public void setPodcastEpisodeDownloadCount(int count) { + setProperty(KEY_PODCAST_EPISODE_DOWNLOAD_COUNT, String.valueOf(count)); + } + + /** + * Returns the Podcast download folder. + */ + public String getPodcastFolder() { + return properties.getProperty(KEY_PODCAST_FOLDER, DEFAULT_PODCAST_FOLDER); + } + + /** + * Sets the Podcast download folder. + */ + public void setPodcastFolder(String folder) { + setProperty(KEY_PODCAST_FOLDER, folder); + } + + /** + * @return The download bitrate limit in Kbit/s. Zero if unlimited. + */ + public long getDownloadBitrateLimit() { + return Long.parseLong(properties.getProperty(KEY_DOWNLOAD_BITRATE_LIMIT, "" + DEFAULT_DOWNLOAD_BITRATE_LIMIT)); + } + + /** + * @param limit The download bitrate limit in Kbit/s. Zero if unlimited. + */ + public void setDownloadBitrateLimit(long limit) { + setProperty(KEY_DOWNLOAD_BITRATE_LIMIT, "" + limit); + } + + /** + * @return The upload bitrate limit in Kbit/s. Zero if unlimited. + */ + public long getUploadBitrateLimit() { + return Long.parseLong(properties.getProperty(KEY_UPLOAD_BITRATE_LIMIT, "" + DEFAULT_UPLOAD_BITRATE_LIMIT)); + } + + /** + * @param limit The upload bitrate limit in Kbit/s. Zero if unlimited. + */ + public void setUploadBitrateLimit(long limit) { + setProperty(KEY_UPLOAD_BITRATE_LIMIT, "" + limit); + } + + /** + * @return The non-SSL stream port. Zero if disabled. + */ + public int getStreamPort() { + return Integer.parseInt(properties.getProperty(KEY_STREAM_PORT, "" + DEFAULT_STREAM_PORT)); + } + + /** + * @param port The non-SSL stream port. Zero if disabled. + */ + public void setStreamPort(int port) { + setProperty(KEY_STREAM_PORT, "" + port); + } + + public String getLicenseEmail() { + return properties.getProperty(KEY_LICENSE_EMAIL, DEFAULT_LICENSE_EMAIL); + } + + public void setLicenseEmail(String email) { + setProperty(KEY_LICENSE_EMAIL, email); + } + + public String getLicenseCode() { + return properties.getProperty(KEY_LICENSE_CODE, DEFAULT_LICENSE_CODE); + } + + public void setLicenseCode(String code) { + setProperty(KEY_LICENSE_CODE, code); + } + + public Date getLicenseDate() { + String value = properties.getProperty(KEY_LICENSE_DATE, DEFAULT_LICENSE_DATE); + return value == null ? null : new Date(Long.parseLong(value)); + } + + public void setLicenseDate(Date date) { + String value = (date == null ? null : String.valueOf(date.getTime())); + setProperty(KEY_LICENSE_DATE, value); + } + + public boolean isLicenseValid() { + return isLicenseValid(getLicenseEmail(), getLicenseCode()) && licenseValidated; + } + + public boolean isLicenseValid(String email, String license) { + if (email == null || license == null) { + return false; + } + return license.equalsIgnoreCase(StringUtil.md5Hex(email.toLowerCase())); + } + + public String getDownsamplingCommand() { + return properties.getProperty(KEY_DOWNSAMPLING_COMMAND, DEFAULT_DOWNSAMPLING_COMMAND); + } + + public void setDownsamplingCommand(String command) { + setProperty(KEY_DOWNSAMPLING_COMMAND, command); + } + + public String getJukeboxCommand() { + return properties.getProperty(KEY_JUKEBOX_COMMAND, DEFAULT_JUKEBOX_COMMAND); + } + + public boolean isRewriteUrlEnabled() { + return getBoolean(KEY_REWRITE_URL, DEFAULT_REWRITE_URL); + } + + public void setRewriteUrlEnabled(boolean rewriteUrl) { + setBoolean(KEY_REWRITE_URL, rewriteUrl); + } + + public boolean isLdapEnabled() { + return getBoolean(KEY_LDAP_ENABLED, DEFAULT_LDAP_ENABLED); + } + + public void setLdapEnabled(boolean ldapEnabled) { + setBoolean(KEY_LDAP_ENABLED, ldapEnabled); + } + + public String getLdapUrl() { + return properties.getProperty(KEY_LDAP_URL, DEFAULT_LDAP_URL); + } + + public void setLdapUrl(String ldapUrl) { + properties.setProperty(KEY_LDAP_URL, ldapUrl); + } + + public String getLdapSearchFilter() { + return properties.getProperty(KEY_LDAP_SEARCH_FILTER, DEFAULT_LDAP_SEARCH_FILTER); + } + + public void setLdapSearchFilter(String ldapSearchFilter) { + properties.setProperty(KEY_LDAP_SEARCH_FILTER, ldapSearchFilter); + } + + public String getLdapManagerDn() { + return properties.getProperty(KEY_LDAP_MANAGER_DN, DEFAULT_LDAP_MANAGER_DN); + } + + public void setLdapManagerDn(String ldapManagerDn) { + properties.setProperty(KEY_LDAP_MANAGER_DN, ldapManagerDn); + } + + public String getLdapManagerPassword() { + String s = properties.getProperty(KEY_LDAP_MANAGER_PASSWORD, DEFAULT_LDAP_MANAGER_PASSWORD); + try { + return StringUtil.utf8HexDecode(s); + } catch (Exception x) { + LOG.warn("Failed to decode LDAP manager password.", x); + return s; + } + } + + public void setLdapManagerPassword(String ldapManagerPassword) { + try { + ldapManagerPassword = StringUtil.utf8HexEncode(ldapManagerPassword); + } catch (Exception x) { + LOG.warn("Failed to encode LDAP manager password.", x); + } + properties.setProperty(KEY_LDAP_MANAGER_PASSWORD, ldapManagerPassword); + } + + public boolean isLdapAutoShadowing() { + return getBoolean(KEY_LDAP_AUTO_SHADOWING, DEFAULT_LDAP_AUTO_SHADOWING); + } + + public void setLdapAutoShadowing(boolean ldapAutoShadowing) { + setBoolean(KEY_LDAP_AUTO_SHADOWING, ldapAutoShadowing); + } + + public boolean isGettingStartedEnabled() { + return getBoolean(KEY_GETTING_STARTED_ENABLED, DEFAULT_GETTING_STARTED_ENABLED); + } + + public void setGettingStartedEnabled(boolean isGettingStartedEnabled) { + setBoolean(KEY_GETTING_STARTED_ENABLED, isGettingStartedEnabled); + } + + public boolean isPortForwardingEnabled() { + return getBoolean(KEY_PORT_FORWARDING_ENABLED, DEFAULT_PORT_FORWARDING_ENABLED); + } + + public void setPortForwardingEnabled(boolean isPortForwardingEnabled) { + setBoolean(KEY_PORT_FORWARDING_ENABLED, isPortForwardingEnabled); + } + + public int getPort() { + return Integer.valueOf(properties.getProperty(KEY_PORT, String.valueOf(DEFAULT_PORT))); + } + + public void setPort(int port) { + setProperty(KEY_PORT, String.valueOf(port)); + } + + public int getHttpsPort() { + return Integer.valueOf(properties.getProperty(KEY_HTTPS_PORT, String.valueOf(DEFAULT_HTTPS_PORT))); + } + + public void setHttpsPort(int httpsPort) { + setProperty(KEY_HTTPS_PORT, String.valueOf(httpsPort)); + } + + public boolean isUrlRedirectionEnabled() { + return getBoolean(KEY_URL_REDIRECTION_ENABLED, DEFAULT_URL_REDIRECTION_ENABLED); + } + + public void setUrlRedirectionEnabled(boolean isUrlRedirectionEnabled) { + setBoolean(KEY_URL_REDIRECTION_ENABLED, isUrlRedirectionEnabled); + } + + public String getUrlRedirectFrom() { + return properties.getProperty(KEY_URL_REDIRECT_FROM, DEFAULT_URL_REDIRECT_FROM); + } + + public void setUrlRedirectFrom(String urlRedirectFrom) { + properties.setProperty(KEY_URL_REDIRECT_FROM, urlRedirectFrom); + } + + public Date getUrlRedirectTrialExpires() { + String value = properties.getProperty(KEY_URL_REDIRECT_TRIAL_EXPIRES, DEFAULT_URL_REDIRECT_TRIAL_EXPIRES); + return value == null ? null : new Date(Long.parseLong(value)); + } + + public void setUrlRedirectTrialExpires(Date date) { + String value = (date == null ? null : String.valueOf(date.getTime())); + setProperty(KEY_URL_REDIRECT_TRIAL_EXPIRES, value); + } + + public Date getVideoTrialExpires() { + String value = properties.getProperty(KEY_VIDEO_TRIAL_EXPIRES, DEFAULT_VIDEO_TRIAL_EXPIRES); + return value == null ? null : new Date(Long.parseLong(value)); + } + + public void setVideoTrialExpires(Date date) { + String value = (date == null ? null : String.valueOf(date.getTime())); + setProperty(KEY_VIDEO_TRIAL_EXPIRES, value); + } + + public String getUrlRedirectContextPath() { + return properties.getProperty(KEY_URL_REDIRECT_CONTEXT_PATH, DEFAULT_URL_REDIRECT_CONTEXT_PATH); + } + + public void setUrlRedirectContextPath(String contextPath) { + properties.setProperty(KEY_URL_REDIRECT_CONTEXT_PATH, contextPath); + } + + public Date getRESTTrialExpires(String client) { + String value = properties.getProperty(KEY_REST_TRIAL_EXPIRES + client, DEFAULT_REST_TRIAL_EXPIRES); + return value == null ? null : new Date(Long.parseLong(value)); + } + + public void setRESTTrialExpires(String client, Date date) { + String value = (date == null ? null : String.valueOf(date.getTime())); + setProperty(KEY_REST_TRIAL_EXPIRES + client, value); + } + + public String getServerId() { + return properties.getProperty(KEY_SERVER_ID, DEFAULT_SERVER_ID); + } + + public void setServerId(String serverId) { + properties.setProperty(KEY_SERVER_ID, serverId); + } + + public long getSettingsChanged() { + return Long.parseLong(properties.getProperty(KEY_SETTINGS_CHANGED, String.valueOf(DEFAULT_SETTINGS_CHANGED))); + } + + public Date getLastScanned() { + String lastScanned = properties.getProperty(KEY_LAST_SCANNED); + return lastScanned == null ? null : new Date(Long.parseLong(lastScanned)); + } + + public void setLastScanned(Date date) { + if (date == null) { + properties.remove(KEY_LAST_SCANNED); + } else { + properties.setProperty(KEY_LAST_SCANNED, String.valueOf(date.getTime())); + } + } + + public boolean isOrganizeByFolderStructure() { + return getBoolean(KEY_ORGANIZE_BY_FOLDER_STRUCTURE, DEFAULT_ORGANIZE_BY_FOLDER_STRUCTURE); + } + + public void setOrganizeByFolderStructure(boolean b) { + setBoolean(KEY_ORGANIZE_BY_FOLDER_STRUCTURE, b); + } + + public boolean isSortAlbumsByYear() { + return getBoolean(KEY_SORT_ALBUMS_BY_YEAR, DEFAULT_SORT_ALBUMS_BY_YEAR); + } + + public void setSortAlbumsByYear(boolean b) { + setBoolean(KEY_SORT_ALBUMS_BY_YEAR, b); + } + + /** + * Returns the locale (for language, date format etc). + * + * @return The locale. + */ + public Locale getLocale() { + String language = properties.getProperty(KEY_LOCALE_LANGUAGE, DEFAULT_LOCALE_LANGUAGE); + String country = properties.getProperty(KEY_LOCALE_COUNTRY, DEFAULT_LOCALE_COUNTRY); + String variant = properties.getProperty(KEY_LOCALE_VARIANT, DEFAULT_LOCALE_VARIANT); + + return new Locale(language, country, variant); + } + + /** + * Sets the locale (for language, date format etc.) + * + * @param locale The locale. + */ + public void setLocale(Locale locale) { + setProperty(KEY_LOCALE_LANGUAGE, locale.getLanguage()); + setProperty(KEY_LOCALE_COUNTRY, locale.getCountry()); + setProperty(KEY_LOCALE_VARIANT, locale.getVariant()); + } + + /** + * Returns the ID of the theme to use. + * + * @return The theme ID. + */ + public String getThemeId() { + return properties.getProperty(KEY_THEME_ID, DEFAULT_THEME_ID); + } + + /** + * Sets the ID of the theme to use. + * + * @param themeId The theme ID + */ + public void setThemeId(String themeId) { + setProperty(KEY_THEME_ID, themeId); + } + + /** + * Returns a list of available themes. + * + * @return A list of available themes. + */ + public synchronized Theme[] getAvailableThemes() { + if (themes == null) { + themes = new ArrayList<Theme>(); + try { + InputStream in = SettingsService.class.getResourceAsStream(THEMES_FILE); + String[] lines = StringUtil.readLines(in); + for (String line : lines) { + String[] elements = StringUtil.split(line); + if (elements.length == 2) { + themes.add(new Theme(elements[0], elements[1])); + } else { + LOG.warn("Failed to parse theme from line: [" + line + "]."); + } + } + } catch (IOException x) { + LOG.error("Failed to resolve list of themes.", x); + themes.add(new Theme("default", "Subsonic default")); + } + } + return themes.toArray(new Theme[themes.size()]); + } + + /** + * Returns a list of available locales. + * + * @return A list of available locales. + */ + public synchronized Locale[] getAvailableLocales() { + if (locales == null) { + locales = new ArrayList<Locale>(); + try { + InputStream in = SettingsService.class.getResourceAsStream(LOCALES_FILE); + String[] lines = StringUtil.readLines(in); + + for (String line : lines) { + locales.add(parseLocale(line)); + } + + } catch (IOException x) { + LOG.error("Failed to resolve list of locales.", x); + locales.add(Locale.ENGLISH); + } + } + return locales.toArray(new Locale[locales.size()]); + } + + private Locale parseLocale(String line) { + String[] s = line.split("_"); + String language = s[0]; + String country = ""; + String variant = ""; + + if (s.length > 1) { + country = s[1]; + } + if (s.length > 2) { + variant = s[2]; + } + return new Locale(language, country, variant); + } + + /** + * Returns the "brand" name. Normally, this is just "Subsonic". + * + * @return The brand name. + */ + public String getBrand() { + return "Subsonic"; + } + + /** + * Returns all music folders. Non-existing and disabled folders are not included. + * + * @return Possibly empty list of all music folders. + */ + public List<MusicFolder> getAllMusicFolders() { + return getAllMusicFolders(false, false); + } + + /** + * Returns all music folders. + * + * @param includeDisabled Whether to include disabled folders. + * @param includeNonExisting Whether to include non-existing folders. + * @return Possibly empty list of all music folders. + */ + public List<MusicFolder> getAllMusicFolders(boolean includeDisabled, boolean includeNonExisting) { + if (cachedMusicFolders == null) { + cachedMusicFolders = musicFolderDao.getAllMusicFolders(); + } + + List<MusicFolder> result = new ArrayList<MusicFolder>(cachedMusicFolders.size()); + for (MusicFolder folder : cachedMusicFolders) { + if ((includeDisabled || folder.isEnabled()) && (includeNonExisting || FileUtil.exists(folder.getPath()))) { + result.add(folder); + } + } + return result; + } + + /** + * Returns the music folder with the given ID. + * + * @param id The ID. + * @return The music folder with the given ID, or <code>null</code> if not found. + */ + public MusicFolder getMusicFolderById(Integer id) { + List<MusicFolder> all = getAllMusicFolders(); + for (MusicFolder folder : all) { + if (id.equals(folder.getId())) { + return folder; + } + } + return null; + } + + /** + * Creates a new music folder. + * + * @param musicFolder The music folder to create. + */ + public void createMusicFolder(MusicFolder musicFolder) { + musicFolderDao.createMusicFolder(musicFolder); + cachedMusicFolders = null; + } + + /** + * Deletes the music folder with the given ID. + * + * @param id The ID of the music folder to delete. + */ + public void deleteMusicFolder(Integer id) { + musicFolderDao.deleteMusicFolder(id); + cachedMusicFolders = null; + } + + /** + * Updates the given music folder. + * + * @param musicFolder The music folder to update. + */ + public void updateMusicFolder(MusicFolder musicFolder) { + musicFolderDao.updateMusicFolder(musicFolder); + cachedMusicFolders = null; + } + + /** + * Returns all internet radio stations. Disabled stations are not returned. + * + * @return Possibly empty list of all internet radio stations. + */ + public List<InternetRadio> getAllInternetRadios() { + return getAllInternetRadios(false); + } + + /** + * Returns the internet radio station with the given ID. + * + * @param id The ID. + * @return The internet radio station with the given ID, or <code>null</code> if not found. + */ + public InternetRadio getInternetRadioById(Integer id) { + for (InternetRadio radio : getAllInternetRadios()) { + if (id.equals(radio.getId())) { + return radio; + } + } + return null; + } + + /** + * Returns all internet radio stations. + * + * @param includeAll Whether disabled stations should be included. + * @return Possibly empty list of all internet radio stations. + */ + public List<InternetRadio> getAllInternetRadios(boolean includeAll) { + List<InternetRadio> all = internetRadioDao.getAllInternetRadios(); + List<InternetRadio> result = new ArrayList<InternetRadio>(all.size()); + for (InternetRadio folder : all) { + if (includeAll || folder.isEnabled()) { + result.add(folder); + } + } + return result; + } + + /** + * Creates a new internet radio station. + * + * @param radio The internet radio station to create. + */ + public void createInternetRadio(InternetRadio radio) { + internetRadioDao.createInternetRadio(radio); + } + + /** + * Deletes the internet radio station with the given ID. + * + * @param id The internet radio station ID. + */ + public void deleteInternetRadio(Integer id) { + internetRadioDao.deleteInternetRadio(id); + } + + /** + * Updates the given internet radio station. + * + * @param radio The internet radio station to update. + */ + public void updateInternetRadio(InternetRadio radio) { + internetRadioDao.updateInternetRadio(radio); + } + + /** + * Returns settings for the given user. + * + * @param username The username. + * @return User-specific settings. Never <code>null</code>. + */ + public UserSettings getUserSettings(String username) { + UserSettings settings = userDao.getUserSettings(username); + return settings == null ? createDefaultUserSettings(username) : settings; + } + + private UserSettings createDefaultUserSettings(String username) { + UserSettings settings = new UserSettings(username); + settings.setFinalVersionNotificationEnabled(true); + settings.setBetaVersionNotificationEnabled(false); + settings.setShowNowPlayingEnabled(true); + settings.setShowChatEnabled(true); + settings.setPartyModeEnabled(false); + settings.setNowPlayingAllowed(true); + settings.setLastFmEnabled(false); + settings.setLastFmUsername(null); + settings.setLastFmPassword(null); + settings.setChanged(new Date()); + + UserSettings.Visibility playlist = settings.getPlaylistVisibility(); + playlist.setCaptionCutoff(35); + playlist.setArtistVisible(true); + playlist.setAlbumVisible(true); + playlist.setYearVisible(true); + playlist.setDurationVisible(true); + playlist.setBitRateVisible(true); + playlist.setFormatVisible(true); + playlist.setFileSizeVisible(true); + + UserSettings.Visibility main = settings.getMainVisibility(); + main.setCaptionCutoff(35); + main.setTrackNumberVisible(true); + main.setArtistVisible(true); + main.setDurationVisible(true); + + return settings; + } + + /** + * Updates settings for the given username. + * + * @param settings The user-specific settings. + */ + public void updateUserSettings(UserSettings settings) { + userDao.updateUserSettings(settings); + } + + /** + * Returns all system avatars. + * + * @return All system avatars. + */ + public List<Avatar> getAllSystemAvatars() { + return avatarDao.getAllSystemAvatars(); + } + + /** + * Returns the system avatar with the given ID. + * + * @param id The system avatar ID. + * @return The avatar or <code>null</code> if not found. + */ + public Avatar getSystemAvatar(int id) { + return avatarDao.getSystemAvatar(id); + } + + /** + * Returns the custom avatar for the given user. + * + * @param username The username. + * @return The avatar or <code>null</code> if not found. + */ + public Avatar getCustomAvatar(String username) { + return avatarDao.getCustomAvatar(username); + } + + /** + * Sets the custom avatar for the given user. + * + * @param avatar The avatar, or <code>null</code> to remove the avatar. + * @param username The username. + */ + public void setCustomAvatar(Avatar avatar, String username) { + avatarDao.setCustomAvatar(avatar, username); + } + + private void setProperty(String key, String value) { + if (value == null) { + properties.remove(key); + } else { + properties.setProperty(key, value); + } + } + + private String[] toStringArray(String s) { + List<String> result = new ArrayList<String>(); + StringTokenizer tokenizer = new StringTokenizer(s, " "); + while (tokenizer.hasMoreTokens()) { + result.add(tokenizer.nextToken()); + } + + return result.toArray(new String[result.size()]); + } + + private void validateLicense() { + String email = getLicenseEmail(); + Date date = getLicenseDate(); + + if (email == null || date == null) { + licenseValidated = false; + return; + } + + licenseValidated = true; + + HttpClient client = new DefaultHttpClient(); + HttpConnectionParams.setConnectionTimeout(client.getParams(), 120000); + HttpConnectionParams.setSoTimeout(client.getParams(), 120000); + HttpGet method = new HttpGet("http://subsonic.org/backend/validateLicense.view" + "?email=" + StringUtil.urlEncode(email) + + "&date=" + date.getTime() + "&version=" + versionService.getLocalVersion()); + try { + ResponseHandler<String> responseHandler = new BasicResponseHandler(); + String content = client.execute(method, responseHandler); + licenseValidated = content != null && content.contains("true"); + if (!licenseValidated) { + LOG.warn("License key is not valid."); + } + } catch (Throwable x) { + LOG.warn("Failed to validate license.", x); + } finally { + client.getConnectionManager().shutdown(); + } + } + + public void validateLicenseAsync() { + new Thread() { + @Override + public void run() { + validateLicense(); + } + }.start(); + } + + public void setInternetRadioDao(InternetRadioDao internetRadioDao) { + this.internetRadioDao = internetRadioDao; + } + + public void setMusicFolderDao(MusicFolderDao musicFolderDao) { + this.musicFolderDao = musicFolderDao; + } + + public void setUserDao(UserDao userDao) { + this.userDao = userDao; + } + + public void setAvatarDao(AvatarDao avatarDao) { + this.avatarDao = avatarDao; + } + + public void setVersionService(VersionService versionService) { + this.versionService = versionService; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/ShareService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/ShareService.java new file mode 100644 index 00000000..cf5860e6 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/ShareService.java @@ -0,0 +1,133 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import net.sourceforge.subsonic.domain.MediaFile; +import org.apache.commons.lang.ObjectUtils; +import org.apache.commons.lang.RandomStringUtils; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.dao.ShareDao; +import net.sourceforge.subsonic.domain.Share; +import net.sourceforge.subsonic.domain.User; + +/** + * Provides services for sharing media. + * + * @author Sindre Mehus + * @see Share + */ +public class ShareService { + + private static final Logger LOG = Logger.getLogger(ShareService.class); + + private ShareDao shareDao; + private SecurityService securityService; + private SettingsService settingsService; + private MediaFileService mediaFileService; + + public List<Share> getAllShares() { + return shareDao.getAllShares(); + } + + public List<Share> getSharesForUser(User user) { + List<Share> result = new ArrayList<Share>(); + for (Share share : getAllShares()) { + if (user.isAdminRole() || ObjectUtils.equals(user.getUsername(), share.getUsername())) { + result.add(share); + } + } + return result; + } + + public Share getShareById(int id) { + return shareDao.getShareById(id); + } + + public List<MediaFile> getSharedFiles(int id) { + List<MediaFile> result = new ArrayList<MediaFile>(); + for (String path : shareDao.getSharedFiles(id)) { + try { + result.add(mediaFileService.getMediaFile(path)); + } catch (Exception x) { + // Ignored + } + } + return result; + } + + public Share createShare(HttpServletRequest request, List<MediaFile> files) throws Exception { + + Share share = new Share(); + share.setName(RandomStringUtils.random(5, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")); + share.setCreated(new Date()); + share.setUsername(securityService.getCurrentUsername(request)); + + Calendar expires = Calendar.getInstance(); + expires.add(Calendar.YEAR, 1); + share.setExpires(expires.getTime()); + + shareDao.createShare(share); + for (MediaFile file : files) { + shareDao.createSharedFiles(share.getId(), file.getPath()); + } + LOG.info("Created share '" + share.getName() + "' with " + files.size() + " file(s)."); + + return share; + } + + public void updateShare(Share share) { + shareDao.updateShare(share); + } + + public void deleteShare(int id) { + shareDao.deleteShare(id); + } + + public String getShareBaseUrl() { + return "http://" + settingsService.getUrlRedirectFrom() + ".subsonic.org/share/"; + } + + public String getShareUrl(Share share) { + return getShareBaseUrl() + share.getName(); + } + + public void setSecurityService(SecurityService securityService) { + this.securityService = securityService; + } + + public void setShareDao(ShareDao shareDao) { + this.shareDao = shareDao; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setMediaFileService(MediaFileService mediaFileService) { + this.mediaFileService = mediaFileService; + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/StatusService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/StatusService.java new file mode 100644 index 00000000..a893166a --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/StatusService.java @@ -0,0 +1,134 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.domain.Player; +import net.sourceforge.subsonic.domain.TransferStatus; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Provides services for maintaining the list of stream, download and upload statuses. + * <p/> + * Note that for stream statuses, the last inactive status is also stored. + * + * @author Sindre Mehus + * @see TransferStatus + */ +public class StatusService { + + private final List<TransferStatus> streamStatuses = new ArrayList<TransferStatus>(); + private final List<TransferStatus> downloadStatuses = new ArrayList<TransferStatus>(); + private final List<TransferStatus> uploadStatuses = new ArrayList<TransferStatus>(); + + // Maps from player ID to latest inactive stream status. + private final Map<String, TransferStatus> inactiveStreamStatuses = new LinkedHashMap<String, TransferStatus>(); + + public synchronized TransferStatus createStreamStatus(Player player) { + // Reuse existing status, if possible. + TransferStatus status = inactiveStreamStatuses.get(player.getId()); + if (status != null) { + status.setActive(true); + } else { + status = createStatus(player, streamStatuses); + } + return status; + } + + public synchronized void removeStreamStatus(TransferStatus status) { + // Move it to the map of inactive statuses. + status.setActive(false); + inactiveStreamStatuses.put(status.getPlayer().getId(), status); + streamStatuses.remove(status); + } + + public synchronized List<TransferStatus> getAllStreamStatuses() { + + List<TransferStatus> result = new ArrayList<TransferStatus>(streamStatuses); + + // Add inactive status for those players that have no active status. + Set<String> activePlayers = new HashSet<String>(); + for (TransferStatus status : streamStatuses) { + activePlayers.add(status.getPlayer().getId()); + } + + for (Map.Entry<String, TransferStatus> entry : inactiveStreamStatuses.entrySet()) { + if (!activePlayers.contains(entry.getKey())) { + result.add(entry.getValue()); + } + } + return result; + } + + public synchronized List<TransferStatus> getStreamStatusesForPlayer(Player player) { + List<TransferStatus> result = new ArrayList<TransferStatus>(); + for (TransferStatus status : streamStatuses) { + if (status.getPlayer().getId().equals(player.getId())) { + result.add(status); + } + } + + // If no active statuses exists, add the inactive one. + if (result.isEmpty()) { + TransferStatus inactiveStatus = inactiveStreamStatuses.get(player.getId()); + if (inactiveStatus != null) { + result.add(inactiveStatus); + } + } + + return result; + } + + public synchronized TransferStatus createDownloadStatus(Player player) { + return createStatus(player, downloadStatuses); + } + + public synchronized void removeDownloadStatus(TransferStatus status) { + downloadStatuses.remove(status); + } + + public synchronized List<TransferStatus> getAllDownloadStatuses() { + return new ArrayList<TransferStatus>(downloadStatuses); + } + + public synchronized TransferStatus createUploadStatus(Player player) { + return createStatus(player, uploadStatuses); + } + + public synchronized void removeUploadStatus(TransferStatus status) { + uploadStatuses.remove(status); + } + + public synchronized List<TransferStatus> getAllUploadStatuses() { + return new ArrayList<TransferStatus>(uploadStatuses); + } + + private synchronized TransferStatus createStatus(Player player, List<TransferStatus> statusList) { + TransferStatus status = new TransferStatus(); + status.setPlayer(player); + statusList.add(status); + return status; + } + +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/TranscodingService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/TranscodingService.java new file mode 100644 index 00000000..2c8b9c5e --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/TranscodingService.java @@ -0,0 +1,530 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.controller.VideoPlayerController; +import net.sourceforge.subsonic.dao.TranscodingDao; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.Player; +import net.sourceforge.subsonic.domain.TranscodeScheme; +import net.sourceforge.subsonic.domain.Transcoding; +import net.sourceforge.subsonic.domain.UserSettings; +import net.sourceforge.subsonic.domain.VideoTranscodingSettings; +import net.sourceforge.subsonic.io.TranscodeInputStream; +import net.sourceforge.subsonic.util.StringUtil; +import net.sourceforge.subsonic.util.Util; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.filefilter.PrefixFileFilter; +import org.apache.commons.lang.StringUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +/** + * Provides services for transcoding media. Transcoding is the process of + * converting an audio stream to a different format and/or bit rate. The latter is + * also called downsampling. + * + * @author Sindre Mehus + * @see TranscodeInputStream + */ +public class TranscodingService { + + private static final Logger LOG = Logger.getLogger(TranscodingService.class); + + private TranscodingDao transcodingDao; + private SettingsService settingsService; + private PlayerService playerService; + + /** + * Returns all transcodings. + * + * @return Possibly empty list of all transcodings. + */ + public List<Transcoding> getAllTranscodings() { + return transcodingDao.getAllTranscodings(); + } + + /** + * Returns all active transcodings for the given player. Only enabled transcodings are returned. + * + * @param player The player. + * @return All active transcodings for the player. + */ + public List<Transcoding> getTranscodingsForPlayer(Player player) { + return transcodingDao.getTranscodingsForPlayer(player.getId()); + } + + /** + * Sets the list of active transcodings for the given player. + * + * @param player The player. + * @param transcodingIds ID's of the active transcodings. + */ + public void setTranscodingsForPlayer(Player player, int[] transcodingIds) { + transcodingDao.setTranscodingsForPlayer(player.getId(), transcodingIds); + } + + /** + * Sets the list of active transcodings for the given player. + * + * @param player The player. + * @param transcodings The active transcodings. + */ + public void setTranscodingsForPlayer(Player player, List<Transcoding> transcodings) { + int[] transcodingIds = new int[transcodings.size()]; + for (int i = 0; i < transcodingIds.length; i++) { + transcodingIds[i] = transcodings.get(i).getId(); + } + setTranscodingsForPlayer(player, transcodingIds); + } + + + /** + * Creates a new transcoding. + * + * @param transcoding The transcoding to create. + */ + public void createTranscoding(Transcoding transcoding) { + transcodingDao.createTranscoding(transcoding); + + // Activate this transcoding for all players? + if (transcoding.isDefaultActive()) { + for (Player player : playerService.getAllPlayers()) { + List<Transcoding> transcodings = getTranscodingsForPlayer(player); + transcodings.add(transcoding); + setTranscodingsForPlayer(player, transcodings); + } + } + } + + /** + * Deletes the transcoding with the given ID. + * + * @param id The transcoding ID. + */ + public void deleteTranscoding(Integer id) { + transcodingDao.deleteTranscoding(id); + } + + /** + * Updates the given transcoding. + * + * @param transcoding The transcoding to update. + */ + public void updateTranscoding(Transcoding transcoding) { + transcodingDao.updateTranscoding(transcoding); + } + + /** + * Returns whether transcoding is required for the given media file and player combination. + * + * @param mediaFile The media file. + * @param player The player. + * @return Whether transcoding will be performed if invoking the + * {@link #getTranscodedInputStream} method with the same arguments. + */ + public boolean isTranscodingRequired(MediaFile mediaFile, Player player) { + return getTranscoding(mediaFile, player, null) != null; + } + + /** + * Returns the suffix for the given player and media file, taking transcodings into account. + * + * @param player The player in question. + * @param file The media file. + * @param preferredTargetFormat Used to select among multiple applicable transcodings. May be {@code null}. + * @return The file suffix, e.g., "mp3". + */ + public String getSuffix(Player player, MediaFile file, String preferredTargetFormat) { + Transcoding transcoding = getTranscoding(file, player, preferredTargetFormat); + return transcoding != null ? transcoding.getTargetFormat() : file.getFormat(); + } + + /** + * Creates parameters for a possibly transcoded or downsampled input stream for the given media file and player combination. + * <p/> + * A transcoding is applied if it is applicable for the format of the given file, and is activated for the + * given player. + * <p/> + * If no transcoding is applicable, the file may still be downsampled, given that the player is configured + * with a bit rate limit which is higher than the actual bit rate of the file. + * <p/> + * Otherwise, a normal input stream to the original file is returned. + * + * @param mediaFile The media file. + * @param player The player. + * @param maxBitRate Overrides the per-player and per-user bitrate limit. May be {@code null}. + * @param preferredTargetFormat Used to select among multiple applicable transcodings. May be {@code null}. + * @param videoTranscodingSettings Parameters used when transcoding video. May be {@code null}. + * @return Parameters to be used in the {@link #getTranscodedInputStream} method. + */ + public Parameters getParameters(MediaFile mediaFile, Player player, Integer maxBitRate, String preferredTargetFormat, + VideoTranscodingSettings videoTranscodingSettings) { + + Parameters parameters = new Parameters(mediaFile, videoTranscodingSettings); + + TranscodeScheme transcodeScheme = getTranscodeScheme(player); + if (maxBitRate == null && transcodeScheme != TranscodeScheme.OFF) { + maxBitRate = transcodeScheme.getMaxBitRate(); + } + + Transcoding transcoding = getTranscoding(mediaFile, player, preferredTargetFormat); + if (transcoding != null) { + parameters.setTranscoding(transcoding); + if (maxBitRate == null) { + maxBitRate = mediaFile.isVideo() ? VideoPlayerController.DEFAULT_BIT_RATE : 128; + } + } else if (maxBitRate != null) { + boolean supported = isDownsamplingSupported(mediaFile); + Integer bitRate = mediaFile.getBitRate(); + if (supported && bitRate != null && bitRate > maxBitRate) { + parameters.setDownsample(true); + } + } + + parameters.setMaxBitRate(maxBitRate); + return parameters; + } + + /** + * Returns a possibly transcoded or downsampled input stream for the given music file and player combination. + * <p/> + * A transcoding is applied if it is applicable for the format of the given file, and is activated for the + * given player. + * <p/> + * If no transcoding is applicable, the file may still be downsampled, given that the player is configured + * with a bit rate limit which is higher than the actual bit rate of the file. + * <p/> + * Otherwise, a normal input stream to the original file is returned. + * + * @param parameters As returned by {@link #getParameters}. + * @return A possible transcoded or downsampled input stream. + * @throws IOException If an I/O error occurs. + */ + public InputStream getTranscodedInputStream(Parameters parameters) throws IOException { + try { + + if (parameters.getTranscoding() != null) { + return createTranscodedInputStream(parameters); + } + + if (parameters.downsample) { + return createDownsampledInputStream(parameters); + } + + } catch (Exception x) { + LOG.warn("Failed to transcode " + parameters.getMediaFile() + ". Using original.", x); + } + + return new FileInputStream(parameters.getMediaFile().getFile()); + } + + + /** + * Returns the strictest transcoding scheme defined for the player and the user. + */ + private TranscodeScheme getTranscodeScheme(Player player) { + String username = player.getUsername(); + if (username != null) { + UserSettings userSettings = settingsService.getUserSettings(username); + return player.getTranscodeScheme().strictest(userSettings.getTranscodeScheme()); + } + + return player.getTranscodeScheme(); + } + + /** + * Returns an input stream by applying the given transcoding to the given music file. + * + * @param parameters Transcoding parameters. + * @return The transcoded input stream. + * @throws IOException If an I/O error occurs. + */ + private InputStream createTranscodedInputStream(Parameters parameters) + throws IOException { + + Transcoding transcoding = parameters.getTranscoding(); + Integer maxBitRate = parameters.getMaxBitRate(); + VideoTranscodingSettings videoTranscodingSettings = parameters.getVideoTranscodingSettings(); + MediaFile mediaFile = parameters.getMediaFile(); + + TranscodeInputStream in = createTranscodeInputStream(transcoding.getStep1(), maxBitRate, videoTranscodingSettings, mediaFile, null); + + if (transcoding.getStep2() != null) { + in = createTranscodeInputStream(transcoding.getStep2(), maxBitRate, videoTranscodingSettings, mediaFile, in); + } + + if (transcoding.getStep3() != null) { + in = createTranscodeInputStream(transcoding.getStep3(), maxBitRate, videoTranscodingSettings, mediaFile, in); + } + + return in; + } + + /** + * Creates a transcoded input stream by interpreting the given command line string. + * This includes the following: + * <ul> + * <li>Splitting the command line string to an array.</li> + * <li>Replacing occurrences of "%s" with the path of the given music file.</li> + * <li>Replacing occurrences of "%t" with the title of the given music file.</li> + * <li>Replacing occurrences of "%l" with the album name of the given music file.</li> + * <li>Replacing occurrences of "%a" with the artist name of the given music file.</li> + * <li>Replacing occurrcences of "%b" with the max bitrate.</li> + * <li>Replacing occurrcences of "%o" with the video time offset (used for scrubbing).</li> + * <li>Replacing occurrcences of "%w" with the video image width.</li> + * <li>Replacing occurrcences of "%h" with the video image height.</li> + * <li>Prepending the path of the transcoder directory if the transcoder is found there.</li> + * </ul> + * + * @param command The command line string. + * @param maxBitRate The maximum bitrate to use. May not be {@code null}. + * @param videoTranscodingSettings Parameters used when transcoding video. May be {@code null}. + * @param mediaFile The media file. + * @param in Data to feed to the process. May be {@code null}. @return The newly created input stream. + */ + private TranscodeInputStream createTranscodeInputStream(String command, Integer maxBitRate, + VideoTranscodingSettings videoTranscodingSettings, MediaFile mediaFile, InputStream in) throws IOException { + + String title = mediaFile.getTitle(); + String album = mediaFile.getAlbumName(); + String artist = mediaFile.getArtist(); + + if (title == null) { + title = "Unknown Song"; + } + if (album == null) { + title = "Unknown Album"; + } + if (artist == null) { + title = "Unknown Artist"; + } + + List<String> result = new LinkedList<String>(Arrays.asList(StringUtil.split(command))); + result.set(0, getTranscodeDirectory().getPath() + File.separatorChar + result.get(0)); + + File tmpFile = null; + + for (int i = 1; i < result.size(); i++) { + String cmd = result.get(i); + if (cmd.contains("%b")) { + cmd = cmd.replace("%b", String.valueOf(maxBitRate)); + } + if (cmd.contains("%t")) { + cmd = cmd.replace("%t", title); + } + if (cmd.contains("%l")) { + cmd = cmd.replace("%l", album); + } + if (cmd.contains("%a")) { + cmd = cmd.replace("%a", artist); + } + if (cmd.contains("%o") && videoTranscodingSettings != null) { + cmd = cmd.replace("%o", String.valueOf(videoTranscodingSettings.getTimeOffset())); + } + if (cmd.contains("%w") && videoTranscodingSettings != null) { + cmd = cmd.replace("%w", String.valueOf(videoTranscodingSettings.getWidth())); + } + if (cmd.contains("%h") && videoTranscodingSettings != null) { + cmd = cmd.replace("%h", String.valueOf(videoTranscodingSettings.getHeight())); + } + if (cmd.contains("%s")) { + + // Work-around for filename character encoding problem on Windows. + // Create temporary file, and feed this to the transcoder. + String path = mediaFile.getFile().getAbsolutePath(); + if (Util.isWindows() && !mediaFile.isVideo() && !StringUtils.isAsciiPrintable(path)) { + tmpFile = File.createTempFile("subsonic", "." + FilenameUtils.getExtension(path)); + tmpFile.deleteOnExit(); + FileUtils.copyFile(new File(path), tmpFile); + LOG.debug("Created tmp file: " + tmpFile); + cmd = cmd.replace("%s", tmpFile.getPath()); + } else { + cmd = cmd.replace("%s", path); + } + } + + result.set(i, cmd); + } + return new TranscodeInputStream(new ProcessBuilder(result), in, tmpFile); + } + + /** + * Returns an applicable transcoding for the given file and player, or <code>null</code> if no + * transcoding should be done. + */ + private Transcoding getTranscoding(MediaFile mediaFile, Player player, String preferredTargetFormat) { + + List<Transcoding> applicableTranscodings = new LinkedList<Transcoding>(); + String suffix = mediaFile.getFormat(); + + for (Transcoding transcoding : getTranscodingsForPlayer(player)) { + for (String sourceFormat : transcoding.getSourceFormatsAsArray()) { + if (sourceFormat.equalsIgnoreCase(suffix)) { + if (isTranscodingInstalled(transcoding)) { + applicableTranscodings.add(transcoding); + } + } + } + } + + if (applicableTranscodings.isEmpty()) { + return null; + } + + for (Transcoding transcoding : applicableTranscodings) { + if (transcoding.getTargetFormat().equalsIgnoreCase(preferredTargetFormat)) { + return transcoding; + } + } + + return applicableTranscodings.get(0); + } + + /** + * Returns a downsampled input stream to the music file. + * + * @param parameters Downsample parameters. + * @throws IOException If an I/O error occurs. + */ + private InputStream createDownsampledInputStream(Parameters parameters) throws IOException { + String command = settingsService.getDownsamplingCommand(); + return createTranscodeInputStream(command, parameters.getMaxBitRate(), parameters.getVideoTranscodingSettings(), + parameters.getMediaFile(), null); + } + + /** + * Returns whether downsampling is supported (i.e., whether LAME is installed or not.) + * + * @param mediaFile If not null, returns whether downsampling is supported for this file. + * @return Whether downsampling is supported. + */ + public boolean isDownsamplingSupported(MediaFile mediaFile) { + if (mediaFile != null) { + boolean isMp3 = "mp3".equalsIgnoreCase(mediaFile.getFormat()); + if (!isMp3) { + return false; + } + } + + String commandLine = settingsService.getDownsamplingCommand(); + return isTranscodingStepInstalled(commandLine); + } + + private boolean isTranscodingInstalled(Transcoding transcoding) { + return isTranscodingStepInstalled(transcoding.getStep1()) && + isTranscodingStepInstalled(transcoding.getStep2()) && + isTranscodingStepInstalled(transcoding.getStep3()); + } + + private boolean isTranscodingStepInstalled(String step) { + if (StringUtils.isEmpty(step)) { + return true; + } + String executable = StringUtil.split(step)[0]; + PrefixFileFilter filter = new PrefixFileFilter(executable); + String[] matches = getTranscodeDirectory().list(filter); + return matches != null && matches.length > 0; + } + + /** + * Returns the directory in which all transcoders are installed. + */ + public File getTranscodeDirectory() { + File dir = new File(SettingsService.getSubsonicHome(), "transcode"); + if (!dir.exists()) { + boolean ok = dir.mkdir(); + if (ok) { + LOG.info("Created directory " + dir); + } else { + LOG.warn("Failed to create directory " + dir); + } + } + return dir; + } + + public void setTranscodingDao(TranscodingDao transcodingDao) { + this.transcodingDao = transcodingDao; + } + + public void setSettingsService(SettingsService settingsService) { + this.settingsService = settingsService; + } + + public void setPlayerService(PlayerService playerService) { + this.playerService = playerService; + } + + public static class Parameters { + private boolean downsample; + private final MediaFile mediaFile; + private final VideoTranscodingSettings videoTranscodingSettings; + private Integer maxBitRate; + private Transcoding transcoding; + + public Parameters(MediaFile mediaFile, VideoTranscodingSettings videoTranscodingSettings) { + this.mediaFile = mediaFile; + this.videoTranscodingSettings = videoTranscodingSettings; + } + + public void setMaxBitRate(Integer maxBitRate) { + this.maxBitRate = maxBitRate; + } + + public boolean isDownsample() { + return downsample; + } + + public void setDownsample(boolean downsample) { + this.downsample = downsample; + } + + public boolean isTranscode() { + return transcoding != null; + } + + public void setTranscoding(Transcoding transcoding) { + this.transcoding = transcoding; + } + + public Transcoding getTranscoding() { + return transcoding; + } + + public MediaFile getMediaFile() { + return mediaFile; + } + + public Integer getMaxBitRate() { + return maxBitRate; + } + + public VideoTranscodingSettings getVideoTranscodingSettings() { + return videoTranscodingSettings; + } + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/VersionService.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/VersionService.java new file mode 100644 index 00000000..e24e6409 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/VersionService.java @@ -0,0 +1,267 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.domain.Version; +import org.apache.commons.io.IOUtils; +import org.apache.http.client.HttpClient; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.BasicResponseHandler; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.params.HttpConnectionParams; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Provides version-related services, including functionality for determining whether a newer + * version of Subsonic is available. + * + * @author Sindre Mehus + */ +public class VersionService { + + private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyyMMdd"); + private static final Logger LOG = Logger.getLogger(VersionService.class); + + private Version localVersion; + private Version latestFinalVersion; + private Version latestBetaVersion; + private Date localBuildDate; + private String localBuildNumber; + + /** + * Time when latest version was fetched (in milliseconds). + */ + private long lastVersionFetched; + + /** + * Only fetch last version this often (in milliseconds.). + */ + private static final long LAST_VERSION_FETCH_INTERVAL = 7L * 24L * 3600L * 1000L; // One week + + /** + * URL from which to fetch latest versions. + */ + private static final String VERSION_URL = "http://subsonic.org/backend/version.view"; + + /** + * Returns the version number for the locally installed Subsonic version. + * + * @return The version number for the locally installed Subsonic version. + */ + public synchronized Version getLocalVersion() { + if (localVersion == null) { + try { + localVersion = new Version(readLineFromResource("/version.txt")); + LOG.info("Resolved local Subsonic version to: " + localVersion); + } catch (Exception x) { + LOG.warn("Failed to resolve local Subsonic version.", x); + } + } + return localVersion; + } + + /** + * Returns the version number for the latest available Subsonic final version. + * + * @return The version number for the latest available Subsonic final version, or <code>null</code> + * if the version number can't be resolved. + */ + public synchronized Version getLatestFinalVersion() { + refreshLatestVersion(); + return latestFinalVersion; + } + + /** + * Returns the version number for the latest available Subsonic beta version. + * + * @return The version number for the latest available Subsonic beta version, or <code>null</code> + * if the version number can't be resolved. + */ + public synchronized Version getLatestBetaVersion() { + refreshLatestVersion(); + return latestBetaVersion; + } + + /** + * Returns the build date for the locally installed Subsonic version. + * + * @return The build date for the locally installed Subsonic version, or <code>null</code> + * if the build date can't be resolved. + */ + public synchronized Date getLocalBuildDate() { + if (localBuildDate == null) { + try { + String date = readLineFromResource("/build_date.txt"); + localBuildDate = DATE_FORMAT.parse(date); + } catch (Exception x) { + LOG.warn("Failed to resolve local Subsonic build date.", x); + } + } + return localBuildDate; + } + + /** + * Returns the build number for the locally installed Subsonic version. + * + * @return The build number for the locally installed Subsonic version, or <code>null</code> + * if the build number can't be resolved. + */ + public synchronized String getLocalBuildNumber() { + if (localBuildNumber == null) { + try { + localBuildNumber = readLineFromResource("/build_number.txt"); + } catch (Exception x) { + LOG.warn("Failed to resolve local Subsonic build number.", x); + } + } + return localBuildNumber; + } + + /** + * Returns whether a new final version of Subsonic is available. + * + * @return Whether a new final version of Subsonic is available. + */ + public boolean isNewFinalVersionAvailable() { + Version latest = getLatestFinalVersion(); + Version local = getLocalVersion(); + + if (latest == null || local == null) { + return false; + } + + return local.compareTo(latest) < 0; + } + + /** + * Returns whether a new beta version of Subsonic is available. + * + * @return Whether a new beta version of Subsonic is available. + */ + public boolean isNewBetaVersionAvailable() { + Version latest = getLatestBetaVersion(); + Version local = getLocalVersion(); + + if (latest == null || local == null) { + return false; + } + + return local.compareTo(latest) < 0; + } + + /** + * Reads the first line from the resource with the given name. + * + * @param resourceName The resource name. + * @return The first line of the resource. + */ + private String readLineFromResource(String resourceName) { + InputStream in = VersionService.class.getResourceAsStream(resourceName); + if (in == null) { + return null; + } + BufferedReader reader = null; + try { + + reader = new BufferedReader(new InputStreamReader(in)); + return reader.readLine(); + + } catch (IOException x) { + return null; + } finally { + IOUtils.closeQuietly(reader); + IOUtils.closeQuietly(in); + } + } + + /** + * Refreshes the latest final and beta versions. + */ + private void refreshLatestVersion() { + long now = System.currentTimeMillis(); + boolean isOutdated = now - lastVersionFetched > LAST_VERSION_FETCH_INTERVAL; + + if (isOutdated) { + try { + lastVersionFetched = now; + readLatestVersion(); + } catch (Exception x) { + LOG.warn("Failed to resolve latest Subsonic version.", x); + } + } + } + + /** + * Resolves the latest available Subsonic version by screen-scraping a web page. + * + * @throws IOException If an I/O error occurs. + */ + private void readLatestVersion() throws IOException { + + HttpClient client = new DefaultHttpClient(); + HttpConnectionParams.setConnectionTimeout(client.getParams(), 10000); + HttpConnectionParams.setSoTimeout(client.getParams(), 10000); + HttpGet method = new HttpGet(VERSION_URL + "?v=" + getLocalVersion()); + String content; + try { + + ResponseHandler<String> responseHandler = new BasicResponseHandler(); + content = client.execute(method, responseHandler); + + } finally { + client.getConnectionManager().shutdown(); + } + + BufferedReader reader = new BufferedReader(new StringReader(content)); + Pattern finalPattern = Pattern.compile("SUBSONIC_FULL_VERSION_BEGIN(.*)SUBSONIC_FULL_VERSION_END"); + Pattern betaPattern = Pattern.compile("SUBSONIC_BETA_VERSION_BEGIN(.*)SUBSONIC_BETA_VERSION_END"); + + try { + String line = reader.readLine(); + while (line != null) { + Matcher finalMatcher = finalPattern.matcher(line); + if (finalMatcher.find()) { + latestFinalVersion = new Version(finalMatcher.group(1)); + LOG.info("Resolved latest Subsonic final version to: " + latestFinalVersion); + } + Matcher betaMatcher = betaPattern.matcher(line); + if (betaMatcher.find()) { + latestBetaVersion = new Version(betaMatcher.group(1)); + LOG.info("Resolved latest Subsonic beta version to: " + latestBetaVersion); + } + line = reader.readLine(); + } + + } finally { + reader.close(); + } + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/AudioPlayer.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/AudioPlayer.java new file mode 100644 index 00000000..902387be --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/AudioPlayer.java @@ -0,0 +1,207 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.jukebox; + +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicReference; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.FloatControl; +import javax.sound.sampled.SourceDataLine; + +import org.apache.commons.io.IOUtils; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.service.JukeboxService; + +import static net.sourceforge.subsonic.service.jukebox.AudioPlayer.State.*; + +/** + * A simple wrapper for playing sound from an input stream. + * <p/> + * Supports pause and resume, but not restarting. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class AudioPlayer { + + private static final Logger LOG = Logger.getLogger(JukeboxService.class); + + private final InputStream in; + private final Listener listener; + private final SourceDataLine line; + private final AtomicReference<State> state = new AtomicReference<State>(PAUSED); + private FloatControl gainControl; + + public AudioPlayer(InputStream in, Listener listener) throws Exception { + this.in = new BufferedInputStream(in); + this.listener = listener; + + AudioFormat format = AudioSystem.getAudioFileFormat(this.in).getFormat(); + line = AudioSystem.getSourceDataLine(format); + line.open(format); + LOG.debug("Opened line " + line); + + if (line.isControlSupported(FloatControl.Type.MASTER_GAIN)) { + gainControl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); + setGain(0.5f); + } + new AudioDataWriter(); + } + + /** + * Starts (or resumes) the player. This only has effect if the current state is + * {@link State#PAUSED}. + */ + public synchronized void play() { + if (state.get() == PAUSED) { + line.start(); + setState(PLAYING); + } + } + + /** + * Pauses the player. This only has effect if the current state is + * {@link State#PLAYING}. + */ + public synchronized void pause() { + if (state.get() == PLAYING) { + setState(PAUSED); + line.stop(); + line.flush(); + } + } + + /** + * Closes the player, releasing all resources. After this the player state is + * {@link State#CLOSED} (unless the current state is {@link State#EOM}). + */ + public synchronized void close() { + if (state.get() != CLOSED && state.get() != EOM) { + setState(CLOSED); + } + + try { + line.stop(); + } catch (Throwable x) { + LOG.warn("Failed to stop player: " + x, x); + } + try { + if (line.isOpen()) { + line.close(); + LOG.debug("Closed line " + line); + } + } catch (Throwable x) { + LOG.warn("Failed to close player: " + x, x); + } + IOUtils.closeQuietly(in); + } + + /** + * Returns the player state. + */ + public State getState() { + return state.get(); + } + + /** + * Sets the gain. + * + * @param gain The gain between 0.0 and 1.0. + */ + public void setGain(float gain) { + if (gainControl != null) { + + double minGainDB = gainControl.getMinimum(); + double maxGainDB = gainControl.getMaximum(); + double ampGainDB = 0.5f * maxGainDB - minGainDB; + double cste = Math.log(10.0) / 20; + double valueDB = minGainDB + (1 / cste) * Math.log(1 + (Math.exp(cste * ampGainDB) - 1) * gain); + + valueDB = Math.min(valueDB, maxGainDB); + valueDB = Math.max(valueDB, minGainDB); + + gainControl.setValue((float) valueDB); + } + } + + /** + * Returns the position in seconds. + */ + public int getPosition() { + return (int) (line.getMicrosecondPosition() / 1000000L); + } + + private void setState(State state) { + if (this.state.getAndSet(state) != state && listener != null) { + listener.stateChanged(this, state); + } + } + + private class AudioDataWriter implements Runnable { + + public AudioDataWriter() { + new Thread(this).start(); + } + + public void run() { + try { + byte[] buffer = new byte[8192]; + + while (true) { + + switch (state.get()) { + case CLOSED: + case EOM: + return; + case PAUSED: + Thread.sleep(250); + break; + case PLAYING: + int n = in.read(buffer); + if (n == -1) { + setState(EOM); + return; + } + line.write(buffer, 0, n); + break; + } + } + } catch (Throwable x) { + LOG.warn("Error when copying audio data: " + x, x); + } finally { + close(); + } + } + } + + public interface Listener { + void stateChanged(AudioPlayer player, State state); + } + + public static enum State { + PAUSED, + PLAYING, + CLOSED, + EOM + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/PlayerTest.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/PlayerTest.java new file mode 100644 index 00000000..30ed2847 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/PlayerTest.java @@ -0,0 +1,75 @@ +package net.sourceforge.subsonic.service.jukebox; + +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.FileInputStream; + +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JSlider; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class PlayerTest implements AudioPlayer.Listener { + + private AudioPlayer player; + + public PlayerTest() throws Exception { + player = new AudioPlayer(new FileInputStream("i:\\tmp\\foo.au"), this); + createGUI(); + } + + private void createGUI() { + JFrame frame = new JFrame(); + + JButton startButton = new JButton("Start"); + JButton stopButton = new JButton("Stop"); + JButton resetButton = new JButton("Reset"); + final JSlider gainSlider = new JSlider(0, 1000); + + startButton.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + player.play(); + } + }); + stopButton.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + player.pause(); + } + }); + resetButton.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + player.close(); + } + }); + gainSlider.addChangeListener(new ChangeListener() { + public void stateChanged(ChangeEvent e) { + float gain = (float) gainSlider.getValue() / 1000.0F; + player.setGain(gain); + } + }); + + frame.setLayout(new FlowLayout()); + frame.add(startButton); + frame.add(stopButton); + frame.add(resetButton); + frame.add(gainSlider); + + frame.pack(); + frame.setVisible(true); + } + + public static void main(String[] args) throws Exception { + new PlayerTest(); + } + + public void stateChanged(AudioPlayer player, AudioPlayer.State state) { + System.out.println(state); + } +} + diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/DefaultMetaDataParser.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/DefaultMetaDataParser.java new file mode 100644 index 00000000..897f39d4 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/DefaultMetaDataParser.java @@ -0,0 +1,74 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.metadata; + +import java.io.File; + +import net.sourceforge.subsonic.domain.MediaFile; + +/** + * Parses meta data by guessing artist, album and song title based on the path of the file. + * + * @author Sindre Mehus + */ +public class DefaultMetaDataParser extends MetaDataParser { + + /** + * Parses meta data for the given file. No guessing or reformatting is done. + * + * @param file The file to parse. + * @return Meta data for the file. + */ + public MetaData getRawMetaData(File file) { + MetaData metaData = new MetaData(); + metaData.setArtist(guessArtist(file)); + metaData.setAlbumName(guessAlbum(file, metaData.getArtist())); + metaData.setTitle(guessTitle(file)); + return metaData; + } + + /** + * Updates the given file with the given meta data. + * This method has no effect. + * + * @param file The file to update. + * @param metaData The new meta data. + */ + public void setMetaData(MediaFile file, MetaData metaData) { + } + + /** + * Returns whether this parser supports tag editing (using the {@link #setMetaData} method). + * + * @return Always false. + */ + public boolean isEditingSupported() { + return false; + } + + /** + * Returns whether this parser is applicable to the given file. + * + * @param file The file in question. + * @return Whether this parser is applicable to the given file. + */ + public boolean isApplicable(File file) { + return file.isFile(); + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/FFmpegParser.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/FFmpegParser.java new file mode 100644 index 00000000..60ae1750 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/FFmpegParser.java @@ -0,0 +1,170 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.metadata; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.io.InputStreamReaderThread; +import net.sourceforge.subsonic.service.ServiceLocator; +import net.sourceforge.subsonic.service.TranscodingService; +import net.sourceforge.subsonic.util.StringUtil; +import org.apache.commons.io.FilenameUtils; + +import java.io.File; +import java.io.InputStream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses meta data from video files using FFmpeg (http://ffmpeg.org/). + * <p/> + * Currently duration, bitrate and dimension are supported. + * + * @author Sindre Mehus + */ +public class FFmpegParser extends MetaDataParser { + + private static final Logger LOG = Logger.getLogger(FFmpegParser.class); + private static final Pattern DURATION_PATTERN = Pattern.compile("Duration: (\\d+):(\\d+):(\\d+).(\\d+)"); + private static final Pattern BITRATE_PATTERN = Pattern.compile("bitrate: (\\d+) kb/s"); + private static final Pattern DIMENSION_PATTERN = Pattern.compile("Video.*?, (\\d+)x(\\d+)"); + private static final Pattern PAR_PATTERN = Pattern.compile("PAR (\\d+):(\\d+)"); + + private TranscodingService transcodingService; + + /** + * Parses meta data for the given music file. No guessing or reformatting is done. + * + * + * @param file The music file to parse. + * @return Meta data for the file. + */ + @Override + public MetaData getRawMetaData(File file) { + + MetaData metaData = new MetaData(); + + try { + + File ffmpeg = new File(transcodingService.getTranscodeDirectory(), "ffmpeg"); + + String[] command = new String[]{ffmpeg.getAbsolutePath(), "-i", file.getAbsolutePath()}; + Process process = Runtime.getRuntime().exec(command); + InputStream stdout = process.getInputStream(); + InputStream stderr = process.getErrorStream(); + + // Consume stdout, we're not interested in that. + new InputStreamReaderThread(stdout, "ffmpeg", true).start(); + + // Read everything from stderr. It will contain text similar to: + // Input #0, avi, from 'foo.avi': + // Duration: 00:00:33.90, start: 0.000000, bitrate: 2225 kb/s + // Stream #0.0: Video: mpeg4, yuv420p, 352x240 [PAR 1:1 DAR 22:15], 29.97 fps, 29.97 tbr, 29.97 tbn, 30k tbc + // Stream #0.1: Audio: pcm_s16le, 44100 Hz, 2 channels, s16, 1411 kb/s + String[] lines = StringUtil.readLines(stderr); + + Integer width = null; + Integer height = null; + Double par = 1.0; + for (String line : lines) { + + Matcher matcher = DURATION_PATTERN.matcher(line); + if (matcher.find()) { + int hours = Integer.parseInt(matcher.group(1)); + int minutes = Integer.parseInt(matcher.group(2)); + int seconds = Integer.parseInt(matcher.group(3)); + metaData.setDurationSeconds(hours * 3600 + minutes * 60 + seconds); + } + + matcher = BITRATE_PATTERN.matcher(line); + if (matcher.find()) { + metaData.setBitRate(Integer.valueOf(matcher.group(1))); + } + + matcher = DIMENSION_PATTERN.matcher(line); + if (matcher.find()) { + width = Integer.valueOf(matcher.group(1)); + height = Integer.valueOf(matcher.group(2)); + } + + // PAR = Pixel Aspect Rate + matcher = PAR_PATTERN.matcher(line); + if (matcher.find()) { + int a = Integer.parseInt(matcher.group(1)); + int b = Integer.parseInt(matcher.group(2)); + if (a > 0 && b > 0) { + par = (double) a / (double) b; + } + } + } + + if (width != null && height != null) { + width = (int) Math.round(width.doubleValue() * par); + metaData.setWidth(width); + metaData.setHeight(height); + } + + + } catch (Throwable x) { + LOG.warn("Error when parsing metadata in " + file, x); + } + + return metaData; + } + + /** + * Not supported. + */ + @Override + public void setMetaData(MediaFile file, MetaData metaData) { + throw new RuntimeException("setMetaData() not supported in " + getClass().getSimpleName()); + } + + /** + * Returns whether this parser supports tag editing (using the {@link #setMetaData} method). + * + * @return Always false. + */ + @Override + public boolean isEditingSupported() { + return false; + } + + /** + * Returns whether this parser is applicable to the given file. + * + * @param file The file in question. + * @return Whether this parser is applicable to the given file. + */ + @Override + public boolean isApplicable(File file) { + String format = FilenameUtils.getExtension(file.getName()).toLowerCase(); + + for (String s : ServiceLocator.getSettingsService().getVideoFileTypesAsArray()) { + if (format.equals(s)) { + return true; + } + } + return false; + } + + public void setTranscodingService(TranscodingService transcodingService) { + this.transcodingService = transcodingService; + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/JaudiotaggerParser.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/JaudiotaggerParser.java new file mode 100644 index 00000000..8fa7659a --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/JaudiotaggerParser.java @@ -0,0 +1,296 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.metadata; + +import net.sourceforge.subsonic.Logger; +import net.sourceforge.subsonic.domain.MediaFile; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang.StringUtils; +import org.jaudiotagger.audio.AudioFile; +import org.jaudiotagger.audio.AudioFileIO; +import org.jaudiotagger.audio.AudioHeader; +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.Tag; +import org.jaudiotagger.tag.datatype.Artwork; +import org.jaudiotagger.tag.reference.GenreTypes; + +import java.io.File; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.logging.LogManager; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses meta data from audio files using the Jaudiotagger library + * (http://www.jthink.net/jaudiotagger/) + * + * @author Sindre Mehus + */ +public class JaudiotaggerParser extends MetaDataParser { + + private static final Logger LOG = Logger.getLogger(JaudiotaggerParser.class); + private static final Pattern GENRE_PATTERN = Pattern.compile("\\((\\d+)\\).*"); + private static final Pattern TRACK_NUMBER_PATTERN = Pattern.compile("(\\d+)/\\d+"); + + static { + try { + LogManager.getLogManager().reset(); + } catch (Throwable x) { + LOG.warn("Failed to turn off logging from Jaudiotagger.", x); + } + } + + /** + * Parses meta data for the given music file. No guessing or reformatting is done. + * + * + * @param file The music file to parse. + * @return Meta data for the file. + */ + @Override + public MetaData getRawMetaData(File file) { + + MetaData metaData = new MetaData(); + + try { + AudioFile audioFile = AudioFileIO.read(file); + Tag tag = audioFile.getTag(); + if (tag != null) { + metaData.setAlbumName(getTagField(tag, FieldKey.ALBUM)); + metaData.setTitle(getTagField(tag, FieldKey.TITLE)); + metaData.setYear(parseInteger(getTagField(tag, FieldKey.YEAR))); + metaData.setGenre(mapGenre(getTagField(tag, FieldKey.GENRE))); + metaData.setDiscNumber(parseInteger(getTagField(tag, FieldKey.DISC_NO))); + metaData.setTrackNumber(parseTrackNumber(getTagField(tag, FieldKey.TRACK))); + + String songArtist = getTagField(tag, FieldKey.ARTIST); + String albumArtist = getTagField(tag, FieldKey.ALBUM_ARTIST); + metaData.setArtist(StringUtils.isBlank(albumArtist) ? songArtist : albumArtist); + } + + AudioHeader audioHeader = audioFile.getAudioHeader(); + if (audioHeader != null) { + metaData.setVariableBitRate(audioHeader.isVariableBitRate()); + metaData.setBitRate((int) audioHeader.getBitRateAsNumber()); + metaData.setDurationSeconds(audioHeader.getTrackLength()); + } + + + } catch (Throwable x) { + LOG.warn("Error when parsing tags in " + file, x); + } + + return metaData; + } + + private String getTagField(Tag tag, FieldKey fieldKey) { + try { + return StringUtils.trimToNull(tag.getFirst(fieldKey)); + } catch (Exception x) { + // Ignored. + return null; + } + } + + /** + * Returns all tags supported by id3v1. + */ + public static SortedSet<String> getID3V1Genres() { + return new TreeSet<String>(GenreTypes.getInstanceOf().getAlphabeticalValueList()); + } + + /** + * Sometimes the genre is returned as "(17)" or "(17)Rock", instead of "Rock". This method + * maps the genre ID to the corresponding text. + */ + private String mapGenre(String genre) { + if (genre == null) { + return null; + } + Matcher matcher = GENRE_PATTERN.matcher(genre); + if (matcher.matches()) { + int genreId = Integer.parseInt(matcher.group(1)); + if (genreId >= 0 && genreId < GenreTypes.getInstanceOf().getSize()) { + return GenreTypes.getInstanceOf().getValueForId(genreId); + } + } + return genre; + } + + /** + * Parses the track number from the given string. Also supports + * track numbers on the form "4/12". + */ + private Integer parseTrackNumber(String trackNumber) { + if (trackNumber == null) { + return null; + } + + Integer result = null; + + try { + result = new Integer(trackNumber); + } catch (NumberFormatException x) { + Matcher matcher = TRACK_NUMBER_PATTERN.matcher(trackNumber); + if (matcher.matches()) { + try { + result = Integer.valueOf(matcher.group(1)); + } catch (NumberFormatException e) { + return null; + } + } + } + + if (Integer.valueOf(0).equals(result)) { + return null; + } + return result; + } + + private Integer parseInteger(String s) { + s = StringUtils.trimToNull(s); + if (s == null) { + return null; + } + try { + Integer result = Integer.valueOf(s); + if (Integer.valueOf(0).equals(result)) { + return null; + } + return result; + } catch (NumberFormatException x) { + return null; + } + } + + /** + * Updates the given file with the given meta data. + * + * @param file The music file to update. + * @param metaData The new meta data. + */ + @Override + public void setMetaData(MediaFile file, MetaData metaData) { + + try { + AudioFile audioFile = AudioFileIO.read(file.getFile()); + Tag tag = audioFile.getTagOrCreateAndSetDefault(); + + tag.setField(FieldKey.ARTIST, StringUtils.trimToEmpty(metaData.getArtist())); + tag.setField(FieldKey.ALBUM_ARTIST, StringUtils.trimToEmpty(metaData.getArtist())); + tag.setField(FieldKey.ALBUM, StringUtils.trimToEmpty(metaData.getAlbumName())); + tag.setField(FieldKey.TITLE, StringUtils.trimToEmpty(metaData.getTitle())); + tag.setField(FieldKey.GENRE, StringUtils.trimToEmpty(metaData.getGenre())); + + Integer track = metaData.getTrackNumber(); + if (track == null) { + tag.deleteField(FieldKey.TRACK); + } else { + tag.setField(FieldKey.TRACK, String.valueOf(track)); + } + + Integer year = metaData.getYear(); + if (year == null) { + tag.deleteField(FieldKey.YEAR); + } else { + tag.setField(FieldKey.YEAR, String.valueOf(year)); + } + + audioFile.commit(); + + } catch (Throwable x) { + LOG.warn("Failed to update tags for file " + file, x); + throw new RuntimeException("Failed to update tags for file " + file + ". " + x.getMessage(), x); + } + } + + /** + * Returns whether this parser supports tag editing (using the {@link #setMetaData} method). + * + * @return Always true. + */ + @Override + public boolean isEditingSupported() { + return true; + } + + /** + * Returns whether this parser is applicable to the given file. + * + * @param file The music file in question. + * @return Whether this parser is applicable to the given file. + */ + @Override + public boolean isApplicable(File file) { + if (!file.isFile()) { + return false; + } + + String format = FilenameUtils.getExtension(file.getName()).toLowerCase(); + + return format.equals("mp3") || + format.equals("m4a") || + format.equals("aac") || + format.equals("ogg") || + format.equals("flac") || + format.equals("wav") || + format.equals("mpc") || + format.equals("mp+") || + format.equals("ape") || + format.equals("wma"); + } + + /** + * Returns whether cover art image data is available in the given file. + * + * @param file The music file. + * @return Whether cover art image data is available. + */ + public boolean isImageAvailable(MediaFile file) { + try { + return getArtwork(file) != null; + } catch (Throwable x) { + LOG.warn("Failed to find cover art tag in " + file, x); + return false; + } + } + + /** + * Returns the cover art image data embedded in the given file. + * + * @param file The music file. + * @return The embedded cover art image data, or <code>null</code> if not available. + */ + public byte[] getImageData(MediaFile file) { + try { + return getArtwork(file).getBinaryData(); + } catch (Throwable x) { + LOG.warn("Failed to find cover art tag in " + file, x); + return null; + } + } + + private Artwork getArtwork(MediaFile file) throws Exception { + AudioFile audioFile = AudioFileIO.read(file.getFile()); + Tag tag = audioFile.getTag(); + return tag == null ? null : tag.getFirstArtwork(); + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaData.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaData.java new file mode 100644 index 00000000..d3fa08a0 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaData.java @@ -0,0 +1,135 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.metadata; + +/** + * Contains meta-data (song title, artist, album etc) for a music file. + * @author Sindre Mehus + */ +public class MetaData { + + private Integer discNumber; + private Integer trackNumber; + private String title; + private String artist; + private String albumName; + private String genre; + private Integer year; + private Integer bitRate; + private boolean variableBitRate; + private Integer durationSeconds; + private Integer width; + private Integer height; + + public Integer getDiscNumber() { + return discNumber; + } + + public void setDiscNumber(Integer discNumber) { + this.discNumber = discNumber; + } + + public Integer getTrackNumber() { + return trackNumber; + } + + public void setTrackNumber(Integer trackNumber) { + this.trackNumber = trackNumber; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getArtist() { + return artist; + } + + public void setArtist(String artist) { + this.artist = artist; + } + + public String getAlbumName() { + return albumName; + } + + public void setAlbumName(String albumName) { + this.albumName = albumName; + } + + public String getGenre() { + return genre; + } + + public void setGenre(String genre) { + this.genre = genre; + } + + public Integer getYear() { + return year; + } + + public void setYear(Integer year) { + this.year = year; + } + + public Integer getBitRate() { + return bitRate; + } + + public void setBitRate(Integer bitRate) { + this.bitRate = bitRate; + } + + public boolean getVariableBitRate() { + return variableBitRate; + } + + public void setVariableBitRate(boolean variableBitRate) { + this.variableBitRate = variableBitRate; + } + + public Integer getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(Integer durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public Integer getWidth() { + return width; + } + + public void setWidth(Integer width) { + this.width = width; + } + + public Integer getHeight() { + return height; + } + + public void setHeight(Integer height) { + this.height = height; + } +} diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParser.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParser.java new file mode 100644 index 00000000..2ed70acc --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParser.java @@ -0,0 +1,162 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.metadata; + +import java.io.File; +import java.util.List; + +import org.apache.commons.io.FilenameUtils; + +import net.sourceforge.subsonic.domain.MediaFile; +import net.sourceforge.subsonic.domain.MusicFolder; +import net.sourceforge.subsonic.service.ServiceLocator; +import net.sourceforge.subsonic.service.SettingsService; + + +/** + * Parses meta data from media files. + * + * @author Sindre Mehus + */ +public abstract class MetaDataParser { + + /** + * Parses meta data for the given file. + * + * @param file The file to parse. + * @return Meta data for the file, never null. + */ + public MetaData getMetaData(File file) { + + MetaData metaData = getRawMetaData(file); + String artist = metaData.getArtist(); + String album = metaData.getAlbumName(); + String title = metaData.getTitle(); + + if (artist == null) { + artist = guessArtist(file); + } + if (album == null) { + album = guessAlbum(file, artist); + } + if (title == null) { + title = guessTitle(file); + } + + title = removeTrackNumberFromTitle(title, metaData.getTrackNumber()); + metaData.setArtist(artist); + metaData.setAlbumName(album); + metaData.setTitle(title); + + return metaData; + } + + /** + * Parses meta data for the given file. No guessing or reformatting is done. + * + * + * @param file The file to parse. + * @return Meta data for the file. + */ + public abstract MetaData getRawMetaData(File file); + + /** + * Updates the given file with the given meta data. + * + * @param file The file to update. + * @param metaData The new meta data. + */ + public abstract void setMetaData(MediaFile file, MetaData metaData); + + /** + * Returns whether this parser is applicable to the given file. + * + * @param file The file in question. + * @return Whether this parser is applicable to the given file. + */ + public abstract boolean isApplicable(File file); + + /** + * Returns whether this parser supports tag editing (using the {@link #setMetaData} method). + * + * @return Whether tag editing is supported. + */ + public abstract boolean isEditingSupported(); + + /** + * Guesses the artist for the given file. + */ + public String guessArtist(File file) { + File parent = file.getParentFile(); + if (isRoot(parent)) { + return null; + } + File grandParent = parent.getParentFile(); + return isRoot(grandParent) ? null : grandParent.getName(); + } + + /** + * Guesses the album for the given file. + */ + public String guessAlbum(File file, String artist) { + File parent = file.getParentFile(); + String album = isRoot(parent) ? null : parent.getName(); + if (artist != null && album != null) { + album = album.replace(artist + " - ", ""); + } + return album; + } + + /** + * Guesses the title for the given file. + */ + public String guessTitle(File file) { + return removeTrackNumberFromTitle(FilenameUtils.getBaseName(file.getPath()), null); + } + + private boolean isRoot(File file) { + SettingsService settings = ServiceLocator.getSettingsService(); + List<MusicFolder> folders = settings.getAllMusicFolders(false, true); + for (MusicFolder folder : folders) { + if (file.equals(folder.getPath())) { + return true; + } + } + return false; + } + + /** + * Removes any prefixed track number from the given title string. + * + * @param title The title with or without a prefixed track number, e.g., "02 - Back In Black". + * @param trackNumber If specified, this is the "true" track number. + * @return The title with the track number removed, e.g., "Back In Black". + */ + protected String removeTrackNumberFromTitle(String title, Integer trackNumber) { + title = title.trim(); + + // Don't remove numbers if true track number is given, and title does not start with it. + if (trackNumber != null && !title.matches("0?" + trackNumber + "[\\.\\- ].*")) { + return title; + } + + String result = title.replaceFirst("^\\d{2}[\\.\\- ]+", ""); + return result.length() == 0 ? title : result; + } +}
\ No newline at end of file diff --git a/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParserFactory.java b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParserFactory.java new file mode 100644 index 00000000..31b56be4 --- /dev/null +++ b/subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParserFactory.java @@ -0,0 +1,51 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.service.metadata; + +import java.io.File; +import java.util.List; + +/** + * Factory for creating meta-data parsers. + * + * @author Sindre Mehus + */ +public class MetaDataParserFactory { + + private List<MetaDataParser> parsers; + + public void setParsers(List<MetaDataParser> parsers) { + this.parsers = parsers; + } + + /** + * Returns a meta-data parser for the given file. + * + * @param file The file in question. + * @return An applicable parser, or <code>null</code> if no parser is found. + */ + public MetaDataParser getParser(File file) { + for (MetaDataParser parser : parsers) { + if (parser.isApplicable(file)) { + return parser; + } + } + return null; + } +} |