Radio: Webradio im Sprachkanal, 44 Sender zum Durchschalten
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

/radio holt den Bot in den Sprachkanal und postet eine Tafel: Auswahlmenue
mit allen Sendern, Stopp, und ein Embed mit dem laufenden Titel, das sich
alle 30 Sekunden selbst nachfuehrt.

Der Titel kommt aus dem Icecast-Strom selbst (Icy-MetaData). Bewusst so und
nicht ueber eine Sender-API: das funktioniert bei jedem Strom, den jemand
eintraegt, und nicht nur bei den drei, die ich kenne. Leere Titel-Bloecke
heissen "unveraendert", deshalb werden bis zu drei gelesen.

Die Tonkette braucht keine Opus-Bibliothek: ffmpeg holt das MP3 und gibt
direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete in
fuenf Sekunden. Sonst muesste @discordjs/opus mit ins Image, und das will
gebaut werden. Neu im Image ist nur ffmpeg.

44 RauteMusik-Sender sind ab Werk eingetragen — dieselben, die im LS25 und
ETS als Bordradio laufen. Die Liste ist nicht abgeschrieben, sondern
gemessen: jede Adresse einzeln angefragt, der Name kommt aus icy-name.
Doppelgaenger sind raus (deutschrap-charts und wackenradio liefern denselben
Strom wie deutschrap und metal). Eingetragen wird einmal, solange die Liste
leer ist — wer loescht, behaelt es geloescht.

Ein Auswahlmenue fasst nur 25 Eintraege. Bei 44 Sendern haette ein Deckel
die letzten 19 stillschweigend verschluckt; stattdessen mehrere Menues,
benannt nach ihrem ersten und letzten Sender.

Der Bot geht raus, wenn der Kanal leer ist, und kommt nach einem Neustart in
den Kanal zurueck — sonst beendet jeder Deploy die Musik endgueltig.

