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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018n5ffHCn5QUjpDtuWTk3fh
This commit is contained in:
@@ -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"
|
||||||
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
*.wasm
|
||||||
|
*.ndp
|
||||||
|
dist/
|
||||||
|
scratch/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.idea/
|
||||||
|
.junie/
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user