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:
Daniel 2026-03-05 23:23:52 +01:00
parent ae1c41f0ae
commit 847c963d86
7 changed files with 1487 additions and 9 deletions

View 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;
}

View file

@ -0,0 +1,329 @@
import type express from 'express';
import { spawn, type ChildProcess } from 'node:child_process';
import {
joinVoiceChannel, createAudioPlayer, createAudioResource,
VoiceConnectionStatus, StreamType, getVoiceConnection, entersState,
} from '@discordjs/voice';
import type { VoiceBasedChannel } from 'discord.js';
import { ChannelType } from 'discord.js';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
import { getState, setState } from '../../core/persistence.js';
import {
fetchPlaces, fetchPlaceChannels, searchStations,
resolveStreamUrl, getPlacesCount,
} from './api.js';
// ── Types ──
interface GuildRadioState {
stationId: string;
stationName: string;
placeName: string;
country: string;
streamUrl: string;
startedAt: string;
ffmpeg: ChildProcess;
player: ReturnType<typeof createAudioPlayer>;
channelId: string;
channelName: string;
}
interface Favorite {
stationId: string;
stationName: string;
placeName: string;
country: string;
placeId: string;
}
// ── State ──
const guildRadioState = new Map<string, GuildRadioState>();
function getFavorites(): Favorite[] {
return getState<Favorite[]>('radio_favorites', []);
}
function setFavorites(favs: Favorite[]): void {
setState('radio_favorites', favs);
}
// ── Streaming ──
function stopStream(guildId: string): void {
const state = guildRadioState.get(guildId);
if (!state) return;
try { state.ffmpeg.kill('SIGKILL'); } catch {}
try { state.player.stop(true); } catch {}
try { getVoiceConnection(guildId)?.destroy(); } catch {}
guildRadioState.delete(guildId);
broadcastState(guildId);
console.log(`[Radio] Stopped stream in guild ${guildId}`);
}
async function startStream(
ctx: PluginContext, guildId: string, voiceChannelId: string,
stationId: string, stationName: string, placeName: string, country: string,
): Promise<{ ok: boolean; error?: string }> {
// Stoppe laufenden Stream in diesem Guild
stopStream(guildId);
// Stream-URL auflösen
const streamUrl = await resolveStreamUrl(stationId);
if (!streamUrl) return { ok: false, error: 'Stream-URL konnte nicht aufgelöst werden' };
// Guild + Channel finden
const guild = ctx.client.guilds.cache.get(guildId);
if (!guild) return { ok: false, error: 'Guild nicht gefunden' };
const channel = guild.channels.cache.get(voiceChannelId) as VoiceBasedChannel | undefined;
if (!channel) return { ok: false, error: 'Voice Channel nicht gefunden' };
// Voice-Channel joinen
const connection = joinVoiceChannel({
channelId: voiceChannelId,
guildId,
adapterCreator: guild.voiceAdapterCreator,
selfDeaf: true,
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, 10_000);
} catch {
connection.destroy();
return { ok: false, error: 'Voice-Verbindung fehlgeschlagen' };
}
// ffmpeg spawnen Radio-Stream → raw PCM
const ffmpeg = spawn('ffmpeg', [
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-i', streamUrl,
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-loglevel', 'error',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
ffmpeg.stderr?.on('data', (d: Buffer) => {
const msg = d.toString().trim();
if (msg) console.error(`[Radio:ffmpeg] ${msg}`);
});
ffmpeg.on('close', (code) => {
if (guildRadioState.has(guildId)) {
console.log(`[Radio] ffmpeg exited (code ${code}), cleaning up`);
guildRadioState.delete(guildId);
try { connection.destroy(); } catch {}
broadcastState(guildId);
}
});
// AudioResource + Player
const resource = createAudioResource(ffmpeg.stdout!, {
inputType: StreamType.Raw,
});
const player = createAudioPlayer();
player.play(resource);
connection.subscribe(player);
player.on('error', (err) => {
console.error(`[Radio] Player error:`, err.message);
stopStream(guildId);
});
// State tracken
const channelName = 'name' in channel ? (channel as any).name : voiceChannelId;
guildRadioState.set(guildId, {
stationId, stationName, placeName, country,
streamUrl, startedAt: new Date().toISOString(),
ffmpeg, player, channelId: voiceChannelId, channelName,
});
broadcastState(guildId);
console.log(`[Radio] ▶ "${stationName}" (${placeName}, ${country}) → ${guild.name}/#${channelName}`);
return { ok: true };
}
function broadcastState(guildId: string): void {
const state = guildRadioState.get(guildId);
sseBroadcast({
type: 'radio',
plugin: 'radio',
guildId,
playing: state ? {
stationId: state.stationId,
stationName: state.stationName,
placeName: state.placeName,
country: state.country,
startedAt: state.startedAt,
channelName: state.channelName,
} : null,
});
}
// ── Plugin ──
const radioPlugin: Plugin = {
name: 'radio',
version: '1.0.0',
description: 'World Radio Radiosender aus aller Welt streamen',
async init() {
fetchPlaces()
.then(p => console.log(`[Radio] ${p.length} Orte gecached`))
.catch(e => console.error('[Radio] Places-Fetch fehlgeschlagen:', e));
},
async onReady(ctx) {
console.log(`[Radio] Discord ready ${ctx.client.guilds.cache.size} Guild(s)`);
},
registerRoutes(app: express.Application, ctx: PluginContext) {
// ── Alle Orte (für Globe) ──
app.get('/api/radio/places', async (_req, res) => {
try {
const places = await fetchPlaces();
res.json(places);
} catch (e: any) {
res.status(502).json({ error: e.message });
}
});
// ── Sender an einem Ort ──
app.get('/api/radio/place/:id/channels', async (req, res) => {
try {
const channels = await fetchPlaceChannels(req.params.id);
res.json(channels);
} catch (e: any) {
res.status(502).json({ error: e.message });
}
});
// ── Suche ──
app.get('/api/radio/search', async (req, res) => {
const q = (req.query.q as string) ?? '';
if (!q.trim()) return res.json([]);
try {
const results = await searchStations(q);
res.json(results);
} catch (e: any) {
res.status(502).json({ error: e.message });
}
});
// ── Verfügbare Guilds + Voice Channels ──
app.get('/api/radio/guilds', (_req, res) => {
const guilds = ctx.client.guilds.cache.map(g => ({
id: g.id,
name: g.name,
icon: g.iconURL({ size: 64 }),
voiceChannels: g.channels.cache
.filter(c => c.type === ChannelType.GuildVoice || c.type === ChannelType.GuildStageVoice)
.map(c => ({
id: c.id,
name: c.name,
members: ('members' in c)
? (c as VoiceBasedChannel).members.filter(m => !m.user.bot).size
: 0,
})),
}));
res.json(guilds);
});
// ── Play ──
app.post('/api/radio/play', async (req, res) => {
const { guildId, voiceChannelId, stationId, stationName, placeName, country } = req.body ?? {};
if (!guildId || !voiceChannelId || !stationId) {
return res.status(400).json({ error: 'guildId, voiceChannelId, stationId required' });
}
const result = await startStream(
ctx, guildId, voiceChannelId, stationId,
stationName ?? '', placeName ?? '', country ?? '',
);
res.json(result);
});
// ── Stop ──
app.post('/api/radio/stop', (req, res) => {
const { guildId } = req.body ?? {};
if (!guildId) return res.status(400).json({ error: 'guildId required' });
stopStream(guildId);
res.json({ ok: true });
});
// ── Favoriten lesen ──
app.get('/api/radio/favorites', (_req, res) => {
res.json(getFavorites());
});
// ── Favorit togglen ──
app.post('/api/radio/favorites', (req, res) => {
const { stationId, stationName, placeName, country, placeId } = req.body ?? {};
if (!stationId) return res.status(400).json({ error: 'stationId required' });
const favs = getFavorites();
const idx = favs.findIndex(f => f.stationId === stationId);
if (idx >= 0) {
favs.splice(idx, 1);
} else {
favs.push({
stationId,
stationName: stationName ?? '',
placeName: placeName ?? '',
country: country ?? '',
placeId: placeId ?? '',
});
}
setFavorites(favs);
sseBroadcast({ type: 'radio_favorites', plugin: 'radio', favorites: favs });
res.json({ favorites: favs });
});
// ── Status ──
app.get('/api/radio/status', (_req, res) => {
const status: Record<string, any> = {};
for (const [gId, st] of guildRadioState) {
status[gId] = {
stationId: st.stationId,
stationName: st.stationName,
placeName: st.placeName,
country: st.country,
startedAt: st.startedAt,
channelName: st.channelName,
};
}
res.json(status);
});
},
getSnapshot() {
const playing: Record<string, any> = {};
for (const [gId, st] of guildRadioState) {
playing[gId] = {
stationId: st.stationId,
stationName: st.stationName,
placeName: st.placeName,
country: st.country,
startedAt: st.startedAt,
channelName: st.channelName,
};
}
return {
radio: {
playing,
favorites: getFavorites(),
placesCount: getPlacesCount(),
},
};
},
async destroy() {
for (const guildId of guildRadioState.keys()) {
stopStream(guildId);
}
console.log('[Radio] Destroyed');
},
};
export default radioPlugin;