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;
|
||||
}
|
||||
329
server/src/plugins/radio/index.ts
Normal file
329
server/src/plugins/radio/index.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue