From cce158b60b26223090272b02d9f59b2f33fe5112 Mon Sep 17 00:00:00 2001 From: Agate Date: Wed, 26 Aug 2020 12:26:27 +0200 Subject: [PATCH] [plugin, scrobbler] Use last.fm API v2 for scrobbling if API key and secret are provided --- api/config/plugins.py | 18 ++++++ .../contrib/scrobbler/README.rst | 10 +++ .../contrib/scrobbler/funkwhale_ready.py | 63 ++++++++++++++----- .../contrib/scrobbler/funkwhale_startup.py | 24 +++---- .../contrib/scrobbler/scrobbler.py | 63 +++++++++++++++++++ api/tests/plugins/test_plugins.py | 4 ++ changes/changelog.d/scrobbler.enhancement | 1 + front/src/components/audio/Player.vue | 2 +- front/src/components/auth/Plugin.vue | 9 ++- 9 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 api/funkwhale_api/contrib/scrobbler/README.rst create mode 100644 changes/changelog.d/scrobbler.enhancement diff --git a/api/config/plugins.py b/api/config/plugins.py index e6ab67915..480580d44 100644 --- a/api/config/plugins.py +++ b/api/config/plugins.py @@ -5,6 +5,7 @@ import subprocess import sys import persisting_theory +from django.core.cache import cache from django.db.models import Q from rest_framework import serializers @@ -28,6 +29,19 @@ _filters = {} _hooks = {} +class PluginCache(object): + def __init__(self, prefix): + self.prefix = prefix + + def get(self, key, default=None): + key = ":".join([self.prefix, key]) + return cache.get(key, default) + + def set(self, key, value, duration=None): + key = ":".join([self.prefix, key]) + return cache.set(key, value, duration) + + def get_plugin_config( name, user=False, @@ -38,6 +52,7 @@ def get_plugin_config( description=None, version=None, label=None, + homepage=None, ): conf = { "name": name, @@ -52,6 +67,8 @@ def get_plugin_config( "source": source, "description": description, "version": version, + "cache": PluginCache(name), + "homepage": homepage, } registry[name] = conf return conf @@ -259,6 +276,7 @@ def serialize_plugin(plugin_conf, confs): "values": confs.get(plugin_conf["name"], {"conf"}).get("conf"), "enabled": plugin_conf["name"] in confs and confs[plugin_conf["name"]]["enabled"], + "homepage": plugin_conf["homepage"], } diff --git a/api/funkwhale_api/contrib/scrobbler/README.rst b/api/funkwhale_api/contrib/scrobbler/README.rst new file mode 100644 index 000000000..c5e787ec4 --- /dev/null +++ b/api/funkwhale_api/contrib/scrobbler/README.rst @@ -0,0 +1,10 @@ +Scrobbler plugin +================ + +A plugin that enables scrobbling to ListenBrainz and Last.fm. + +If you're scrobbling to last.fm, you will need to create an `API account `_ +and add two variables two your .env file: + +- ``FUNKWHALE_PLUGIN_SCROBBLER_LASTFM_API_KEY=apikey`` +- ``FUNKWHALE_PLUGIN_SCROBBLER_LASTFM_API_SECRET=apisecret`` diff --git a/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py b/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py index b7a278f83..7a4606ae9 100644 --- a/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py +++ b/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py @@ -6,6 +6,7 @@ from . import scrobbler # https://listenbrainz.org/lastfm-proxy DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com" +LASTFM_SCROBBLER_URL = "https://ws.audioscrobbler.com/2.0/" @plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN) @@ -17,23 +18,51 @@ def forward_to_scrobblers(listening, conf, **kwargs): password = conf.get("password") url = conf.get("url", DEFAULT_SCROBBLER_URL) or DEFAULT_SCROBBLER_URL if username and password: - PLUGIN["logger"].info("Forwarding scrobbler to %s", url) session = plugins.get_session() - session_key, now_playing_url, scrobble_url = scrobbler.handshake_v1( - session=session, url=url, username=username, password=password - ) - scrobbler.submit_now_playing_v1( - session=session, - track=listening.track, - session_key=session_key, - now_playing_url=now_playing_url, - ) - scrobbler.submit_scrobble_v1( - session=session, - track=listening.track, - scrobble_time=listening.creation_date, - session_key=session_key, - scrobble_url=scrobble_url, - ) + if ( + PLUGIN["settings"]["lastfm_api_key"] + and PLUGIN["settings"]["lastfm_api_secret"] + and url == DEFAULT_SCROBBLER_URL + ): + PLUGIN["logger"].info("Forwarding scrobble to %s", LASTFM_SCROBBLER_URL) + session_key = PLUGIN["cache"].get( + "lastfm:sessionkey:{}".format(listening.user.pk) + ) + if not session_key: + PLUGIN["logger"].debug("Authenticating…") + session_key = scrobbler.handshake_v2( + username=username, + password=password, + scrobble_url=LASTFM_SCROBBLER_URL, + session=session, + api_key=PLUGIN["settings"]["lastfm_api_key"], + api_secret=PLUGIN["settings"]["lastfm_api_secret"], + ) + PLUGIN["cache"].set( + "lastfm:sessionkey:{}".format(listening.user.pk), session_key + ) + scrobbler.submit_scrobble_v2( + session=session, + track=listening.track, + scrobble_time=listening.creation_date, + session_key=session_key, + scrobble_url=LASTFM_SCROBBLER_URL, + api_key=PLUGIN["settings"]["lastfm_api_key"], + api_secret=PLUGIN["settings"]["lastfm_api_secret"], + ) + + else: + PLUGIN["logger"].info("Forwarding scrobble to %s", url) + session_key, now_playing_url, scrobble_url = scrobbler.handshake_v1( + session=session, url=url, username=username, password=password + ) + scrobbler.submit_scrobble_v1( + session=session, + track=listening.track, + scrobble_time=listening.creation_date, + session_key=session_key, + scrobble_url=scrobble_url, + ) + PLUGIN["logger"].info("Scrobble sent!") else: PLUGIN["logger"].debug("No scrobbler configuration for user, skipping") diff --git a/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py b/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py index 7dcdd4b4c..2be2a842e 100644 --- a/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py +++ b/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py @@ -1,19 +1,13 @@ -""" -A plugin that enables scrobbling to ListenBrainz and Last.fm. - -If you're scrobbling to last.fm, you will need to create an `API account `_ -and add two variables two your .env file: - -- ``FUNKWHALE_PLUGIN_SCROBBLER_LASTFM_API_KEY=apikey`` -- ``FUNKWHALE_PLUGIN_SCROBBLER_LASTFM_API_SECRET=apisecret`` - -""" from config import plugins PLUGIN = plugins.get_plugin_config( name="scrobbler", label="Scrobbler", - description="A plugin that enables scrobbling to ListenBrainz and Last.fm", + description=( + "A plugin that enables scrobbling to ListenBrainz and Last.fm. " + "It must be configured on the server if you use Last.fm." + ), + homepage="https://dev.funkwhale.audio/funkwhale/funkwhale/-/blob/develop/api/funkwhale_api/contrib/scrobbler/README.rst", # noqa version="0.1", user=True, conf=[ @@ -34,8 +28,8 @@ PLUGIN = plugins.get_plugin_config( {"name": "username", "type": "text", "label": "Your scrobbler username"}, {"name": "password", "type": "password", "label": "Your scrobbler password"}, ], - # settings=[ - # {"name": "lastfm_api_key", "type": "text"}, - # {"name": "lastfm_api_secret", "type": "text"}, - # ] + settings=[ + {"name": "lastfm_api_key", "type": "text"}, + {"name": "lastfm_api_secret", "type": "text"}, + ], ) diff --git a/api/funkwhale_api/contrib/scrobbler/scrobbler.py b/api/funkwhale_api/contrib/scrobbler/scrobbler.py index 3cf82be26..965b31fde 100644 --- a/api/funkwhale_api/contrib/scrobbler/scrobbler.py +++ b/api/funkwhale_api/contrib/scrobbler/scrobbler.py @@ -96,3 +96,66 @@ def get_scrobble_payload(track, date, suffix="[0]"): if date: data["i{}".format(suffix)] = int(date.timestamp()) return data + + +def get_scrobble2_payload(track, date, suffix="[0]"): + """ + Documentation available at https://web.archive.org/web/20190531021725/https://www.last.fm/api/submissions + """ + upload = track.uploads.filter(duration__gte=0).first() + data = { + "artist{}".format(suffix): track.artist.name, + "track{}".format(suffix): track.title, + "duration{}".format(suffix): upload.duration if upload else 0, + "album{}".format(suffix): (track.album.title if track.album else "") or "", + "trackNumber{}".format(suffix): track.position or "", + "mbid{}".format(suffix): str(track.mbid) or "", + "chosenByUser{}".format(suffix): "P", # Source: P = chosen by user + } + if date: + offset = upload.duration / 2 if upload.duration else 0 + data["timestamp{}".format(suffix)] = int(date.timestamp()) - offset + return data + + +def handshake_v2(username, password, session, api_key, api_secret, scrobble_url): + params = { + "method": "auth.getMobileSession", + "username": username, + "password": password, + "api_key": api_key, + } + params["api_sig"] = hash_request(params, api_secret) + response = session.post(scrobble_url, params) + if 'status="ok"' not in response.text: + raise ScrobblerException(response.text) + + session_key = response.text.split("")[1].split("")[0] + return session_key + + +def submit_scrobble_v2( + session, track, scrobble_time, session_key, scrobble_url, api_key, api_secret, +): + params = { + "method": "track.scrobble", + "api_key": api_key, + "sk": session_key, + } + params.update(get_scrobble2_payload(track, scrobble_time)) + params["api_sig"] = hash_request(params, api_secret) + response = session.post(scrobble_url, params) + if 'status="ok"' not in response.text: + raise ScrobblerException(response.text) + + +def hash_request(data, secret_key): + string = "" + items = data.keys() + items = sorted(items) + for i in items: + string += str(i) + string += str(data[i]) + string += secret_key + string_to_hash = string.encode("utf8") + return hashlib.md5(string_to_hash).hexdigest() diff --git a/api/tests/plugins/test_plugins.py b/api/tests/plugins/test_plugins.py index ab06fe1ba..ac0e046b4 100644 --- a/api/tests/plugins/test_plugins.py +++ b/api/tests/plugins/test_plugins.py @@ -207,6 +207,7 @@ def test_serialize_plugin(): "source": False, "label": "test_plugin", "values": None, + "homepage": None, } assert plugins.serialize_plugin(plugin, plugins.get_confs()) == expected @@ -230,6 +231,7 @@ def test_serialize_plugin_user(factories): "source": False, "label": "test_plugin", "values": None, + "homepage": None, } assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected @@ -242,6 +244,7 @@ def test_serialize_plugin_user_enabled(factories): description="Hello world", conf=[{"name": "foo", "type": "boolean"}], user=True, + homepage="https://example.com", ) factories["common.PluginConfiguration"]( @@ -256,6 +259,7 @@ def test_serialize_plugin_user_enabled(factories): "source": False, "label": "test_plugin", "values": {"foo": "bar"}, + "homepage": "https://example.com", } assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected diff --git a/changes/changelog.d/scrobbler.enhancement b/changes/changelog.d/scrobbler.enhancement new file mode 100644 index 000000000..1568ce8a8 --- /dev/null +++ b/changes/changelog.d/scrobbler.enhancement @@ -0,0 +1 @@ +[plugin, scrobbler] Use last.fm API v2 for scrobbling if API key and secret are provided \ No newline at end of file diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 98219045e..12907ef0e 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -493,7 +493,7 @@ export default { this.getSound(toPreload) this.nextTrackPreloaded = true } - if (t > this.listenDelay || d - t < 30) { + if (t > (d / 2)) { let onlyTrack = this.$store.state.queue.tracks.length === 1 if (this.listeningRecorded != this.currentTrack) { this.listeningRecorded = this.currentTrack diff --git a/front/src/components/auth/Plugin.vue b/front/src/components/auth/Plugin.vue index b5614048c..ea5394b66 100644 --- a/front/src/components/auth/Plugin.vue +++ b/front/src/components/auth/Plugin.vue @@ -1,7 +1,14 @@