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
This commit is contained in:
@@ -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 <command> ...`
|
||||
|
||||
## 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.
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user