Skip to content

Instantly share code, notes, and snippets.

@TimDorand
Created June 19, 2026 15:20
Show Gist options
  • Select an option

  • Save TimDorand/304c260c0ec56090308e074df38fc58e to your computer and use it in GitHub Desktop.

Select an option

Save TimDorand/304c260c0ec56090308e074df38fc58e to your computer and use it in GitHub Desktop.
Migrate from Radio King to Azuracast
#!/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