From 39fdc4276f3ea0bc616917cc87e31cc730b265fd Mon Sep 17 00:00:00 2001 From: poprhythm Date: Sat, 19 Sep 2026 23:40:00 +0000 Subject: [PATCH] Add subwave skill for SUB/WAVE admin-API interactions Wraps show/persona/schedule/trigger calls that were previously done by hand with raw docker exec curl, and documents the gotchas hit tonight (whole-object PUT/POST semantics, the frequency enum, the silent default-persona trap, and the port-not-published-to-host setup). Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA --- .claude/skills/subwave/SKILL.md | 100 +++++++ .claude/skills/subwave/subwave_api.py | 379 ++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 .claude/skills/subwave/SKILL.md create mode 100644 .claude/skills/subwave/subwave_api.py diff --git a/.claude/skills/subwave/SKILL.md b/.claude/skills/subwave/SKILL.md new file mode 100644 index 0000000..ae8957f --- /dev/null +++ b/.claude/skills/subwave/SKILL.md @@ -0,0 +1,100 @@ +--- +name: subwave +description: Manage SUB/WAVE (self-hosted AI radio station) shows, DJ personas, the weekly schedule grid, and live overrides. Use for any SUB/WAVE admin-API work — creating a new anchor show, minting a DJ persona, scheduling a slot, or triggering a show live. +--- + +# SUB/WAVE admin API + +A CLI wrapper (`subwave_api.py`, in this skill's directory) around the +`sub-wave-controller` admin REST API, distilled from a long session of +building ~30 anchor-playlist shows and DJ personas by hand with raw +`docker exec curl` calls. Use it instead of re-deriving the request shapes +each time. + +## Setup + +Auth comes from `subwave/.env` (`ADMIN_USER`/`ADMIN_PASS`) at the repo root — +no flags needed. **The admin API (port 7701) is not published to the host** — +`docker ps` shows `sub-wave-controller` with only `7701/tcp` (internal), not +`0.0.0.0:7701->7701`. Every call must go through +`docker exec sub-wave-controller curl ...`, which is exactly what this script +does. Don't waste a round trip re-checking this — it's a deliberate +container-internal-only setup, not a misconfiguration. + +Run it as: `python3 .claude/skills/subwave/subwave_api.py ...` + +## Commands + +``` +persona list +persona create --name N --voice V --tagline T --soul S + [--frequency silent|quiet|moderate|chatty|aggressive] + [--humour 0-10] [--warmth 0-10] [--local-colour 0-10] + +show list +show get --id ID +show create --name N --topic T --playlist-id ID + [--genres a,b,c] [--moods a,b] [--energies a,b] + [--eras 1990-2000,2005-2010] [--persona-id ID] +show set-persona --id ID --persona-id ID +show set-name --id ID --name N +show set-topic --id ID --topic T + +schedule grid # full 7x24 weekly grid, show names +schedule open-slots # every null (day, hour) pair +schedule set --show-id ID --day 0-6 --hour 0-23 # day 0 = Sunday +schedule set-random --show-id ID # pick a random open slot + +trigger --show-id ID [--minutes 60] # live override, starts immediately +``` + +Playlists themselves (search/curate/create anchor playlists in Navidrome) are +a separate concern — use the **navidrome** skill for that, then pass the +resulting playlist id to `show create --playlist-id`. + +## Gotchas learned the hard way + +- **`POST /shows` needs the show wrapped in `{"show": {...}}`** — posting the + show object bare fails with a schema error (`expected object, received + undefined`). The CLI handles this. +- **New shows need a full show object, not a partial patch** — there's no + PATCH endpoint. To change one field (persona, name, topic), read the full + current show from `schedule.json`, mutate the one field, and re-POST the + whole thing. `show set-persona`/`set-name`/`set-topic` do exactly this. +- **`PUT /schedule` takes the *entire* weekly grid**, not a single slot — + read the current grid, mutate one `[day][hour]` cell, PUT the whole thing + back. `schedule set`/`set-random` do this for you. The response's + `dropped` count should always be `0`; a nonzero value means a show id in + the grid doesn't exist anymore (a bug elsewhere, not addressed here). +- **`POST /settings` for a new persona is also whole-array append**, not a + single-persona-create endpoint — there is no such endpoint. Read + `personas`, append one object, POST `{"personas": [...]}`. +- **Persona `frequency` is a strict enum**: `silent | quiet | moderate | + chatty | aggressive`. Passing `"normal"` (an easy guess) fails with `must + be one of: silent, quiet, moderate, chatty, aggressive` — the CLI defaults + to `moderate` to sidestep this. +- **A show with no explicit `personaId` inherits whatever `p_default0` is** + (one of the original 3 built-in personas) — and if that persona has + `djMode: false`, the show runs with **zero DJ talk breaks** and sounds + silent/DJ-less. Always set a real persona explicitly once you know what + voice/character you want; don't assume a blank field means "no host," it + means "the accidental default host." +- **`/schedule/override` (the live-trigger endpoint) starts immediately** — + `startedAt: Date.now()`. There's no way to schedule a *future* start time + through this endpoint; "play this at 9pm" only works if it's already ~9pm, + or by placing the show in the recurring weekly grid at that hour instead. +- **Two shows can't cleanly share one weekly slot** — if you want two moods + to alternate in the same hour across the week (e.g. show A on + Sun/Tue/Thu/Sat, show B on Mon/Wed/Fri), that's just setting the same + `[hour]` column to different show ids on different `day` rows via repeated + `schedule set` calls — there's no "alternate" primitive, just per-day-row + assignment. +- **Kokoro voice pool has ~28 usable English voices** (`af_*`/`am_*` + American, `bf_*`/`bm_*` British) sharing one 27MB `voices-v1.0.bin` file — + installing/using more costs nothing. `PERSONA_LIMIT = 48` is the only hard + ceiling (`/app/src/schemas/persona.ts` inside the controller container). +- **A library rescan can surface artists that "don't exist" on a first + check** — Navidrome's index lags real disk state. If a user swears an + artist should be there, `navidrome_api.py`-equivalent rescan + (`startScan`/`getScanStatus` via the Subsonic API) before concluding it's + actually missing. diff --git a/.claude/skills/subwave/subwave_api.py b/.claude/skills/subwave/subwave_api.py new file mode 100644 index 0000000..998f4af --- /dev/null +++ b/.claude/skills/subwave/subwave_api.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""SUB/WAVE admin-API CLI. Auth from subwave/.env, calls proxied through +`docker exec sub-wave-controller curl ...` because the controller's admin API +(port 7701) is not published to the host -- only reachable inside the +sub-wave-controller container itself. + +Usage: + subwave_api.py persona list + subwave_api.py persona create --name N --voice V --tagline T --soul S + [--frequency silent|quiet|moderate|chatty|aggressive] + [--humour 0-10] [--warmth 0-10] [--local-colour 0-10] + [--script-length concise|...] [--link-style natural|...] + subwave_api.py show list + subwave_api.py show get --id ID + subwave_api.py show create --name N --topic T --playlist-id ID + [--genres a,b,c] [--moods a,b] [--energies a,b] + [--eras 1990-2000,2005-2010] [--persona-id ID] + subwave_api.py show set-persona --id ID --persona-id ID + subwave_api.py show set-name --id ID --name N + subwave_api.py show set-topic --id ID --topic T + subwave_api.py schedule grid + subwave_api.py schedule open-slots + subwave_api.py schedule set --show-id ID --day 0-6 --hour 0-23 + subwave_api.py schedule set-random --show-id ID + subwave_api.py trigger --show-id ID [--minutes 60] +""" +import argparse +import json +import subprocess +import sys +import random + +ENV_PATH = "/home/poprhythm/docker-infrastructure/subwave/.env" +STATE_SHOWS = "/srv/subwave/state/schedule.json" +STATE_SETTINGS = "/srv/subwave/state/settings.json" +CONTAINER = "sub-wave-controller" +BASE_URL = "http://localhost:7701" + + +def load_creds(): + creds = {} + with open(ENV_PATH) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + creds[k] = v + return creds["ADMIN_USER"], creds["ADMIN_PASS"] + + +def api_call(method, path, body=None): + """Runs curl inside the controller container. Body, if given, is written + to a temp file on the host, docker cp'd in, and cleaned up after -- avoids + quoting hell with large/nested JSON on the command line.""" + user, password = load_creds() + if body is None: + cmd = [ + "docker", "exec", CONTAINER, "sh", "-c", + f"curl -s -u '{user}:{password}' -X {method} {BASE_URL}{path}", + ] + result = subprocess.run(cmd, capture_output=True, text=True) + else: + import tempfile + import os + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump(body, f) + local_path = f.name + remote_path = f"/tmp/{os.path.basename(local_path)}" + try: + subprocess.run(["docker", "cp", local_path, f"{CONTAINER}:{remote_path}"], check=True) + cmd = [ + "docker", "exec", CONTAINER, "sh", "-c", + f"curl -s -u '{user}:{password}' -X {method} {BASE_URL}{path} " + f"-H 'Content-Type: application/json' -d @{remote_path}", + ] + result = subprocess.run(cmd, capture_output=True, text=True) + finally: + subprocess.run(["docker", "exec", CONTAINER, "rm", "-f", remote_path], + capture_output=True) + os.unlink(local_path) + if result.returncode != 0: + raise RuntimeError(f"docker exec failed: {result.stderr}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + raise RuntimeError(f"non-JSON response: {result.stdout[:500]}") + + +def load_state(path): + with open(path) as f: + return json.load(f) + + +def parse_eras(s): + eras = [] + for part in s.split(","): + a, b = part.split("-") + eras.append({"fromYear": int(a), "toYear": int(b)}) + return eras + + +# ---- persona ---- + +def cmd_persona_list(args): + d = load_state(STATE_SETTINGS) + for p in d["personas"]: + voice = p.get("tts", {}).get("voice", "") + print(f"{p['id']} | {p['name']:<12} | {voice:<12} | djMode={p.get('djMode')}") + + +def cmd_persona_create(args): + d = load_state(STATE_SETTINGS) + new_persona = { + "name": args.name, + "tagline": args.tagline, + "frequency": args.frequency, + "scriptLength": args.script_length, + "djMode": True, + "linkStyle": args.link_style, + "humour": args.humour, + "localColour": args.local_colour, + "warmth": args.warmth, + "soul": args.soul, + "language": "", + "avatar": "", + "tts": {"engine": "kokoro", "cloudProvider": "openai", "voice": args.voice, + "gainDb": 0, "speed": 1}, + "skills": None, + "tags": [], + } + personas = d["personas"] + [new_persona] + resp = api_call("POST", "/settings", {"personas": personas}) + if "error" in resp: + print(f"error: {resp['error']}", file=sys.stderr) + sys.exit(1) + d2 = load_state(STATE_SETTINGS) + for p in d2["personas"]: + if p["name"] == args.name and p not in d["personas"]: + print(f"created '{args.name}': {p['id']} (voice={args.voice})") + return + print(f"created '{args.name}' (re-read settings to confirm id)") + + +# ---- show ---- + +def cmd_show_list(args): + d = load_state(STATE_SHOWS) + for s in d["shows"]: + print(f"{s['id']} | {s['name']}") + + +def cmd_show_get(args): + d = load_state(STATE_SHOWS) + for s in d["shows"]: + if s["id"] == args.id: + print(json.dumps(s, indent=2)) + return + print(f"no such show: {args.id}", file=sys.stderr) + sys.exit(1) + + +def cmd_show_create(args): + show = { + "name": args.name, + "topic": args.topic, + "personaId": args.persona_id or "p_default0", + "guestPersonaIds": [], + "banter": False, + "pauseTalk": False, + "programme": False, + "segmentSkill": "", + "moods": args.moods.split(",") if args.moods else [], + "themeId": "", + "genres": args.genres.split(",") if args.genres else [], + "energies": args.energies.split(",") if args.energies else [], + "eras": parse_eras(args.eras) if args.eras else [], + "vocals": "", + "filtersStrict": True, + "maxTrackSeconds": args.max_track_seconds, + "minTrackLengthSeconds": None, + "fadeAtShowEnd": None, + "playlistIds": [args.playlist_id], + "playlistStrict": True, + "playlistExhaust": True, + "excludedPlaylistIds": [], + "tags": [], + } + resp = api_call("POST", "/shows", {"show": show}) + if "error" in resp: + print(f"error: {resp['error']}", file=sys.stderr) + sys.exit(1) + saved = resp.get("show", {}) + print(f"created '{args.name}': {saved.get('id')}") + + +def _update_show_field(show_id, field, value): + d = load_state(STATE_SHOWS) + for s in d["shows"]: + if s["id"] == show_id: + s = dict(s) + s[field] = value + resp = api_call("POST", "/shows", {"show": s}) + if "error" in resp: + print(f"error: {resp['error']}", file=sys.stderr) + sys.exit(1) + print(f"{show_id}: {field} -> {value}") + return + print(f"no such show: {show_id}", file=sys.stderr) + sys.exit(1) + + +def cmd_show_set_persona(args): + _update_show_field(args.id, "personaId", args.persona_id) + + +def cmd_show_set_name(args): + _update_show_field(args.id, "name", args.name) + + +def cmd_show_set_topic(args): + _update_show_field(args.id, "topic", args.topic) + + +# ---- schedule ---- + +def cmd_schedule_grid(args): + d = load_state(STATE_SHOWS) + names = {s["id"]: s["name"] for s in d["shows"]} + for day in "0123456": + row = [names.get(v, "null") if v else "null" for v in d["schedule"][day]] + print(f"day {day}:", row) + + +def cmd_schedule_open_slots(args): + d = load_state(STATE_SHOWS) + open_slots = [] + for day in "0123456": + for h, v in enumerate(d["schedule"][day]): + if v is None: + open_slots.append((day, h)) + print(f"{len(open_slots)} open slots") + for day, h in open_slots: + print(f" day {day} hour {h}") + + +def cmd_schedule_set(args): + d = load_state(STATE_SHOWS) + schedule = d["schedule"] + schedule[str(args.day)][args.hour] = args.show_id + resp = api_call("PUT", "/schedule", {"schedule": schedule}) + if "error" in resp: + print(f"error: {resp['error']}", file=sys.stderr) + sys.exit(1) + print(f"set day {args.day} hour {args.hour} -> {args.show_id} (dropped={resp.get('dropped')})") + + +def cmd_schedule_set_random(args): + d = load_state(STATE_SHOWS) + schedule = d["schedule"] + open_slots = [(day, h) for day in "0123456" for h, v in enumerate(schedule[day]) if v is None] + if not open_slots: + print("no open slots", file=sys.stderr) + sys.exit(1) + day, h = random.choice(open_slots) + schedule[day][h] = args.show_id + resp = api_call("PUT", "/schedule", {"schedule": schedule}) + if "error" in resp: + print(f"error: {resp['error']}", file=sys.stderr) + sys.exit(1) + print(f"set day {day} hour {h} -> {args.show_id} (dropped={resp.get('dropped')})") + + +# ---- trigger ---- + +def cmd_trigger(args): + resp = api_call("POST", "/schedule/override", + {"showId": args.show_id, "minutes": args.minutes, "until": "fixed"}) + if "error" in resp: + print(f"error: {resp['error']}", file=sys.stderr) + sys.exit(1) + print(json.dumps(resp)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="cmd", required=True) + + persona = sub.add_parser("persona") + persona_sub = persona.add_subparsers(dest="subcmd", required=True) + + p = persona_sub.add_parser("list") + p.set_defaults(func=cmd_persona_list) + + p = persona_sub.add_parser("create") + p.add_argument("--name", required=True) + p.add_argument("--voice", required=True, help="Kokoro voice id, e.g. am_puck") + p.add_argument("--tagline", required=True) + p.add_argument("--soul", required=True) + p.add_argument("--frequency", default="moderate", + choices=["silent", "quiet", "moderate", "chatty", "aggressive"]) + p.add_argument("--script-length", default="concise") + p.add_argument("--link-style", default="natural") + p.add_argument("--humour", type=int, default=5) + p.add_argument("--warmth", type=int, default=6) + p.add_argument("--local-colour", type=int, default=5) + p.set_defaults(func=cmd_persona_create) + + show = sub.add_parser("show") + show_sub = show.add_subparsers(dest="subcmd", required=True) + + p = show_sub.add_parser("list") + p.set_defaults(func=cmd_show_list) + + p = show_sub.add_parser("get") + p.add_argument("--id", required=True) + p.set_defaults(func=cmd_show_get) + + p = show_sub.add_parser("create") + p.add_argument("--name", required=True) + p.add_argument("--topic", required=True) + p.add_argument("--playlist-id", required=True, help="Navidrome playlist id (the anchor)") + p.add_argument("--genres", default="") + p.add_argument("--moods", default="") + p.add_argument("--energies", default="") + p.add_argument("--eras", default="", help="e.g. 1990-1995,2000-2005") + p.add_argument("--persona-id", default="") + p.add_argument("--max-track-seconds", type=int, default=None) + p.set_defaults(func=cmd_show_create) + + p = show_sub.add_parser("set-persona") + p.add_argument("--id", required=True) + p.add_argument("--persona-id", required=True) + p.set_defaults(func=cmd_show_set_persona) + + p = show_sub.add_parser("set-name") + p.add_argument("--id", required=True) + p.add_argument("--name", required=True) + p.set_defaults(func=cmd_show_set_name) + + p = show_sub.add_parser("set-topic") + p.add_argument("--id", required=True) + p.add_argument("--topic", required=True) + p.set_defaults(func=cmd_show_set_topic) + + schedule = sub.add_parser("schedule") + schedule_sub = schedule.add_subparsers(dest="subcmd", required=True) + + p = schedule_sub.add_parser("grid") + p.set_defaults(func=cmd_schedule_grid) + + p = schedule_sub.add_parser("open-slots") + p.set_defaults(func=cmd_schedule_open_slots) + + p = schedule_sub.add_parser("set") + p.add_argument("--show-id", required=True) + p.add_argument("--day", type=int, required=True, help="0=Sunday .. 6=Saturday") + p.add_argument("--hour", type=int, required=True, help="0-23") + p.set_defaults(func=cmd_schedule_set) + + p = schedule_sub.add_parser("set-random") + p.add_argument("--show-id", required=True) + p.set_defaults(func=cmd_schedule_set_random) + + p = sub.add_parser("trigger") + p.add_argument("--show-id", required=True) + p.add_argument("--minutes", type=int, default=60) + p.set_defaults(func=cmd_trigger) + + 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()