diff --git a/src/bot/commands/radio.js b/src/bot/commands/radio.js index 2e940d9..e264fb8 100644 --- a/src/bot/commands/radio.js +++ b/src/bot/commands/radio.js @@ -8,6 +8,7 @@ import { moduleEnabled } from '../../modules.js'; import { listStations, getStation, setRadioState } from '../../db.js'; import { spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken, + radioQuelle, } from '../radio.js'; export const data = new SlashCommandBuilder() @@ -80,12 +81,15 @@ export async function execute(interaction) { // Verbinden dauert länger als die drei Sekunden, die Discord gibt await interaction.deferReply(); - let station = schonAn ? getStation(schonAn.stationId) : null; + // Der Heimsender, nicht die laufende Quelle: läuft gerade ein + // YouTube-Titel, gehört in die Tafel trotzdem der Sender, auf den danach + // zurückgefallen wird. + let station = schonAn?.heimStationId ? getStation(schonAn.heimStationId) : null; let titel = schonAn?.titel ?? null; if (!schonAn) { station = stationen[0]; try { - await spielen(kanal, station); + await spielen(kanal, radioQuelle(station)); } catch (error) { console.error('[radio]', error); await interaction.editReply(`❌ ${error.message || 'Verbindung in den Sprachkanal fehlgeschlagen.'}`); @@ -98,7 +102,7 @@ export async function execute(interaction) { const jetzt = laeuft(interaction.guild.id); const nachricht = await interaction.editReply({ embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId, - { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden() })], + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden(), quelle: jetzt?.quelle })], components: tafelKomponenten(stationen, station?.id), }); diff --git a/src/bot/radio.js b/src/bot/radio.js index 8c3d433..32a3780 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -1,9 +1,21 @@ -// Radio im Sprachkanal. +// Radio im Sprachkanal — Webradio und, seit dem 28.08.2026, auch YouTube. // -// 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. +// Hier stand jahrelang „bewusst nur Webradio und kein YouTube", mit der +// Begründung, genau daran seien Groovy und Rythm gestorben. Der Einwand ist +// nicht widerlegt, die Entscheidung ist trotzdem gefallen. Was bleibt, ist +// die Konsequenz daraus: YouTube ist der Teil, der von aussen kaputtgehen +// kann, also muss ein Ausfall *sichtbar* sein statt still. Was yt-dlp weiss, +// steht in `youtube.js`, samt Fehlerzeile fürs Panel. +// +// Der ganze Unterschied zwischen den beiden Quellen ist einer: **ein Sender +// endet nie, ein Titel schon.** Daran hängt alles Weitere — die +// Warteschlange, das Überspringen, und die Frage, was ein `Idle` des Spielers +// bedeutet: beim Sender „die Leitung ist weg" (neu aufbauen), bei YouTube +// „fertig, der nächste bitte". +// +// Deshalb gibt es *eine* Abspielfunktion und *einen* `Idle`-Melder, die nach +// der Art der Quelle verzweigen, und keinen zweiten Pfad daneben. Zwei +// Pfade, die dasselbe tun sollen, driften auseinander. // // Die Tonkette: ffmpeg holt den Sender und gibt rohes PCM aus, die // Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief @@ -18,6 +30,10 @@ // 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. +// +// Bei YouTube hängt vor ffmpeg noch yt-dlp: es lädt und schiebt die Bytes +// durch eine Rohrleitung. Warum nicht die fertige Adresse an ffmpeg, steht +// im Kopf von `youtube.js` — kurz: die läuft ab. import { spawn } from 'node:child_process'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags, @@ -35,8 +51,10 @@ import { import { moduleEnabled } from '../modules.js'; import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting, + queueNext, } from '../db.js'; import { senderGrundstock } from './radio-sender.js'; +import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.js'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; @@ -61,8 +79,38 @@ export function ffmpegPruefen() { } // 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 } +// Speicher; was davon einen Neustart überlebt, steht in `radio_state` und in +// der Warteschlange. +const laufend = new Map(); +// guildId → { player, ffmpeg, ytdlp, resource, quelle, heimStationId, +// channelId, titel, seit, tonLaeuft, client } + +// ── Die Quelle ──────────────────────────────────────────────────────────── +// +// Zwei Arten, eine Form. Alles, was danach kommt, fragt nur noch `art` — +// damit gibt es genau eine Stelle, an der sich Sender und Titel unterscheiden, +// statt eines zweiten Abspielweges neben dem ersten. + +/** @param {object} station Zeile aus `radio_stations` */ +export const radioQuelle = (station) => ({ art: 'radio', station }); +/** @param {object} titel Eintrag aus der Warteschlange (oder von `aufloesen`) */ +export const youtubeQuelle = (titel) => ({ art: 'youtube', titel }); + +/** Wie die Quelle heisst, wenn man sie hinschreibt */ +const quelleName = (q) => (q?.art === 'youtube' ? q.titel?.titel : q?.station?.name) ?? null; + +// Wer gerade mitten im Quellenwechsel steckt. Als eigene Menge und nicht als +// Merker am Eintrag: `spielen` ersetzt den Eintrag, ein Merker daran wäre +// nach dem Wechsel weg — und genau während des Wechsels wird er gebraucht. +const wechselt = new Set(); + +// Wie oft hintereinander ein Titel keinen Ton gebracht hat, je Server. +// +// Ohne diese Zählung rauscht eine Warteschlange mit einem kaputten Eintrag +// in Millisekunden durch: kein Ton → sofort `Idle` → nächster → kein Ton. +// Nach dieser Zahl Fehlschlägen in Folge wird angehalten und gesagt, warum. +const fehlschlaege = new Map(); +const FEHLSCHLAG_GRENZE = 3; // ── Sender-Titel ────────────────────────────────────────────────────────── @@ -122,18 +170,23 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) { // ── Ton ─────────────────────────────────────────────────────────────────── /** - * ffmpeg als Tonquelle. + * ffmpeg als Wandler. * - * `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein - * einzelner Netz-Schluckauf würde ihn sonst endgültig beenden. + * `-reconnect` gilt nur für Adressen aus dem Netz und ist dort kein Beiwerk: + * ein Radiostream läuft stundenlang, und ein einzelner Netz-Schluckauf würde + * ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option + * sinnlos — es gibt nichts, wohin sich neu verbinden liesse. + * + * @param {string} eingabe Adresse oder `pipe:0` + * @param {{netz: boolean}} opts */ -function tonquelle(url) { +function wandler(eingabe, { netz }) { 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', + ...(netz ? ['-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'] : []), '-analyzeduration', '0', '-loglevel', 'error', - '-i', url, + '-i', eingabe, '-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 — @@ -152,6 +205,38 @@ function tonquelle(url) { return ff; } +/** + * Die Prozesskette für eine Quelle. + * + * Sender: ffmpeg holt selbst. YouTube: yt-dlp holt und schiebt durch ein Rohr. + * + * Die Fehlerbehandlung an den Rohrenden ist Pflicht und kein Übereifer: wird + * ffmpeg abgeräumt (Stopp, Überspringen, Wechsel), während yt-dlp noch + * schreibt, kommt ein EPIPE — und ein Strom ohne 'error'-Hörer reisst den + * ganzen Prozess mit. + * + * @returns {{ffmpeg: object, ytdlp: object|null}} + */ +function tonquelle(quelle) { + if (quelle.art !== 'youtube') { + return { ffmpeg: wandler(quelle.station.url, { netz: true }), ytdlp: null }; + } + const yt = tonstrom(quelle.titel.url); + const ff = wandler('pipe:0', { netz: false }); + yt.stdout.on('error', () => {}); + ff.stdin.on('error', () => {}); + yt.stdout.pipe(ff.stdin); + return { ffmpeg: ff, ytdlp: yt }; +} + +/** Beide Prozesse einer Kette abräumen — an einer Stelle, damit keiner stehenbleibt */ +function ketteAbraeumen(eintrag) { + eintrag?.ffmpeg?.kill('SIGKILL'); + // Ohne das lädt ein übersprungener Titel munter weiter: yt-dlp merkt vom + // Ende der Rohrleitung nichts, solange niemand es ihm sagt. + eintrag?.ytdlp?.kill('SIGKILL'); +} + /** * Der Adapter zwischen Discord-Verbindung und Sprachbibliothek — mit Protokoll. * @@ -406,8 +491,11 @@ export function stoppen(guildId) { let abgeraeumt; if (s) { s.player.stop(true); - s.ffmpeg?.kill('SIGKILL'); + ketteAbraeumen(s); laufend.delete(guildId); + // Sonst zählt der nächste Start ab dem alten Stand weiter und bricht + // womöglich sofort wieder ab. + fehlschlaege.delete(guildId); // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es // liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab. // @@ -433,13 +521,21 @@ export async function radioBeenden() { } /** - * Sender im Sprachkanal starten (oder umschalten). + * Eine Quelle 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. + * + * Dass hier eine *Quelle* ankommt und kein Sender, ist der Kern des Umbaus + * vom 28.08.2026: Sender und YouTube-Titel gehen denselben Weg, und der + * Unterschied lebt in `tonquelle()` und im `Idle`-Melder — nicht in zwei + * Abspielfunktionen, die auseinanderdriften. + * + * @param {object} kanal Sprachkanal + * @param {{art: string}} quelle aus `radioQuelle()` oder `youtubeQuelle()` */ -export async function spielen(kanal, station) { +export async function spielen(kanal, quelle) { const guildId = kanal.guild.id; const vorher = laufend.get(guildId); @@ -551,8 +647,8 @@ export async function spielen(kanal, station) { behaviors: { noSubscriber: NoSubscriberBehavior.Play }, }); - vorher?.ffmpeg?.kill('SIGKILL'); - const ff = tonquelle(station.url); + ketteAbraeumen(vorher); + const { ffmpeg: ff, ytdlp } = tonquelle(quelle); const resource = createAudioResource(ff.stdout, { inputType: StreamType.Raw, inlineVolume: true, // ohne das gibt es kein `resource.volume` @@ -579,34 +675,148 @@ export async function spielen(kanal, station) { 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. + // Hier entscheidet sich, was ein Ende bedeutet — und das ist bei den + // beiden Quellen genau gegensätzlich: + // + // Sender → ein Radiostream endet nie von selbst. Tut er es doch, + // ist die Leitung weg: neu aufbauen statt dazustehen. + // YouTube → ein Titel endet immer. Das ist kein Fehler, sondern das + // Stichwort für den nächsten. + // + // Der Melder wird nur einmal je Spieler gehängt und überlebt jeden + // Quellenwechsel. Deshalb wird der aktuelle Stand hier frisch aus + // `laufend` geholt und nicht aus dem Abschluss: `kanal` und `quelle` + // von damals sind nach dem ersten Umschalten veraltet. (Vorher hing + // an dieser Stelle `kanal` fest — nach einem Kanalwechsel hätte der + // Wiederanlauf in den alten Kanal gespielt.) player.on(AudioPlayerStatus.Idle, () => { const jetzt = laufend.get(guildId); if (!jetzt) return; + if (jetzt.quelle?.art === 'youtube') { + weiter(jetzt.client, guildId).catch((e) => + console.error('[radio] Weiterschalten:', e.message)); + return; + } console.error('[radio] Strom abgerissen, neuer Versuch'); - const s = getStation(jetzt.stationId); - if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000); + const s = getStation(jetzt.quelle?.station?.id); + if (!s) return; + setTimeout(async () => { + const k = await jetzt.client.channels.fetch(jetzt.channelId).catch(() => null); + if (k) spielen(k, radioQuelle(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, + player, ffmpeg: ff, ytdlp, resource, quelle, channelId: kanal.id, + // Der Sender, auf den nach der Warteschlange zurückgefallen wird. + // Bei einem Sender ist das er selbst; bei einem YouTube-Titel bleibt + // stehen, was vorher lief — sonst wüsste niemand mehr, wohin zurück. + heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null), + titel: quelle.art === 'youtube' ? quelle.titel.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}`); + kanalStatusSetzen(kanal.client, kanal.id, + quelle.art === 'youtube' ? `▶️ ${quelle.titel.titel}` : `📻 ${quelle.station.name}`); beobachten.then((ok) => { eintrag.tonLaeuft = ok; - if (!ok) console.error(`[radio] kein Ton von ${station.name} — ffmpeg da? ${ffmpegDa}`); + // Ein Titel, der wirklich klingt, macht die Zählung der Fehlschläge + // gegenstandslos — sonst hielte ein alter Ausrutscher die Bremse an. + if (ok) fehlschlaege.delete(guildId); + if (!ok) { + console.error(`[radio] kein Ton von ${quelleName(quelle)} — ` + + `ffmpeg da? ${ffmpegDa}${quelle.art === 'youtube' ? `, yt-dlp da? ${ytdlpVorhanden()}` : ''}`); + } }); return verbindung; } +/** + * Der nächste Titel — oder zurück aufs Radio, oder raus. + * + * Die Reihenfolge ist die Entscheidung vom 28.08.2026: erst die + * Warteschlange, dann der Sender, der vor dem ersten Titel lief, und erst + * wenn es auch den nicht gibt, geht der Bot. Das Radio ist die Grundstellung, + * YouTube die Unterbrechung. + * + * Die Bremse davor ist kein Übereifer: ein Eintrag, der keinen Ton bringt, + * geht sofort wieder auf `Idle`. Ohne Zählung wäre eine Warteschlange mit + * einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte + * gesehen, warum. + */ +export async function weiter(client, guildId, { erzwungen = false } = {}) { + // Zwei Auslöser gleichzeitig — der Titel endet, *und* jemand drückt + // „Überspringen" — dürfen nicht zwei Titel gleichzeitig starten. + if (wechselt.has(guildId)) return null; + wechselt.add(guildId); + try { + const jetzt = laufend.get(guildId); + const kanalId = jetzt?.channelId; + if (!kanalId) return null; + + // Kam vom laufenden Titel je ein Ton? Wenn nicht, war er kaputt. + // Beim Überspringen von Hand zählt das nicht mit: wer weiterdrückt, + // hat den Titel abgebrochen, nicht der Titel sich selbst. + if (!erzwungen && jetzt.quelle?.art === 'youtube' && jetzt.tonLaeuft !== true) { + const n = (fehlschlaege.get(guildId) ?? 0) + 1; + fehlschlaege.set(guildId, n); + console.error(`[radio] ohne Ton geblieben: ${quelleName(jetzt.quelle)} (${n}. in Folge)`); + if (n >= FEHLSCHLAG_GRENZE) { + console.error(`[radio] ${n} Titel hintereinander ohne Ton — angehalten. ` + + 'Ursache steht im Panel unter Radio → yt-dlp.'); + fehlschlaege.delete(guildId); + stoppen(guildId); + clearRadioState(guildId); + await tafelnMelden(client, guildId); + return null; + } + } + + const kanal = await client.channels.fetch(kanalId).catch(() => null); + if (!kanal?.isVoiceBased()) return null; + + const naechster = queueNext(guildId); + const quelle = naechster + ? youtubeQuelle(naechster) + : (() => { + const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; + return heim ? radioQuelle(heim) : null; + })(); + + if (!quelle) { + // Nichts mehr da und kein Sender, auf den zurückzufallen wäre. + stoppen(guildId); + clearRadioState(guildId); + await tafelnMelden(client, guildId); + return null; + } + + await spielen(kanal, quelle); + setRadioState({ + guildId, + channelId: kanalId, + stationId: laufend.get(guildId)?.heimStationId ?? null, + panelChannelId: radioState(guildId)?.panel_channel_id, + panelMessageId: radioState(guildId)?.panel_message_id, + }); + // Beim Rückfall auf den Sender steht sonst noch der letzte Liedtitel + // in der Statuszeile und behauptet, er liefe. + if (quelle.art === 'radio') { + const { titel } = await titelHolen(quelle.station.url); + titelMerken(client, guildId, titel); + } + await tafelnMelden(client, guildId); + return quelle; + } finally { + wechselt.delete(guildId); + } +} + // ── Steuertafel ─────────────────────────────────────────────────────────── // Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je @@ -703,10 +913,22 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) { .setFooter({ text: brandFooter('RADIO') }) .setTimestamp(); const laut = radioLautstaerke(); - if (!station) { + // Läuft ein YouTube-Titel, ist der Sender nur pausiert und nicht aus. + // Das gehört hierhin, sonst behauptet die Radio-Tafel „nichts läuft", + // während im Kanal hörbar etwas läuft. + const ytLaeuft = stand.quelle?.art === 'youtube'; + if (!station && !ytLaeuft) { return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n' + `Startet mit ${lautText(laut)} — vorab einstellbar.`); } + if (ytLaeuft) { + embed.setDescription(`▶️ **${stand.quelle.titel.titel}**${kanalId ? ` in <#${kanalId}>` : ''}\n` + + (station + ? `_danach zurück auf **${station.name}**_` + : '_aus der Warteschlange — `/musik` zeigt sie_')); + embed.addFields({ name: 'Lautstärke', value: lautText(laut), inline: true }); + return embed; + } embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`); // Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier, @@ -739,14 +961,36 @@ async function tafelAktualisieren(client, guildId) { if (!nachricht) return; const jetzt = laufend.get(guildId); - const station = jetzt ? getStation(jetzt.stationId) : null; + // Welcher Sender in der Radio-Tafel angehakt ist, richtet sich nach dem + // Heimsender und nicht nach der laufenden Quelle: während ein + // YouTube-Titel läuft, soll dort weiter stehen, wohin zurückgefallen wird. + const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null; await nachricht.edit({ embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId, - { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], - components: tafelKomponenten(listStations(), jetzt?.stationId), + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })], + components: tafelKomponenten(listStations(), station?.id), }).catch(() => {}); } +// Wer sonst noch neu gezeichnet werden will, wenn sich etwas geändert hat. +// +// Eine Anmeldung statt eines Imports, weil die YouTube-Tafel `radio.js` +// braucht und `radio.js` sie sonst zurück — ein Ringschluss zwischen zwei +// Dateien, den niemand beim Lesen erwartet. So zeigt die Abhängigkeit in eine +// Richtung: `radio-musik.js` kennt das Radio, das Radio kennt nur „jemand +// möchte Bescheid". +const melder = []; +export function beiAenderung(fn) { melder.push(fn); } + +/** Alle Tafeln neu zeichnen — die Radio-Tafel und alles Angemeldete */ +export async function tafelnMelden(client, guildId) { + await tafelAktualisieren(client, guildId).catch(() => {}); + for (const fn of melder) { + await fn(client, guildId).catch((e) => + console.error('[radio] Tafel zeichnen:', e.message)); + } +} + // ── Anbindung ───────────────────────────────────────────────────────────── /** Ist ausser dem Bot noch jemand im Kanal? */ @@ -765,6 +1009,16 @@ export function startRadio(client) { + 'Im Container nachinstallieren oder das Image neu bauen.'); }); + // Dieselbe Frage für yt-dlp, und aus demselben Grund: ohne die Antwort + // sähe ein fehlendes yt-dlp aus wie „YouTube geht nicht mehr". Ein + // fehlendes yt-dlp lässt das Radio unberührt — deshalb ist das eine + // Meldung und kein Abbruch. + ytdlpPruefen().then((da) => { + if (da) return; + console.error('[radio] yt-dlp fehlt — Sender laufen weiter, YouTube nicht. ' + + 'Im Panel unter Radio → yt-dlp steht, was fehlt.'); + }); + // Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert // selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte. setInterval(async () => { @@ -773,13 +1027,17 @@ export function startRadio(client) { // 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); + // YouTube hat keine Icy-Metadaten und braucht sie nicht: der + // Titel stand schon fest, als er eingereiht wurde. Eine + // Abfrage dagegen wäre eine Anfrage ins Leere. + if (s.quelle?.art !== 'radio') continue; + const station = getStation(s.quelle.station.id); 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); + await tafelnMelden(client, guildId); } } } catch (error) { @@ -797,7 +1055,10 @@ export function startRadio(client) { if (jemandDa(kanal)) return; stoppen(vorher.guild.id); clearRadioState(vorher.guild.id); - await tafelAktualisieren(client, vorher.guild.id); + // Die Warteschlange bleibt absichtlich stehen: der Kanal hat sich + // geleert, die Wünsche sind deshalb nicht hinfällig. Wer sie los + // sein will, drückt „Leeren". + await tafelnMelden(client, vorher.guild.id); } catch (error) { console.error('[radio] Sprachkanal-Wechsel:', error.message); } @@ -808,8 +1069,16 @@ export function startRadio(client) { client.once(Events.ClientReady, async () => { if (!moduleEnabled('radio')) return; for (const stand of radioStates()) { + // Erst die Warteschlange, dann der Sender. + // + // Mitten in einem Titel wieder einzusteigen geht nicht — es gibt + // keine gespeicherte Position. Und andersherum käme die Liste nie + // dran: ein Sender endet nicht, also gäbe es kein `Idle`, das + // weiterschaltet. Deshalb diese Reihenfolge und nicht die andere. + const naechster = queueNext(stand.guild_id); const station = stand.station_id ? getStation(stand.station_id) : null; - const kanal = station ? await client.channels.fetch(stand.channel_id).catch(() => null) : null; + const quelle = naechster ? youtubeQuelle(naechster) : (station ? radioQuelle(station) : null); + const kanal = quelle ? 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. @@ -817,9 +1086,13 @@ export function startRadio(client) { continue; } try { - await spielen(kanal, station); - await tafelAktualisieren(client, stand.guild_id); - console.log(`[radio] wieder in ${kanal.name}: ${station.name}`); + await spielen(kanal, quelle); + // Der Heimsender überlebt den Neustart nur über `radio_state`; + // ohne diese Zeile fiele die Liste danach ins Leere. + const s = laufend.get(stand.guild_id); + if (s && station) s.heimStationId = station.id; + await tafelnMelden(client, stand.guild_id); + console.log(`[radio] wieder in ${kanal.name}: ${quelleName(quelle)}`); } catch (e) { console.error('[radio] Rückkehr fehlgeschlagen:', e.message); clearRadioState(stand.guild_id); @@ -860,7 +1133,10 @@ export async function handleRadioInteraction(interaction) { }); return true; } - await spielen(ziel, station); + // Ein Sender von Hand gewählt schlägt die Warteschlange: wer hier + // klickt, will jetzt diesen Sender hören und nicht erst nach drei + // eingereihten Titeln. Die Liste bleibt stehen und kommt danach dran. + await spielen(ziel, radioQuelle(station)); setRadioState({ guildId, channelId: ziel.id, @@ -874,11 +1150,16 @@ export async function handleRadioInteraction(interaction) { } const jetzt = laufend.get(guildId); - const station = jetzt ? getStation(jetzt.stationId) : null; + const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null; await interaction.update({ embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId, - { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], - components: tafelKomponenten(listStations(), jetzt?.stationId), + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })], + components: tafelKomponenten(listStations(), station?.id), }).catch(() => {}); + // Die zweite Tafel zeigt dieselbe Wirklichkeit und muss mitgezogen + // werden — sonst behauptet sie nach einem Stopp weiter, es liefe etwas. + for (const fn of melder) { + await fn(interaction.client, guildId).catch(() => {}); + } return true; } diff --git a/src/runtime-settings.js b/src/runtime-settings.js index eec6d00..76caac6 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -380,9 +380,18 @@ export const LAUT_SCHRITT = 10; export const LAUT_VORGABE = 50; export function radioLautstaerke() { - const roh = Number(getSetting('radio_lautstaerke')); - if (!Number.isFinite(roh)) return LAUT_VORGABE; - return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(roh))); + const roh = getSetting('radio_lautstaerke'); + // Der unbelegte Fall muss VOR der Umwandlung abgefangen werden. + // + // `Number(null)` ist 0, und 0 ist endlich — die Prüfung unten hat „noch + // nie eingestellt" deshalb nie erwischt, sondern auf LAUT_MIN geklemmt. + // Ergebnis: eine frische Installation startete mit 10 % statt mit den 50, + // die zwei Zeilen weiter oben als Vorgabe begründet werden. Gemessen am + // 28.08.2026 gegen eine leere Datenbank. + if (roh === null || roh === undefined || roh === '') return LAUT_VORGABE; + const wert = Number(roh); + if (!Number.isFinite(wert)) return LAUT_VORGABE; + return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(wert))); } /**