diff --git a/api/plugins/fw_scrobbler/__init__.py b/api/plugins/fw_scrobbler/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/plugins/fw_scrobbler/apps.py b/api/plugins/fw_scrobbler/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..7cc5d2683806ac380059084f47b3a473d1e965f5
--- /dev/null
+++ b/api/plugins/fw_scrobbler/apps.py
@@ -0,0 +1,11 @@
+from funkwhale_api import plugins
+
+
+class Plugin(plugins.Plugin):
+ name = "fw_scrobbler"
+ help = "A simple plugin that enables scrobbling to ListenBrainz and Last.fm"
+ version = "0.1"
+
+ def load(self):
+ from . import config
+ from . import hooks
diff --git a/api/plugins/fw_scrobbler/config.py b/api/plugins/fw_scrobbler/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..186ad5e563943919d150fcb8c19550d35b52ec71
--- /dev/null
+++ b/api/plugins/fw_scrobbler/config.py
@@ -0,0 +1,35 @@
+from funkwhale_api import plugins
+
+plugin = plugins.get_plugin("fw_scrobbler")
+service = plugins.config.SettingSection("service", "Scrobbling Service")
+
+
+@plugin.user_settings.register
+class URL(plugins.config.StringSetting):
+ section = service
+ name = "url"
+ default = ""
+ verbose_name = "URL of the scrobbler service"
+ help = (
+ "Suggested choices:\n\n",
+ "- LastFM (default if left empty): http://post.audioscrobbler.com\n",
+ "- ListenBrainz: http://proxy.listenbrainz.org/",
+ "- ListenBrainz: http://proxy.listenbrainz.org/",
+ "- Libre.fm: http://turtle.libre.fm/",
+ )
+
+
+@plugin.user_settings.register
+class Username(plugins.config.StringSetting):
+ section = service
+ name = "username"
+ default = ""
+ verbose_name = "Your scrobbler username"
+
+
+@plugin.user_settings.register
+class Password(plugins.config.PasswordSetting):
+ section = service
+ name = "password"
+ default = ""
+ verbose_name = "Your scrobbler password"
diff --git a/api/plugins/fw_scrobbler/hooks.py b/api/plugins/fw_scrobbler/hooks.py
new file mode 100644
index 0000000000000000000000000000000000000000..4769bbf723b6d0536ec411b5e39bd48924857aa1
--- /dev/null
+++ b/api/plugins/fw_scrobbler/hooks.py
@@ -0,0 +1,33 @@
+from funkwhale_api import plugins
+
+from . import scrobbler
+
+plugin = plugins.get_plugin("fw_scrobbler")
+
+# https://listenbrainz.org/lastfm-proxy
+DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com"
+
+
+@plugin.hooks.connect("history.listening.created")
+def forward_to_scrobblers(listening, plugin_conf, **kwargs):
+ if plugin_conf["user"] is None:
+ raise plugins.Skip()
+
+ username = plugin_conf["user"]["settings"].get("service__username")
+ password = plugin_conf["user"]["settings"].get("service__password")
+ url = plugin_conf["user"]["settings"].get("service__url", DEFAULT_SCROBBLER_URL)
+ if username and password:
+ plugin.logger.info("Forwarding scrobbler to %s", url)
+ session = plugin.get_requests_session()
+ session_key, 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,
+ )
+ else:
+ plugin.logger.debug("No scrobbler configuration for user, skipping")
diff --git a/api/plugins/fw_scrobbler/scrobbler.py b/api/plugins/fw_scrobbler/scrobbler.py
new file mode 100644
index 0000000000000000000000000000000000000000..84653ca6cc08971e465e6f3e31d8725cfd6d033a
--- /dev/null
+++ b/api/plugins/fw_scrobbler/scrobbler.py
@@ -0,0 +1,82 @@
+import hashlib
+import time
+
+import time
+
+from funkwhale_api import plugins
+
+from . import scrobbler
+
+# https://github.com/jlieth/legacy-scrobbler
+plugin = plugins.get_plugin("fw_scrobbler")
+
+
+class ScrobblerException(Exception):
+ pass
+
+
+def handshake_v1(session, url, username, password):
+ timestamp = str(int(time.time())).encode("utf-8")
+ password_hash = hashlib.md5(password.encode("utf-8")).hexdigest()
+ auth = hashlib.md5(password_hash.encode("utf-8") + timestamp).hexdigest()
+ params = {
+ "hs": "true",
+ "p": "1.2",
+ "c": plugin.name,
+ "v": plugin.version,
+ "u": username,
+ "t": timestamp,
+ "a": auth,
+ }
+
+ session = plugin.get_requests_session()
+ plugin.logger.debug(
+ "Performing scrobbler handshake for username %s at %s", username, url
+ )
+ handshake_response = session.get(url, params=params)
+ # process response
+ result = handshake_response.text.split("\n")
+ if len(result) >= 4 and result[0] == "OK":
+ session_key = result[1]
+ # nowplaying_url = result[2]
+ scrobble_url = result[3]
+ elif result[0] == "BANNED":
+ raise ScrobblerException("BANNED")
+ elif result[0] == "BADAUTH":
+ raise ScrobblerException("BADAUTH")
+ elif result[0] == "BADTIME":
+ raise ScrobblerException("BADTIME")
+ else:
+ raise ScrobblerException(handshake_response.text)
+
+ plugin.logger.debug("Handshake successful, scrobble url: %s", scrobble_url)
+ return session_key, scrobble_url
+
+
+def submit_scrobble_v1(session, scrobble_time, track, session_key, scrobble_url):
+ payload = get_scrobble_payload(track, scrobble_time)
+ plugin.logger.debug("Sending scrobble with payload %s", payload)
+ payload["s"] = session_key
+ response = session.post(scrobble_url, payload)
+ response.raise_for_status()
+ if response.text.startswith("OK"):
+ return
+ elif response.text.startswith("BADSESSION"):
+ raise ScrobblerException("Remote server says the session is invalid")
+ else:
+ raise ScrobblerException(response.text)
+
+ plugin.logger.debug("Scrobble successfull!")
+
+
+def get_scrobble_payload(track, scrobble_time):
+ upload = track.uploads.filter(duration__gte=0).first()
+ return {
+ "a[0]": track.artist.name,
+ "t[0]": track.title,
+ "i[0]": int(scrobble_time.timestamp()),
+ "l[0]": upload.duration if upload else 0,
+ "b[0]": track.album.title or "",
+ "n[0]": track.position or "",
+ "m[0]": str(track.mbid) or "",
+ }