- Split AudioScrobblerSevice into two classes - Change api to https in last.fm Audio Scrobbler Signed-off-by: Shen-Ta Hsieh <ibmibmibm.tw@gmail.com> Signed-off-by: Andrew DeMaria <lostonamountain@gmail.com>master
parent
af97925124
commit
e3b3bc9d2b
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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. |
||||||
|
* <br/> |
||||||
|
* 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<RegistrationData> queue = new LinkedBlockingQueue<RegistrationData>(); |
||||||
|
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: |
||||||
|
* <p/> |
||||||
|
* 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" |
||||||
|
* <p/> |
||||||
|
* If authentication fails, <code>null</code> 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<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 UnsupportedEncodingException, ClientProtocolException, 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(URI url) throws IOException, ClientProtocolException { |
||||||
|
HttpGet method = new HttpGet(url); |
||||||
|
method.setConfig(requestConfig); |
||||||
|
return executeRequest(method); |
||||||
|
} |
||||||
|
|
||||||
|
private String[] executePostRequest(String url, Map<String, String> parameters) throws UnsupportedEncodingException, ClientProtocolException, 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)); |
||||||
|
request.setConfig(requestConfig); |
||||||
|
return executeRequest(request); |
||||||
|
} |
||||||
|
|
||||||
|
private String[] executeRequest(HttpUriRequest request) throws ClientProtocolException, IOException { |
||||||
|
try (CloseableHttpClient client = HttpClients.createDefault()) { |
||||||
|
ResponseHandler<String> 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; |
||||||
|
} |
||||||
|
|
||||||
|
} |
Loading…
Reference in new issue