[plugin, scrobbler] Use last.fm API v2 for scrobbling if API key and secret are provided
This commit is contained in:
parent
00b6fb512f
commit
cce158b60b
|
@ -5,6 +5,7 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import persisting_theory
|
import persisting_theory
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
@ -28,6 +29,19 @@ _filters = {}
|
||||||
_hooks = {}
|
_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(
|
def get_plugin_config(
|
||||||
name,
|
name,
|
||||||
user=False,
|
user=False,
|
||||||
|
@ -38,6 +52,7 @@ def get_plugin_config(
|
||||||
description=None,
|
description=None,
|
||||||
version=None,
|
version=None,
|
||||||
label=None,
|
label=None,
|
||||||
|
homepage=None,
|
||||||
):
|
):
|
||||||
conf = {
|
conf = {
|
||||||
"name": name,
|
"name": name,
|
||||||
|
@ -52,6 +67,8 @@ def get_plugin_config(
|
||||||
"source": source,
|
"source": source,
|
||||||
"description": description,
|
"description": description,
|
||||||
"version": version,
|
"version": version,
|
||||||
|
"cache": PluginCache(name),
|
||||||
|
"homepage": homepage,
|
||||||
}
|
}
|
||||||
registry[name] = conf
|
registry[name] = conf
|
||||||
return conf
|
return conf
|
||||||
|
@ -259,6 +276,7 @@ def serialize_plugin(plugin_conf, confs):
|
||||||
"values": confs.get(plugin_conf["name"], {"conf"}).get("conf"),
|
"values": confs.get(plugin_conf["name"], {"conf"}).get("conf"),
|
||||||
"enabled": plugin_conf["name"] in confs
|
"enabled": plugin_conf["name"] in confs
|
||||||
and confs[plugin_conf["name"]]["enabled"],
|
and confs[plugin_conf["name"]]["enabled"],
|
||||||
|
"homepage": plugin_conf["homepage"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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``
|
|
@ -6,6 +6,7 @@ from . import scrobbler
|
||||||
|
|
||||||
# https://listenbrainz.org/lastfm-proxy
|
# https://listenbrainz.org/lastfm-proxy
|
||||||
DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com"
|
DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com"
|
||||||
|
LASTFM_SCROBBLER_URL = "https://ws.audioscrobbler.com/2.0/"
|
||||||
|
|
||||||
|
|
||||||
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
||||||
|
@ -17,23 +18,51 @@ def forward_to_scrobblers(listening, conf, **kwargs):
|
||||||
password = conf.get("password")
|
password = conf.get("password")
|
||||||
url = conf.get("url", DEFAULT_SCROBBLER_URL) or DEFAULT_SCROBBLER_URL
|
url = conf.get("url", DEFAULT_SCROBBLER_URL) or DEFAULT_SCROBBLER_URL
|
||||||
if username and password:
|
if username and password:
|
||||||
PLUGIN["logger"].info("Forwarding scrobbler to %s", url)
|
|
||||||
session = plugins.get_session()
|
session = plugins.get_session()
|
||||||
session_key, now_playing_url, scrobble_url = scrobbler.handshake_v1(
|
if (
|
||||||
session=session, url=url, username=username, password=password
|
PLUGIN["settings"]["lastfm_api_key"]
|
||||||
)
|
and PLUGIN["settings"]["lastfm_api_secret"]
|
||||||
scrobbler.submit_now_playing_v1(
|
and url == DEFAULT_SCROBBLER_URL
|
||||||
session=session,
|
):
|
||||||
track=listening.track,
|
PLUGIN["logger"].info("Forwarding scrobble to %s", LASTFM_SCROBBLER_URL)
|
||||||
session_key=session_key,
|
session_key = PLUGIN["cache"].get(
|
||||||
now_playing_url=now_playing_url,
|
"lastfm:sessionkey:{}".format(listening.user.pk)
|
||||||
)
|
)
|
||||||
scrobbler.submit_scrobble_v1(
|
if not session_key:
|
||||||
session=session,
|
PLUGIN["logger"].debug("Authenticating…")
|
||||||
track=listening.track,
|
session_key = scrobbler.handshake_v2(
|
||||||
scrobble_time=listening.creation_date,
|
username=username,
|
||||||
session_key=session_key,
|
password=password,
|
||||||
scrobble_url=scrobble_url,
|
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:
|
else:
|
||||||
PLUGIN["logger"].debug("No scrobbler configuration for user, skipping")
|
PLUGIN["logger"].debug("No scrobbler configuration for user, skipping")
|
||||||
|
|
|
@ -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
|
from config import plugins
|
||||||
|
|
||||||
PLUGIN = plugins.get_plugin_config(
|
PLUGIN = plugins.get_plugin_config(
|
||||||
name="scrobbler",
|
name="scrobbler",
|
||||||
label="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",
|
version="0.1",
|
||||||
user=True,
|
user=True,
|
||||||
conf=[
|
conf=[
|
||||||
|
@ -34,8 +28,8 @@ PLUGIN = plugins.get_plugin_config(
|
||||||
{"name": "username", "type": "text", "label": "Your scrobbler username"},
|
{"name": "username", "type": "text", "label": "Your scrobbler username"},
|
||||||
{"name": "password", "type": "password", "label": "Your scrobbler password"},
|
{"name": "password", "type": "password", "label": "Your scrobbler password"},
|
||||||
],
|
],
|
||||||
# settings=[
|
settings=[
|
||||||
# {"name": "lastfm_api_key", "type": "text"},
|
{"name": "lastfm_api_key", "type": "text"},
|
||||||
# {"name": "lastfm_api_secret", "type": "text"},
|
{"name": "lastfm_api_secret", "type": "text"},
|
||||||
# ]
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -96,3 +96,66 @@ def get_scrobble_payload(track, date, suffix="[0]"):
|
||||||
if date:
|
if date:
|
||||||
data["i{}".format(suffix)] = int(date.timestamp())
|
data["i{}".format(suffix)] = int(date.timestamp())
|
||||||
return data
|
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,
|
"source": False,
|
||||||
"label": "test_plugin",
|
"label": "test_plugin",
|
||||||
"values": None,
|
"values": None,
|
||||||
|
"homepage": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert plugins.serialize_plugin(plugin, plugins.get_confs()) == expected
|
assert plugins.serialize_plugin(plugin, plugins.get_confs()) == expected
|
||||||
|
@ -230,6 +231,7 @@ def test_serialize_plugin_user(factories):
|
||||||
"source": False,
|
"source": False,
|
||||||
"label": "test_plugin",
|
"label": "test_plugin",
|
||||||
"values": None,
|
"values": None,
|
||||||
|
"homepage": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
|
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
|
||||||
|
@ -242,6 +244,7 @@ def test_serialize_plugin_user_enabled(factories):
|
||||||
description="Hello world",
|
description="Hello world",
|
||||||
conf=[{"name": "foo", "type": "boolean"}],
|
conf=[{"name": "foo", "type": "boolean"}],
|
||||||
user=True,
|
user=True,
|
||||||
|
homepage="https://example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
factories["common.PluginConfiguration"](
|
factories["common.PluginConfiguration"](
|
||||||
|
@ -256,6 +259,7 @@ def test_serialize_plugin_user_enabled(factories):
|
||||||
"source": False,
|
"source": False,
|
||||||
"label": "test_plugin",
|
"label": "test_plugin",
|
||||||
"values": {"foo": "bar"},
|
"values": {"foo": "bar"},
|
||||||
|
"homepage": "https://example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
|
assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
[plugin, scrobbler] Use last.fm API v2 for scrobbling if API key and secret are provided
|
|
@ -493,7 +493,7 @@ export default {
|
||||||
this.getSound(toPreload)
|
this.getSound(toPreload)
|
||||||
this.nextTrackPreloaded = true
|
this.nextTrackPreloaded = true
|
||||||
}
|
}
|
||||||
if (t > this.listenDelay || d - t < 30) {
|
if (t > (d / 2)) {
|
||||||
let onlyTrack = this.$store.state.queue.tracks.length === 1
|
let onlyTrack = this.$store.state.queue.tracks.length === 1
|
||||||
if (this.listeningRecorded != this.currentTrack) {
|
if (this.listeningRecorded != this.currentTrack) {
|
||||||
this.listeningRecorded = this.currentTrack
|
this.listeningRecorded = this.currentTrack
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<form :class="['ui form', {loading: isLoading}]" @submit.prevent="submit">
|
<form :class="['ui segment form', {loading: isLoading}]" @submit.prevent="submit">
|
||||||
<h3>{{ plugin.label }}</h3>
|
<h3>{{ plugin.label }}</h3>
|
||||||
<div v-if="plugin.description" v-html="markdown.makeHtml(plugin.description)"></div>
|
<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 class="ui clearing hidden divider"></div>
|
||||||
<div v-if="errors.length > 0" role="alert" class="ui negative message">
|
<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>
|
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while saving plugin</translate></h4>
|
||||||
|
|
Loading…
Reference in New Issue