A Python Extism plugin for Navidrome that scrobbles playback to a self-hosted Scrobbled (Last.fm-compatible) server, plus Gitea Actions CI that lints, tests, builds/validates, and publishes the .ndp artifact to pkgs.daubney.dev. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018n5ffHCn5QUjpDtuWTk3fh
542 lines
16 KiB
Python
542 lines
16 KiB
Python
# Scrobbled scrobbler plugin for Navidrome
|
|
#
|
|
# Scrobbles Navidrome playback to a self-hosted Scrobbled (Last.fm-compatible)
|
|
# server (see https://scrobbled.uk and the ScrobblingService source).
|
|
#
|
|
# Build with:
|
|
# extism-py plugin/__init__.py -o dist/plugin.wasm
|
|
#
|
|
# There is no Python PDK for Navidrome plugins, so host functions are called
|
|
# directly via the extism:host/user namespace, following the pattern used by
|
|
# Navidrome's own nowplaying-py / coverartarchive-py example plugins.
|
|
|
|
import base64
|
|
import json
|
|
|
|
import extism
|
|
|
|
try:
|
|
from hashlib import md5 as _hashlib_md5
|
|
except ImportError: # pragma: no cover - depends on the extism-py runtime
|
|
_hashlib_md5 = None
|
|
|
|
try:
|
|
from urllib.parse import urlencode as _urllib_urlencode
|
|
except ImportError: # pragma: no cover - depends on the extism-py runtime
|
|
_urllib_urlencode = None
|
|
|
|
|
|
# =============================================================================
|
|
# Constants
|
|
# =============================================================================
|
|
|
|
# Excluded from the Last.fm-style request signature, per ScrobblingService's
|
|
# src/scrobbler/lastfm/signature.py::UNSIGNED_PARAMS.
|
|
UNSIGNED_PARAMS = frozenset({"format", "callback", "api_sig"})
|
|
|
|
# Navidrome's ScrobblerError sentinel strings (plugins/pdk/go/scrobbler/scrobbler.go).
|
|
# Raising an Exception with exactly one of these messages tells Navidrome how
|
|
# to treat the failure (retry, mark unauthorized, or give up).
|
|
ERR_NOT_AUTHORIZED = "scrobbler(not_authorized)"
|
|
ERR_RETRY_LATER = "scrobbler(retry_later)"
|
|
ERR_UNRECOVERABLE = "scrobbler(unrecoverable)"
|
|
KNOWN_SCROBBLER_ERRORS = frozenset({ERR_NOT_AUTHORIZED, ERR_RETRY_LATER, ERR_UNRECOVERABLE})
|
|
|
|
SESSION_KEY_CACHE_KEY = "sk"
|
|
|
|
CONFIG_REQUIRED_KEYS = (
|
|
"server_url",
|
|
"api_key",
|
|
"shared_secret",
|
|
"scrobbled_username",
|
|
"scrobbled_password",
|
|
)
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Raised locally when required plugin configuration is missing."""
|
|
|
|
|
|
# =============================================================================
|
|
# Raw host function imports
|
|
# =============================================================================
|
|
# These are custom host functions provided by Navidrome. We import them using
|
|
# the extism:host/user namespace. Do not call these directly - use the
|
|
# wrapper functions below, which handle JSON marshalling and memory management.
|
|
|
|
|
|
@extism.import_fn("extism:host/user", "http_send")
|
|
def _http_send(offset: int) -> int: ...
|
|
|
|
|
|
@extism.import_fn("extism:host/user", "kvstore_get")
|
|
def _kvstore_get(offset: int) -> int: ...
|
|
|
|
|
|
@extism.import_fn("extism:host/user", "kvstore_set")
|
|
def _kvstore_set(offset: int) -> int: ...
|
|
|
|
|
|
@extism.import_fn("extism:host/user", "kvstore_delete")
|
|
def _kvstore_delete(offset: int) -> int: ...
|
|
|
|
|
|
# =============================================================================
|
|
# Host function wrappers
|
|
# =============================================================================
|
|
|
|
|
|
def _host_call(raw_fn, request_obj: dict) -> dict:
|
|
"""Shared JSON-over-memory envelope for calling a raw host function."""
|
|
request_bytes = json.dumps(request_obj).encode("utf-8")
|
|
request_mem = extism.memory.alloc(request_bytes)
|
|
response_offset = raw_fn(request_mem.offset)
|
|
response_mem = extism.memory.find(response_offset)
|
|
response = json.loads(extism.memory.string(response_mem))
|
|
|
|
if response.get("error"):
|
|
raise Exception(f"host call failed: {response['error']}")
|
|
|
|
return response
|
|
|
|
|
|
def http_send(request: dict) -> dict:
|
|
"""Send an HTTP request via Navidrome's http host service.
|
|
|
|
Returns the parsed `result` object: {"statusCode": int, "headers": {...},
|
|
"body": "<base64>"}.
|
|
"""
|
|
response = _host_call(_http_send, {"request": request})
|
|
return response["result"]
|
|
|
|
|
|
def kv_get(key: str) -> str | None:
|
|
"""Get a string value from the plugin's persistent KVStore, or None.
|
|
|
|
KVStoreGetResponse.Value is a Go []byte, which Go's encoding/json
|
|
marshals as a base64 string - decode it back to text here.
|
|
"""
|
|
response = _host_call(_kvstore_get, {"key": key})
|
|
if not response.get("exists"):
|
|
return None
|
|
return base64.b64decode(response["value"]).decode("utf-8")
|
|
|
|
|
|
def kv_set(key: str, value: str) -> None:
|
|
"""Store a string value in the plugin's persistent KVStore.
|
|
|
|
KVStoreSetRequest.Value is a Go []byte, which Go's encoding/json expects
|
|
as a base64 string on the wire.
|
|
"""
|
|
encoded_value = base64.b64encode(value.encode("utf-8")).decode("ascii")
|
|
_host_call(_kvstore_set, {"key": key, "value": encoded_value})
|
|
|
|
|
|
def kv_delete(key: str) -> None:
|
|
"""Delete a value from the plugin's persistent KVStore."""
|
|
_host_call(_kvstore_delete, {"key": key})
|
|
|
|
|
|
# =============================================================================
|
|
# MD5 signing
|
|
# =============================================================================
|
|
|
|
|
|
def _md5_fallback_hex(data: bytes) -> str:
|
|
"""Pure-Python MD5, used only if hashlib.md5 isn't available in the
|
|
extism-py runtime."""
|
|
|
|
def left_rotate(x: int, amount: int) -> int:
|
|
x &= 0xFFFFFFFF
|
|
return ((x << amount) | (x >> (32 - amount))) & 0xFFFFFFFF
|
|
|
|
s = [
|
|
7,
|
|
12,
|
|
17,
|
|
22,
|
|
7,
|
|
12,
|
|
17,
|
|
22,
|
|
7,
|
|
12,
|
|
17,
|
|
22,
|
|
7,
|
|
12,
|
|
17,
|
|
22,
|
|
5,
|
|
9,
|
|
14,
|
|
20,
|
|
5,
|
|
9,
|
|
14,
|
|
20,
|
|
5,
|
|
9,
|
|
14,
|
|
20,
|
|
5,
|
|
9,
|
|
14,
|
|
20,
|
|
4,
|
|
11,
|
|
16,
|
|
23,
|
|
4,
|
|
11,
|
|
16,
|
|
23,
|
|
4,
|
|
11,
|
|
16,
|
|
23,
|
|
4,
|
|
11,
|
|
16,
|
|
23,
|
|
6,
|
|
10,
|
|
15,
|
|
21,
|
|
6,
|
|
10,
|
|
15,
|
|
21,
|
|
6,
|
|
10,
|
|
15,
|
|
21,
|
|
6,
|
|
10,
|
|
15,
|
|
21,
|
|
]
|
|
k = [int(abs(__import__("math").sin(i + 1)) * 2**32) & 0xFFFFFFFF for i in range(64)]
|
|
|
|
a0, b0, c0, d0 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476
|
|
|
|
original_len_bits = (len(data) * 8) & 0xFFFFFFFFFFFFFFFF
|
|
data = data + b"\x80"
|
|
while len(data) % 64 != 56:
|
|
data += b"\x00"
|
|
data += original_len_bits.to_bytes(8, byteorder="little")
|
|
|
|
for chunk_start in range(0, len(data), 64):
|
|
chunk = data[chunk_start : chunk_start + 64]
|
|
m = [int.from_bytes(chunk[i : i + 4], byteorder="little") for i in range(0, 64, 4)]
|
|
|
|
a, b, c, d = a0, b0, c0, d0
|
|
|
|
for i in range(64):
|
|
if i < 16:
|
|
f = (b & c) | (~b & d)
|
|
g = i
|
|
elif i < 32:
|
|
f = (d & b) | (~d & c)
|
|
g = (5 * i + 1) % 16
|
|
elif i < 48:
|
|
f = b ^ c ^ d
|
|
g = (3 * i + 5) % 16
|
|
else:
|
|
f = c ^ (b | ~d)
|
|
g = (7 * i) % 16
|
|
|
|
f = (f + a + k[i] + m[g]) & 0xFFFFFFFF
|
|
a = d
|
|
d = c
|
|
c = b
|
|
b = (b + left_rotate(f, s[i])) & 0xFFFFFFFF
|
|
|
|
a0 = (a0 + a) & 0xFFFFFFFF
|
|
b0 = (b0 + b) & 0xFFFFFFFF
|
|
c0 = (c0 + c) & 0xFFFFFFFF
|
|
d0 = (d0 + d) & 0xFFFFFFFF
|
|
|
|
digest = b"".join(x.to_bytes(4, byteorder="little") for x in (a0, b0, c0, d0))
|
|
return digest.hex()
|
|
|
|
|
|
def _md5_hex(data: bytes) -> str:
|
|
if _hashlib_md5 is not None:
|
|
return _hashlib_md5(data).hexdigest()
|
|
return _md5_fallback_hex(data)
|
|
|
|
|
|
def compute_api_sig(params: dict, secret: str) -> str:
|
|
"""Last.fm-style request signature, ported verbatim from ScrobblingService's
|
|
src/scrobbler/lastfm/signature.py::sign: sort every param except
|
|
format/callback/api_sig by key, concatenate key+value pairs, append the
|
|
shared secret, and md5-hex the result."""
|
|
payload = "".join(
|
|
f"{name}{params[name]}" for name in sorted(params) if name not in UNSIGNED_PARAMS
|
|
)
|
|
return _md5_hex((payload + secret).encode("utf-8"))
|
|
|
|
|
|
# =============================================================================
|
|
# Form encoding
|
|
# =============================================================================
|
|
|
|
|
|
def _percent_encode(value: str) -> str:
|
|
unreserved = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~")
|
|
out = []
|
|
for byte in value.encode("utf-8"):
|
|
char = chr(byte)
|
|
if char in unreserved:
|
|
out.append(char)
|
|
elif char == " ":
|
|
out.append("+")
|
|
else:
|
|
out.append(f"%{byte:02X}")
|
|
return "".join(out)
|
|
|
|
|
|
def _fallback_urlencode(params: dict) -> str:
|
|
return "&".join(
|
|
f"{_percent_encode(str(k))}={_percent_encode(str(v))}" for k, v in params.items()
|
|
)
|
|
|
|
|
|
def encode_form_body(params: dict) -> str:
|
|
if _urllib_urlencode is not None:
|
|
return _urllib_urlencode(params)
|
|
return _fallback_urlencode(params)
|
|
|
|
|
|
def _encode_body_b64(params: dict) -> str:
|
|
"""Form-encode params and base64-encode the result, since Navidrome's
|
|
HTTPRequest.Body is a Go []byte, which Go's encoding/json expects as a
|
|
base64 string on the wire."""
|
|
return base64.b64encode(encode_form_body(params).encode("utf-8")).decode("ascii")
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration
|
|
# =============================================================================
|
|
|
|
|
|
def _read_config() -> dict:
|
|
cfg = {}
|
|
for key in CONFIG_REQUIRED_KEYS:
|
|
value = extism.Config.get_str(key)
|
|
if not value:
|
|
raise ConfigError(f"missing required config: {key}")
|
|
cfg[key] = value
|
|
cfg["navidrome_username"] = extism.Config.get_str("navidrome_username") or None
|
|
return cfg
|
|
|
|
|
|
# =============================================================================
|
|
# Scrobbled (Last.fm-compatible) API client
|
|
# =============================================================================
|
|
|
|
|
|
def _api_url(server_url: str) -> str:
|
|
return server_url.rstrip("/") + "/2.0/"
|
|
|
|
|
|
def _decode_json_body(result: dict) -> dict:
|
|
body_bytes = base64.b64decode(result.get("body", "") or "")
|
|
try:
|
|
return json.loads(body_bytes)
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
raise Exception(ERR_UNRECOVERABLE) from exc
|
|
|
|
|
|
def _raise_for_lastfm_error(status_code: int, parsed: dict) -> None:
|
|
code = parsed.get("error")
|
|
if status_code >= 500 or code == 29:
|
|
raise Exception(ERR_RETRY_LATER)
|
|
if code == 4:
|
|
raise Exception(ERR_NOT_AUTHORIZED)
|
|
raise Exception(ERR_UNRECOVERABLE)
|
|
|
|
|
|
def get_session_key(cfg: dict, force_reauth: bool = False) -> str:
|
|
"""Return a cached Scrobbled session key, performing the auth.getMobileSession
|
|
handshake (and caching the result) if none is cached yet or force_reauth is set."""
|
|
if not force_reauth:
|
|
cached = kv_get(SESSION_KEY_CACHE_KEY)
|
|
if cached:
|
|
return cached
|
|
|
|
params = {
|
|
"method": "auth.getMobileSession",
|
|
"api_key": cfg["api_key"],
|
|
"username": cfg["scrobbled_username"],
|
|
"password": cfg["scrobbled_password"],
|
|
"format": "json",
|
|
}
|
|
params["api_sig"] = compute_api_sig(params, cfg["shared_secret"])
|
|
|
|
try:
|
|
result = http_send(
|
|
{
|
|
"method": "POST",
|
|
"url": _api_url(cfg["server_url"]),
|
|
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
|
"body": _encode_body_b64(params),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
raise Exception(ERR_RETRY_LATER) from exc
|
|
|
|
parsed = _decode_json_body(result)
|
|
|
|
if "error" in parsed:
|
|
_raise_for_lastfm_error(result.get("statusCode", 0), parsed)
|
|
|
|
sk = parsed["session"]["key"]
|
|
kv_set(SESSION_KEY_CACHE_KEY, sk)
|
|
return sk
|
|
|
|
|
|
def call_lastfm(
|
|
method: str, params: dict, cfg: dict | None = None, allow_reauth: bool = True
|
|
) -> dict:
|
|
"""Call a session-authenticated, signed Scrobbled/Last.fm method."""
|
|
if cfg is None:
|
|
cfg = _read_config()
|
|
|
|
sk = get_session_key(cfg)
|
|
|
|
full_params = dict(params)
|
|
full_params["method"] = method
|
|
full_params["api_key"] = cfg["api_key"]
|
|
full_params["sk"] = sk
|
|
full_params["format"] = "json"
|
|
full_params["api_sig"] = compute_api_sig(full_params, cfg["shared_secret"])
|
|
|
|
try:
|
|
result = http_send(
|
|
{
|
|
"method": "POST",
|
|
"url": _api_url(cfg["server_url"]),
|
|
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
|
"body": _encode_body_b64(full_params),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
raise Exception(ERR_RETRY_LATER) from exc
|
|
|
|
parsed = _decode_json_body(result)
|
|
|
|
if "error" not in parsed:
|
|
return parsed
|
|
|
|
code = parsed["error"]
|
|
status_code = result.get("statusCode", 0)
|
|
|
|
if status_code >= 500 or code == 29:
|
|
raise Exception(ERR_RETRY_LATER)
|
|
|
|
if code == 9:
|
|
# Invalid session key: drop the cached one, re-authenticate once, and
|
|
# retry the call exactly once with the fresh session key.
|
|
if allow_reauth:
|
|
kv_delete(SESSION_KEY_CACHE_KEY)
|
|
get_session_key(cfg, force_reauth=True)
|
|
return call_lastfm(method, params, cfg=cfg, allow_reauth=False)
|
|
raise Exception(ERR_NOT_AUTHORIZED)
|
|
|
|
if code == 4:
|
|
raise Exception(ERR_NOT_AUTHORIZED)
|
|
|
|
raise Exception(ERR_UNRECOVERABLE)
|
|
|
|
|
|
# =============================================================================
|
|
# Track mapping
|
|
# =============================================================================
|
|
|
|
|
|
def build_track_params(track: dict) -> dict:
|
|
"""Map a Navidrome TrackInfo dict onto Last.fm/Scrobbled request params."""
|
|
params = {
|
|
"artist": track["artist"],
|
|
"track": track["title"],
|
|
}
|
|
if track.get("album"):
|
|
params["album"] = track["album"]
|
|
if track.get("albumArtist"):
|
|
params["albumArtist"] = track["albumArtist"]
|
|
if track.get("duration"):
|
|
params["duration"] = str(round(track["duration"]))
|
|
if track.get("trackNumber"):
|
|
params["trackNumber"] = str(track["trackNumber"])
|
|
if track.get("mbzRecordingId"):
|
|
params["mbid"] = track["mbzRecordingId"]
|
|
return params
|
|
|
|
|
|
# =============================================================================
|
|
# Plugin exports
|
|
# =============================================================================
|
|
|
|
|
|
def _log_and_reraise(exc: Exception, context: str) -> None:
|
|
extism.log(extism.LogLevel.Error, f"{context}: {exc}")
|
|
if str(exc) in KNOWN_SCROBBLER_ERRORS:
|
|
raise exc
|
|
raise Exception(ERR_UNRECOVERABLE)
|
|
|
|
|
|
@extism.plugin_fn
|
|
def nd_scrobbler_is_authorized():
|
|
"""Fast local check - no network call. True if the plugin is fully
|
|
configured and (if navidrome_username is set) the request matches it."""
|
|
input_data = extism.input_json()
|
|
username = input_data.get("username", "")
|
|
|
|
try:
|
|
cfg = _read_config()
|
|
except ConfigError as exc:
|
|
extism.log(extism.LogLevel.Error, f"is_authorized: {exc}")
|
|
extism.output_str(json.dumps(False))
|
|
return
|
|
|
|
authorized = bool(username) and (
|
|
not cfg["navidrome_username"] or username == cfg["navidrome_username"]
|
|
)
|
|
extism.output_str(json.dumps(authorized))
|
|
|
|
|
|
@extism.plugin_fn
|
|
def nd_scrobbler_now_playing():
|
|
input_data = extism.input_json()
|
|
try:
|
|
call_lastfm("track.updateNowPlaying", build_track_params(input_data["track"]))
|
|
except Exception as exc:
|
|
_log_and_reraise(exc, "now_playing")
|
|
|
|
|
|
@extism.plugin_fn
|
|
def nd_scrobbler_scrobble():
|
|
input_data = extism.input_json()
|
|
try:
|
|
params = build_track_params(input_data["track"])
|
|
params["timestamp"] = input_data["timestamp"]
|
|
call_lastfm("track.scrobble", params)
|
|
except Exception as exc:
|
|
_log_and_reraise(exc, "scrobble")
|
|
|
|
|
|
@extism.plugin_fn
|
|
def nd_scrobbler_playback_report():
|
|
# Required export, but Scrobbled has no playback-state concept beyond
|
|
# now-playing/scrobble, so this is a deliberate no-op.
|
|
try:
|
|
input_data = extism.input_json()
|
|
extism.log(
|
|
extism.LogLevel.Info,
|
|
f"playback_report state={input_data.get('state')} (no-op)",
|
|
)
|
|
except Exception:
|
|
pass
|