Eine Quelle, zwei Arten -- Sender und Titel gehen denselben Weg

Der ganze Unterschied ist einer: ein Sender endet nie, ein Titel schon.
Also verzweigt genau eine Abspielfunktion und genau ein Idle-Melder nach
der Art der Quelle, statt dass ein zweiter Pfad daneben entsteht -- zwei
Pfade, die dasselbe tun sollen, driften auseinander.

Beim Sender heisst Idle "die Leitung ist weg" (neu aufbauen), bei YouTube
"fertig, der naechste bitte". Danach: Warteschlange, sonst der Sender von
vorher, sonst raus. Das Radio ist die Grundstellung, YouTube die
Unterbrechung.

Bremse gegen das Durchrutschen: ein Eintrag ohne Ton geht sofort wieder
auf Idle. Ohne Zaehlung waere eine Liste mit einem kaputten Eintrag in
Sekundenbruchteilen leer, und niemand haette gesehen warum. Nach drei
Fehlschlaegen in Folge wird angehalten und der Grund gemeldet. Von Hand
Uebersprungenes zaehlt nicht mit.

Zwei Fehler nebenbei, beide vorher schon da:

Der Idle-Melder hielt `kanal` aus dem Abschluss des ERSTEN Aufrufs fest.
Nach einem Kanalwechsel haette der Wiederanlauf in den alten Kanal
gespielt. Er liest den Kanal jetzt frisch aus `laufend`.

