// 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: ffmpeg holt den Sender und gibt rohes PCM aus, die // Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief // Opus unverändert durch (ffmpeg → Ogg/Opus → Discord), das war eine Stufe // sparsamer — aber an unveränderten Opus-Paketen lässt sich die Lautstärke // nicht drehen, dafür muss man an die Abtastwerte. // // Der alte Einwand gegen eine Opus-Bibliothek galt `@discordjs/opus`: das // hat für Node 22/glibc 2.36 kein fertiges Paket und will gebaut werden // (nachgemessen am 14.08.2026, `npm i` bricht in node:22-slim ab). // `opusscript` ist reines JavaScript, installiert in 740 ms ohne Compiler // und kostet gemessene 0,3 % eines Kerns fürs Kodieren eines Dauerstroms. // Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja // schon Abtastwerte. 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 { brandEmbed } from '../embeds.js'; import { brandFooter, radioLautstaerke, LAUT_MIN, LAUT_MAX, LAUT_SCHRITT, } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting, } from '../db.js'; import { senderGrundstock } from './radio-sender.js'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; /** * Ist ffmpeg überhaupt da? * * Ohne ffmpeg setzt sich der Bot in den Sprachkanal und schweigt — nichts * stürzt ab, nichts fehlt sichtbar, es kommt nur kein Ton. Genau diese Sorte * Fehler soll niemand raten müssen, also wird sie einmal beim Start geprüft * und danach im Discord angesagt statt nur ins Log geschrieben. */ let ffmpegDa = null; // null = noch nicht geprüft export function ffmpegVorhanden() { return ffmpegDa; } export function ffmpegPruefen() { return new Promise((fertig) => { const p = spawn(FFMPEG, ['-version'], { stdio: 'ignore' }); p.on('error', () => { ffmpegDa = false; fertig(false); }); p.on('close', (code) => { ffmpegDa = code === 0; fertig(ffmpegDa); }); }); } // 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, [ // Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher // dahinter und wurde dort als Ausgabe-Option gelesen, also ignoriert. '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', '-analyzeduration', '0', '-loglevel', 'error', '-i', url, '-vn', // manche Sender legen ein Coverbild bei // Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke // drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet — // 192 kB/s durch eine Rohrleitung im selben Prozessbaum, das fällt // nicht ins Gewicht. '-f', 's16le', '-acodec', 'pcm_s16le', '-ar', '48000', '-ac', '2', '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; } /** * Der Adapter zwischen Discord-Verbindung und Sprachbibliothek — mit Protokoll. * * Ein Beitritt zum Sprachkanal ist ein Dreischritt, und `signalling` heisst * nur „einer davon fehlt", ohne zu sagen welcher: * * 1. Der Bot bittet Discord über die bestehende Verbindung um den Beitritt. * 2. Discord antwortet mit dem eigenen Sprachstatus (darin die Sitzungs-ID). * 3. Discord nennt den Sprachserver (Adresse und Zugangswort). * * Fehlt 1, ist die Verbindung zu Discord nicht bereit. Fehlt 2, kommen die * Sprachstatus-Meldungen nicht an — dann fehlt der Intent oder der eigene * Mitgliedseintrag. Fehlt 3, hat Discord den Beitritt abgelehnt, praktisch * immer wegen fehlender Rechte im Kanal. * * Ohne diese drei Häkchen bleibt nur Raten, und Raten hat hier schon zweimal * nicht gereicht. */ function beobachteterAdapter(guild, schritte) { const echt = guild.voiceAdapterCreator; return (methods) => { const gepatcht = { ...methods, onVoiceStateUpdate: (daten) => { schritte.eigenerStatus = true; return methods.onVoiceStateUpdate(daten); }, onVoiceServerUpdate: (daten) => { schritte.sprachserver = true; // Ein leerer Endpunkt ist kein Versehen: Discord schickt das, // wenn es den Sprachserver gerade neu zuteilt. Die Bibliothek // wartet dann auf die zweite Meldung — kommt die nie, bleibt // sie fuer immer bei "signalling" stehen, ohne Fehler. // // Gesammelt statt ueberschrieben: bei einem Rueckfall nach // "signalling" nennt Discord den Server erneut. Vorher hat die // zweite Meldung den Endpunkt verdeckt, an dem es wirklich // gescheitert ist. schritte.endpunkte.push(daten?.endpoint ?? null); return methods.onVoiceServerUpdate(daten); }, }; const adapter = echt(gepatcht); return { ...adapter, sendPayload: (daten) => { const ok = adapter.sendPayload(daten); schritte.angefragt = ok; return ok; }, }; }; } /** * Die Schritte des Netzteils der Bibliothek, in ihrer eigenen Zählung. * * Nachgesehen in `@discordjs/voice` 0.18 (`NetworkingStatusCode`), nicht * geraten. Genau hier liegt der Unterschied, den `connecting → signalling` * verschweigt: die Verbindung fällt bei *jedem* Websocket-Schluss ausser 4014 * stumm dorthin zurück, egal ob der UDP-Teil je begonnen hat. */ const NETZSCHRITT = ['Websocket auf', 'Anmeldung', 'UDP-Handschlag', 'Protokollwahl', 'bereit', 'Wiederaufnahme', 'zu']; const ANMELDUNG = 1; const UDP_HANDSCHLAG = 2; const PROTOKOLLWAHL = 3; const ZU = 6; // Damit dasselbe Netzteil nicht zweimal beobachtet wird. Bei einem Rückfall // baut die Bibliothek ein neues — das soll durchaus wieder mitgeschrieben // werden, deshalb sitzt die Markierung am Objekt und nicht an der Verbindung. const BEOBACHTET = Symbol('radio-netzteil-beobachtet'); /** Der Netz-Mitschnitt als lesbare Zeile */ export function netzWeg(netz = []) { return netz .map((e) => (e.schliessung !== undefined ? `Websocket zu (${e.schliessung})` : NETZSCHRITT[e.schritt] ?? `Schritt ${e.schritt}`)) .join(' → '); } /** Aus den Häkchen und dem Mitschnitt wird ein Satz, der sagt, was zu tun ist */ export function beitrittsDiagnose(schritte) { if (schritte.angefragt === false) { return 'Die Verbindung zu Discord war nicht bereit — der Beitritt ging gar nicht erst raus.'; } if (!schritte.eigenerStatus && !schritte.sprachserver) { return 'Discord hat auf den Beitritt nicht geantwortet. Das ist fast immer eine ' + 'fehlende Berechtigung **Verbinden** im Sprachkanal selbst.'; } if (!schritte.sprachserver) { return 'Discord kennt den Beitritt, nennt aber keinen Sprachserver — ' + 'darf der Bot in diesem Kanal sprechen?'; } if (!schritte.eigenerStatus) { return 'Der Sprachserver steht, aber der eigene Sprachstatus kam nie an — ' + 'dem Bot fehlt der Intent **GuildVoiceStates**.'; } // Ab hier ist alles angekommen. Jetzt entscheidet der Weg, den die // Verbindung genommen hat. const endpunkte = schritte.endpunkte ?? []; const endpunkt = endpunkte.find(Boolean) ?? null; if (endpunkt === null) { return 'Discord hat den Beitritt bestätigt, aber keinen Sprachserver zugeteilt. ' + 'Das passiert bei einem Regionswechsel — im Kanal unter *Region überschreiben* ' + 'einmal eine feste Region wählen.'; } const kamAnsNetz = (schritte.weg ?? []).includes('connecting'); if (!kamAnsNetz) { return `Der Sprachserver (${endpunkt}) wurde genannt, aber die Verbindung ` + 'kam nie bis zum Netzwerk-Schritt.'; } // Sie war beim Netzwerk-Schritt und ist zurückgefallen. *Wo* sie dort // stehen blieb, ist die eigentliche Auskunft — und die stand hier vorher // nicht, weshalb an dieser Stelle „ausgehendes UDP dicht" behauptet wurde. // Gemessen am 14.08.2026: UDP kam auf hohen Ports sehr wohl raus (STUN // 19302 antwortet, TCP 2096 zum Sprachserver offen). Die Behauptung war // also falsch. Jetzt entscheidet der Mitschnitt statt einer Vermutung. const netz = schritte.netz ?? []; const schluss = netz.filter((e) => e.schliessung !== undefined).at(-1)?.schliessung; // Der letzte *bedeutsame* Schritt. „zu" steht immer am Ende, weil das // Netzteil sich erst schliesst und dann den Code meldet — wer den nimmt, // kommt nie bei einer Ursache an. Genau das ist am 14.08.2026 passiert: // der Mitschnitt hatte die Antwort, die Auswertung hat sie verworfen. const letzterSchritt = netz .filter((e) => e.schritt !== undefined && e.schritt !== ZU) .at(-1)?.schritt; const zu = schluss === undefined ? 'ohne Schliesscode' : `Schliesscode ${schluss}`; // 4017 ist eindeutig, unabhaengig davon, wie weit es kam: Discord verlangt // seit dem 01.03.2026 DAVE (Ende-zu-Ende-Verschluesselung) in allen // Nicht-Stage-Sprachkanaelen und weist aeltere Gegenstellen sofort ab. if (schluss === 4017) { return `Discord hat die Verbindung zu **${endpunkt}** abgewiesen (4017): Sprachkanäle ` + 'verlangen seit dem 01.03.2026 **DAVE**, die Ende-zu-Ende-Verschlüsselung. ' + 'Dafür braucht es `@discordjs/voice` ab 0.19 samt `@snazzah/davey` — ältere ' + 'Fassungen melden `max_dave_protocol_version: 0` und werden abgewiesen. ' + 'Stage-Kanäle sind ausgenommen.'; } if (letzterSchritt === UDP_HANDSCHLAG) { // `performIPDiscovery` hat in 0.18 keine Zeitschranke und die // UDP-Keepalives werden nicht mehr ausgewertet — bleibt die Antwort // aus, hängt der Schritt, bis Discord den Websocket zumacht. return `Der Sprachserver **${endpunkt}** wurde erreicht, aber auf den UDP-Handschlag ` + `kam keine Antwort (${zu}). Discord-Sprache antwortet auf hohen Ports ` + '(50000–65535) — ausgehendes UDP dorthin freigeben. ' + 'Gegenprobe: `node tools/udp-pruefen.mjs`.'; } if (letzterSchritt === ANMELDUNG || letzterSchritt === 0) { return `Der Websocket zu **${endpunkt}** ging wieder zu, bevor der UDP-Teil überhaupt ` + `begann (${zu}). Das ist kein Netzproblem: 4006 heisst ungültige Sitzung, ` + '4009 Zeitüberschreitung, 4011 unbekannter Server.'; } if (letzterSchritt === PROTOKOLLWAHL) { return `Der UDP-Handschlag mit **${endpunkt}** ging durch, aber Discord schickte keine ` + `Sitzungsbeschreibung (${zu}). Das deutet auf den Verschlüsselungsmodus — ` + '`generateDependencyReport()` zeigt, welche Bibliothek geladen ist.'; } // Kein Mitschnitt vom Netzteil: dann ist die Ursache offen, und genau das // wird gesagt. Eine Prüfung, die nichts sehen kann, meldet nicht "UDP". return `Die Verbindung zu **${endpunkt}** kam bis zum Netzwerk-Schritt und fiel zurück, ` + `aber wo genau, ist nicht erfasst (${netzWeg(netz) || 'kein Mitschnitt'}). ` + 'Ursache offen.'; } // ── Kanal-Status ────────────────────────────────────────────────────────── /** * Was gerade läuft, in die Statuszeile des Sprachkanals schreiben. * * Der Endpunkt ist UNDOKUMENTIERT. Discord hat ihn nie in die API-Doku * aufgenommen — die Pull Requests dazu liegen seit 2023 offen — und * discord.js hat die Umsetzung ausdrücklich abgelehnt („closed as not * planned"). Deshalb ein roher Aufruf, und deshalb ist damit zu rechnen, * dass er eines Tages ohne Ankündigung verschwindet. * * Daraus folgt die Bauweise: der Status ist Beiwerk. Er darf das Radio unter * keinen Umständen mitreissen, und wenn er nicht geht, sagt er einmal warum * und schweigt dann. Ein Fehler alle 30 Sekunden im Log ist keine Meldung * mehr, sondern Rauschen. * * Das nötige Recht heisst „Kanalstatus festlegen" und sitzt auf Bit 48. * discord.js kennt es nicht einmal als Konstante, deshalb steht die Zahl hier. */ export const RECHT_KANALSTATUS = 1n << 48n; // 281474976710656 let statusAus = null; // null = versuchen; sonst der Grund fürs Aufgeben async function kanalStatusSetzen(client, kanalId, text) { if (statusAus || !client || !kanalId) return; try { // 500 Zeichen ist die Grenze; Liedtitel sind kürzer, aber ein Sender // mit kaputten Metadaten soll den Aufruf nicht zum Absturz bringen. await client.rest.put(`/channels/${kanalId}/voice-status`, { body: { status: String(text).slice(0, 500) }, }); } catch (e) { statusAus = e?.status === 403 ? 'dem Bot fehlt das Recht „Kanalstatus festlegen" im Sprachkanal' : `${e?.status ?? '?'} — ${e?.message ?? 'unbekannt'}`; console.error(`[radio] Kanal-Status abgeschaltet: ${statusAus}. ` + 'Das Radio läuft weiter, nur die Zeile am Kanal bleibt leer. ' + 'Nächster Sender-Start versucht es erneut.'); } } /** Läuft gerade etwas auf diesem Server? */ export const laeuft = (guildId) => laufend.get(guildId) ?? null; /** * Titel merken und an den Kanal schreiben. * * An einer Stelle, weil der Titel aus drei Richtungen kommt: beim Start über * den Befehl, beim Umschalten über die Tafel und laufend über den Abgleich. * Vorher wäre jede davon eine eigene Gelegenheit gewesen, den Status zu * vergessen. */ export function titelMerken(client, guildId, titel) { const s = laufend.get(guildId); if (!s || !titel || titel === s.titel) return false; s.titel = titel; kanalStatusSetzen(client, s.channelId, `🎵 ${titel}`); return true; } /** * Lautstärke ändern — sofort hörbar, ohne den Strom anzufassen. * * Genau dafür läuft die Kette über PCM: `setVolume` greift in den laufenden * Datenstrom. Der Weg über ffmpeg (`-af volume=…`) hätte einen Neustart des * Prozesses gebraucht, also bei jedem Klick eine hörbare Lücke. * * Gibt den tatsächlich gesetzten Wert zurück, nicht den gewünschten — wer am * Anschlag dreht, soll das am Rückgabewert merken können. */ export function lautstaerkeSetzen(guildId, wunsch) { const wert = Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(wunsch))); setSetting('radio_lautstaerke', String(wert)); // Läuft gerade etwas, greift es sofort; läuft nichts, gilt es beim // nächsten Start — deshalb steht der Wert in den Einstellungen und nicht // nur am laufenden Strom. laufend.get(guildId)?.resource?.volume?.setVolume(wert / 100); return wert; } /** 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); // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es // liefe ein Lied, der längst vorbei ist. Leerer Text räumt sie ab. kanalStatusSetzen(s.client, s.channelId, ''); } 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); // Wird nur gefüllt, wenn wirklich neu verbunden wird const schritte = { angefragt: null, eigenerStatus: false, sprachserver: false, endpunkte: [], weg: [], netz: [], }; let verbindung = getVoiceConnection(guildId); if (!verbindung || verbindung.joinConfig.channelId !== kanal.id) { verbindung?.destroy(); verbindung = joinVoiceChannel({ channelId: kanal.id, guildId, adapterCreator: beobachteterAdapter(kanal.guild, schritte), selfDeaf: true, // der Bot muss nicht hören, nur senden }); // Der Weg, den die Verbindung nimmt, unterscheidet zwei Sackgassen, // die von aussen gleich aussehen: nie über „signalling" hinaus (dann // fehlt der Sprachserver) oder immer wieder dorthin zurück (dann // scheitert der UDP-Austausch und die Bibliothek fängt von vorn an). verbindung.on('stateChange', (_, neuerStand) => { if (schritte.weg.at(-1) !== neuerStand.status) schritte.weg.push(neuerStand.status); }); // Der Weg allein reicht nicht: er sagt „connecting → signalling", aber // nicht, ob der UDP-Teil je begonnen hat. Das weiss nur das Netzteil // der Bibliothek, und das meldet seinen Fortschritt ausschliesslich // über `debug` — was Token und Sitzungsschlüssel mitdruckt. Deshalb // wird hier nur die Schrittnummer mitgeschrieben, nie die Nachricht. verbindung.on(VoiceConnectionStatus.Connecting, () => { const netzteil = verbindung.state.networking; if (!netzteil || netzteil[BEOBACHTET]) return; netzteil[BEOBACHTET] = true; schritte.netz.push({ schritt: netzteil.state.code }); netzteil.on('stateChange', (_, neu) => { // Gegen den letzten *Schritt* vergleichen, nicht gegen den // letzten Eintrag: sonst steht "zu" zweimal da, einmal vor und // einmal nach dem Schliesscode. const vorher = schritte.netz.filter((e) => e.schritt !== undefined).at(-1)?.schritt; if (vorher !== neu.code) schritte.netz.push({ schritt: neu.code }); }); // Den Schliesscode wirft die Bibliothek weg: ausser bei 4014 geht // sie damit stumm zurück nach „signalling". Hier bleibt er stehen. netzteil.on('close', (code) => schritte.netz.push({ schliessung: code })); }); // Ohne diesen Hörer beendet ein Fehler aus dem Netzteil den ganzen // Prozess: `VoiceConnection` reicht ihn als 'error' weiter, und ein // EventEmitter ohne 'error'-Hörer wirft. Genau dieser Weg ist im // Fehlerfall offen — die UDP-Erkennung meldet sich darüber. verbindung.on('error', (e) => console.error('[radio] Verbindung:', e.message)); // Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen // bleibt die Verbindung dann still stehen, statt sich zu erholen. // // 4014 heisst „rausgeworfen oder verschoben" — da hilft kein // Wiederverbinden, das wäre nur ein Ringen mit dem Moderator. verbindung.on(VoiceConnectionStatus.Disconnected, async (_, stand) => { if (stand.closeCode === 4014) { stoppen(guildId); clearRadioState(guildId); return; } try { await Promise.race([ entersState(verbindung, VoiceConnectionStatus.Signalling, 5000), entersState(verbindung, VoiceConnectionStatus.Connecting, 5000), ]); } catch { stoppen(guildId); clearRadioState(guildId); } }); } // Auf die fertige Verbindung warten, bevor gesendet wird. // // `joinVoiceChannel` kommt sofort zurueck — der Bot steht dann schon im // Kanal, aber der UDP-Weg wird erst ausgehandelt. Wer sofort losspielt, // schickt gegen eine Verbindung, die noch nicht steht: der Bot sitzt // sichtbar drin und es kommt kein Ton. Genau so sah der Fehler aus. try { await entersState(verbindung, VoiceConnectionStatus.Ready, 20_000); } catch { // Wo genau sie hängen blieb, entscheidet die Ursache — deshalb steht // der Zustand in der Meldung und nicht nur „ging nicht": // // signalling → Discord hat den Sprachserver nie genannt. Das ist ein // Rechte- oder Gateway-Problem, kein Netzproblem. // connecting → der Sprachserver ist bekannt, aber der UDP-Austausch // mit ihm scheitert. Praktisch immer: ausgehendes UDP // ist dicht (Container, Firewall, Hoster). const steckt = verbindung.state.status; verbindung.destroy(); laufend.delete(guildId); const grund = beitrittsDiagnose(schritte); console.error(`[radio] Beitritt steckt bei "${steckt}" — ` + `angefragt: ${schritte.angefragt}, eigener Status: ${schritte.eigenerStatus}, ` + `Sprachserver: ${schritte.sprachserver}, ` + `Endpunkt: ${schritte.endpunkte.map((e) => e ?? '(leer)').join(', ') || '—'}, ` + `Weg: ${(schritte.weg ?? []).join(' → ') || '(keiner)'}, ` + `Netzteil: ${netzWeg(schritte.netz) || '(nie erreicht)'}`); throw new Error(`Sprachverbindung kam nicht zustande. ${grund}`); } 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, { inputType: StreamType.Raw, inlineVolume: true, // ohne das gibt es kein `resource.volume` }); // Gleich beim ersten Rahmen, nicht erst wenn jemand einen Knopf drückt: // die Vorgabe ist leise, damit niemand erschrickt. resource.volume?.setVolume(radioLautstaerke() / 100); player.play(resource); verbindung.subscribe(player); // Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts // liefert, bleibt der Spieler in `buffering` haengen — ohne Fehler, ohne // Ton. Das wird gemerkt, damit die Tafel es sagen kann. const beobachten = new Promise((fertig) => { const timer = setTimeout(() => fertig(false), 8000); const beiWechsel = (_, neuerStand) => { if (neuerStand.status !== AudioPlayerStatus.Playing) return; clearTimeout(timer); player.off('stateChange', beiWechsel); fertig(true); }; player.on('stateChange', beiWechsel); }); 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); }); } const eintrag = { player, ffmpeg: ff, resource, stationId: station.id, channelId: kanal.id, titel: null, seit: Date.now(), tonLaeuft: null, client: kanal.client, }; laufend.set(guildId, eintrag); // Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen // erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort — // besser als eine leere Zeile, während schon Musik läuft. statusAus = null; kanalStatusSetzen(kanal.client, kanal.id, `📻 ${station.name}`); beobachten.then((ok) => { eintrag.tonLaeuft = ok; if (!ok) console.error(`[radio] kein Ton von ${station.name} — ffmpeg da? ${ffmpegDa}`); }); 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 B–W" 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), knopfReihe(), ]; } 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(knopfReihe()); 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); const leiserKnopf = (amAnschlag) => new ButtonBuilder() .setCustomId('radio_leiser').setLabel('Leiser').setEmoji('🔉') .setStyle(ButtonStyle.Secondary).setDisabled(amAnschlag); const lauterKnopf = (amAnschlag) => new ButtonBuilder() .setCustomId('radio_lauter').setLabel('Lauter').setEmoji('🔊') .setStyle(ButtonStyle.Secondary).setDisabled(amAnschlag); /** * Die Knopfreihe — immer die fünfte und letzte, deshalb höchstens fünf Knöpfe. * * Am Anschlag wird der Knopf ausgegraut statt stumm nichts zu tun: ein Knopf, * der sich drücken lässt und nichts bewirkt, sieht aus wie ein kaputter Knopf. */ function knopfReihe() { const laut = radioLautstaerke(); return new ActionRowBuilder().addComponents( leiserKnopf(laut <= LAUT_MIN), lauterKnopf(laut >= LAUT_MAX), stoppKnopf(), aktualisierenKnopf(), ); } /** 30 % ist leise, 100 % ist voll aufgedreht — das Zeichen sagt es vorab */ const lautText = (p) => `${p <= 30 ? '🔈' : p <= 100 ? '🔉' : '🔊'} **${p} %**`; /** Wie viele Sender passen überhaupt in eine Tafel? */ export const TAFEL_GRENZE = JE_MENU * MAX_MENUS; export function tafelEmbed(client, station, titel, kanalId, stand = {}) { const embed = brandEmbed(client, 'RADIO') .setTitle('📻 Radio') .setFooter({ text: brandFooter('RADIO') }) .setTimestamp(); const laut = radioLautstaerke(); if (!station) { return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n' + `Startet mit ${lautText(laut)} — vorab einstellbar.`); } embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`); // Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier, // woran es liegt — statt dass jemand im leeren Kanal sitzt und wartet. if (stand.tonLaeuft === false) { embed.addFields({ name: '⚠️ Kein Ton', value: stand.ffmpeg === false ? 'Auf dem Server fehlt **ffmpeg**. Ohne das kann der Bot keinen Ton umwandeln.' : 'Der Sender liefert nichts. Adresse im Panel mit **Reinhören** prüfen.', }); return embed; } embed.addFields( { name: 'Läuft gerade', value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_', }, { name: 'Lautstärke', value: lautText(laut), inline: true }, ); 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, { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], 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(); ffmpegPruefen().then((da) => { if (da) return; console.error('[radio] ffmpeg fehlt — der Bot kann keinen Ton umwandeln. ' + 'Im Container nachinstallieren oder das Image neu bauen.'); }); // 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); // Nur bei echter Änderung neu zeichnen — und nur dann den // Kanal-Status anfassen. Discord begrenzt beides. if (titelMerken(client, guildId, 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:'); const knoepfe = ['radio_stop', 'radio_refresh', 'radio_leiser', 'radio_lauter']; if (!istAuswahl && !knoepfe.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 (customId === 'radio_leiser' || customId === 'radio_lauter') { // Die Rückmeldung ist die neu gezeichnete Tafel am Ende dieser // Funktion: neuer Prozentwert, und am Anschlag graut der Knopf aus. const schritt = customId === 'radio_lauter' ? LAUT_SCHRITT : -LAUT_SCHRITT; lautstaerkeSetzen(guildId, radioLautstaerke() + schritt); } 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); titelMerken(interaction.client, guildId, 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, { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], components: tafelKomponenten(listStations(), jetzt?.stationId), }).catch(() => {}); return true; }