From 435cb931b65f07305dd90c17c138b83f0288e473 Mon Sep 17 00:00:00 2001 From: poprhythm Date: Sun, 13 Sep 2026 23:20:02 +0000 Subject: [PATCH] Add navidrome skill for Subsonic API playlist curation Distilled from tonight's session building SUB/WAVE anchor-playlist shows by hand: a CLI (search/artist/album/playlist get/create/replace/ add/remove) plus a playlist-audit command that flags accidental full-album dumps, replacing the one-off curl/Python snippets used throughout. SKILL.md documents the gotchas hit along the way (case-sensitive artist matching, deluxe-reissue duplicate tracks, createPlaylist's full-replace semantics) and this session's curation conventions (15-20 tracks/artist, prefer playlistStrict). Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX --- .claude/skills/navidrome/SKILL.md | 84 ++++++++ .claude/skills/navidrome/navidrome_api.py | 252 ++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 .claude/skills/navidrome/SKILL.md create mode 100755 .claude/skills/navidrome/navidrome_api.py diff --git a/.claude/skills/navidrome/SKILL.md b/.claude/skills/navidrome/SKILL.md new file mode 100644 index 0000000..3e75fdb --- /dev/null +++ b/.claude/skills/navidrome/SKILL.md @@ -0,0 +1,84 @@ +--- +name: navidrome +description: Query and edit the Navidrome music library/playlists (search artists/albums, inspect or build playlists, audit for accidental full-album dumps). Use for any SUB/WAVE anchor-playlist curation work or general Navidrome library lookups. +--- + +# Navidrome (Subsonic API) + +A CLI wrapper (`navidrome_api.py`, in this skill's directory) around Navidrome's +Subsonic API, distilled from a long session of building SUB/WAVE anchor-playlist +shows by hand. Use it instead of writing one-off `curl`/Python snippets — every +gotcha below was hit at least once doing it the ad-hoc way. + +## Setup + +Auth/URL come from `/srv/subwave/state/setup-config.json` (world-readable), +already pointed at `navidrome:4533` — the script rewrites that host to +`localhost` for you. No flags needed for auth. + +Run it as: `python3 .claude/skills/navidrome/navidrome_api.py ...` + +## Commands + +``` +search [--artists N] [--albums N] [--songs N] # raw multi-type search +artist # albums + track counts for one artist +album # track ids for one album +playlists # list all playlists (id, count, name) +playlist get # dump entries +playlist create --ids id1,id2,... # new playlist +playlist replace --ids id1,id2,... # FULL REPLACE of contents +playlist add --ids id1,id2,... # append in place +playlist remove --ids id1,id2,... # remove by id, in place +playlist audit [--min-size N] # full-album-dump check (see below) +``` + +## Gotchas learned the hard way + +- **Artist name casing is inconsistent and case-sensitive matching silently + drops real hits.** `eels` is lowercase, `CAKE` is uppercase, most others are + title-case. A naive `artist["name"] == "Eels"` filter returns "not found" even + though the artist is right there. `artist`/`album` in this script already do + case-insensitive exact matching — don't re-derive this bug with a fresh `curl`. + +- **An artist with 0 albums from `getArtist` isn't necessarily absent** — it + may only have loose tracks scattered across compilations (soundtrack/box-set + albums credited to "Various Artists"). Fall back to `search3` with a high + `songCount` and filter by exact artist name (the `artist` command does this + automatically when it finds zero grouped albums). + +- **Reissues/deluxe editions duplicate the base album's tracks under a + different album name** (`Doolittle` vs `Doolittle 25`, `Bricks Are Heavy` vs + a live/remix bonus disc, etc). When curating, pick tracks from ONE edition — + check `album` output for suspiciously large counts before assuming it's all + distinct songs. + +- **`createPlaylist` with `playlistId` set REPLACES the entire contents** — + it is not additive. Use `playlist replace` only when you intend to overwrite + everything (e.g., rebuilding after a curation pass). Use `playlist add` / + `playlist remove` for incremental edits to an existing playlist. + +- **No native "remove by id" in the Subsonic API** — `playlist remove` here + works by reading the current entries, filtering out the unwanted ids in + Python, then doing a full `replace`. This is safe (order-preserving for + everything you keep) but means a remove is really a replace under the hood. + +- **`playlist audit`'s "FULL ALBUM" flag is a signal, not a verdict.** A + genuinely short album (say, 10 tracks) will always show `10/10` with nothing + left to trim, and an artist you were explicitly told to go "heavy" on is + fine full. Use it to know where to *look*, then use judgment (or ask) before + trimming. + +## Curation conventions established this session + +- Default to **~15–20 tracks per artist** for an anchor playlist, not a full + discography — unless the user explicitly asks for heavier coverage of a + specific artist ("heavy Nirvana", "triple that, focusing on XTC"). +- Prefer **`playlistStrict: true`** on SUB/WAVE shows with a pinned anchor + playlist. Soft-anchor (`playlistStrict: false`) was tried and rolled back + station-wide after live testing showed the playlist rarely won picks over + the genre/mood fallback pool, and the fallback's unfiltered "explore" source + could drift the show off-genre (see `subwave/bug-report-soft-anchor-drift.md`). +- Cross-show duplicate tracks are fine for these anchor-genre shows (the + no-duplicate rule only applies to the historical radio-chart recreation + shows) — don't spend time deduplicating against other playlists unless asked. diff --git a/.claude/skills/navidrome/navidrome_api.py b/.claude/skills/navidrome/navidrome_api.py new file mode 100755 index 0000000..4dc0bd2 --- /dev/null +++ b/.claude/skills/navidrome/navidrome_api.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Navidrome Subsonic-API CLI. Auth/config lifted from /srv/subwave/state/setup-config.json. + +Usage: + navidrome_api.py search [--artists N] [--albums N] [--songs N] + navidrome_api.py artist # case-insensitive; lists albums + track counts + navidrome_api.py album # lists tracks with ids for one album + navidrome_api.py playlists # list all playlists (name, id, song count) + navidrome_api.py playlist get # dump entries (artist | album | title | id) + navidrome_api.py playlist create --ids id1,id2,... + navidrome_api.py playlist replace --ids id1,id2,... # createPlaylist w/ playlistId: FULL REPLACE + navidrome_api.py playlist add --ids id1,id2,... # updatePlaylist songIdToAdd: in-place append + navidrome_api.py playlist remove --ids id1,id2,... # updatePlaylist songIdToRemove + navidrome_api.py playlist audit # per-artist/per-album breakdown + full-album flags +""" +import argparse +import hashlib +import json +import secrets +import sys +import urllib.parse +import urllib.request +from collections import defaultdict + +SETUP_CONFIG = "/srv/subwave/state/setup-config.json" + + +def load_config(): + cfg = json.load(open(SETUP_CONFIG))["navidrome"] + return { + "url": cfg["url"].replace("navidrome:", "localhost:"), + "user": cfg["user"], + "pass": cfg["pass"], + } + + +def call(endpoint, params=None, doseq=False): + cfg = load_config() + salt = secrets.token_hex(6) + token = hashlib.md5((cfg["pass"] + salt).encode()).hexdigest() + p = {"u": cfg["user"], "t": token, "s": salt, "v": "1.16.1", "c": "navidrome-skill", "f": "json"} + if params: + p.update(params) + q = urllib.parse.urlencode(p, doseq=doseq) + with urllib.request.urlopen(f"{cfg['url']}/rest/{endpoint}?{q}") as r: + d = json.load(r)["subsonic-response"] + if d.get("status") != "ok": + err = d.get("error", {}) + raise RuntimeError(f"{endpoint} failed: {err.get('code')} {err.get('message')}") + return d + + +def find_artist_exact(name): + """Case-insensitive exact-name match. Navidrome artist tags are inconsistently + cased ('eels' lowercase, 'CAKE' uppercase) - a naive `== name` filter silently + drops real hits. This is the #1 false-negative bug hit repeatedly this session.""" + resp = call("search3", {"query": name, "artistCount": 10, "songCount": 0, "albumCount": 0}) + for a in resp.get("searchResult3", {}).get("artist", []): + if a["name"].lower() == name.lower(): + return a + return None + + +def cmd_search(args): + resp = call("search3", {"query": args.query, "artistCount": args.artists, + "albumCount": args.albums, "songCount": args.songs}) + r = resp.get("searchResult3", {}) + for a in r.get("artist", []): + print(f"artist | {a['name']} [{a['id']}]") + for al in r.get("album", []): + print(f"album | {al['artist']} - {al['name']} ({al.get('songCount')} tracks) [{al['id']}]") + for s in r.get("song", []): + print(f"song | {s['artist']} - {s['title']} ({s.get('album')}) [{s['id']}]") + + +def cmd_artist(args): + a = find_artist_exact(args.name) + if not a: + print(f"No exact match for '{args.name}'. Loose/compilation tracks may still exist -- " + f"try: navidrome_api.py search '{args.name}' --songs 20") + return + r = call("getArtist", {"id": a["id"]}) + albums = r.get("artist", {}).get("album", []) + if not albums: + print(f"{a['name']}: artist entry exists but 0 grouped albums -- checking loose tracks...") + resp = call("search3", {"query": args.name, "artistCount": 0, "songCount": 30, "albumCount": 0}) + for s in resp.get("searchResult3", {}).get("song", []): + if s["artist"].lower() == args.name.lower(): + print(f" {s['title']} ({s.get('album')}) [{s['id']}]") + return + total = sum(al.get("songCount", 0) for al in albums) + print(f"{a['name']}: {len(albums)} albums, {total} tracks") + for al in albums: + print(f" {al['name']} ({al.get('year')}) - {al.get('songCount')} tracks [{al['id']}]") + + +def cmd_album(args): + a = find_artist_exact(args.artist) + if not a: + print(f"No exact artist match for '{args.artist}'") + return + r = call("getArtist", {"id": a["id"]}) + for al in r.get("artist", {}).get("album", []): + if al["name"].lower() == args.album.lower(): + aresp = call("getAlbum", {"id": al["id"]}) + for s in aresp.get("album", {}).get("song", []): + print(f" {s.get('track', '?')}. {s['title']} [{s['id']}]") + return + print(f"No album '{args.album}' found for {a['name']}") + + +def cmd_playlists(args): + resp = call("getPlaylists") + for p in resp.get("playlists", {}).get("playlist", []): + print(f"{p['id']} | {p.get('songCount', '?'):>4} tracks | {p['name']}") + + +def cmd_playlist_get(args): + resp = call("getPlaylist", {"id": args.id}) + for e in resp["playlist"]["entry"]: + print(f"{e['artist']} | {e['album']} | {e['title']} [{e['id']}]") + + +def cmd_playlist_create(args): + ids = args.ids.split(",") + resp = call("createPlaylist", {"name": args.name, "songId": ids}, doseq=True) + pl = resp.get("playlist", {}) + print(f"created '{args.name}': {pl.get('id')} ({pl.get('songCount')} tracks)") + + +def cmd_playlist_replace(args): + ids = args.ids.split(",") + resp = call("createPlaylist", {"playlistId": args.id, "songId": ids}, doseq=True) + pl = resp.get("playlist", {}) + print(f"replaced {args.id}: now {pl.get('songCount')} tracks") + + +def cmd_playlist_add(args): + ids = args.ids.split(",") + call("updatePlaylist", {"playlistId": args.id, "songIdToAdd": ids}, doseq=True) + resp = call("getPlaylist", {"id": args.id}) + print(f"added {len(ids)}; {args.id} now has {len(resp['playlist']['entry'])} tracks") + + +def cmd_playlist_remove(args): + remove_ids = set(args.ids.split(",")) + resp = call("getPlaylist", {"id": args.id}) + entries = resp["playlist"]["entry"] + remaining = [e["id"] for e in entries if e["id"] not in remove_ids] + resp2 = call("createPlaylist", {"playlistId": args.id, "songId": remaining}, doseq=True) + print(f"removed {len(entries) - len(remaining)}; {args.id} now has " + f"{resp2.get('playlist', {}).get('songCount')} tracks") + + +def cmd_playlist_audit(args): + """The recurring 'did we dump a full album in here' check from tonight's session: + group by (artist, album), then compare each group's count against that album's + real total songCount. An exact match is a strong full-catalog signal worth a + second look -- but treat it as a flag, not an automatic verdict: a genuinely + short album (e.g. a 10-track record) will always show 10/10 with no trimming + possible, and an artist you were explicitly told to go 'heavy' on is fine full.""" + resp = call("getPlaylist", {"id": args.id}) + entries = resp["playlist"]["entry"] + print(f"total tracks: {len(entries)}") + by_album = defaultdict(list) + for e in entries: + by_album[(e["artist"], e["album"])].append(e) + + for (artist, album), tracks in sorted(by_album.items(), key=lambda x: -len(x[1])): + if len(tracks) < args.min_size: + continue + real_total = None + try: + aresp = call("search3", {"query": album, "artistCount": 0, "songCount": 0, "albumCount": 10}) + for al in aresp.get("searchResult3", {}).get("album", []): + if al["artist"] == artist and al["name"] == album: + real_total = al.get("songCount") + break + except Exception: + pass + flag = "" + if real_total is not None and real_total == len(tracks): + flag = " <-- FULL ALBUM (playlist count == real album length)" + print(f" {artist} - {album}: {len(tracks)}" + f"{f'/{real_total}' if real_total is not None else ''} tracks{flag}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("search") + p.add_argument("query") + p.add_argument("--artists", type=int, default=5) + p.add_argument("--albums", type=int, default=5) + p.add_argument("--songs", type=int, default=5) + p.set_defaults(func=cmd_search) + + p = sub.add_parser("artist") + p.add_argument("name") + p.set_defaults(func=cmd_artist) + + p = sub.add_parser("album") + p.add_argument("artist") + p.add_argument("album") + p.set_defaults(func=cmd_album) + + p = sub.add_parser("playlists") + p.set_defaults(func=cmd_playlists) + + pl = sub.add_parser("playlist") + pl_sub = pl.add_subparsers(dest="subcmd", required=True) + + p = pl_sub.add_parser("get") + p.add_argument("id") + p.set_defaults(func=cmd_playlist_get) + + p = pl_sub.add_parser("create") + p.add_argument("name") + p.add_argument("--ids", required=True, help="comma-separated song ids") + p.set_defaults(func=cmd_playlist_create) + + p = pl_sub.add_parser("replace") + p.add_argument("id") + p.add_argument("--ids", required=True, help="comma-separated song ids; FULL REPLACE of contents") + p.set_defaults(func=cmd_playlist_replace) + + p = pl_sub.add_parser("add") + p.add_argument("id") + p.add_argument("--ids", required=True, help="comma-separated song ids to append") + p.set_defaults(func=cmd_playlist_add) + + p = pl_sub.add_parser("remove") + p.add_argument("id") + p.add_argument("--ids", required=True, help="comma-separated song ids to remove") + p.set_defaults(func=cmd_playlist_remove) + + p = pl_sub.add_parser("audit") + p.add_argument("id") + p.add_argument("--min-size", type=int, default=8, help="only show (artist,album) groups >= this size") + p.set_defaults(func=cmd_playlist_audit) + + args = parser.parse_args() + try: + args.func(args) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()