import { siteSettings } from "@/lib/services/site-settings"; /** * Best-effort radio stream helpers, shared by the public API and the jobs * worker. They poll the now-playing / listeners endpoints configured in * website_settings (AzureCast / Icecast / Shoutcast all differ), parsing the * common shapes. Everything fails soft (returns null) on error/missing config. */ // Block SSRF — only allow http/https to public IPs (no private/loopback/link-local). const PRIVATE_IP_RE = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|::1|fe80:|fc00:|fd00:|localhost)/i; function isSafeUrl(url: string): boolean { try { const u = new URL(url); if (u.protocol !== "http:" && u.protocol !== "https:") return false; if (PRIVATE_IP_RE.test(u.hostname)) return false; return true; } catch { return false; } } export interface NowPlaying { title: string; artist: string | null; } async function fetchJson(url: string, ms = 4000): Promise { if (!isSafeUrl(url)) return null; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); try { const res = await fetch(url, { signal: controller.signal, cache: "no-store", }); const text = await res.text(); try { return JSON.parse(text); } catch { return text; // plain-text "Artist - Title" (Shoutcast/Icecast metadata) } } catch { return null; } finally { clearTimeout(timer); } } function parseNowPlaying(d: unknown): NowPlaying | null { if (!d) return null; // AzureCast: { now_playing: { song: { title, artist } } } const azure = ( d as { now_playing?: { song?: { title?: string; artist?: string; text?: string }; }; } ).now_playing?.song; if (azure?.title || azure?.text) { return { title: azure.title ?? azure.text ?? "", artist: azure.artist ?? null, }; } // Generic JSON: { title, artist } or { songtitle } const generic = d as { title?: string; artist?: string; songtitle?: string }; if (generic.title) return { title: generic.title, artist: generic.artist ?? null }; if (generic.songtitle) { const [a, t] = generic.songtitle.split(" - "); return t ? { title: t.trim(), artist: a.trim() } : { title: generic.songtitle, artist: null }; } // Plain text "Artist - Title" if (typeof d === "string" && d.trim()) { const parts = d.split(" - "); return parts.length > 1 ? { title: parts.slice(1).join(" - ").trim(), artist: parts[0].trim() } : { title: d.trim(), artist: null }; } return null; } export async function fetchNowPlaying(): Promise { const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? ""; if (!url) return null; return parseNowPlaying(await fetchJson(url)); } export async function fetchListeners(): Promise { const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? ""; if (!url) return null; const d = await fetchJson(url); if (d == null) return null; const obj = d as { listeners?: unknown; current_listeners?: unknown }; const raw = typeof d === "number" ? d : (obj.listeners ?? obj.current_listeners ?? (typeof d === "string" ? Number(d) : null)); const n = Number( typeof raw === "object" && raw && "total" in (raw as Record) ? (raw as { total: unknown }).total : raw, ); return Number.isFinite(n) ? n : null; }