feat: Radio Plugin – 3D Globe mit weltweiten Radiosendern
- 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>
This commit is contained in:
parent
ae1c41f0ae
commit
847c963d86
7 changed files with 1487 additions and 9 deletions
104
server/src/plugins/radio/api.ts
Normal file
104
server/src/plugins/radio/api.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// ── 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue