commit 2dccf7d97215caa9bf51911fb5751b8d807dc363 Author: Matthew Daubney Date: Sun Sep 27 23:07:02 2026 +0100 Add Navidrome Scrobbled scrobbler plugin 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 Claude-Session: https://claude.ai/code/session_018n5ffHCn5QUjpDtuWTk3fh diff --git a/.gitea/actions/notify/action.yml b/.gitea/actions/notify/action.yml new file mode 100644 index 0000000..616fe5f --- /dev/null +++ b/.gitea/actions/notify/action.yml @@ -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" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..97833af --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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< --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 +``` + +## 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. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d379fb1 --- /dev/null +++ b/manifest.json @@ -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" } + } + } +} diff --git a/plugin/__init__.py b/plugin/__init__.py new file mode 100644 index 0000000..53cf2f0 --- /dev/null +++ b/plugin/__init__.py @@ -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": ""}. + """ + 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c6a440a --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/tests/stubs/extism.py b/tests/stubs/extism.py new file mode 100644 index 0000000..7cf36c8 --- /dev/null +++ b/tests/stubs/extism.py @@ -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() diff --git a/tests/test_logic.py b/tests/test_logic.py new file mode 100644 index 0000000..9352f01 --- /dev/null +++ b/tests/test_logic.py @@ -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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..837f1e4 --- /dev/null +++ b/uv.lock @@ -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" }, +]