`radioLautstaerke()` gab bei unbelegter Einstellung 10 zurueck statt der
50, die der Kommentar zwei Zeilen darueber als Vorgabe begruendet:
`Number(null)` ist 0 und damit endlich, die Pruefung auf "nicht endlich"
hat den unbelegten Fall nie erwischt und auf die Untergrenze geklemmt.
Eine frische Installation startete also sehr leise. Gemessen gegen eine
leere Datenbank, mit Gegenproben fuer leeren String, Unsinn, 0 und 999.
This commit is contained in:
2026-08-28 13:33:42 +02:00
parent af16bc1b18
commit 409b28e9e0
3 changed files with 341 additions and 47 deletions
+7 -3
View File
@@ -8,6 +8,7 @@ import { moduleEnabled } from '../../modules.js';
import { listStations, getStation, setRadioState } from '../../db.js'; import { listStations, getStation, setRadioState } from '../../db.js';
import { import {
spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken, spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken,
radioQuelle,
} from '../radio.js'; } from '../radio.js';
export const data = new SlashCommandBuilder() 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 // Verbinden dauert länger als die drei Sekunden, die Discord gibt
await interaction.deferReply(); 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; let titel = schonAn?.titel ?? null;
if (!schonAn) { if (!schonAn) {
station = stationen[0]; station = stationen[0];
try { try {
await spielen(kanal, station); await spielen(kanal, radioQuelle(station));
} catch (error) { } catch (error) {
console.error('[radio]', error); console.error('[radio]', error);
await interaction.editReply(`${error.message || 'Verbindung in den Sprachkanal fehlgeschlagen.'}`); 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 jetzt = laeuft(interaction.guild.id);
const nachricht = await interaction.editReply({ const nachricht = await interaction.editReply({
embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId, 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), components: tafelKomponenten(stationen, station?.id),
}); });
+322 -41
View File
@@ -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 // Hier stand jahrelang „bewusst nur Webradio und kein YouTube", mit der
// gestorben, und was davon übrig ist, ist ein Dauerlauf gegen kaputte // Begründung, genau daran seien Groovy und Rythm gestorben. Der Einwand ist
// Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine // nicht widerlegt, die Entscheidung ist trotzdem gefallen. Was bleibt, ist
// Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet. // 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 // Die Tonkette: ffmpeg holt den Sender und gibt rohes PCM aus, die
// Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief // 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. // und kostet gemessene 0,3 % eines Kerns fürs Kodieren eines Dauerstroms.
// Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja // Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja
// schon Abtastwerte. // 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 { spawn } from 'node:child_process';
import { import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags, ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags,
@@ -35,8 +51,10 @@ import {
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { import {
listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting, listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting,
queueNext,
} from '../db.js'; } from '../db.js';
import { senderGrundstock } from './radio-sender.js'; import { senderGrundstock } from './radio-sender.js';
import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.js';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; 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 // Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im
// Speicher; was davon einen Neustart überlebt, steht in `radio_state`. // Speicher; was davon einen Neustart überlebt, steht in `radio_state` und in
const laufend = new Map(); // guildId → { player, ffmpeg, stationId, channelId, titel } // 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 ────────────────────────────────────────────────────────── // ── Sender-Titel ──────────────────────────────────────────────────────────
@@ -122,18 +170,23 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
// ── Ton ─────────────────────────────────────────────────────────────────── // ── Ton ───────────────────────────────────────────────────────────────────
/** /**
* ffmpeg als Tonquelle. * ffmpeg als Wandler.
* *
* `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein * `-reconnect` gilt nur für Adressen aus dem Netz und ist dort kein Beiwerk:
* einzelner Netz-Schluckauf würde ihn sonst endgültig beenden. * 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, [ const ff = spawn(FFMPEG, [
// Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher // Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher
// dahinter und wurde dort als Ausgabe-Option gelesen, also ignoriert. // 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', '-analyzeduration', '0', '-loglevel', 'error',
'-i', url, '-i', eingabe,
'-vn', // manche Sender legen ein Coverbild bei '-vn', // manche Sender legen ein Coverbild bei
// Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke // Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke
// drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet — // drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet —
@@ -152,6 +205,38 @@ function tonquelle(url) {
return ff; 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. * Der Adapter zwischen Discord-Verbindung und Sprachbibliothek — mit Protokoll.
* *
@@ -406,8 +491,11 @@ export function stoppen(guildId) {
let abgeraeumt; let abgeraeumt;
if (s) { if (s) {
s.player.stop(true); s.player.stop(true);
s.ffmpeg?.kill('SIGKILL'); ketteAbraeumen(s);
laufend.delete(guildId); 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 // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es
// liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab. // 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 * Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler
* bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für * bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für
* die Zuhörer eine hörbare Pause. * 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 guildId = kanal.guild.id;
const vorher = laufend.get(guildId); const vorher = laufend.get(guildId);
@@ -551,8 +647,8 @@ export async function spielen(kanal, station) {
behaviors: { noSubscriber: NoSubscriberBehavior.Play }, behaviors: { noSubscriber: NoSubscriberBehavior.Play },
}); });
vorher?.ffmpeg?.kill('SIGKILL'); ketteAbraeumen(vorher);
const ff = tonquelle(station.url); const { ffmpeg: ff, ytdlp } = tonquelle(quelle);
const resource = createAudioResource(ff.stdout, { const resource = createAudioResource(ff.stdout, {
inputType: StreamType.Raw, inputType: StreamType.Raw,
inlineVolume: true, // ohne das gibt es kein `resource.volume` inlineVolume: true, // ohne das gibt es kein `resource.volume`
@@ -579,34 +675,148 @@ export async function spielen(kanal, station) {
if (!vorher) { if (!vorher) {
player.on('error', (e) => console.error('[radio] Spieler:', e.message)); player.on('error', (e) => console.error('[radio] Spieler:', e.message));
// Ein Radiostream endet nie von selbst. Tut er es doch, ist die // Hier entscheidet sich, was ein Ende bedeutet — und das ist bei den
// Leitung weg — dann neu aufbauen statt still dazustehen. // 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, () => { player.on(AudioPlayerStatus.Idle, () => {
const jetzt = laufend.get(guildId); const jetzt = laufend.get(guildId);
if (!jetzt) return; 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'); console.error('[radio] Strom abgerissen, neuer Versuch');
const s = getStation(jetzt.stationId); const s = getStation(jetzt.quelle?.station?.id);
if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000); 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 = { const eintrag = {
player, ffmpeg: ff, resource, stationId: station.id, channelId: kanal.id, player, ffmpeg: ff, ytdlp, resource, quelle, channelId: kanal.id,
titel: null, seit: Date.now(), tonLaeuft: null, client: kanal.client, // 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); laufend.set(guildId, eintrag);
// Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen // Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen
// erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort — // erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort —
// besser als eine leere Zeile, während schon Musik läuft. // besser als eine leere Zeile, während schon Musik läuft.
statusAus = null; 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) => { beobachten.then((ok) => {
eintrag.tonLaeuft = 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; 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 ─────────────────────────────────────────────────────────── // ── Steuertafel ───────────────────────────────────────────────────────────
// Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je // 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') }) .setFooter({ text: brandFooter('RADIO') })
.setTimestamp(); .setTimestamp();
const laut = radioLautstaerke(); 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' return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n'
+ `Startet mit ${lautText(laut)} — vorab einstellbar.`); + `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}>` : ''}`); embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`);
// Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier, // 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; if (!nachricht) return;
const jetzt = laufend.get(guildId); 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({ await nachricht.edit({
embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId, embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId,
{ tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })],
components: tafelKomponenten(listStations(), jetzt?.stationId), components: tafelKomponenten(listStations(), station?.id),
}).catch(() => {}); }).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 ───────────────────────────────────────────────────────────── // ── Anbindung ─────────────────────────────────────────────────────────────
/** Ist ausser dem Bot noch jemand im Kanal? */ /** Ist ausser dem Bot noch jemand im Kanal? */
@@ -765,6 +1009,16 @@ export function startRadio(client) {
+ 'Im Container nachinstallieren oder das Image neu bauen.'); + '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 // Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert
// selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte. // selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte.
setInterval(async () => { setInterval(async () => {
@@ -773,13 +1027,17 @@ export function startRadio(client) {
// seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig. // seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig.
try { try {
for (const [guildId, s] of laufend) { 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; if (!station) continue;
const { titel } = await titelHolen(station.url); const { titel } = await titelHolen(station.url);
// Nur bei echter Änderung neu zeichnen — und nur dann den // Nur bei echter Änderung neu zeichnen — und nur dann den
// Kanal-Status anfassen. Discord begrenzt beides. // Kanal-Status anfassen. Discord begrenzt beides.
if (titelMerken(client, guildId, titel)) { if (titelMerken(client, guildId, titel)) {
await tafelAktualisieren(client, guildId); await tafelnMelden(client, guildId);
} }
} }
} catch (error) { } catch (error) {
@@ -797,7 +1055,10 @@ export function startRadio(client) {
if (jemandDa(kanal)) return; if (jemandDa(kanal)) return;
stoppen(vorher.guild.id); stoppen(vorher.guild.id);
clearRadioState(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) { } catch (error) {
console.error('[radio] Sprachkanal-Wechsel:', error.message); console.error('[radio] Sprachkanal-Wechsel:', error.message);
} }
@@ -808,8 +1069,16 @@ export function startRadio(client) {
client.once(Events.ClientReady, async () => { client.once(Events.ClientReady, async () => {
if (!moduleEnabled('radio')) return; if (!moduleEnabled('radio')) return;
for (const stand of radioStates()) { 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 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)) { if (!kanal?.isVoiceBased() || !jemandDa(kanal)) {
// Niemand mehr da, oder der Sender wurde gelöscht — dann gilt // Niemand mehr da, oder der Sender wurde gelöscht — dann gilt
// der Merker als verbraucht statt in einen leeren Kanal zu gehen. // der Merker als verbraucht statt in einen leeren Kanal zu gehen.
@@ -817,9 +1086,13 @@ export function startRadio(client) {
continue; continue;
} }
try { try {
await spielen(kanal, station); await spielen(kanal, quelle);
await tafelAktualisieren(client, stand.guild_id); // Der Heimsender überlebt den Neustart nur über `radio_state`;
console.log(`[radio] wieder in ${kanal.name}: ${station.name}`); // 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) { } catch (e) {
console.error('[radio] Rückkehr fehlgeschlagen:', e.message); console.error('[radio] Rückkehr fehlgeschlagen:', e.message);
clearRadioState(stand.guild_id); clearRadioState(stand.guild_id);
@@ -860,7 +1133,10 @@ export async function handleRadioInteraction(interaction) {
}); });
return true; 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({ setRadioState({
guildId, guildId,
channelId: ziel.id, channelId: ziel.id,
@@ -874,11 +1150,16 @@ export async function handleRadioInteraction(interaction) {
} }
const jetzt = laufend.get(guildId); const jetzt = laufend.get(guildId);
const station = jetzt ? getStation(jetzt.stationId) : null; const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null;
await interaction.update({ await interaction.update({
embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId, embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId,
{ tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })],
components: tafelKomponenten(listStations(), jetzt?.stationId), components: tafelKomponenten(listStations(), station?.id),
}).catch(() => {}); }).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; return true;
} }
+12 -3
View File
@@ -380,9 +380,18 @@ export const LAUT_SCHRITT = 10;
export const LAUT_VORGABE = 50; export const LAUT_VORGABE = 50;
export function radioLautstaerke() { export function radioLautstaerke() {
const roh = Number(getSetting('radio_lautstaerke')); const roh = getSetting('radio_lautstaerke');
if (!Number.isFinite(roh)) return LAUT_VORGABE; // Der unbelegte Fall muss VOR der Umwandlung abgefangen werden.
return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(roh))); //
// `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)));
} }
/** /**