Kein YouTube. Genau daran sind Groovy und Rythm gestorben, und was davon
uebrig ist, ist ein Dauerlauf gegen kaputte Extraktoren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:40:15 +02:00
co-authored by Claude Opus 5
parent b7f54185f7
commit 89f6dfcd29
17 changed files with 994 additions and 4 deletions
+417
View File
@@ -0,0 +1,417 @@
// Radio im Sprachkanal.
//
// Bewusst nur Webradio und kein YouTube: genau daran sind Groovy und Rythm
// gestorben, und was davon übrig ist, ist ein Dauerlauf gegen kaputte
// Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine
// Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet.
//
// Die Tonkette kommt ohne Opus-Bibliothek aus: ffmpeg holt das MP3 und gibt
// direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete
// in fünf Sekunden. Sonst müsste @discordjs/opus mit ins Image, und das will
// gebaut werden.
import { spawn } from 'node:child_process';
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags,
StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
} from 'discord.js';
import {
joinVoiceChannel, createAudioPlayer, createAudioResource, StreamType,
AudioPlayerStatus, VoiceConnectionStatus, NoSubscriberBehavior,
entersState, getVoiceConnection,
} from '@discordjs/voice';
import prism from 'prism-media';
import { brandEmbed } from '../embeds.js';
import { brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState } from '../db.js';
import { senderGrundstock } from './radio-sender.js';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
// Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im
// Speicher; was davon einen Neustart überlebt, steht in `radio_state`.
const laufend = new Map(); // guildId → { player, ffmpeg, stationId, channelId, titel }
// ── Sender-Titel ──────────────────────────────────────────────────────────
/**
* Was läuft gerade? Aus dem Icecast-Datenstrom selbst.
*
* Mit `Icy-MetaData: 1` schiebt der Server alle `icy-metaint` Bytes einen
* Titel-Block in den Ton. Bewusst so und nicht über eine Sender-API: das
* funktioniert bei jedem Icecast-Server, den jemand einträgt, und nicht nur
* bei den drei, die ich kenne.
*
* Ein leerer Block heisst „unverändert" — deshalb werden bis zu drei gelesen,
* bevor aufgegeben wird. Das sind im schlimmsten Fall rund 48 KB.
*/
export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
const steuer = new AbortController();
let leser = null;
try {
const r = await fetch(url, {
headers: { 'Icy-MetaData': '1', 'User-Agent': 'd4rkbot-radio/1.0' },
signal: AbortSignal.any([steuer.signal, AbortSignal.timeout(timeoutMs)]),
});
const abstand = Number(r.headers.get('icy-metaint') || 0);
const sender = r.headers.get('icy-name') || null;
if (!abstand || !r.body) { steuer.abort(); return { titel: null, sender }; }
leser = r.body.getReader();
let puffer = Buffer.alloc(0);
let gelesen = 0; // Anfang des nächsten Titel-Blocks
for (let i = 0; i < bloecke; i++) {
// 4080 ist die grösstmögliche Blocklänge (255 × 16)
while (puffer.length < gelesen + abstand + 4081) {
const { value, done } = await leser.read();
if (done) return { titel: null, sender };
puffer = Buffer.concat([puffer, Buffer.from(value)]);
}
const start = gelesen + abstand;
const laenge = puffer[start] * 16;
if (laenge > 0) {
const roh = puffer.slice(start + 1, start + 1 + laenge).toString('utf8');
const titel = roh.match(/StreamTitle='([^']*)'/)?.[1]?.trim();
if (titel) return { titel, sender };
}
gelesen = start + 1 + laenge;
}
return { titel: null, sender };
} catch {
// Sender weg, Zeitüberschreitung, kaputte Kopfzeilen — dann steht im
// Embed eben nur der Sendername. Das ist kein Grund, etwas abzubrechen.
return { titel: null, sender: null };
} finally {
await leser?.cancel().catch(() => {});
steuer.abort();
}
}
// ── Ton ───────────────────────────────────────────────────────────────────
/**
* ffmpeg als Tonquelle.
*
* `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein
* einzelner Netz-Schluckauf würde ihn sonst endgültig beenden.
*/
function tonquelle(url) {
const ff = spawn(FFMPEG, [
'-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5',
'-i', url,
'-analyzeduration', '0', '-loglevel', 'error',
'-vn', // manche Sender legen ein Coverbild bei
'-acodec', 'libopus', '-f', 'ogg',
'-ar', '48000', '-ac', '2', '-b:a', '96k',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
// Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen
ff.stderr.on('data', (d) => {
const text = d.toString().trim();
if (text) console.error('[radio] ffmpeg:', text.slice(0, 200));
});
ff.on('error', (e) => console.error('[radio] ffmpeg startet nicht:', e.message));
return ff;
}
/** Läuft gerade etwas auf diesem Server? */
export const laeuft = (guildId) => laufend.get(guildId) ?? null;
/** Alles abräumen — Verbindung, Spieler, ffmpeg */
export function stoppen(guildId) {
const s = laufend.get(guildId);
if (s) {
s.player.stop(true);
s.ffmpeg?.kill('SIGKILL');
laufend.delete(guildId);
}
getVoiceConnection(guildId)?.destroy();
}
/**
* Sender im Sprachkanal starten (oder umschalten).
*
* Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler
* bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für
* die Zuhörer eine hörbare Pause.
*/
export async function spielen(kanal, station) {
const guildId = kanal.guild.id;
const vorher = laufend.get(guildId);
let verbindung = getVoiceConnection(guildId);
if (!verbindung || verbindung.joinConfig.channelId !== kanal.id) {
verbindung?.destroy();
verbindung = joinVoiceChannel({
channelId: kanal.id,
guildId,
adapterCreator: kanal.guild.voiceAdapterCreator,
selfDeaf: true, // der Bot muss nicht hören, nur senden
});
// Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen
// bleibt die Verbindung dann still stehen, statt sich zu erholen.
verbindung.on(VoiceConnectionStatus.Disconnected, async () => {
try {
await Promise.race([
entersState(verbindung, VoiceConnectionStatus.Signalling, 5000),
entersState(verbindung, VoiceConnectionStatus.Connecting, 5000),
]);
} catch {
stoppen(guildId);
clearRadioState(guildId);
}
});
}
const player = vorher?.player ?? createAudioPlayer({
// Niemand mehr im Kanal? Trotzdem weiterlaufen lassen statt zu pausieren
// — die Aufräumung macht der Leer-Melder, und ein pausierter Stream
// hätte beim Weiterlaufen eine Lücke.
behaviors: { noSubscriber: NoSubscriberBehavior.Play },
});
vorher?.ffmpeg?.kill('SIGKILL');
const ff = tonquelle(station.url);
const resource = createAudioResource(ff.stdout.pipe(new prism.opus.OggDemuxer()), {
inputType: StreamType.Opus,
});
player.play(resource);
verbindung.subscribe(player);
if (!vorher) {
player.on('error', (e) => console.error('[radio] Spieler:', e.message));
// Ein Radiostream endet nie von selbst. Tut er es doch, ist die
// Leitung weg — dann neu aufbauen statt still dazustehen.
player.on(AudioPlayerStatus.Idle, () => {
const jetzt = laufend.get(guildId);
if (!jetzt) return;
console.error('[radio] Strom abgerissen, neuer Versuch');
const s = getStation(jetzt.stationId);
if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000);
});
}
laufend.set(guildId, {
player, ffmpeg: ff, stationId: station.id, channelId: kanal.id,
titel: null, seit: Date.now(),
});
return verbindung;
}
// ── Steuertafel ───────────────────────────────────────────────────────────
// Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je
// Nachricht. Bei 44 Sendern reicht ein Menü also nicht — und stillschweigend
// die letzten 19 wegzulassen wäre die schlechteste aller Lösungen. Deshalb
// mehrere Menüs, benannt nach ihrem Anfangsbuchstaben-Bereich.
const JE_MENU = 25;
const MAX_MENUS = 4; // die fünfte Reihe gehört den Knöpfen
/**
* "Main … Metal" — damit man weiss, in welchem Menü man suchen muss.
*
* Bewusst die Namen und nicht die Anfangsbuchstaben: die Sender stehen in
* der Reihenfolge, die im Panel eingestellt ist, und die ist üblicherweise
* nach Musikrichtung sortiert. Ein „Sender BW" wäre dann schlicht falsch.
*/
function bereich(gruppe) {
const text = `${gruppe[0].name}${gruppe[gruppe.length - 1].name}`;
return text.length <= 100 ? text : `${text.slice(0, 97)}`;
}
export function tafelKomponenten(stationen, aktivId) {
if (stationen.length === 0) {
const leer = new StringSelectMenuBuilder()
.setCustomId('radio_select:0')
.setPlaceholder('Keine Sender eingetragen')
.setDisabled(true)
// Ein Menü ohne Eintrag lehnt Discord ab — Platzhalter muss sein
.setOptions(new StringSelectMenuOptionBuilder().setLabel('—').setValue('leer'));
return [
new ActionRowBuilder().addComponents(leer),
new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()),
];
}
const gruppen = [];
for (let i = 0; i < stationen.length && gruppen.length < MAX_MENUS; i += JE_MENU) {
gruppen.push(stationen.slice(i, i + JE_MENU));
}
const reihen = gruppen.map((gruppe, nr) => {
const menu = new StringSelectMenuBuilder()
.setCustomId(`radio_select:${nr}`)
.setPlaceholder(gruppen.length > 1 ? bereich(gruppe) : 'Sender wählen …')
.addOptions(gruppe.map((s) => {
const o = new StringSelectMenuOptionBuilder()
.setLabel(s.name.slice(0, 100))
.setValue(String(s.id))
.setDefault(s.id === aktivId);
if (s.emoji) o.setEmoji(s.emoji);
return o;
}));
return new ActionRowBuilder().addComponents(menu);
});
reihen.push(new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()));
return reihen;
}
const stoppKnopf = () => new ButtonBuilder()
.setCustomId('radio_stop').setLabel('Stopp').setEmoji('⏹️').setStyle(ButtonStyle.Danger);
const aktualisierenKnopf = () => new ButtonBuilder()
.setCustomId('radio_refresh').setLabel('Aktualisieren').setEmoji('🔄').setStyle(ButtonStyle.Secondary);
/** Wie viele Sender passen überhaupt in eine Tafel? */
export const TAFEL_GRENZE = JE_MENU * MAX_MENUS;
export function tafelEmbed(client, station, titel, kanalId) {
const embed = brandEmbed(client, 'RADIO')
.setTitle('📻 Radio')
.setFooter({ text: brandFooter('RADIO') })
.setTimestamp();
if (!station) {
return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.');
}
embed
.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`)
.addFields({ name: 'Läuft gerade', value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_' });
return embed;
}
/** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */
async function tafelAktualisieren(client, guildId) {
const stand = radioState(guildId);
if (!stand?.panel_message_id) return;
const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null);
const nachricht = await kanal?.messages?.fetch(stand.panel_message_id).catch(() => null);
if (!nachricht) return;
const jetzt = laufend.get(guildId);
const station = jetzt ? getStation(jetzt.stationId) : null;
await nachricht.edit({
embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId)],
components: tafelKomponenten(listStations(), jetzt?.stationId),
}).catch(() => {});
}
// ── Anbindung ─────────────────────────────────────────────────────────────
/** Ist ausser dem Bot noch jemand im Kanal? */
function jemandDa(kanal) {
return kanal?.members?.filter((m) => !m.user.bot).size > 0;
}
export function startRadio(client) {
// Ohne Sender ist das Modul nutzlos, und niemand tippt 44 Adressen ab.
// Passiert genau einmal — wer löscht, behält es gelöscht.
senderGrundstock();
// Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert
// selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte.
setInterval(async () => {
if (!moduleEnabled('radio')) return;
// Alles einpacken: eine unbehandelte Rejection in einem Timer beendet
// seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig.
try {
for (const [guildId, s] of laufend) {
const station = getStation(s.stationId);
if (!station) continue;
const { titel } = await titelHolen(station.url);
if (titel && titel !== s.titel) {
s.titel = titel;
await tafelAktualisieren(client, guildId);
}
}
} catch (error) {
console.error('[radio] Titel-Abgleich:', error.message);
}
}, 30_000).unref();
// Leert sich der Kanal, geht der Bot. Sonst sendet er stundenlang gegen
// eine leere Wand und hält eine ffmpeg-Instanz am Leben.
client.on(Events.VoiceStateUpdate, async (vorher) => {
try {
const s = laufend.get(vorher.guild?.id);
if (!s || vorher.channelId !== s.channelId) return;
const kanal = await client.channels.fetch(s.channelId).catch(() => null);
if (jemandDa(kanal)) return;
stoppen(vorher.guild.id);
clearRadioState(vorher.guild.id);
await tafelAktualisieren(client, vorher.guild.id);
} catch (error) {
console.error('[radio] Sprachkanal-Wechsel:', error.message);
}
});
// Nach einem Neustart zurück in den Kanal — ein Deploy soll die Musik
// nicht endgültig beenden, sondern nur kurz unterbrechen.
client.once(Events.ClientReady, async () => {
if (!moduleEnabled('radio')) return;
for (const stand of radioStates()) {
const station = stand.station_id ? getStation(stand.station_id) : null;
const kanal = station ? await client.channels.fetch(stand.channel_id).catch(() => null) : null;
if (!kanal?.isVoiceBased() || !jemandDa(kanal)) {
// Niemand mehr da, oder der Sender wurde gelöscht — dann gilt
// der Merker als verbraucht statt in einen leeren Kanal zu gehen.
clearRadioState(stand.guild_id);
continue;
}
try {
await spielen(kanal, station);
await tafelAktualisieren(client, stand.guild_id);
console.log(`[radio] wieder in ${kanal.name}: ${station.name}`);
} catch (e) {
console.error('[radio] Rückkehr fehlgeschlagen:', e.message);
clearRadioState(stand.guild_id);
}
}
});
}
/** Knöpfe und Auswahlmenü der Steuertafel */
export async function handleRadioInteraction(interaction) {
const { customId } = interaction;
const istAuswahl = customId.startsWith('radio_select:');
if (!istAuswahl && !['radio_stop', 'radio_refresh'].includes(customId)) return false;
const guildId = interaction.guild?.id;
const eigenerKanal = interaction.member?.voice?.channel;
if (customId === 'radio_stop') {
stoppen(guildId);
clearRadioState(guildId);
} else if (istAuswahl) {
const station = getStation(Number(interaction.values[0]));
// Wer nicht im Sprachkanal sitzt, soll den anderen nicht den Sender
// umstellen können — dafür reicht ein Hinweis statt einer Sperre.
const ziel = eigenerKanal ?? (laufend.get(guildId)
? await interaction.client.channels.fetch(laufend.get(guildId).channelId).catch(() => null)
: null);
if (!station || !ziel) {
await interaction.reply({
content: '❌ Geh erst in einen Sprachkanal, dann wähle einen Sender.',
flags: MessageFlags.Ephemeral,
});
return true;
}
await spielen(ziel, station);
setRadioState({
guildId,
channelId: ziel.id,
stationId: station.id,
panelChannelId: interaction.message.channelId,
panelMessageId: interaction.message.id,
});
// Den Titel gleich mitholen, sonst steht 30 Sekunden „wird abgefragt"
const { titel } = await titelHolen(station.url);
const s = laufend.get(guildId);
if (s) s.titel = titel;
}
const jetzt = laufend.get(guildId);
const station = jetzt ? getStation(jetzt.stationId) : null;
await interaction.update({
embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId)],
components: tafelKomponenten(listStations(), jetzt?.stationId),
}).catch(() => {});
return true;
}