Fix: Tile proxy for black globe + radio voicestats modal

- Globe was black because Radio Garden CDN (rg-tiles.b-cdn.net) returns
  403 without Referer: radio.garden header. Added server-side tile proxy
  /api/radio/tile/:z/:x/:y with in-memory cache (max 500 tiles).
- Added radio_voicestats SSE broadcast (every 5s) with voice ping,
  gateway ping, status, channel name, and connected-since timestamp.
- Added clickable "Verbunden" connection indicator in RadioTab bottom
  bar with live ping display and connection details modal (matching
  soundboard's existing modal pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 11:19:19 +01:00
parent b9a9347356
commit 63afc55836
4 changed files with 286 additions and 5 deletions

View file

@ -14,6 +14,15 @@ import {
resolveStreamUrl, getPlacesCount,
} from './api.js';
// ── Tile proxy cache (in-memory) ──
const TILE_CDN = 'https://rg-tiles.b-cdn.net';
const tileCache = new Map<string, { data: Buffer; contentType: string }>();
const TILE_CACHE_MAX = 500; // max cached tiles (~15-25 MB)
// ── Voice connection tracking ──
const connectedSince = new Map<string, string>();
let voiceStatsInterval: ReturnType<typeof setInterval> | undefined;
// ── Types ──
interface GuildRadioState {
stationId: string;
@ -68,6 +77,7 @@ function stopStream(guildId: string): void {
try { state.player.stop(true); } catch {}
try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {}
guildRadioState.delete(guildId);
connectedSince.delete(guildId);
broadcastState(guildId);
console.log(`[Radio] Stopped stream in guild ${guildId}`);
}
@ -195,6 +205,25 @@ const radioPlugin: Plugin = {
async onReady(ctx) {
console.log(`[Radio] Discord ready ${ctx.client.guilds.cache.size} Guild(s)`);
// Voice stats broadcast every 5s
voiceStatsInterval = setInterval(() => {
if (guildRadioState.size === 0) return;
for (const [gId, st] of guildRadioState) {
const conn = getVoiceConnection(gId, 'radio');
const status = conn?.state?.status ?? 'unknown';
if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString());
const ch = ctx.client.channels.cache.get(st.channelId);
sseBroadcast({
type: 'radio_voicestats', plugin: 'radio', guildId: gId,
voicePing: (conn?.ping as any)?.ws ?? null,
gatewayPing: ctx.client.ws.ping,
status,
channelName: ch && 'name' in ch ? (ch as any).name : null,
connectedSince: connectedSince.get(gId) ?? null,
});
}
}, 5_000);
},
registerRoutes(app: express.Application, ctx: PluginContext) {
@ -231,6 +260,44 @@ const radioPlugin: Plugin = {
}
});
// ── Tile Proxy (Radio Garden CDN requires Referer) ──
app.get('/api/radio/tile/:z/:x/:y', async (req, res) => {
const { z, x, y } = req.params;
const key = `${z}/${x}/${y}`;
// Serve from cache
const cached = tileCache.get(key);
if (cached) {
res.set('Content-Type', cached.contentType);
res.set('Cache-Control', 'public, max-age=86400');
res.send(cached.data);
return;
}
try {
const resp = await fetch(`${TILE_CDN}/${key}.jpg`, {
headers: { Referer: 'https://radio.garden/' },
});
if (!resp.ok) { res.status(resp.status).end(); return; }
const buf = Buffer.from(await resp.arrayBuffer());
const ct = resp.headers.get('content-type') ?? 'image/jpeg';
// Cache (evict oldest if full)
if (tileCache.size >= TILE_CACHE_MAX) {
const first = tileCache.keys().next().value;
if (first) tileCache.delete(first);
}
tileCache.set(key, { data: buf, contentType: ct });
res.set('Content-Type', ct);
res.set('Cache-Control', 'public, max-age=86400');
res.send(buf);
} catch {
res.status(502).end();
}
});
// ── Verfügbare Guilds + Voice Channels ──
app.get('/api/radio/guilds', (_req, res) => {
const guilds = ctx.client.guilds.cache.map(g => ({
@ -356,6 +423,7 @@ const radioPlugin: Plugin = {
},
async destroy() {
if (voiceStatsInterval) clearInterval(voiceStatsInterval);
for (const guildId of guildRadioState.keys()) {
stopStream(guildId);
}