Add Navidrome Scrobbled scrobbler plugin
CI / lint (push) Successful in 10s
CI / test (push) Successful in 10s
CI / build (push) Successful in 25s
CI / publish (push) Successful in 9s
CI / notify (push) Successful in 5s

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
This commit is contained in:
2026-09-27 23:07:02 +01:00
co-authored by Claude Sonnet 5
commit 2dccf7d972
11 changed files with 1169 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
name: Notify
description: Publish a workflow result to the project's ntfy topic. Never fails the calling job.
inputs:
status:
description: Overall result (success, failure or cancelled)
required: true
title:
description: Notification title, e.g. "CI main"
required: true
message:
description: Extra lines appended to the notification body
required: false
default: ""
url:
description: ntfy server base URL
required: true
topic:
description: ntfy topic
required: true
user:
description: ntfy username
required: true
password:
description: ntfy password
required: true
runs:
using: composite
steps:
- name: Send ntfy notification
shell: bash
continue-on-error: true
env:
STATUS: ${{ inputs.status }}
TITLE: ${{ inputs.title }}
EXTRA: ${{ inputs.message }}
NTFY_URL: ${{ inputs.url }}
NTFY_TOPIC: ${{ inputs.topic }}
NTFY_USER: ${{ inputs.user }}
NTFY_PASSWORD: ${{ inputs.password }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
run: |
case "$STATUS" in
success) icon="✅"; priority=3; tags='["white_check_mark"]' ;;
cancelled) icon="⚪"; priority=3; tags='["heavy_minus_sign"]' ;;
*) icon="❌"; priority=4; tags='["x"]' ;;
esac
commit_msg="$(git log -1 --format=%s "$SHA" 2>/dev/null || echo "")"
body="$(printf '%s @ %s by %s\n%s\n%s' "$REF_NAME" "${SHA:0:7}" "$ACTOR" "$commit_msg" "$EXTRA")"
payload="$(jq -n \
--arg topic "$NTFY_TOPIC" \
--arg title "$TITLE $icon" \
--arg message "$body" \
--arg click "$RUN_URL" \
--argjson priority "$priority" \
--argjson tags "$tags" \
'{topic: $topic, title: $title, message: $message, click: $click, priority: $priority, tags: $tags}')"
curl -fsS -m 15 -u "$NTFY_USER:$NTFY_PASSWORD" \
-H 'Content-Type: application/json' \
--data-binary "$payload" "$NTFY_URL" -o /dev/null \
|| echo "::warning::ntfy notification failed"
+124
View File
@@ -0,0 +1,124 @@
name: CI
# Checks run on every push and PR. Pushes to main and v* tags build and package
# the plugin, then publish the .ndp artifact to pkgs.daubney.dev - but only
# once every check has passed. Every run is reported to ntfy.
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
workflow_dispatch:
env:
UV_VERSION: "0.7.21"
NAVIDROME_VERSION: "0.64.2"
PLUGIN_ID: navidrome-scrobbled
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v6
with:
# Exact version: avoids an unauthenticated GitHub API lookup for "latest".
version: ${{ env.UV_VERSION }}
- run: uv sync --locked
- run: uv run ruff check
- run: uv run ruff format --check
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v6
with:
version: ${{ env.UV_VERSION }}
- run: uv sync --locked
- run: uv run python -m unittest discover -s tests -v
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install extism-py and Binaryen
# Official installer: fetches the latest extism-py release for this
# OS/arch and installs wasm-merge/wasm-opt (Binaryen) if missing.
run: curl -fsSL https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
- run: make build
- run: make package
- name: Install navidrome CLI
run: |
curl -fsSL "https://github.com/navidrome/navidrome/releases/download/v${NAVIDROME_VERSION}/navidrome_${NAVIDROME_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin navidrome
- run: navidrome plugin validate dist/${{ env.PLUGIN_ID }}.ndp
- uses: actions/upload-artifact@v4
with:
name: ${{ env.PLUGIN_ID }}
path: dist/${{ env.PLUGIN_ID }}.ndp
if-no-files-found: error
publish:
needs: [lint, test, build]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ${{ env.PLUGIN_ID }}
path: dist
- name: Upload plugin artifact to ProGet
env:
API_KEY: ${{ secrets.PKGS_API_KEY }}
BASE: https://${{ vars.PKGS_HOST }}/endpoints/${{ vars.PKGS_ASSET_FEED }}/content/${{ env.PLUGIN_ID }}
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then dirs="${GITHUB_REF_NAME#v}"; else dirs="main sha-${GITHUB_SHA::7}"; fi
# POST creates or overwrites (ProGet rejects PUT to an existing path).
for dir in $dirs; do
curl -fsS -X POST -H "X-ApiKey: $API_KEY" -H "Content-Type: application/octet-stream" \
--data-binary "@dist/${PLUGIN_ID}.ndp" "$BASE/$dir/${PLUGIN_ID}.ndp"
curl -fsS -X POST -H "X-ApiKey: $API_KEY" -H "Content-Type: application/json" \
--data-binary "@manifest.json" "$BASE/$dir/manifest.json"
echo "uploaded $dir/${PLUGIN_ID}.ndp"
done
notify:
needs: [lint, test, build, publish]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: summary
name: Summarise the run
env:
NEEDS: ${{ toJSON(needs) }}
run: |
python3 - <<'PY'
import json, os
needs = json.loads(os.environ["NEEDS"])
results = {job: info["result"] for job, info in needs.items()}
if "failure" in results.values():
status = "failure"
elif "cancelled" in results.values():
status = "cancelled"
else:
status = "success"
ran = [f"{job}: {result}" for job, result in results.items() if result != "skipped"]
ref = os.environ["GITHUB_REF"]
title = f"Release {os.environ['GITHUB_REF_NAME']}" if ref.startswith("refs/tags/") else f"CI {os.environ['GITHUB_REF_NAME']}"
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
out.write(f"status={status}\ntitle={title}\n")
out.write("message<<EOF\n" + "\n".join(ran) + "\nEOF\n")
PY
- uses: ./.gitea/actions/notify
with:
status: ${{ steps.summary.outputs.status }}
title: ${{ steps.summary.outputs.title }}
message: ${{ steps.summary.outputs.message }}
url: ${{ vars.NTFY_URL }}
topic: ${{ vars.NTFY_TOPIC }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}
+10
View File
@@ -0,0 +1,10 @@
*.wasm
*.ndp
dist/
scratch/
__pycache__/
*.pyc
.venv/
.idea/
.junie/
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
PLUGIN_ID = navidrome-scrobbled
WASM_FILE = dist/plugin.wasm
NDP_FILE = dist/$(PLUGIN_ID).ndp
.PHONY: build package validate test clean
build: $(WASM_FILE)
$(WASM_FILE): plugin/__init__.py
mkdir -p dist
extism-py plugin/__init__.py -o $(WASM_FILE)
package: $(NDP_FILE)
$(NDP_FILE): $(WASM_FILE) manifest.json
zip -j $(NDP_FILE) manifest.json $(WASM_FILE)
validate: package
navidrome plugin validate $(NDP_FILE)
test:
python3 -m unittest discover -s tests -v
clean:
rm -rf dist
+96
View File
@@ -0,0 +1,96 @@
# Scrobbled Navidrome Plugin
A [Navidrome](https://www.navidrome.org/) scrobbler plugin that sends now-playing
updates and scrobbles to a self-hosted [Scrobbled](https://scrobbled.uk) server
(a Last.fm Audioscrobbler 2.0-compatible service).
It's a WebAssembly plugin built with [Extism](https://extism.org/), written in
Python. Navidrome has no first-class Python SDK for plugins ("no Python PDK"),
so this plugin calls Navidrome's host services directly, following the pattern
used by Navidrome's own [`nowplaying-py`](https://github.com/navidrome/navidrome/tree/master/plugins/examples/nowplaying-py)
and [`coverartarchive-py`](https://github.com/navidrome/navidrome/tree/master/plugins/examples/coverartarchive-py)
example plugins.
## How it works
Navidrome calls this plugin's four `nd_scrobbler_*` exports as users play
music. The plugin translates each call into a signed, session-authenticated
request against Scrobbled's `/2.0/` Last.fm-compatible API:
- `nd_scrobbler_is_authorized` — a fast local check (no network call) that the
plugin is configured and, if `navidrome_username` is set, that the request
is for that user.
- `nd_scrobbler_now_playing` → `track.updateNowPlaying`
- `nd_scrobbler_scrobble` → `track.scrobble`
- `nd_scrobbler_playback_report` — required by Navidrome's Scrobbler
capability, but implemented as a no-op since Scrobbled has no playback-state
concept beyond now-playing/scrobble.
The Scrobbled session key (`sk`), obtained once via the `auth.getMobileSession`
handshake, is cached in Navidrome's per-plugin KVStore so it isn't re-derived
from the configured password on every call. If Scrobbled reports the cached
session key as invalid, the plugin re-authenticates once and retries.
This is a single-account plugin: one Navidrome user's plays scrobble to one
Scrobbled account, configured directly in the plugin's config.
## Configuration
Set these via the Navidrome admin UI (Plugins page) or
`navidrome plugin edit <id> --config '...'`:
| Key | Required | Description |
| --- | --- | --- |
| `server_url` | yes | Base URL of your Scrobbled instance, e.g. `https://scrobbled.uk` |
| `api_key` | yes | API key from a Scrobbled "app" you create in its web UI |
| `shared_secret` | yes | Shared secret paired with the API key |
| `scrobbled_username` | yes | Your Scrobbled account username |
| `scrobbled_password` | yes | Your Scrobbled account password (used only for the session handshake, never sent on scrobble calls) |
| `navidrome_username` | no | If set, only this Navidrome username is treated as authorized |
You also need to assign the plugin to the relevant Navidrome user(s), e.g.:
```sh
navidrome plugin edit navidrome-scrobbled --users <your-navidrome-username>
```
## Building
Requires [`extism-py`](https://github.com/extism/python-pdk) and
[Binaryen](https://github.com/WebAssembly/binaryen) (`wasm-opt`/`wasm-merge`)
on `PATH`. The official installer fetches both:
```sh
curl -fsSL https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash
make build # compiles plugin/__init__.py -> dist/plugin.wasm
make package # packages manifest.json + dist/plugin.wasm -> dist/navidrome-scrobbled.ndp
make validate # navidrome plugin validate dist/navidrome-scrobbled.ndp (needs a navidrome binary on PATH)
```
## Testing
```sh
make test # or: python3 -m unittest discover -s tests -v
```
`tests/test_logic.py` exercises the plugin's pure business logic (request
signing, form-encoding, track-field mapping) under plain CPython, using a
minimal fake `extism` module (`tests/stubs/extism.py`) — no wasm toolchain
required. If a checkout of the `ScrobblingService` server source is available
on the same machine, it also cross-checks the signing algorithm against that
project's own implementation.
## Installing
Copy the built `.ndp` file into your Navidrome plugins folder (or install it
via whatever mechanism your Navidrome deployment provides for plugin files),
then configure and assign users as described above.
## CI/CD
Gitea Actions (`.gitea/workflows/ci.yml`) lints and tests every push and pull
request, builds and packages the `.ndp` on every push, and — on pushes to
`main` or `v*` tags — publishes the built artifact to the same
[pkgs.daubney.dev](https://pkgs.daubney.dev) ProGet asset feed used by
`ScrobblingService`, under the `navidrome-scrobbled` content path.
+51
View File
@@ -0,0 +1,51 @@
{
"name": "Scrobbled",
"author": "Matt Daubney",
"version": "0.1.0",
"description": "Scrobbles Navidrome playback to a self-hosted Scrobbled (Last.fm-compatible) server.",
"website": "https://scrobbled.uk",
"permissions": {
"http": {
"reason": "Send now-playing updates and scrobbles to the user's configured Scrobbled server.",
"requiredHosts": ["*"]
},
"kvstore": {
"reason": "Cache the Scrobbled session key from the auth handshake so it isn't re-derived from the password on every call."
},
"users": {
"reason": "Required by the scrobbler capability; only fires for the Navidrome user(s) the admin assigns to this plugin."
}
},
"config": {
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["server_url", "api_key", "shared_secret", "scrobbled_username", "scrobbled_password"],
"properties": {
"server_url": {
"type": "string",
"title": "Scrobbled server URL",
"description": "Base URL of your Scrobbled instance, e.g. https://scrobbled.uk (the plugin appends /2.0/).",
"default": "https://scrobbled.uk"
},
"api_key": { "type": "string", "title": "API key" },
"shared_secret": { "type": "string", "title": "Shared secret" },
"scrobbled_username": { "type": "string", "title": "Scrobbled username" },
"scrobbled_password": {
"type": "string",
"title": "Scrobbled password",
"description": "Used only for the one-time/re-auth session handshake, never sent on scrobble calls."
},
"navidrome_username": {
"type": "string",
"title": "Restrict to Navidrome username (optional)",
"description": "If set, only this Navidrome username is treated as authorized. Leave blank to allow any user the admin assigns via the plugin's users permission."
}
}
},
"uiSchema": {
"scrobbled_password": { "ui:widget": "password" },
"shared_secret": { "ui:widget": "password" }
}
}
}
+541
View File
@@ -0,0 +1,541 @@
# 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
+18
View File
@@ -0,0 +1,18 @@
[project]
name = "navidromescrobbledplugin"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
dev = [
"ruff>=0.16.9",
]
[tool.ruff]
line-length = 100
target-version = "py313"
src = ["plugin", "tests"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
+54
View File
@@ -0,0 +1,54 @@
"""Minimal fake `extism` module for testing plugin/__init__.py's pure business
logic under plain CPython, without the real extism-py/wasm runtime.
Never shipped or compiled - only used by tests/test_logic.py, which puts this
directory at the front of sys.path before importing the plugin module.
"""
def import_fn(namespace, name):
def decorator(fn):
return fn
return decorator
def plugin_fn(fn):
return fn
class LogLevel:
Info = "info"
Error = "error"
def log(level, message):
pass
class Config:
@staticmethod
def get_str(key):
return None
def input_json():
return {}
def output_str(value):
pass
class _Memory:
def alloc(self, data):
raise NotImplementedError("host memory is not available in tests")
def find(self, offset):
raise NotImplementedError("host memory is not available in tests")
def string(self, mem):
raise NotImplementedError("host memory is not available in tests")
memory = _Memory()
+142
View File
@@ -0,0 +1,142 @@
"""Pure-logic tests for plugin/__init__.py.
These exercise only the business logic that has no dependency on the real
extism-py/wasm runtime (signing, form-encoding, track-field mapping), so they
run under plain python3/uv, with no wasm toolchain installed.
"""
import os
import sys
import unittest
from urllib.parse import parse_qs
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STUBS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stubs")
# The stub `extism` module must be importable before `plugin` is, and take
# priority over any real `extism` package that happens to be installed.
sys.path.insert(0, STUBS_DIR)
sys.path.insert(0, REPO_ROOT)
import plugin # noqa: E402 (import after sys.path setup, by design)
SCROBBLING_SERVICE_SRC = "/Users/matt/PycharmProjects/ScrobblingService/src"
try:
sys.path.insert(0, SCROBBLING_SERVICE_SRC)
from scrobbler.lastfm import signature as scrobbling_service_signature
except ImportError:
scrobbling_service_signature = None
class ComputeApiSigTests(unittest.TestCase):
def test_matches_known_fixture(self):
params = {
"method": "auth.getMobileSession",
"api_key": "k",
"username": "alice",
"password": "p",
"format": "json",
}
# Hand-computed per the documented algorithm: sort keys (excluding
# format/callback/api_sig), concatenate key+value, append secret, md5-hex.
payload = "api_keykmethodauth.getMobileSessionpasswordpusernamealice"
expected = plugin._md5_hex((payload + "secret").encode("utf-8"))
self.assertEqual(plugin.compute_api_sig(params, "secret"), expected)
def test_excludes_unsigned_params(self):
with_format = {"a": "1", "format": "json"}
without_format = {"a": "1"}
self.assertEqual(
plugin.compute_api_sig(with_format, "secret"),
plugin.compute_api_sig(without_format, "secret"),
)
@unittest.skipUnless(
scrobbling_service_signature is not None,
"ScrobblingService repo not available on this machine",
)
def test_matches_scrobbling_service_signature(self):
params = {
"method": "track.scrobble",
"api_key": "abc123",
"sk": "sesskey",
"artist": "Boards of Canada",
"track": "Roygbiv",
"timestamp": "1700000000",
"format": "json",
}
expected = scrobbling_service_signature.sign(params, "shh")
self.assertEqual(plugin.compute_api_sig(params, "shh"), expected)
class Md5Tests(unittest.TestCase):
def test_known_vectors(self):
self.assertEqual(plugin._md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e")
self.assertEqual(plugin._md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72")
def test_fallback_matches_hashlib(self):
data = b"the quick brown fox jumps over the lazy dog, 123456789"
self.assertEqual(plugin._md5_fallback_hex(data), plugin._md5_hex(data))
class EncodeFormBodyTests(unittest.TestCase):
def test_round_trips_special_characters(self):
params = {"a": "1 2", "b": "x&y", "c": "h=llo"}
body = plugin.encode_form_body(params)
parsed = {k: v[0] for k, v in parse_qs(body).items()}
self.assertEqual(parsed, params)
def test_fallback_matches_urllib(self):
# Cross-check the hand-rolled fallback encoder against the real stdlib
# urlencode, since it must behave identically when it's the one used
# (i.e. when the extism-py runtime lacks urllib.parse).
from urllib.parse import urlencode as real_urlencode
params = {"a": "1 2", "b": "x&y=z", "c": "héllo"}
self.assertEqual(plugin._fallback_urlencode(params), real_urlencode(params))
class BuildTrackParamsTests(unittest.TestCase):
def test_required_fields_only(self):
track = {"artist": "Artist", "title": "Title"}
self.assertEqual(plugin.build_track_params(track), {"artist": "Artist", "track": "Title"})
def test_optional_fields_included_when_present(self):
track = {
"artist": "Artist",
"title": "Title",
"album": "Album",
"albumArtist": "Album Artist",
"duration": 123.6,
"trackNumber": 4,
"mbzRecordingId": "mbid-123",
}
params = plugin.build_track_params(track)
self.assertEqual(
params,
{
"artist": "Artist",
"track": "Title",
"album": "Album",
"albumArtist": "Album Artist",
"duration": "124",
"trackNumber": "4",
"mbid": "mbid-123",
},
)
def test_falsy_optional_fields_omitted(self):
track = {
"artist": "Artist",
"title": "Title",
"album": "",
"albumArtist": None,
"duration": 0,
"trackNumber": 0,
"mbzRecordingId": "",
}
self.assertEqual(plugin.build_track_params(track), {"artist": "Artist", "track": "Title"})
if __name__ == "__main__":
unittest.main()
Generated
+43
View File
@@ -0,0 +1,43 @@
version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "navidromescrobbledplugin"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.16.9" }]
[[package]]
name = "ruff"
version = "0.16.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
]