""" routes/airadio_bridge.py — AI-Radio Bridge Blueprint Bridges OpenVoiceUI to the per-VPS AI-Radio service (Next.js, on the shared `jambot-shared` Docker network at http://ai-radio:3000). All endpoints are prefixed `/api/airadio/`. Frontend voice-tag parser and canvas pages call these routes; the Flask app then proxies to the scoped AI-Radio "agent API" at `${AIRADIO_URL}/api/agent/*`, signing every outbound request with `X-JamBot-Agent-Key` + `X-JamBot-User`. Spec: /mnt/system/base/airadio/docs/PLAN/18-OVU-INTEGRATION.md Endpoints registered: POST /api/airadio/push-song POST /api/airadio/push-playlist POST /api/airadio/set-image GET /api/airadio/resolve POST /api/airadio/play GET /api/airadio/inbox POST /api/airadio/send-to-friend POST /api/airadio/vote POST /api/airadio/friend-request POST /api/airadio/friend-accept POST /api/airadio/friend-decline GET /api/airadio/library """ import base64 import hashlib import json import logging import mimetypes import os import re from pathlib import Path import requests as http_requests from flask import Blueprint, jsonify, request from services.paths import ( GENERATED_MUSIC_DIR, KNOWN_FACES_DIR, MUSIC_DIR, RUNTIME_DIR, UPLOADS_DIR, ) from routes.music import ( load_generated_music_metadata, load_music_metadata, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Configuration (env at module load) # --------------------------------------------------------------------------- # Default to the public internet URL so standalone OVU installs (no JamBot # provisioner) can still reach the platform's public catalog without any # additional config. JamBot clients set AIRADIO_URL=http://ai-radio:3000 # in their compose .env to short-circuit over the shared Docker network. AIRADIO_URL = os.environ.get("AIRADIO_URL", "https://ai-radio.jam-bot.com").rstrip("/") # When set, the bridge will answer read-only requests via AI-Radio's # /public/* endpoints even when no API key is configured. True by default — # keyless browse and play is the whole point of the public API. Writes # (push, vote, friend, save, inbox) always require a key regardless. AIRADIO_PUBLIC_FALLBACK = os.environ.get("AIRADIO_PUBLIC_FALLBACK", "1") not in ("0", "false", "False", "") # Per-user key (aia_sk_*) takes precedence over the platform-shared agent key # when set. Generated by the user at ai-radio.jam-bot.com/settings. AIRADIO_USER_KEY = os.environ.get("AIRADIO_USER_KEY", "") AIRADIO_AGENT_KEY = os.environ.get("AIRADIO_AGENT_KEY", "") AIRADIO_JAMBOT_USER = os.environ.get("AIRADIO_JAMBOT_USER", "") AIRADIO_TIMEOUT = int(os.environ.get("AIRADIO_TIMEOUT", "30")) # Persistent override for AIRADIO_USER_KEY. Lives in the runtime dir which is # bind-mounted from the host (/mnt/clients//openvoiceui/runtime-config), # so the value survives container restarts and re-images. Written by # /api/airadio/set-user-key, read at request time to avoid module-reload churn. _USER_KEY_PERSIST_PATH = RUNTIME_DIR / "airadio-user-key" def _read_persisted_user_key() -> str: """Return the persisted aia_sk_* key, or empty string if none.""" try: if _USER_KEY_PERSIST_PATH.is_file(): val = _USER_KEY_PERSIST_PATH.read_text().strip() if val.startswith("aia_sk_"): return val except OSError: pass return "" def _effective_user_key() -> str: """Persisted key wins; env var is the fallback.""" return _read_persisted_user_key() or AIRADIO_USER_KEY # --------------------------------------------------------------------------- # Whitelisted local directories for set-image (security: NO arbitrary paths) # --------------------------------------------------------------------------- # Paths the agent is allowed to upload from. Anything outside these is rejected. # Mirrors the rule in PLAN/18-OVU-INTEGRATION.md §3.3. _FACES_DIR = RUNTIME_DIR / "faces" _TEMP_DIR = RUNTIME_DIR / "1temp" _IMAGE_WHITELIST_DIRS = [ UPLOADS_DIR.resolve(), _FACES_DIR.resolve(), KNOWN_FACES_DIR.resolve(), GENERATED_MUSIC_DIR.resolve(), _TEMP_DIR.resolve(), ] # URL-prefix → on-disk dir map for paths like "/uploads/foo.png" _URL_PREFIX_MAP = { "/uploads/": UPLOADS_DIR, "/faces/": _FACES_DIR, "/known_faces/": KNOWN_FACES_DIR, "/generated_music/": GENERATED_MUSIC_DIR, "/1temp/": _TEMP_DIR, } # --------------------------------------------------------------------------- # Blueprint # --------------------------------------------------------------------------- airadio_bp = Blueprint("airadio", __name__) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _bridge_has_auth() -> bool: """True iff we have a key to authenticate writes (push/vote/friend/etc).""" return bool(AIRADIO_URL) and bool(_effective_user_key() or AIRADIO_AGENT_KEY) def _bridge_has_backend() -> bool: """True iff we know where AI-Radio lives — enough for public-only reads.""" return bool(AIRADIO_URL) # Back-compat shim — old code calls _bridge_configured(). Reads can proceed in # public mode; writes still need auth. Caller context determines which matters. def _bridge_configured() -> bool: return _bridge_has_auth() def _bridge_not_configured_response(for_write: bool = False): """503 when env vars are missing. `for_write=True` clarifies the message.""" missing = [] if not AIRADIO_URL: missing.append("AIRADIO_URL") if for_write and not _effective_user_key() and not AIRADIO_AGENT_KEY: missing.append( "AIRADIO_USER_KEY (generate at ai-radio.jam-bot.com/settings) " "or legacy AIRADIO_AGENT_KEY + AIRADIO_JAMBOT_USER" ) msg = ( "AI-Radio write requires an account — the public browse/play surface " "works without one, but push/vote/friend/save need an aia_sk_* key. " f"Missing env: {', '.join(missing) or 'unknown'}" ) if for_write else ( "AI-Radio bridge is not configured. " f"Missing env: {', '.join(missing) or 'unknown'}" ) return ( jsonify({ "ok": False, "error": {"code": "BRIDGE_NOT_CONFIGURED", "message": msg}, }), 503, ) # Paths that are available via /public/* without auth. Each entry maps the # authenticated /api/agent/<...> path to its public equivalent. If a path is # NOT in this table, falling back to public mode isn't possible and the # endpoint will return 503 when no auth is present. def _public_equivalent(agent_path: str) -> str | None: """Return the /public/* path for a given /api/agent/* path, or None.""" if not agent_path.startswith("/api/agent/"): return None suffix = agent_path[len("/api/agent/"):] # Direct 1:1 mappings MAP = { "catalog": "/public/catalog", "interpret": "/public/interpret", } if suffix in MAP: return MAP[suffix] # Prefix-based mappings if suffix.startswith("catalog/"): # /api/agent/catalog/ → /public/song/ return f"/public/song/{suffix[len('catalog/'):]}" if suffix.startswith("queue"): # All /api/agent/queue/* have identical shape at /public/queue/* return f"/public/{suffix}" if suffix.startswith("u/"): # /api/agent/u/ → /public/u/ # /api/agent/u//songs → /public/u//songs # /api/agent/u//playlists → /public/u//playlists return f"/public/{suffix}" if suffix.startswith("playlist/"): # /api/agent/playlist/ (GET) → /public/playlist/ return f"/public/{suffix}" if suffix.startswith("song/") and suffix.endswith("/stream-url"): # No signed-url needed publicly — caller should use /public/stream/.mp3 directly song_id = suffix[len("song/"):-len("/stream-url")] return f"/public/stream/{song_id}.mp3" return None def _agent_headers() -> dict: """ Auth headers attached to every outbound AI-Radio call. Preferred path: Authorization: Bearer aia_sk_* (per-user key from the AI-Radio settings page). Identity is embedded in the key. Legacy fallback: X-JamBot-Agent-Key + X-JamBot-User (platform-shared secret + subdomain-as-user). Retained so existing clients keep working during migration. """ eff_key = _effective_user_key() if eff_key: return {"Authorization": f"Bearer {eff_key}"} return { "X-JamBot-Agent-Key": AIRADIO_AGENT_KEY, "X-JamBot-User": AIRADIO_JAMBOT_USER, } def _airadio_call(method, path, json=None, files=None, params=None, data=None): """ Wrapper around `requests` that targets AI-Radio with the auth headers. If the bridge has no auth key but the requested path has a public equivalent AND the method is GET, transparently rewrite to /public/* and drop the auth headers. This is what makes keyless OVU installs able to browse + play without any API key at all. `mode: "public"` is tagged on the response so the agent can surface "to upload your own songs, connect an account" without guessing. Returns a dict shaped: {"ok": bool, "data": , "error": , "status": int, "mode": "auth"|"public"} """ mode = "auth" outbound_path = path outbound_headers = _agent_headers() # Public fallback: only if there's no auth to send and the endpoint has a # public equivalent. `_public_equivalent()` ONLY returns mappings for # read-safe / inference paths (catalog, song detail, queue, u/, # playlist, interpret, stream). Anything that mutates server state (push, # vote, friend, save, set-image, send-to-friend) is never mapped, so # writes cannot silently fall through to an unauthenticated call. if AIRADIO_PUBLIC_FALLBACK and not _bridge_has_auth(): pub = _public_equivalent(path) if pub: outbound_path = pub outbound_headers = {} mode = "public" url = f"{AIRADIO_URL}{outbound_path}" try: print(f"📻 AI-Radio [{mode}] {method} {outbound_path}") resp = http_requests.request( method, url, headers=outbound_headers, json=json, files=files, params=params, data=data, timeout=AIRADIO_TIMEOUT, ) except http_requests.Timeout as exc: logger.warning("AI-Radio timeout: %s %s — %s", method, path, exc) return { "ok": False, "data": None, "status": 504, "mode": mode, "error": { "code": "BRIDGE_TIMEOUT", "message": f"AI-Radio timed out after {AIRADIO_TIMEOUT}s", }, } except http_requests.RequestException as exc: logger.warning("AI-Radio request failed: %s %s — %s", method, path, exc) return { "ok": False, "data": None, "status": 502, "mode": mode, "error": { "code": "BRIDGE_UNREACHABLE", "message": f"Could not reach AI-Radio: {exc}", }, } # Try to parse a JSON body — AI-Radio always returns JSON per its contract. try: body = resp.json() except ValueError: body = None if 200 <= resp.status_code < 300: # AI-Radio's own contract is { ok, data?, error? }. Pass it through if # present; otherwise treat the parsed body as `data`. if isinstance(body, dict) and "ok" in body: return { "ok": bool(body.get("ok")), "data": body.get("data"), "error": body.get("error"), "status": resp.status_code, "mode": mode, } return { "ok": True, "data": body, "error": None, "status": resp.status_code, "mode": mode, } # Non-2xx — propagate AI-Radio's error if it gave us one. if isinstance(body, dict) and body.get("error"): return { "ok": False, "data": body.get("data"), "error": body.get("error"), "status": resp.status_code, "mode": mode, } return { "ok": False, "data": None, "status": resp.status_code, "mode": mode, "error": { "code": "AIRADIO_HTTP_ERROR", "message": f"AI-Radio returned HTTP {resp.status_code}", "body": (resp.text or "")[:400], }, } def _bridge_response(result): """Convert an `_airadio_call` result into a Flask (json, status) tuple.""" payload = { "ok": result["ok"], "data": result.get("data"), "error": result.get("error"), "mode": result.get("mode", "auth"), } # Preserve AI-Radio's status code for non-success cases; otherwise 200. status = 200 if result["ok"] else (result.get("status") or 502) return jsonify(payload), status _AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".ogg", ".webm", ".flac") def _norm_title(s: str) -> str: """Lowercase, collapse non-alphanumerics to single hyphens. Used to match a spoken/slugified title (`ship-that-shit`, `Ship That Shit`) to a file.""" return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-") def _in_base(candidate: Path, base_dir: Path) -> bool: """True if candidate resolves inside base_dir (path-traversal guard).""" try: candidate.resolve().relative_to(base_dir.resolve()) return True except ValueError: return False def _resolve_local_song(filename: str, playlist: str): """ Look up a local audio track in either the library or the generated playlist. Accepts an exact filename, a stem without extension, OR the song's title (fuzzy) — matching what the skill/voice-tag promises. Returns (Path, metadata_dict) or (None, None) if not found / playlist invalid / path-traversal attempt. """ if not filename: return None, None if playlist == "generated": base_dir = GENERATED_MUSIC_DIR meta = load_generated_music_metadata() elif playlist == "library": base_dir = MUSIC_DIR meta = load_music_metadata() else: return None, None # Strict containment — reject `..`, absolute paths, anything not in base. safe_name = Path(filename).name if safe_name != filename or not base_dir.is_dir(): return None, None # 1. Exact filename as given (already has a real extension). cand = base_dir / safe_name if _in_base(cand, base_dir) and cand.is_file(): return cand, meta.get(safe_name, {}) # 2. Stem given without extension → try the known audio extensions. stem = Path(safe_name).suffix.lower() in _AUDIO_EXTS and Path(safe_name).stem or safe_name for ext in _AUDIO_EXTS: cand = base_dir / (stem + ext) if _in_base(cand, base_dir) and cand.is_file(): return cand, meta.get(cand.name, {}) # 3. Fuzzy: normalize the query and match against each file's stem AND its # metadata title. Exact-normalized hit wins; otherwise first containment. want = _norm_title(stem) if not want: return None, None fallback = None for f in sorted(base_dir.iterdir()): if not f.is_file() or f.suffix.lower() not in _AUDIO_EXTS: continue m = meta.get(f.name, {}) norms = {_norm_title(f.stem)} if m.get("title"): norms.add(_norm_title(m["title"])) if want in norms: return f, m if fallback is None and any(want in n or n in want for n in norms if n): fallback = (f, m) return fallback if fallback else (None, None) def _resolve_image_path(raw_path: str): """ Map a user-supplied image path (URL-style like `/uploads/foo.png` or a bare filename) to an absolute Path inside one of the whitelisted dirs. Returns Path on success, None on rejection. Strict — no `..`, no absolute paths outside the whitelist. """ if not raw_path or not isinstance(raw_path, str): return None # Reject anything containing `..` outright before anything else. if ".." in raw_path: return None # Reject absolute paths that aren't already inside a whitelisted dir. candidate = None if raw_path.startswith("/"): # First, try matching a known URL prefix. for prefix, base in _URL_PREFIX_MAP.items(): if raw_path.startswith(prefix): relative = raw_path[len(prefix):] if "/" in relative or "\\" in relative: # No nested subdirs allowed (matches existing /uploads style). return None candidate = (base / relative).resolve() break if candidate is None: # An absolute path that doesn't begin with one of our prefixes is # never accepted — that would let the agent reach into the host FS. return None else: # Bare filename — assume uploads/ by convention. if "/" in raw_path or "\\" in raw_path: return None candidate = (UPLOADS_DIR / raw_path).resolve() if not candidate.is_file(): return None # Final containment check against the whitelist. for allowed in _IMAGE_WHITELIST_DIRS: try: candidate.relative_to(allowed) return candidate except ValueError: continue return None # Keys AI-Radio's push-song `meta` schema actually accepts (see # /mnt/system/base/airadio/app/api/agent/push-song/route.ts). Everything else # the bridge tracks internally (source, playlist, playlistId, ...) is dropped # here so it never reaches — nor is silently mangled by — the server validator. _AIRADIO_META_KEYS = ( "title", "artist", "genre", "duration", "isPublic", "sunoId", "sourceHash", "cover", ) def _encode_cover_data_url(cover_path: str) -> str | None: """Read a local image file and return a `data:` URL, or None if unreadable. AI-Radio's push-song decodes a `meta.cover` data URL into a song_cover image. Used so an agent can attach artwork in the same call that uploads the audio.""" try: p = Path(cover_path) if not p.is_file(): return None raw = p.read_bytes() if not raw: return None mime, _ = mimetypes.guess_type(str(p)) if not mime or not mime.startswith("image/"): mime = "image/png" return f"data:{mime};base64," + base64.b64encode(raw).decode("ascii") except OSError as exc: logger.warning("AI-Radio cover read failed for %s: %s", cover_path, exc) return None _COVER_EXTS = (".png", ".jpg", ".jpeg", ".webp") def _find_sibling_cover(song_path: Path) -> str | None: """Return artwork that lives next to the song, or None. Looks for `.` beside the audio file, then a `covers/.` subdir. This is what makes covers AUTOMATIC: save artwork as `.png` alongside `.mp3` and every push attaches it with no explicit cover_path and no change to the voice-tag.""" for ext in _COVER_EXTS: cand = song_path.with_suffix(ext) if cand.is_file(): return str(cand) covers_dir = song_path.parent / "covers" if covers_dir.is_dir(): for ext in _COVER_EXTS: cand = covers_dir / (song_path.stem + ext) if cand.is_file(): return str(cand) return None def _build_song_meta(fields: dict, audio_bytes: bytes, cover_path: str | None) -> dict: """Translate the bridge's flat field dict into AI-Radio's `meta` JSON shape. AI-Radio's /api/agent/push-song expects multipart `audio` (File) + `meta` (JSON string). `duration` MUST be an int; an optional cover travels as a `data:` URL inside meta; absent a sunoId we hash the bytes into a sourceHash so re-pushes dedupe instead of piling up duplicate rows.""" meta: dict = {} for k in _AIRADIO_META_KEYS: if fields.get(k) not in (None, ""): meta[k] = fields[k] # server schema is z.number().int(); callers hand us a string. if "duration" in meta: try: meta["duration"] = int(float(meta["duration"])) except (TypeError, ValueError): del meta["duration"] if not meta.get("sunoId") and not meta.get("sourceHash"): meta["sourceHash"] = hashlib.sha256(audio_bytes).hexdigest() if cover_path and not meta.get("cover"): cov = _encode_cover_data_url(cover_path) if cov: meta["cover"] = cov return meta def _stream_song(local_path: Path, fields: dict, cover_path: str | None = None): """ POST a song file to AI-Radio's /api/agent/push-song as multipart/form-data. AI-Radio expects field `audio` (the File) + field `meta` (a JSON string) — NOT `file` + flat form fields. `fields` is the bridge's internal flat metadata dict; `_build_song_meta` reshapes it to the contract the deployed server validates, so every caller (push-song, push-playlist) hits it correctly. AI-Radio dedupes via sunoId/sourceHash. """ raw = local_path.read_bytes() mime, _ = mimetypes.guess_type(str(local_path)) if not mime: mime = "audio/mpeg" # No explicit cover? Auto-attach artwork sitting next to the song. if not cover_path: cover_path = _find_sibling_cover(local_path) meta = _build_song_meta(fields, raw, cover_path) files = {"audio": (local_path.name, raw, mime)} return _airadio_call( "POST", "/api/agent/push-song", files=files, data={"meta": json.dumps(meta)}, ) def _stream_image(local_path: Path, fields: dict): """POST an image file to AI-Radio's /api/agent/set-image. The server expects multipart `image` (the File) + `meta` (a JSON string of `{target, targetId?}`) — NOT `file` + flat fields. `fields` already carries the right keys, so we just JSON-encode it under `meta`.""" mime, _ = mimetypes.guess_type(str(local_path)) if not mime: mime = "application/octet-stream" with open(local_path, "rb") as fh: files = {"image": (local_path.name, fh, mime)} return _airadio_call( "POST", "/api/agent/set-image", files=files, data={"meta": json.dumps(fields or {})}, ) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @airadio_bp.route("/api/airadio/push-song", methods=["POST"]) def push_song(): """ Push a single song from MUSIC_DIR (library) or GENERATED_MUSIC_DIR (generated) to AI-Radio. Body: { "filename": "...", "playlist": "library"|"generated", "title": optional, "cover_path": optional local image path } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} filename = body.get("filename") playlist = body.get("playlist", "library") title = body.get("title") cover_path = body.get("cover_path") local_path, meta = _resolve_local_song(filename, playlist) if local_path is None: # Auto-detect: if not found in the stated dir, try the other one. # The JS bridge never sends a playlist param, so it always defaults to # "library" — but most agent-pushed songs live in "generated". alt_playlist = "generated" if playlist == "library" else "library" local_path, meta = _resolve_local_song(filename, alt_playlist) if local_path is not None: playlist = alt_playlist else: return jsonify({ "ok": False, "error": { "code": "NOT_FOUND", "message": f"Song '{filename}' not found in library or generated playlist", }, }), 404 fields = { "title": title or (meta or {}).get("title") or local_path.stem, "source": "ovu-library" if playlist == "library" else "ovu-generated", "playlist": playlist, } # Carry through any useful metadata so AI-Radio doesn't lose it. for src_key, dst_key in ( ("artist", "artist"), ("genre", "genre"), ("description", "description"), ("duration_seconds", "duration"), ("suno_id", "sunoId"), ): if meta and meta.get(src_key) is not None: fields[dst_key] = str(meta[src_key]) print(f"📻 push-song: {local_path.name} → AI-Radio (playlist={playlist})") result = _stream_song(local_path, fields, cover_path=cover_path) if not result["ok"]: return _bridge_response(result) # Normalize success shape: { ok, songId, url } as the spec requires, # while still passing through anything else AI-Radio gave us. data = result.get("data") or {} out = { "ok": True, "songId": data.get("songId") or data.get("id"), "url": data.get("url"), "data": data, } return jsonify(out), 200 @airadio_bp.route("/api/airadio/push-playlist", methods=["POST"]) def push_playlist(): """ Push a whole playlist to AI-Radio (inline mode — one song per request). Body: { "name": "...", "playlist": "library"|"generated", "description": optional } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} name = body.get("name") playlist = body.get("playlist", "library") description = body.get("description", "") if not name: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'name' field"}, }), 400 if playlist == "generated": meta = load_generated_music_metadata() elif playlist == "library": meta = load_music_metadata() else: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": f"Unknown playlist '{playlist}'"}, }), 400 # The metadata files are flat {filename: {...}}. There's no concept of a # named in-file playlist yet — the social spec says we should look for # songs whose metadata mentions the playlist name. For MVP, we treat the # playlist arg as "all songs in this directory" if `name` matches a tag, # otherwise we send all songs in the dir. AI-Radio dedupes by sourceHash. matching_files = [] name_lower = (name or "").strip().lower() for fname, entry in (meta or {}).items(): playlist_tags = entry.get("playlists") or [] if isinstance(playlist_tags, str): playlist_tags = [playlist_tags] tag_match = any( isinstance(t, str) and t.lower() == name_lower for t in playlist_tags ) if tag_match: matching_files.append(fname) if not matching_files: # Fallback: the whole directory. base_dir = GENERATED_MUSIC_DIR if playlist == "generated" else MUSIC_DIR for f in base_dir.iterdir(): if f.is_file() and f.suffix.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".webm"}: matching_files.append(f.name) print(f"📻 push-playlist: '{name}' ({playlist}) — {len(matching_files)} song(s)") # 1. Create the playlist on AI-Radio first. create_payload = { "name": name, "description": description, "source": "ovu-library" if playlist == "library" else "ovu-generated", } create_result = _airadio_call("POST", "/api/agent/push-playlist", json=create_payload) if not create_result["ok"]: return _bridge_response(create_result) playlist_data = create_result.get("data") or {} playlist_id = playlist_data.get("id") or playlist_data.get("playlistId") # 2. Push each song inline. Track per-song results so the caller sees # which ones failed without aborting the whole batch. pushed = [] failed = [] for fname in matching_files: local_path, song_meta = _resolve_local_song(fname, playlist) if local_path is None: failed.append({"filename": fname, "error": "NOT_FOUND"}) continue fields = { "title": (song_meta or {}).get("title") or local_path.stem, "source": "ovu-library" if playlist == "library" else "ovu-generated", "playlist": playlist, "playlistId": str(playlist_id) if playlist_id else "", "playlistName": name, } for src_key, dst_key in ( ("artist", "artist"), ("genre", "genre"), ("description", "description"), ("duration_seconds", "duration"), ("suno_id", "sunoId"), ): if song_meta and song_meta.get(src_key) is not None: fields[dst_key] = str(song_meta[src_key]) song_result = _stream_song(local_path, fields) if song_result["ok"]: sd = song_result.get("data") or {} pushed.append({ "filename": fname, "songId": sd.get("songId") or sd.get("id"), "url": sd.get("url"), }) else: failed.append({ "filename": fname, "error": (song_result.get("error") or {}).get("code", "PUSH_FAILED"), "message": (song_result.get("error") or {}).get("message"), }) return jsonify({ "ok": True, "playlist": playlist_data, "pushed_count": len(pushed), "failed_count": len(failed), "pushed": pushed, "failed": failed, }), 200 @airadio_bp.route("/api/airadio/set-image", methods=["POST"]) def set_image(): """ Update an avatar / banner / song cover / playlist cover on AI-Radio. Body: { "target": "avatar"|"banner"|"song_cover"|"playlist_cover", "target_id": optional, "path": "/uploads/..." } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} target = body.get("target") target_id = body.get("target_id", "") raw_path = body.get("path", "") if target not in ("avatar", "banner", "song_cover", "playlist_cover"): return jsonify({ "ok": False, "error": { "code": "VALIDATION_FAILED", "message": "target must be one of avatar, banner, song_cover, playlist_cover", }, }), 400 local_path = _resolve_image_path(raw_path) if local_path is None: return jsonify({ "ok": False, "error": { "code": "VALIDATION_FAILED", "message": ( "Image path must point to an existing file under one of the " "whitelisted dirs (uploads, faces, known_faces, generated_music, 1temp). " "No '..' or absolute paths outside the whitelist." ), }, }), 400 if target in ("song_cover", "playlist_cover") and not target_id: return jsonify({ "ok": False, "error": { "code": "VALIDATION_FAILED", "message": f"target_id is required when target='{target}'", }, }), 400 fields = {"target": target} if target_id: fields["targetId"] = str(target_id) print(f"📻 set-image: target={target} id={target_id or '-'} file={local_path.name}") result = _stream_image(local_path, fields) return _bridge_response(result) @airadio_bp.route("/api/airadio/resolve", methods=["GET"]) def resolve(): """ Fuzzy-resolve a song or playlist query → { id, url, title, artist }. Query params: type=song|playlist, query= """ if not _bridge_configured(): return _bridge_not_configured_response() rtype = request.args.get("type", "song") query = request.args.get("query", "") if rtype not in ("song", "playlist"): return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "type must be 'song' or 'playlist'"}, }), 400 if not query: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'query' param"}, }), 400 print(f"📻 resolve: type={rtype} query='{query}'") result = _airadio_call( "GET", "/api/agent/resolve", params={"type": rtype, "query": query}, ) return _bridge_response(result) @airadio_bp.route("/api/airadio/play", methods=["POST"]) def play(): """ Resolve a song / playlist if needed, then return its URL+metadata. The frontend music player handles actual playback — this endpoint is a helper for the voice tag parser that needs `{ url, title, ... }`. Body: { "type": "song"|"playlist", "id": "..." OR "query": "..." } """ if not _bridge_configured(): return _bridge_not_configured_response() body = request.get_json(silent=True) or {} rtype = body.get("type", "song") rid = body.get("id") query = body.get("query") if rtype not in ("song", "playlist"): return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "type must be 'song' or 'playlist'"}, }), 400 if not rid and not query: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Provide either 'id' or 'query'"}, }), 400 # Resolve a query → id first if necessary. if not rid: print(f"📻 play: resolving '{query}' (type={rtype})") resolved = _airadio_call( "GET", "/api/agent/resolve", params={"type": rtype, "query": query}, ) if not resolved["ok"]: return _bridge_response(resolved) rdata = resolved.get("data") or {} rid = rdata.get("id") if not rid: return jsonify({ "ok": False, "error": {"code": "NOT_FOUND", "message": f"No {rtype} matching '{query}'"}, }), 404 print(f"📻 play: type={rtype} id={rid}") # Ask AI-Radio for the canonical URL for that ID. result = _airadio_call( "GET", "/api/agent/resolve", params={"type": rtype, "id": rid}, ) if not result["ok"]: return _bridge_response(result) data = result.get("data") or {} return jsonify({ "ok": True, "type": rtype, "id": data.get("id") or rid, "url": data.get("url"), "title": data.get("title"), "artist": data.get("artist"), "data": data, }), 200 @airadio_bp.route("/api/airadio/inbox", methods=["GET"]) def inbox(): """Songs friends have sent to the current user (unread + recent).""" if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) print("📻 inbox: fetching") result = _airadio_call("GET", "/api/agent/inbox") return _bridge_response(result) @airadio_bp.route("/api/airadio/send-to-friend", methods=["POST"]) def send_to_friend(): """ Send a song to a friend. Body: { "song_id": optional, "song_title": optional, "receiver_handle": "@example", "note": optional } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} song_id = body.get("song_id") song_title = body.get("song_title") receiver = body.get("receiver_handle") note = body.get("note", "") if not receiver: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'receiver_handle'"}, }), 400 if not song_id and not song_title: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Provide 'song_id' or 'song_title'"}, }), 400 if not song_id: print(f"📻 send-to-friend: resolving song '{song_title}'") resolved = _airadio_call( "GET", "/api/agent/resolve", params={"type": "song", "query": song_title}, ) if not resolved["ok"]: return _bridge_response(resolved) song_id = (resolved.get("data") or {}).get("id") if not song_id: return jsonify({ "ok": False, "error": {"code": "NOT_FOUND", "message": f"No song matching '{song_title}'"}, }), 404 print(f"📻 send-to-friend: song={song_id} receiver={receiver}") result = _airadio_call( "POST", "/api/agent/send-to-friend", json={"songId": song_id, "receiverHandle": receiver, "note": note}, ) return _bridge_response(result) @airadio_bp.route("/api/airadio/vote", methods=["POST"]) def vote(): """ Vote on a song. Body: { "song_id": optional, "song_title": optional, "value": 1|-1|0 } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} song_id = body.get("song_id") song_title = body.get("song_title") value = body.get("value") if value not in (1, -1, 0): return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "'value' must be 1, -1, or 0"}, }), 400 if not song_id and not song_title: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Provide 'song_id' or 'song_title'"}, }), 400 if not song_id: print(f"📻 vote: resolving song '{song_title}'") resolved = _airadio_call( "GET", "/api/agent/resolve", params={"type": "song", "query": song_title}, ) if not resolved["ok"]: return _bridge_response(resolved) song_id = (resolved.get("data") or {}).get("id") if not song_id: return jsonify({ "ok": False, "error": {"code": "NOT_FOUND", "message": f"No song matching '{song_title}'"}, }), 404 print(f"📻 vote: song={song_id} value={value}") result = _airadio_call("POST", "/api/agent/vote", json={"songId": song_id, "value": value}) return _bridge_response(result) # --------------------------------------------------------------------------- # Friend management — pure pass-through # --------------------------------------------------------------------------- def _friend_passthrough(action_path: str): if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} handle = body.get("handle") if not handle: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'handle'"}, }), 400 print(f"📻 {action_path}: handle={handle}") result = _airadio_call("POST", action_path, json={"handle": handle}) return _bridge_response(result) @airadio_bp.route("/api/airadio/friend-request", methods=["POST"]) def friend_request(): return _friend_passthrough("/api/agent/friend-request") @airadio_bp.route("/api/airadio/friend-accept", methods=["POST"]) def friend_accept(): return _friend_passthrough("/api/agent/friend-accept") @airadio_bp.route("/api/airadio/friend-decline", methods=["POST"]) def friend_decline(): return _friend_passthrough("/api/agent/friend-decline") # --------------------------------------------------------------------------- # Library # --------------------------------------------------------------------------- @airadio_bp.route("/api/airadio/library", methods=["GET"]) def library(): """Current user's library on AI-Radio (own songs + playlists).""" if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) print("📻 library: fetching") result = _airadio_call("GET", "/api/agent/library") return _bridge_response(result) # --------------------------------------------------------------------------- # Full catalog API (doc 22 — additive, cross-user) # --------------------------------------------------------------------------- # Query params we forward to /api/agent/catalog. Anything else is dropped. _CATALOG_PASSTHROUGH_PARAMS = ("q", "genre", "artist", "sort", "cursor", "limit") @airadio_bp.route("/api/airadio/catalog", methods=["GET"]) def catalog(): """ Platform-wide public song catalog search. Query params: q, genre, artist, sort=new|trending|top_week|top_all|plays, cursor, limit (<=100, default 24). Returns {items: [...], nextCursor}. Each item has a signed `streamUrl` valid for 15 minutes that can be fed directly to the OVU music player — no Clerk session needed. """ if not _bridge_has_backend(): return _bridge_not_configured_response() params = { k: request.args.get(k) for k in _CATALOG_PASSTHROUGH_PARAMS if request.args.get(k) is not None } print(f"📻 catalog: {params}") result = _airadio_call("GET", "/api/agent/catalog", params=params or None) return _bridge_response(result) @airadio_bp.route("/api/airadio/catalog/", methods=["GET"]) def catalog_detail(song_id): """Single song detail (catalog shape + viewer flags: liked, voted, saved).""" if not _bridge_has_backend(): return _bridge_not_configured_response() if not song_id: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing song id"}, }), 400 print(f"📻 catalog/{song_id}") result = _airadio_call("GET", f"/api/agent/catalog/{song_id}") return _bridge_response(result) @airadio_bp.route("/api/airadio/u//songs", methods=["GET"]) def user_songs(username): """Paginated public songs by @ (all songs if it's the caller).""" if not _bridge_has_backend(): return _bridge_not_configured_response() if not username: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing username"}, }), 400 handle = username.lstrip("@") params = {} for k in ("cursor", "limit"): v = request.args.get(k) if v is not None: params[k] = v print(f"📻 u/{handle}/songs: {params}") result = _airadio_call( "GET", f"/api/agent/u/{handle}/songs", params=params or None, ) return _bridge_response(result) @airadio_bp.route("/api/airadio/song//stream-url", methods=["POST"]) def song_stream_url(song_id): """Issue a signed 15-min stream URL for the given song.""" if not _bridge_has_backend(): return _bridge_not_configured_response() if not song_id: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing song id"}, }), 400 print(f"📻 stream-url: song={song_id}") result = _airadio_call("POST", f"/api/agent/song/{song_id}/stream-url") return _bridge_response(result) @airadio_bp.route("/api/airadio/library/save", methods=["POST"]) def library_save(): """ Save another user's song to the caller's library. Body: { "song_id": "..." } (accepts "songId" too for symmetry) """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} song_id = body.get("song_id") or body.get("songId") if not song_id: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'song_id'"}, }), 400 print(f"📻 library/save: song={song_id}") result = _airadio_call( "POST", "/api/agent/library/save", json={"songId": song_id}, ) return _bridge_response(result) @airadio_bp.route("/api/airadio/playlist", methods=["POST"]) def playlist_create(): """ Create a new AI-Radio playlist owned by the caller. Body: { "name": "...", "description"?: "...", "is_public"?: bool } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} name = body.get("name") if not name: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'name'"}, }), 400 payload = {"name": name} if body.get("description") is not None: payload["description"] = body.get("description") # Accept both is_public and isPublic from the caller. if "is_public" in body: payload["isPublic"] = bool(body.get("is_public")) elif "isPublic" in body: payload["isPublic"] = bool(body.get("isPublic")) print(f"📻 playlist/create: name={name}") result = _airadio_call("POST", "/api/agent/playlist", json=payload) return _bridge_response(result) @airadio_bp.route("/api/airadio/playlist/", methods=["GET"]) def playlist_read(playlist_id): """Read a playlist + ordered songs (each with a signed streamUrl).""" if not _bridge_has_backend(): return _bridge_not_configured_response() if not playlist_id: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing playlist id"}, }), 400 print(f"📻 playlist/{playlist_id}") result = _airadio_call("GET", f"/api/agent/playlist/{playlist_id}") return _bridge_response(result) # --------------------------------------------------------------------------- # Queue / Radio — voice-shaped shortcuts (doc 22+). Every endpoint returns # {items, reason, nextCursor}. OVU can feed `items` straight into the music # player. Each item has a 15-min signed streamUrl. # --------------------------------------------------------------------------- # Query params forwarded to every queue endpoint. _QUEUE_PASSTHROUGH_PARAMS = ( "mood", "genre", "freshness", "audience", "similarTo", "artist", "excludeHeard", "window", "q", "limit", "cursor", # bpmMin/bpmMax are rejected by the upstream today — forward so the # caller sees the VALIDATION_FAILED from AI-Radio unchanged. "bpmMin", "bpmMax", ) def _queue_params(): return { k: request.args.get(k) for k in _QUEUE_PASSTHROUGH_PARAMS if request.args.get(k) is not None } def _queue_proxy(path): if not _bridge_has_backend(): return _bridge_not_configured_response() params = _queue_params() print(f"📻 {path}: {params}") result = _airadio_call("GET", path, params=params or None) return _bridge_response(result) @airadio_bp.route("/api/airadio/queue", methods=["GET"]) def queue_generic(): """Generic queue resolver — all QueueParams are URL params.""" return _queue_proxy("/api/agent/queue") @airadio_bp.route("/api/airadio/queue/new", methods=["GET"]) def queue_new(): return _queue_proxy("/api/agent/queue/new") @airadio_bp.route("/api/airadio/queue/trending", methods=["GET"]) def queue_trending(): return _queue_proxy("/api/agent/queue/trending") @airadio_bp.route("/api/airadio/queue/top", methods=["GET"]) def queue_top(): return _queue_proxy("/api/agent/queue/top") @airadio_bp.route("/api/airadio/queue/random", methods=["GET"]) def queue_random(): return _queue_proxy("/api/agent/queue/random") @airadio_bp.route("/api/airadio/queue/friends", methods=["GET"]) def queue_friends(): return _queue_proxy("/api/agent/queue/friends") @airadio_bp.route("/api/airadio/queue/following", methods=["GET"]) def queue_following(): return _queue_proxy("/api/agent/queue/following") @airadio_bp.route("/api/airadio/queue/me", methods=["GET"]) def queue_me(): return _queue_proxy("/api/agent/queue/me") @airadio_bp.route("/api/airadio/queue/liked", methods=["GET"]) def queue_liked(): return _queue_proxy("/api/agent/queue/liked") @airadio_bp.route("/api/airadio/queue/unheard", methods=["GET"]) def queue_unheard(): return _queue_proxy("/api/agent/queue/unheard") @airadio_bp.route("/api/airadio/queue/genre/", methods=["GET"]) def queue_genre(slug): if not slug: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing genre slug"}, }), 400 return _queue_proxy(f"/api/agent/queue/genre/{slug}") @airadio_bp.route("/api/airadio/queue/mood/", methods=["GET"]) def queue_mood(mood): if not mood: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing mood"}, }), 400 return _queue_proxy(f"/api/agent/queue/mood/{mood}") @airadio_bp.route("/api/airadio/queue/artist/", methods=["GET"]) def queue_artist(username): # Accept "@example" as well as "example" handle = (username or "").lstrip("@") if not handle: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing username"}, }), 400 return _queue_proxy(f"/api/agent/queue/artist/{handle}") @airadio_bp.route("/api/airadio/queue/similar/", methods=["GET"]) def queue_similar(song_id): if not song_id: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing song id"}, }), 400 return _queue_proxy(f"/api/agent/queue/similar/{song_id}") @airadio_bp.route("/api/airadio/interpret", methods=["POST"]) def interpret(): """Natural-language phrase → QueueParams + items.""" if not _bridge_has_backend(): return _bridge_not_configured_response() body = request.get_json(silent=True) or {} query = body.get("query") if not query: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'query'"}, }), 400 print(f"📻 interpret: '{query}'") result = _airadio_call("POST", "/api/agent/interpret", json={"query": query}) return _bridge_response(result) # --------------------------------------------------------------------------- # Library search (dedup) — "is this song already on my AI-Radio?" # --------------------------------------------------------------------------- def _title_tokens(s: str): import re as _re return [t for t in _re.split(r"[^a-z0-9]+", (s or "").lower()) if t] @airadio_bp.route("/api/airadio/library/search", methods=["GET"]) def library_search(): """ Search ONLY the current user's AI-Radio library by title. Use this before pushing a song to avoid re-uploading a track the user already owns on AI-Radio. The upstream dedupes by `sourceHash`, but the agent should still know BEFORE the push whether the song exists so it can tell the user instead of silently no-op'ing. Query params: q — required, title text (fuzzy-matched) limit — optional, default 10, max 50 Returns: { ok: true, query: "", exists: bool, // any fuzzy match? exact: bool, // true iff at least one match is an exact title matches: [ { id, title, artist, coverUrl, plays, createdAt, ... } ] } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) q = (request.args.get("q") or "").strip() if not q: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'q'"}, }), 400 try: limit = max(1, min(50, int(request.args.get("limit", "10")))) except (TypeError, ValueError): limit = 10 print(f"📻 library/search: q='{q}' limit={limit}") # Pull the caller's library — the upstream scopes this to the authenticated # user automatically (Bearer aia_sk_* or platform key + X-JamBot-User). lib = _airadio_call("GET", "/api/agent/library") if not lib["ok"]: return _bridge_response(lib) data = lib.get("data") or {} # The library payload shape is { songs: [...], playlists: [...] } — be # defensive, the API version still moves. songs = data.get("songs") or data.get("items") or [] if not isinstance(songs, list): songs = [] q_lower = q.lower().strip() q_tokens = set(_title_tokens(q)) matches = [] exact = False for s in songs: if not isinstance(s, dict): continue title = (s.get("title") or "").strip() if not title: continue t_lower = title.lower() if t_lower == q_lower: exact = True matches.append((0, s)) continue if q_lower and q_lower in t_lower: matches.append((1, s)) continue t_tokens = set(_title_tokens(title)) if q_tokens and q_tokens.issubset(t_tokens): matches.append((2, s)) continue # Token overlap fallback: all-but-one token match if q_tokens and len(q_tokens) > 1: overlap = q_tokens & t_tokens if len(overlap) >= max(1, len(q_tokens) - 1): matches.append((3, s)) matches.sort(key=lambda pair: pair[0]) slimmed = [] for _, s in matches[:limit]: slimmed.append({ "id": s.get("id") or s.get("songId"), "title": s.get("title"), "artist": s.get("artist") or s.get("artistHandle") or s.get("owner"), "coverUrl": s.get("coverUrl") or s.get("cover"), "plays": s.get("plays"), "createdAt": s.get("createdAt") or s.get("created_at"), "isPublic": s.get("isPublic"), }) return jsonify({ "ok": True, "query": q, "exists": len(matches) > 0, "exact": exact, "count": len(matches), "matches": slimmed, }), 200 # --------------------------------------------------------------------------- # Per-user API key — write aia_sk_* to the workspace .env so subsequent # requests authenticate as that specific AI-Radio user. # --------------------------------------------------------------------------- @airadio_bp.route("/api/airadio/set-user-key", methods=["POST"]) def set_user_key(): """ Persist a per-user AI-Radio API key for this workspace. Body: { "key": "aia_sk_..." } The key is written to RUNTIME_DIR/airadio-user-key, which is bind-mounted from the host so it survives container restarts and image upgrades. Subsequent /api/airadio/* calls pick the new key up immediately (no restart required) — `_effective_user_key()` reads the file at request time. """ body = request.get_json(silent=True) or {} key = (body.get("key") or "").strip() if not key: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'key'"}, }), 400 if not key.startswith("aia_sk_") or len(key) < 20: return jsonify({ "ok": False, "error": { "code": "VALIDATION_FAILED", "message": "Key must look like 'aia_sk_...' (minimum 20 chars)", }, }), 400 # Probe the key before persisting so a typo doesn't replace a working key. try: probe = http_requests.get( f"{AIRADIO_URL}/api/agent/library", headers={"Authorization": f"Bearer {key}"}, timeout=AIRADIO_TIMEOUT, ) except http_requests.RequestException as exc: return jsonify({ "ok": False, "error": {"code": "BRIDGE_UNREACHABLE", "message": str(exc)}, }), 502 if probe.status_code in (401, 403): return jsonify({ "ok": False, "error": { "code": "AUTH_API_KEY_INVALID", "message": "AI-Radio rejected the key. Check that it was copied correctly and not rotated.", }, }), 401 if probe.status_code >= 500: return jsonify({ "ok": False, "error": { "code": "AIRADIO_HTTP_ERROR", "message": f"AI-Radio returned HTTP {probe.status_code} during key probe", }, }), 502 try: _USER_KEY_PERSIST_PATH.parent.mkdir(parents=True, exist_ok=True) _USER_KEY_PERSIST_PATH.write_text(key + "\n") # Best-effort tighten — runtime dir lives on a 777 mount; the key file # itself we keep readable only by the running user when possible. try: os.chmod(_USER_KEY_PERSIST_PATH, 0o600) except OSError: pass except OSError as exc: return jsonify({ "ok": False, "error": {"code": "WORKSPACE_KEY_WRITE_FAILED", "message": str(exc)}, }), 500 print(f"📻 set-user-key: persisted AIRADIO_USER_KEY → {_USER_KEY_PERSIST_PATH}") return jsonify({ "ok": True, "message": "AI-Radio user key saved and active for this workspace.", "path": str(_USER_KEY_PERSIST_PATH), "restartRequired": False, }), 200 # --------------------------------------------------------------------------- # Reply to a received song (wraps send-to-friend with a reply-of pointer) # --------------------------------------------------------------------------- @airadio_bp.route("/api/airadio/reply-to-send", methods=["POST"]) def reply_to_send(): """ Reply to a song a friend sent you by sending another song back. Body: { "send_id": "", "song_id": optional, "song_title": optional, "note": optional } """ if not _bridge_has_auth(): return _bridge_not_configured_response(for_write=True) body = request.get_json(silent=True) or {} send_id = body.get("send_id") or body.get("sendId") song_id = body.get("song_id") or body.get("songId") song_title = body.get("song_title") or body.get("songTitle") note = body.get("note", "") if not send_id: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Missing 'send_id'"}, }), 400 if not song_id and not song_title: return jsonify({ "ok": False, "error": {"code": "VALIDATION_FAILED", "message": "Provide 'song_id' or 'song_title'"}, }), 400 if not song_id: resolved = _airadio_call( "GET", "/api/agent/resolve", params={"type": "song", "query": song_title}, ) if not resolved["ok"]: return _bridge_response(resolved) song_id = (resolved.get("data") or {}).get("id") if not song_id: return jsonify({ "ok": False, "error": {"code": "NOT_FOUND", "message": f"No song matching '{song_title}'"}, }), 404 print(f"📻 reply-to-send: in-reply-to={send_id} song={song_id}") result = _airadio_call( "POST", "/api/agent/reply-to-send", json={"sendId": send_id, "songId": song_id, "note": note}, ) return _bridge_response(result)