Commit 84981df5 authored by Agate's avatar Agate 💬

Merge branch 'lastfm-api-new' into 'develop'

[plugin, scrobbler] Use last.fm API v2 for scrobbling if API key and secret are provided

See merge request !1216
parents 00b6fb51 cce158b6
Pipeline #11989 passed with stages
in 9 minutes and 35 seconds
......@@ -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"],
}
......
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``
......@@ -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")
"""
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"},
],
)
......@@ -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()
......@@ -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
......
[plugin, scrobbler] Use last.fm API v2 for scrobbling if API key and secret are provided
\ No newline at end of file
......@@ -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
......
<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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment