105 lines
2.9 KiB
TypeScript
105 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;
|
||
|
|
}
|