#!/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()