115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
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<unknown> {
|
|
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<NowPlaying | null> {
|
|
const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
|
|
if (!url) return null;
|
|
return parseNowPlaying(await fetchJson(url));
|
|
}
|
|
|
|
export async function fetchListeners(): Promise<number | null> {
|
|
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<string, unknown>)
|
|
? (raw as { total: unknown }).total
|
|
: raw,
|
|
);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|