- Radio Garden API Client (30K+ Orte, Sender-Suche, Stream-URL Auflösung) - Discord Voice Streaming via ffmpeg (PCM Pipeline) - Interactive 3D Globe (globe.gl) mit allen Radiosender-Standorten - Sender-Panel mit Play/Stop/Favoriten - Live-Suche nach Sendern und Städten - Now-Playing Bar mit Equalizer-Animation - Guild/Voice-Channel Auswahl - SSE Broadcasting für Live-Updates - Favoriten-System mit Persistenz - Responsive Design (Mobile/Tablet/Desktop) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
// ── Radio Garden API Client ──
|
|
|
|
const BASE = 'https://radio.garden/api';
|
|
const UA = 'GamingHub/1.0';
|
|
|
|
export interface RadioPlace {
|
|
id: string;
|
|
geo: [number, number];
|
|
title: string;
|
|
country: string;
|
|
size: number;
|
|
}
|
|
|
|
export interface RadioChannel {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
export interface SearchHit {
|
|
id: string;
|
|
type: string;
|
|
title: string;
|
|
subtitle: string;
|
|
url: string;
|
|
}
|
|
|
|
// ── Cache ──
|
|
let placesCache: RadioPlace[] = [];
|
|
let placesCacheTime = 0;
|
|
const PLACES_TTL = 24 * 60 * 60 * 1000; // 24h
|
|
|
|
// ── Helpers ──
|
|
async function apiFetch(path: string): Promise<any> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
if (!res.ok) throw new Error(`Radio Garden API ${res.status}: ${path}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
/** Alle Orte mit Radiosendern weltweit (~30K Einträge, gecached 24h) */
|
|
export async function fetchPlaces(): Promise<RadioPlace[]> {
|
|
if (placesCache.length > 0 && Date.now() - placesCacheTime < PLACES_TTL) {
|
|
return placesCache;
|
|
}
|
|
const data = await apiFetch('/ara/content/places');
|
|
placesCache = (data?.data?.list ?? []).map((p: any) => ({
|
|
id: p.id,
|
|
geo: p.geo,
|
|
title: p.title,
|
|
country: p.country,
|
|
size: p.size ?? 1,
|
|
}));
|
|
placesCacheTime = Date.now();
|
|
console.log(`[Radio] Fetched ${placesCache.length} places from Radio Garden`);
|
|
return placesCache;
|
|
}
|
|
|
|
/** Sender an einem bestimmten Ort */
|
|
export async function fetchPlaceChannels(placeId: string): Promise<RadioChannel[]> {
|
|
const data = await apiFetch(`/ara/content/page/${placeId}/channels`);
|
|
const channels: RadioChannel[] = [];
|
|
for (const section of data?.data?.content ?? []) {
|
|
for (const item of section.items ?? []) {
|
|
const href: string = item.href ?? item.page?.url ?? '';
|
|
const match = href.match(/\/listen\/([^/]+)/);
|
|
if (match) {
|
|
channels.push({ id: match[1], title: item.title ?? 'Unbekannt' });
|
|
}
|
|
}
|
|
}
|
|
return channels;
|
|
}
|
|
|
|
/** Sender/Orte/Länder suchen */
|
|
export async function searchStations(query: string): Promise<SearchHit[]> {
|
|
const data = await apiFetch(`/search?q=${encodeURIComponent(query)}`);
|
|
return (data?.hits?.hits ?? []).map((h: any) => ({
|
|
id: h._id ?? '',
|
|
type: h._source?.type ?? 'unknown',
|
|
title: h._source?.title ?? '',
|
|
subtitle: h._source?.subtitle ?? '',
|
|
url: h._source?.url ?? '',
|
|
}));
|
|
}
|
|
|
|
/** Stream-URL auflösen (302 Redirect → tatsächliche Icecast/Shoutcast URL) */
|
|
export async function resolveStreamUrl(channelId: string): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(`${BASE}/ara/content/listen/${channelId}/channel.mp3`, {
|
|
redirect: 'manual',
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
return res.headers.get('location') ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getPlacesCount(): number {
|
|
return placesCache.length;
|
|
}
|