From e3b3bc9d2bf0f71f2e0d4788a704a7326de3be9b Mon Sep 17 00:00:00 2001 From: Shen-Ta Hsieh Date: Mon, 28 Oct 2019 15:27:21 +0800 Subject: [PATCH] Move Last.fm logic into separate helper class - Split AudioScrobblerSevice into two classes - Change api to https in last.fm Audio Scrobbler Signed-off-by: Shen-Ta Hsieh Signed-off-by: Andrew DeMaria --- .../player/service/AudioScrobblerService.java | 284 +--------------- .../service/scrobbler/LastFMScrobbler.java | 304 ++++++++++++++++++ 2 files changed, 316 insertions(+), 272 deletions(-) create mode 100644 airsonic-main/src/main/java/org/airsonic/player/service/scrobbler/LastFMScrobbler.java diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/AudioScrobblerService.java b/airsonic-main/src/main/java/org/airsonic/player/service/AudioScrobblerService.java index e1da4dcc..d3311879 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/AudioScrobblerService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/AudioScrobblerService.java @@ -18,59 +18,28 @@ Based upon Subsonic, Copyright 2009 (C) Sindre Mehus */ package org.airsonic.player.service; - import org.airsonic.player.domain.MediaFile; import org.airsonic.player.domain.UserSettings; -import org.airsonic.player.util.StringUtil; -import org.apache.commons.codec.digest.DigestUtils; -import org.apache.http.NameValuePair; -import org.apache.http.client.ResponseHandler; -import org.apache.http.client.config.RequestConfig; -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.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.message.BasicNameValuePair; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.airsonic.player.service.scrobbler.LastFMScrobbler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.net.URI; -import java.util.*; -import java.util.concurrent.LinkedBlockingQueue; +import java.util.Date; /** * Provides services for "audioscrobbling", which is the process of - * registering what songs are played at www.last.fm. - *

- * See http://www.last.fm/api/submissions - * - * @author Sindre Mehus + * registering what songs are played at website. */ @Service public class AudioScrobblerService { - private static final Logger LOG = LoggerFactory.getLogger(AudioScrobblerService.class); - private static final int MAX_PENDING_REGISTRATION = 2000; - - private RegistrationThread thread; - private final LinkedBlockingQueue queue = new LinkedBlockingQueue(); - + private LastFMScrobbler lastFMScrobbler; @Autowired private SettingsService settingsService; - private final RequestConfig requestConfig = RequestConfig.custom() - .setConnectTimeout(15000) - .setSocketTimeout(15000) - .build(); /** - * Registers the given media file at www.last.fm. This method returns immediately, the actual registration is done - * by a separate thread. + * Registers the given media file at audio scrobble service. This method returns immediately, the actual registration is done + * by separate threads. * * @param mediaFile The media file to register. * @param username The user which played the music file. @@ -78,249 +47,20 @@ public class AudioScrobblerService { * @param time Event time, or {@code null} to use current time. */ public synchronized void register(MediaFile mediaFile, String username, boolean submission, Date time) { - - 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, time); - if (registrationData == null) { - return; - } - - try { - queue.put(registrationData); - } catch (InterruptedException x) { - LOG.warn("Interrupted while queuing Last.fm scrobble.", x); - } - } - - private RegistrationData createRegistrationData(MediaFile mediaFile, String username, boolean submission, Date time) { - if (mediaFile == null || mediaFile.isVideo()) { - return null; - } - - UserSettings userSettings = settingsService.getUserSettings(username); - if (!userSettings.isLastFmEnabled() || userSettings.getLastFmUsername() == null || userSettings.getLastFmPassword() == null) { - return null; - } - - 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 = time == null ? new Date() : time; - 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.info("Successfully registered " + (registrationData.submission ? "submission" : "now playing") + - " for song '" + registrationData.title + "' for user " + registrationData.username + " at Last.fm: " + registrationData.time); - } - } - - /** - * Returns the following lines if authentication succeeds: - *

- * 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" - *

- * If authentication fails, null 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); - URI uri = new URI("http", - /* userInfo= */ null, "post.audioscrobbler.com", -1, - "/", - String.format("hs=true&p=1.2.1&c=%s&v=%s&u=%s&t=%s&a=%s", - clientId, clientVersion, registrationData.username, - timestamp, authToken), - /* fragment= */ null); - - String[] lines = executeGetRequest(uri); - - 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 params = new HashMap(); - 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 params = new HashMap(); - 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(URI url) throws IOException { - HttpGet method = new HttpGet(url); - method.setConfig(requestConfig); - return executeRequest(method); - } - - private String[] executePostRequest(String url, Map parameters) throws IOException { - List params = new ArrayList(); - for (Map.Entry entry : parameters.entrySet()) { - params.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); - } - - HttpPost request = new HttpPost(url); - request.setEntity(new UrlEncodedFormEntity(params, StringUtil.ENCODING_UTF8)); - request.setConfig(requestConfig); - return executeRequest(request); - } - - private String[] executeRequest(HttpUriRequest request) throws IOException { - - try (CloseableHttpClient client = HttpClients.createDefault()) { - ResponseHandler responseHandler = new BasicResponseHandler(); - String response = client.execute(request, responseHandler); - return response.split("\\n"); - + UserSettings userSettings = settingsService.getUserSettings(username); + if (userSettings.isLastFmEnabled() && userSettings.getLastFmUsername() != null && userSettings.getLastFmPassword() != null) { + if (lastFMScrobbler == null) { + lastFMScrobbler = new LastFMScrobbler(); + } + lastFMScrobbler.register(mediaFile, userSettings.getLastFmUsername(), userSettings.getLastFmPassword(), submission, time); } } 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(60L * 1000L); // Wait 60 seconds. - } 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; - } - } diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/scrobbler/LastFMScrobbler.java b/airsonic-main/src/main/java/org/airsonic/player/service/scrobbler/LastFMScrobbler.java new file mode 100644 index 00000000..d8bb649b --- /dev/null +++ b/airsonic-main/src/main/java/org/airsonic/player/service/scrobbler/LastFMScrobbler.java @@ -0,0 +1,304 @@ +/* + This file is part of Airsonic. + + Airsonic 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. + + Airsonic 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 Airsonic. If not, see . + + Copyright 2016 (C) Airsonic Authors + Based upon Subsonic, Copyright 2009 (C) Sindre Mehus + */ +package org.airsonic.player.service.scrobbler; + +import org.airsonic.player.domain.MediaFile; +import org.airsonic.player.util.StringUtil; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.http.NameValuePair; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.config.RequestConfig; +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.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicNameValuePair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.*; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Provides services for "audioscrobbling" at www.last.fm. + *
+ * See https://www.last.fm/api/submissions + */ +public class LastFMScrobbler { + + private static final Logger LOG = LoggerFactory.getLogger(LastFMScrobbler.class); + private static final int MAX_PENDING_REGISTRATION = 2000; + + private RegistrationThread thread; + private final LinkedBlockingQueue queue = new LinkedBlockingQueue(); + private final RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(15000) + .setSocketTimeout(15000) + .build(); + + + /** + * 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 last.fm username. + * @param password last.fm password. + * @param submission Whether this is a submission or a now playing notification. + * @param time Event time, or {@code null} to use current time. + */ + public synchronized void register(MediaFile mediaFile, String username, String password, boolean submission, Date time) { + 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, password, submission, time); + if (registrationData == null) { + return; + } + + try { + queue.put(registrationData); + } catch (InterruptedException x) { + LOG.warn("Interrupted while queuing Last.fm scrobble: " + x.toString()); + } + } + + private RegistrationData createRegistrationData(MediaFile mediaFile, String username, String password, boolean submission, Date time) { + RegistrationData reg = new RegistrationData(); + reg.username = username; + reg.password = password; + reg.artist = mediaFile.getArtist(); + reg.album = mediaFile.getAlbumName(); + reg.title = mediaFile.getTitle(); + reg.duration = mediaFile.getDurationSeconds() == null ? 0 : mediaFile.getDurationSeconds(); + reg.time = time == null ? new Date() : time; + 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 URISyntaxException, ClientProtocolException, IOException { + 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.info("Successfully registered " + (registrationData.submission ? "submission" : "now playing") + + " for song '" + registrationData.title + "' for user " + registrationData.username + " at Last.fm: " + registrationData.time); + } + } + + /** + * Returns the following lines if authentication succeeds: + *

+ * Line 0: Always "OK" + * Line 1: Session ID, e.g., "17E61E13454CDD8B68E8D7DEEEDF6170" + * Line 2: URL to use for now playing, e.g., "https://post.audioscrobbler.com:80/np_1.2" + * Line 3: URL to use for submissions, e.g., "https://post2.audioscrobbler.com:80/protocol_1.2" + *

+ * If authentication fails, null is returned. + */ + private String[] authenticate(RegistrationData registrationData) throws URISyntaxException, ClientProtocolException, IOException { + String clientId = "sub"; + String clientVersion = "0.1"; + long timestamp = System.currentTimeMillis() / 1000L; + String authToken = calculateAuthenticationToken(registrationData.password, timestamp); + URI uri = new URI("https", + /* userInfo= */ null, "post.audioscrobbler.com", -1, + "/", + String.format("hs=true&p=1.2.1&c=%s&v=%s&u=%s&t=%s&a=%s", + clientId, clientVersion, registrationData.username, + timestamp, authToken), + /* fragment= */ null); + + String[] lines = executeGetRequest(uri); + + 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 UnsupportedEncodingException, ClientProtocolException, IOException { + Map params = new HashMap(); + 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 UnsupportedEncodingException, ClientProtocolException, IOException { + Map params = new HashMap(); + 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(URI url) throws IOException, ClientProtocolException { + HttpGet method = new HttpGet(url); + method.setConfig(requestConfig); + return executeRequest(method); + } + + private String[] executePostRequest(String url, Map parameters) throws UnsupportedEncodingException, ClientProtocolException, IOException { + List params = new ArrayList(); + for (Map.Entry entry : parameters.entrySet()) { + params.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); + } + + HttpPost request = new HttpPost(url); + request.setEntity(new UrlEncodedFormEntity(params, StringUtil.ENCODING_UTF8)); + request.setConfig(requestConfig); + return executeRequest(request); + } + + private String[] executeRequest(HttpUriRequest request) throws ClientProtocolException, IOException { + try (CloseableHttpClient client = HttpClients.createDefault()) { + ResponseHandler responseHandler = new BasicResponseHandler(); + String response = client.execute(request, responseHandler); + return response.split("\\r?\\n"); + } + } + + private class RegistrationThread extends Thread { + private RegistrationThread() { + super("LastFMScrobbler Registration"); + } + + @Override + public void run() { + while (true) { + RegistrationData registrationData = null; + try { + registrationData = queue.take(); + scrobble(registrationData); + } catch (IOException x) { + handleNetworkError(registrationData, x.toString()); + } catch (Exception x) { + LOG.warn("Error in Last.fm registration: " + x.toString()); + } + } + } + + private void handleNetworkError(RegistrationData registrationData, String errorMessage) { + try { + queue.put(registrationData); + LOG.info("Last.fm registration for '" + registrationData.title + + "' encountered network error: " + errorMessage + ". Will try again later. In queue: " + queue.size()); + } catch (InterruptedException x) { + LOG.error("Failed to reschedule Last.fm registration for '" + registrationData.title + "': " + x.toString()); + } + try { + sleep(60L * 1000L); // Wait 60 seconds. + } catch (InterruptedException x) { + LOG.error("Failed to sleep after Last.fm registration failure for '" + registrationData.title + "': " + x.toString()); + } + } + } + + 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; + } + +}