- Add textbox in user settings page for ListenBrainz token - Add changes to db - Add db colume to store MusicBrainz Recording ID - Add db colume to store ListenBrainz token - Add test for reading id - Add tag on testing file - Add localization entry Signed-off-by: Shen-Ta Hsieh <ibmibmibm.tw@gmail.com> Signed-off-by: Andrew DeMaria <lostonamountain@gmail.com>master
parent
e3b3bc9d2b
commit
84df4e3b94
@ -0,0 +1,230 @@ |
|||||||
|
/* |
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
<databaseChangeLog |
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||||
|
<changeSet id="add-media-file-mb-recording-id" author="BestSteve"> |
||||||
|
<preConditions onFail="MARK_RAN"> |
||||||
|
<not> |
||||||
|
<columnExists tableName="media_file" columnName="mb_recording_id" /> |
||||||
|
</not> |
||||||
|
</preConditions> |
||||||
|
<addColumn tableName="media_file"> |
||||||
|
<column name="mb_recording_id" type="${varchar_type}"> |
||||||
|
<constraints nullable="true" /> |
||||||
|
</column> |
||||||
|
</addColumn> |
||||||
|
</changeSet> |
||||||
|
</databaseChangeLog> |
@ -0,0 +1,7 @@ |
|||||||
|
<databaseChangeLog |
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||||
|
<include file="add-media-file-mb-recording-id.xml" relativeToChangelogFile="true"/> |
||||||
|
<include file="support-listenbrainz.xml" relativeToChangelogFile="true"/> |
||||||
|
</databaseChangeLog> |
@ -0,0 +1,18 @@ |
|||||||
|
<databaseChangeLog |
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||||
|
<changeSet id="support-listenbrainz" author="BestSteve"> |
||||||
|
<preConditions onFail="MARK_RAN"> |
||||||
|
<not> |
||||||
|
<columnExists tableName="user_settings" columnName="listenbrainz_enabled" /> |
||||||
|
</not> |
||||||
|
</preConditions> |
||||||
|
<addColumn tableName="user_settings"> |
||||||
|
<column name="listenbrainz_enabled" type="boolean" defaultValueBoolean="false"> |
||||||
|
<constraints nullable="false" /> |
||||||
|
</column> |
||||||
|
<column name="listenbrainz_token" type="${varchar_type}" /> |
||||||
|
</addColumn> |
||||||
|
</changeSet> |
||||||
|
</databaseChangeLog> |
Binary file not shown.
Loading…
Reference in new issue