Der Anlass ist eine Beobachtung aus dem Betrieb: der Bot kam mit voller
Aussteuerung in den Kanal, und wer schon drin sass, ist erschrocken. Dass
sich jeder den Bot fuer sich leiser drehen kann, hilft erst *nachdem* es zu
laut war. Also liegt die Vorgabe jetzt bei 50 %, und lauter machen kostet
zwei Knopfdruecke.
Dafuer musste die Tonkette umgebaut werden. Bisher lief Opus unveraendert
durch (ffmpeg -> Ogg/Opus -> Discord). Das war eine Stufe sparsamer, aber an
fertigen Opus-Paketen laesst sich die Lautstaerke nicht drehen -- dafuer muss
man an die Abtastwerte. Jetzt: ffmpeg gibt PCM aus, die Sprachbibliothek
regelt und kodiert.
Der alte Einwand im Dateikopf ("sonst muesste @discordjs/opus mit ins Image,
und das will gebaut werden") stimmt weiterhin -- fuer @discordjs/opus.
Nachgemessen am 14.08.2026 in node:22-slim:
@discordjs/opus npm i bricht ab, kein Prebuild fuer node-v127/glibc-2.36,
node-gyp will bauen, im Image fehlen die Werkzeuge
opusscript reines JavaScript, installiert in 740 ms, kein Compiler
0,3 % eines Kerns fuers Kodieren eines Dauerstroms
Der Umweg ueber Opus-Dekodieren entfaellt dabei ganz, ffmpeg liefert ja schon
Abtastwerte. Deshalb 0,3 % und nicht 2,9 %, was Dekodieren und Kodieren
zusammen gekostet haetten.
Die Kette ist mit echtem ffmpeg gegen einen Sinuston geprueft: 200
Opus-Pakete fuer 4,0 s Ton, kein Versatz, und die Lautstaerke laesst sich
mitten im Strom aendern. Genau darum geht der Weg ueber PCM und nicht ueber
ffmpegs volume-Filter -- der haette bei jedem Klick einen Neustart des
Prozesses gebraucht, also eine hoerbare Luecke.
Am Panel zwei Knoepfe in der bestehenden Reihe, Schritte von 10, Bereich 10
bis 200. Am Anschlag graut der Knopf aus, statt sich druecken zu lassen und
nichts zu tun. Die Rueckmeldung ist die neu gezeichnete Tafel mit dem neuen
Prozentwert.
Der Wert steht in den Einstellungen, nicht in radio_state: der Merker dort
wird beim Stoppen geloescht, die Lautstaerke soll das ueberleben.
Dazu der Kanal-Status: was laeuft, steht jetzt auch in der Statuszeile des
Sprachkanals und wird mit dem Titel nachgefuehrt.
ACHTUNG, das ist ein UNDOKUMENTIERTER Endpunkt.
PUT /channels/{id}/voice-status -- Discord hat ihn nie in die API-Doku
aufgenommen, die Pull Requests dazu liegen seit 2023 offen, und discord.js
hat die Umsetzung als "not planned" geschlossen. Roher Aufruf ueber
client.rest also, und er kann ohne Ankuendigung verschwinden.
Deshalb ist er als Beiwerk gebaut: faellt er aus, sagt er einmal warum und
schweigt dann -- ein Fehler alle 30 Sekunden waere kein Hinweis mehr,
sondern Rauschen. Das Radio laeuft in jedem Fall weiter. Der naechste
Sender-Start versucht es erneut, falls das Recht inzwischen erteilt wurde.
Das Recht heisst "Kanalstatus festlegen" und sitzt auf Bit 48. discord.js
kennt es nicht einmal als Konstante, deshalb steht die Zahl im Quelltext.
Ungeprueft, weil dafuer ein echter Sprachkanal noetig ist: ob das Recht dem
Bot hier tatsaechlich erteilt ist. Faellt es aus, steht der Grund im Log.
Beim Stoppen wird die Zeile geleert -- sonst behauptet sie stundenlang, es
liefe ein Lied, das laengst vorbei ist.
Nebenbei: prism-media wurde direkt importiert, stand aber nie in der
package.json. Der Import ist mit dem Ogg-Auspacker weggefallen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
867 lines
40 KiB
JavaScript
867 lines
40 KiB
JavaScript
// 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;
|
||
}
|