You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
230 lines
9.3 KiB
230 lines
9.3 KiB
/*
|
|
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 com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.airsonic.player.domain.MediaFile;
|
|
import org.apache.http.client.ClientProtocolException;
|
|
import org.apache.http.client.config.RequestConfig;
|
|
import org.apache.http.client.methods.HttpPost;
|
|
import org.apache.http.client.methods.HttpUriRequest;
|
|
import org.apache.http.entity.StringEntity;
|
|
import org.apache.http.impl.client.CloseableHttpClient;
|
|
import org.apache.http.impl.client.HttpClients;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.io.IOException;
|
|
import java.util.*;
|
|
import java.util.concurrent.LinkedBlockingQueue;
|
|
|
|
/**
|
|
* Provides services for "audioscrobbling" at listenbrainz.org.
|
|
* <br/>
|
|
* See https://listenbrainz.readthedocs.io/
|
|
*/
|
|
public class ListenBrainzScrobbler {
|
|
|
|
private static final Logger LOG = LoggerFactory.getLogger(ListenBrainzScrobbler.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 listenbrainz.org. This method returns immediately, the actual registration is done
|
|
* by a separate thread.
|
|
*
|
|
* @param mediaFile The media file to register.
|
|
* @param token The token to authentication user on ListenBrainz.
|
|
* @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 token, boolean submission, Date time) {
|
|
if (thread == null) {
|
|
thread = new RegistrationThread();
|
|
thread.start();
|
|
}
|
|
|
|
if (queue.size() >= MAX_PENDING_REGISTRATION) {
|
|
LOG.warn("ListenBrainz scrobbler queue is full. Ignoring '" + mediaFile.getTitle() + "'");
|
|
return;
|
|
}
|
|
|
|
RegistrationData registrationData = createRegistrationData(mediaFile, token, submission, time);
|
|
if (registrationData == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
queue.put(registrationData);
|
|
} catch (InterruptedException x) {
|
|
LOG.warn("Interrupted while queuing ListenBrainz scrobble: " + x.toString());
|
|
}
|
|
}
|
|
|
|
private RegistrationData createRegistrationData(MediaFile mediaFile, String token, boolean submission, Date time) {
|
|
RegistrationData reg = new RegistrationData();
|
|
reg.token = token;
|
|
reg.artist = mediaFile.getArtist();
|
|
reg.album = mediaFile.getAlbumName();
|
|
reg.title = mediaFile.getTitle();
|
|
reg.musicBrainzReleaseId = mediaFile.getMusicBrainzReleaseId();
|
|
reg.musicBrainzRecordingId = mediaFile.getMusicBrainzRecordingId();
|
|
reg.trackNumber = mediaFile.getTrackNumber();
|
|
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 listenbrainz.org, using the protocol defined at https://listenbrainz.readthedocs.io/en/latest/dev/api.html.
|
|
*
|
|
* @param registrationData Registration data for the song.
|
|
*/
|
|
private void scrobble(RegistrationData registrationData) throws ClientProtocolException, IOException {
|
|
if (registrationData == null || registrationData.token == null) {
|
|
return;
|
|
}
|
|
|
|
if (!submit(registrationData)) {
|
|
LOG.warn("Failed to scrobble song '" + registrationData.title + "' at ListenBrainz.");
|
|
} else {
|
|
LOG.info("Successfully registered " +
|
|
(registrationData.submission ? "submission" : "now playing") +
|
|
" for song '" + registrationData.title + "'" +
|
|
" at ListenBrainz: " + registrationData.time);
|
|
}
|
|
}
|
|
/**
|
|
* Returns if submission succeeds.
|
|
*/
|
|
private boolean submit(RegistrationData registrationData) throws ClientProtocolException, IOException {
|
|
Map<String, Object> additional_info = new HashMap<String, Object>();
|
|
additional_info.computeIfAbsent("release_mbid", k -> registrationData.musicBrainzReleaseId);
|
|
additional_info.computeIfAbsent("recording_mbid", k -> registrationData.musicBrainzRecordingId);
|
|
additional_info.computeIfAbsent("tracknumber", k -> registrationData.trackNumber);
|
|
|
|
Map<String, Object> track_metadata = new HashMap<String, Object>();
|
|
if (additional_info.size() > 0) {
|
|
track_metadata.put("additional_info", additional_info);
|
|
}
|
|
track_metadata.computeIfAbsent("artist_name", k -> registrationData.artist);
|
|
track_metadata.computeIfAbsent("track_name", k -> registrationData.title);
|
|
track_metadata.computeIfAbsent("release_name", k -> registrationData.album);
|
|
|
|
Map<String, Object> payload = new HashMap<String, Object>();
|
|
if (track_metadata.size() > 0) {
|
|
payload.put("track_metadata", track_metadata);
|
|
}
|
|
|
|
Map<String, Object> content = new HashMap<String, Object>();
|
|
|
|
if (registrationData.submission) {
|
|
payload.put("listened_at", Long.valueOf(registrationData.time.getTime() / 1000L));
|
|
content.put("listen_type", "single");
|
|
} else {
|
|
content.put("listen_type", "playing_now");
|
|
}
|
|
|
|
List<Map<String, Object>> payloads = new ArrayList<Map<String, Object>>();
|
|
payloads.add(payload);
|
|
content.put("payload", payloads);
|
|
|
|
ObjectMapper mapper = new ObjectMapper();
|
|
String json = mapper.writeValueAsString(content);
|
|
|
|
executeJsonPostRequest("https://api.listenbrainz.org/1/submit-listens", registrationData.token, json);
|
|
|
|
return true;
|
|
}
|
|
|
|
private boolean executeJsonPostRequest(String url, String token, String json) throws ClientProtocolException, IOException {
|
|
HttpPost request = new HttpPost(url);
|
|
request.setEntity(new StringEntity(json, "UTF-8"));
|
|
request.setHeader("Authorization", "token " + token);
|
|
request.setHeader("Content-type", "application/json; charset=utf-8");
|
|
|
|
executeRequest(request);
|
|
return true;
|
|
}
|
|
|
|
private void executeRequest(HttpUriRequest request) throws ClientProtocolException, IOException {
|
|
CloseableHttpClient client = HttpClients.createDefault();
|
|
client.execute(request);
|
|
}
|
|
|
|
private class RegistrationThread extends Thread {
|
|
private RegistrationThread() {
|
|
super("ListenBrainzScrobbler Registration");
|
|
}
|
|
|
|
@Override
|
|
public void run() {
|
|
while (true) {
|
|
RegistrationData registrationData = null;
|
|
try {
|
|
registrationData = queue.take();
|
|
scrobble(registrationData);
|
|
} catch (ClientProtocolException x) {
|
|
} catch (IOException x) {
|
|
handleNetworkError(registrationData, x.toString());
|
|
} catch (Exception x) {
|
|
LOG.warn("Error in ListenBrainz registration: " + x.toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void handleNetworkError(RegistrationData registrationData, String errorMessage) {
|
|
try {
|
|
queue.put(registrationData);
|
|
LOG.info("ListenBrainz registration for '" + registrationData.title +
|
|
"' encountered network error: " + errorMessage + ". Will try again later. In queue: " + queue.size());
|
|
} catch (InterruptedException x) {
|
|
LOG.error("Failed to reschedule ListenBrainz registration for '" + registrationData.title + "': " + x.toString());
|
|
}
|
|
try {
|
|
sleep(60L * 1000L); // Wait 60 seconds.
|
|
} catch (InterruptedException x) {
|
|
LOG.error("Failed to sleep after ListenBrainz registration failure for '" + registrationData.title + "': " + x.toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
private static class RegistrationData {
|
|
private String token;
|
|
private String artist;
|
|
private String album;
|
|
private String title;
|
|
private String musicBrainzReleaseId;
|
|
private String musicBrainzRecordingId;
|
|
private Integer trackNumber;
|
|
private int duration;
|
|
private Date time;
|
|
public boolean submission;
|
|
}
|
|
|
|
}
|
|
|