Created
June 19, 2026 15:20
-
-
Save TimDorand/304c260c0ec56090308e074df38fc58e to your computer and use it in GitHub Desktop.
Migrate from Radio King to Azuracast
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| RadioKing → AzuraCast migration (no pip install needed — stdlib only) | |
| ======================================================================= | |
| Two independent phases — run them separately if needed: | |
| PHASE=download only pull tracks from RadioKing (no AzuraCast needed) | |
| PHASE=upload only push already-downloaded tracks to AzuraCast | |
| (default) run both phases | |
| Usage: | |
| export RK_API_TOKEN="eyJhbGci..." # RadioKing JWT | |
| export AZ_HOST="https://radio.yourvps.com" # or http:// if no TLS yet | |
| export AZ_API_KEY="your-azuracast-api-key" | |
| export AZ_STATION_ID="1" | |
| export DOWNLOAD_DIR="./rk_downloads" # optional | |
| export AZ_SKIP_SSL="1" # set if self-signed cert | |
| # Phase 1 – download everything from RadioKing now: | |
| PHASE=download python3 migrate-radioking-to-azuracast.py | |
| # Phase 2 – upload to AzuraCast once it's ready: | |
| PHASE=upload python3 migrate-radioking-to-azuracast.py | |
| Resumable: saves state to ./migration_state.json. | |
| """ | |
| import os, sys, json, time, uuid, ssl, socket | |
| from pathlib import Path | |
| from urllib.request import urlopen, Request | |
| from urllib.parse import urlencode | |
| from urllib.error import HTTPError | |
| socket.setdefaulttimeout(60) # ponytail: global timeout so hanging API calls fail fast | |
| # ── Config ──────────────────────────────────────────────────────────────────── | |
| RK_RADIO_ID = "292531" | |
| RK_TOKEN = os.environ.get("RK_API_TOKEN", "") | |
| AZ_HOST = os.environ.get("AZ_HOST", "").rstrip("/") | |
| AZ_STATION_ID = os.environ.get("AZ_STATION_ID", "1") | |
| AZ_API_KEY = os.environ.get("AZ_API_KEY", "") | |
| DOWNLOAD_DIR = Path(os.environ.get("DOWNLOAD_DIR", "./rk_downloads")) | |
| STATE_FILE = Path("./migration_state.json") | |
| RATE_LIMIT = 0.25 # seconds between API calls | |
| PHASE = os.environ.get("PHASE", "both") # download | upload | both | |
| AZ_SKIP_SSL = os.environ.get("AZ_SKIP_SSL", "") # set to "1" for self-signed certs | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| RK_BASE = "https://api.radioking.io" | |
| AZ_BASE = f"{AZ_HOST}/api" | |
| # SSL context for AzuraCast (bypass verification for self-hosted/self-signed) | |
| _az_ssl = ssl.create_default_context() | |
| if AZ_SKIP_SSL: | |
| _az_ssl.check_hostname = False | |
| _az_ssl.verify_mode = ssl.CERT_NONE | |
| # ── HTTP helpers (stdlib only) ──────────────────────────────────────────────── | |
| def http_get(url, headers=None, ssl_ctx=None) -> dict: | |
| req = Request(url, headers=headers or {}) | |
| with urlopen(req, timeout=30, context=ssl_ctx) as r: | |
| return json.loads(r.read().decode()) | |
| def http_get_stream(url, headers=None, dest: Path = None, ssl_ctx=None): | |
| req = Request(url, headers=headers or {}) | |
| with urlopen(req, timeout=120, context=ssl_ctx) as r: | |
| with open(dest, "wb") as f: | |
| while chunk := r.read(65536): | |
| f.write(chunk) | |
| def http_post_json(url, data: dict, headers=None, ssl_ctx=None) -> dict: | |
| body = json.dumps(data).encode() | |
| h = {"Content-Type": "application/json", **(headers or {})} | |
| req = Request(url, data=body, headers=h, method="POST") | |
| with urlopen(req, timeout=30, context=ssl_ctx) as r: | |
| return json.loads(r.read().decode()) | |
| def http_put_json(url, data: dict, headers=None, ssl_ctx=None) -> dict: | |
| body = json.dumps(data).encode() | |
| h = {"Content-Type": "application/json", **(headers or {})} | |
| req = Request(url, data=body, headers=h, method="PUT") | |
| with urlopen(req, timeout=30, context=ssl_ctx) as r: | |
| return json.loads(r.read().decode()) | |
| def http_post_multipart(url, fields: dict, filepath: Path, headers=None, ssl_ctx=None) -> dict: | |
| boundary = uuid.uuid4().hex | |
| body = b"" | |
| for name, value in fields.items(): | |
| body += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode() | |
| body += f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filepath.name}\"\r\nContent-Type: audio/mpeg\r\n\r\n".encode() | |
| body += filepath.read_bytes() | |
| body += f"\r\n--{boundary}--\r\n".encode() | |
| h = {"Content-Type": f"multipart/form-data; boundary={boundary}", **(headers or {})} | |
| req = Request(url, data=body, headers=h, method="POST") | |
| with urlopen(req, timeout=300, context=ssl_ctx) as r: | |
| return json.loads(r.read().decode()) | |
| # ── State ───────────────────────────────────────────────────────────────────── | |
| def load_state(): | |
| if STATE_FILE.exists(): | |
| return json.loads(STATE_FILE.read_text()) | |
| return {"uploaded": {}, "az_playlists": {}, "done": {}} | |
| def save_state(state): | |
| STATE_FILE.write_text(json.dumps(state, indent=2)) | |
| # ── RadioKing ───────────────────────────────────────────────────────────────── | |
| def rk_get(path, params=None): | |
| qs = ("?" + urlencode(params)) if params else "" | |
| url = f"{RK_BASE}{path}{qs}" | |
| print(f" [rk_get] {url[:100]}", flush=True) | |
| try: | |
| data = http_get(url, {"Authorization": f"Bearer {RK_TOKEN}"}) | |
| except HTTPError as e: | |
| if e.code == 401: | |
| print(f" [RK 401] Check RK_API_TOKEN") | |
| raise | |
| except Exception as e: | |
| print(f" [rk_get ERROR] {e}", flush=True) | |
| raise | |
| print(f" [rk_get] got {type(data).__name__}, len={len(data) if hasattr(data, '__len__') else '?'}", flush=True) | |
| time.sleep(RATE_LIMIT) | |
| return data | |
| def fetch_rk_playlists(): | |
| return rk_get(f"/radio/{RK_RADIO_ID}/playlist") | |
| _all_tracks_cache = None | |
| def fetch_all_rk_tracks(): | |
| # ponytail: API ignores idplaylist and offset params, always returns full library | |
| global _all_tracks_cache | |
| if _all_tracks_cache is not None: | |
| return _all_tracks_cache | |
| batch = rk_get(f"/radio/{RK_RADIO_ID}/track") | |
| _all_tracks_cache = batch if isinstance(batch, list) else batch.get("data", []) | |
| print(f" total library: {len(_all_tracks_cache)} tracks") | |
| return _all_tracks_cache | |
| def fetch_rk_playlist_tracks(playlist_id): | |
| all_tracks = fetch_all_rk_tracks() | |
| return [t for t in all_tracks if any(p["idplaylist"] == playlist_id for p in t.get("playlists", []))] | |
| def download_track(track, dest_dir: Path): | |
| track_id = track.get("idtrack") or track.get("id") | |
| title = track.get("title") or track.get("filename") or str(track_id) | |
| safe = "".join(c if c.isalnum() or c in " ._-" else "_" for c in title).strip() | |
| dest = dest_dir / f"{track_id}_{safe[:80]}.mp3" | |
| if dest.exists(): | |
| return dest | |
| url = track.get("audio_url") or f"{RK_BASE}/radio/{RK_RADIO_ID}/track/{track_id}/listen" | |
| print(f" ↓ {safe[:60]}") | |
| try: | |
| http_get_stream(url, headers={"Authorization": f"Bearer {RK_TOKEN}"}, dest=dest) | |
| time.sleep(RATE_LIMIT) | |
| return dest | |
| except Exception as e: | |
| print(f" ✗ download failed: {e}") | |
| if dest.exists(): | |
| dest.unlink() | |
| return None | |
| # ── AzuraCast ───────────────────────────────────────────────────────────────── | |
| AZ_H = {"X-API-Key": AZ_API_KEY, "Accept": "application/json"} | |
| def az_get(path): | |
| return http_get(f"{AZ_BASE}{path}", AZ_H, ssl_ctx=_az_ssl) | |
| def ensure_az_playlist(name: str, state: dict) -> int: | |
| if name in state["az_playlists"]: | |
| return state["az_playlists"][name] | |
| resp = http_post_json(f"{AZ_BASE}/station/{AZ_STATION_ID}/playlists", { | |
| "name": name, "type": "default", "source": "songs", | |
| "order": "shuffle", "is_enabled": True, | |
| }, AZ_H, ssl_ctx=_az_ssl) | |
| pid = resp["id"] | |
| state["az_playlists"][name] = pid | |
| save_state(state) | |
| print(f" + Created AZ playlist '{name}' (id={pid})") | |
| return pid | |
| def upload_to_az(filepath: Path, folder: str, state: dict): | |
| key = filepath.name | |
| if key in state["uploaded"]: | |
| return state["uploaded"][key] | |
| az_path = f"{folder[:60]}/{filepath.name}" | |
| print(f" ↑ uploading…") | |
| try: | |
| resp = http_post_multipart( | |
| f"{AZ_BASE}/station/{AZ_STATION_ID}/files", | |
| {"path": az_path}, | |
| filepath, | |
| AZ_H, | |
| ssl_ctx=_az_ssl, | |
| ) | |
| uid = str(resp.get("unique_id") or resp.get("id") or "") | |
| if uid: | |
| state["uploaded"][key] = uid | |
| save_state(state) | |
| return uid or None | |
| except Exception as e: | |
| print(f" ✗ upload failed: {e}") | |
| return None | |
| def assign_to_playlist(uid: str, az_pl_id: int): | |
| try: | |
| http_put_json(f"{AZ_BASE}/station/{AZ_STATION_ID}/file/{uid}", | |
| {"playlists": [az_pl_id]}, AZ_H, ssl_ctx=_az_ssl) | |
| except Exception as e: | |
| print(f" ✗ playlist assign failed: {e}") | |
| # ── Main ────────────────────────────────────────────────────────────────────── | |
| def main(): | |
| global AZ_H, AZ_BASE | |
| AZ_H = {"X-API-Key": AZ_API_KEY, "Accept": "application/json"} | |
| AZ_BASE = f"{AZ_HOST}/api" | |
| need_rk = PHASE in ("download", "both") | |
| need_az = PHASE in ("upload", "both") | |
| if need_rk and not RK_TOKEN: | |
| print("ERROR: set RK_API_TOKEN"); sys.exit(1) | |
| if need_az and not all([AZ_HOST, AZ_API_KEY, AZ_STATION_ID]): | |
| print("ERROR: set AZ_HOST, AZ_API_KEY, AZ_STATION_ID"); sys.exit(1) | |
| DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
| state = load_state() | |
| # ── Phase: download from RadioKing ──────────────────────────────────────── | |
| if need_rk: | |
| print("Fetching RadioKing playlists…") | |
| rk_playlists = fetch_rk_playlists() | |
| state["rk_playlists"] = rk_playlists | |
| save_state(state) | |
| nonempty = [p for p in rk_playlists if p["nbtracks"] > 0] | |
| print(f" {len(rk_playlists)} total, {len(nonempty)} non-empty\n") | |
| for pl in rk_playlists: | |
| name, pl_id, nb = pl["name"], pl["idplaylist"], pl["nbtracks"] | |
| if nb == 0: | |
| print(f"[skip] {name}"); continue | |
| print(f"\n── {name} ({nb} tracks)") | |
| pl_dir = DOWNLOAD_DIR / str(pl_id) | |
| pl_dir.mkdir(exist_ok=True) | |
| tracks = fetch_rk_playlist_tracks(pl_id) | |
| if not tracks: | |
| print(f" [warn] 0 tracks — verify ?idplaylist param"); continue | |
| state.setdefault("rk_tracks", {})[str(pl_id)] = tracks | |
| save_state(state) | |
| ok = fail = skip = 0 | |
| for track in tracks: | |
| track_id = track.get("idtrack") or track.get("id") | |
| dl_key = f"dl_{pl_id}_{track_id}" | |
| if dl_key in state.get("done", {}): | |
| skip += 1; continue | |
| local = download_track(track, pl_dir) | |
| if not local: | |
| fail += 1; continue | |
| state.setdefault("done", {})[dl_key] = str(local) | |
| save_state(state) | |
| ok += 1 | |
| print(f" ↓ {ok} downloaded ✗ {fail} skipped {skip}") | |
| # ── Phase: upload to AzuraCast ──────────────────────────────────────────── | |
| if need_az: | |
| if not state["az_playlists"]: | |
| for pl in az_get(f"/station/{AZ_STATION_ID}/playlists"): | |
| state["az_playlists"][pl["name"]] = pl["id"] | |
| save_state(state) | |
| rk_playlists = state.get("rk_playlists") or fetch_rk_playlists() | |
| for pl in rk_playlists: | |
| name, pl_id, nb = pl["name"], pl["idplaylist"], pl["nbtracks"] | |
| if nb == 0: | |
| continue | |
| print(f"\n── {name} ({nb} tracks)") | |
| pl_dir = DOWNLOAD_DIR / str(pl_id) | |
| az_pl_id = ensure_az_playlist(name, state) | |
| tracks = state.get("rk_tracks", {}).get(str(pl_id)) or fetch_rk_playlist_tracks(pl_id) | |
| ok = fail = skip = 0 | |
| for track in tracks: | |
| track_id = track.get("idtrack") or track.get("id") | |
| up_key = f"up_{pl_id}_{track_id}" | |
| if up_key in state.get("done", {}): | |
| skip += 1; continue | |
| matches = list(pl_dir.glob(f"{track_id}_*.mp3")) | |
| if not matches: | |
| print(f" [miss] {track_id} — run PHASE=download first") | |
| fail += 1; continue | |
| uid = upload_to_az(matches[0], name, state) | |
| if not uid: | |
| fail += 1; continue | |
| assign_to_playlist(uid, az_pl_id) | |
| state.setdefault("done", {})[up_key] = uid | |
| save_state(state) | |
| ok += 1 | |
| print(f" ↑ {ok} uploaded ✗ {fail} skipped {skip}") | |
| print("\n✅ Done.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment