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