aboutsummaryrefslogtreecommitdiff
path: root/subsonic-main/src/main/java/net/sourceforge/subsonic/service
diff options
context:
space:
mode:
Diffstat (limited to 'subsonic-main/src/main/java/net/sourceforge/subsonic/service')
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/AdService.java71
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/AudioScrobblerService.java331
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/JukeboxService.java206
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaFileService.java614
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/MediaScannerService.java354
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/MusicIndexService.java250
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/NetworkService.java336
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlayerService.java317
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/PlaylistService.java426
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/PodcastService.java599
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/RatingService.java101
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/SearchService.java567
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/SecurityService.java303
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/ServiceLocator.java46
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/SettingsService.java1254
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/ShareService.java133
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/StatusService.java134
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/TranscodingService.java530
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/VersionService.java267
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/AudioPlayer.java207
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/jukebox/PlayerTest.java75
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/DefaultMetaDataParser.java74
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/FFmpegParser.java170
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/JaudiotaggerParser.java296
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaData.java135
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParser.java162
-rw-r--r--subsonic-main/src/main/java/net/sourceforge/subsonic/service/metadata/MetaDataParserFactory.java51
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" -&gt; ["A"]</em><br/>
+ * The expression <em>"The"</em> will create the index <em>"The" -&gt; ["The"]</em><br/>
+ * The expression <em>"A(A&Aring;&AElig;)"</em> will create the index <em>"A" -&gt; ["A", "&Aring;", "&AElig;"]</em><br/>
+ * The expression <em>"X-Z(XYZ)"</em> will create the index <em>"X-Z" -&gt; ["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;
+ }
+}