From cce158b60b26223090272b02d9f59b2f33fe5112 Mon Sep 17 00:00:00 2001
From: Agate <me@agate.blue>
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 e6ab679152..480580d44c 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 0000000000..c5e787ec48
--- /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 <https://www.last.fm/api/account/create>`_
+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 b7a278f83b..7a4606ae96 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 7dcdd4b4c6..2be2a842e0 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 <https://www.last.fm/api/account/create>`_
-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 3cf82be260..965b31fde0 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("<key>")[1].split("</key>")[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 ab06fe1ba1..ac0e046b4c 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 0000000000..1568ce8a83
--- /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 98219045e5..12907ef0ed 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 b5614048c1..ea5394b665 100644
--- a/front/src/components/auth/Plugin.vue
+++ b/front/src/components/auth/Plugin.vue
@@ -1,7 +1,14 @@
 <template>
-  <form :class="['ui form', {loading: isLoading}]" @submit.prevent="submit">  
+  <form :class="['ui segment form', {loading: isLoading}]" @submit.prevent="submit">  
     <h3>{{ plugin.label }}</h3>
     <div v-if="plugin.description" v-html="markdown.makeHtml(plugin.description)"></div>
+    <template v-if="plugin.homepage" >
+      <div class="ui small hidden divider"></div>
+      <a :href="plugin.homepage" target="_blank">
+        <i class="external icon"></i>
+        <translate translate-context="Footer/*/List item.Link/Short, Noun">Documentation</translate>
+      </a>
+    </template>
     <div class="ui clearing hidden divider"></div> 
     <div v-if="errors.length > 0" role="alert" class="ui negative message">
       <h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while saving plugin</translate></h4>
-- 
GitLab