Author SHA1 Message Date
D4rkst3r 7e7d04378a Pause fuer YouTube -- und der Bot bleibt draussen, wenn er rausgeht
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Zwei Befunde aus dem Betrieb.

1. Der Bot ging aus dem leeren Kanal und kam sofort wieder rein.

`stoppen()` hielt erst den Spieler an und loeschte dann aus `laufend`.
`player.stop()` meldet den Leerlauf aber SYNCHRON -- gemessen mit
@discordjs/voice 0.19: der Idle-Melder laeuft mitten in stop(), noch vor
der naechsten Zeile. Er fand den Eintrag also noch, hielt das Ende fuer
"Titel vorbei" und schaltete weiter: naechster Titel oder zurueck auf den
Heimsender. Von aussen genau das gemeldete Bild.

Jetzt wird erst geloescht, dann angehalten. Nachgestellt mit einem echten
AudioPlayer und beiden Reihenfolgen -- die Kontrolle ist dabei die alte
Reihenfolge: sie schaltet nachweislich weiter (1x), die neue nicht (0x).
Ohne diese Gegenprobe koennte der Test etwas ganz anderes messen.

2. Im YouTube-Modus fehlte die Pause.

`pausieren()` gibt es jetzt, ausdruecklich nur fuer YouTube. Ein Sender
laesst sich nicht sinnvoll anhalten: er laeuft beim Betreiber weiter,
waehrend hier die Leitung volllaeuft, und beim Fortsetzen kaeme eine
Konserve, die immer weiter hinterherhinkt. Fuer einen Sender heisst Pause
Stopp -- dafuer gibt es den Stopp-Knopf, und der Pause-Knopf graut aus.

Die verstrichene Zeit rechnet Pausen heraus, sonst liefe der
Fortschrittsbalken nach dem Fortsetzen um die Pausenlaenge voraus.
`verstrichen()` steht an EINER Stelle, die sich Discord-Tafel und Panel
teilen -- zwei Rechnungen waeren zwei Gelegenheiten, die Pause zu
vergessen.

Die Musik-Tafel bekommt eine zweite Knopfreihe: mit der Pause waeren es
sechs Knoepfe, Discord laesst fuenf je Reihe zu. Platz ist reichlich (vier
von fuenf Reihen), und die Trennung ist ohnehin sinnvoller -- oben, was
gerade klingt, unten, was mit der Liste passiert. Nachgezaehlt: 2 Reihen
ohne Liste, 4 mit.

Beim Quellenwechsel wird ausdruecklich fortgesetzt: der Spieler ueberlebt
den Wechsel, und stand er auf Pause, waere der neue Titel stumm gestartet
waehrend der Eintrag "laeuft" sagt.

Nebenbei: mein Feld-Abgleich hielt eine zweite Kopie der Feldliste von
`radioStand()` und driftete beim ersten neuen Feld prompt ab -- er meldete
`pausiert` als fehlend, obwohl die API es liefert. Er liest die Form jetzt
aus der Quelle, statt sie doppelt zu pflegen.
2026-08-28 21:46:24 +02:00
D4rkst3r 15babcd75d Zwei Fehler aus dem Betrieb: fehlender Import, geschlossenes stdin
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Beide aus dem Serverlog vom 28.08.2026, beide meine.

1. `getStation is not defined` bei jedem /api/radio/status, sobald etwas
lief. Die Funktion stand in api.js im Code und in keinem Import.

Weder `node --check` noch das Laden des Moduls sieht das: ESM prueft beim
Verlinken nur, ob die IMPORTIERTEN Namen in der Quelle existieren, nicht
ob die BENUTZTEN importiert sind. Mein API-Testlauf kam nie an die Zeile,
weil dort nie etwas lief -- genau die Luecke, die ich beim Ausliefern als
"nicht geprueft" benannt hatte.

Ein Pruefer dafuer liegt jetzt im Scratchpad und meldet fuer src/ null
Treffer. Er hat zwei Gegenproben: ein eingebauter Aufruf ohne Import muss
gefunden werden, ein dynamisch importierter darf NICHT gemeldet werden.
Seine ersten beiden Fassungen haben Fehlalarm geschlagen (14 bzw. 11
Treffer, alle dynamische Importe) -- ein Pruefer, der Richtiges als falsch
meldet, ist so wertlos wie einer, der nichts sieht.

2. `Cannot read properties of null (reading 'on')` in tonquelle, direkt
gefolgt von `ffmpeg: pipe:0: Invalid data found when processing input`.

`wandler` startete ffmpeg immer mit stdio ['ignore','pipe','pipe'] --
stdin also ZU. Fuer YouTube bekommt ffmpeg aber `-i pipe:0`. Damit las es
aus /dev/null, und `ff.stdin` war null, woran schon das Anhaengen der
Fehlerbehandlung zerbrach. Nachgestellt und bestaetigt: dieselbe
stdio-Form liefert stdin === null, mit 'pipe' eine Rohrleitung.

stdin ist jetzt offen, wenn die Eingabe `pipe:0` ist. Und `tonquelle`
prueft die Rohrenden, bevor es sie anfasst: startet ein Prozess nicht,
gibt es eine Meldung statt eines TypeErrors, der die ganze Interaktion
mitreisst.
2026-08-28 14:17:36 +02:00
D4rkst3r e6f8e3c3c5 Beim Sender kam die Warteschlange nie dran
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Im Betrieb gemeldet: Link eingereiht, Bot blieb beim Radio.

`ggfAnwerfen` stieg aus, sobald ueberhaupt etwas lief -- Sender wie Titel.
Der Kommentar daneben sagte "die Liste kommt dran, wenn sie dran ist", und
beim Sender war das schlicht falsch: ein Sender endet nie, es gibt also
kein Idle, das weiterschaltet. Der einzige Idle, den es gibt, ist ein
abgerissener Strom -- und der startet denselben Sender neu. Der Titel
wartete auf einen Moment, der nie kommt.

Damit war auch der Rueckweg unerreichbar: "Liste leer -> zurueck auf den
Sender" setzt voraus, dass man ueberhaupt erst vom Sender wegkommt.

Jetzt uebernimmt ein frisch eingereihter Titel sofort, wenn ein Sender
laeuft. Der Sender ist nicht verloren -- er steht als heimStation im
laufenden Eintrag und kommt zurueck, sobald die Liste leer ist. Ein
laufender YouTube-Titel wird weiterhin nicht unterbrochen.

Die Regel steckte als Bedingung mitten in der Funktion und war von aussen
weder zu lesen noch zu pruefen. Sie heisst jetzt `uebernimmtSofort()` und
laesst sich mit allen drei Eingaben durchspielen -- genau das ist gemacht:
nichts laeuft -> true, Sender laeuft -> true (war vorher false, das war der
Fehler), YouTube laeuft -> false.

Der Quellenwechsel geht jetzt ins Log. Von aussen sieht "YouTube
uebernimmt" genauso aus wie "nichts passiert", und der Unterschied ist
genau der, den es hier zu sehen gibt.

Die Musik-Tafel behauptete "kommt als Naechstes dran" -- korrigiert auf
"uebernimmt sofort".
2026-08-28 14:09:09 +02:00
D4rkst3r c46581a548 Merge pull request 'Radio youtube' (#2) from radio-youtube into main
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Reviewed-on: #2
2026-08-28 14:02:21 +02:00
4 changed files with 202 additions and 26 deletions
+9
View File
@@ -2224,6 +2224,7 @@ export default function Settings({ me }) {
</span> </span>
<div className="radio-now-text"> <div className="radio-now-text">
<strong> <strong>
{radio.laeuft.pausiert && '⏸️ '}
{radio.laeuft.art === 'youtube' {radio.laeuft.art === 'youtube'
? radio.laeuft.titel ? radio.laeuft.titel
: radio.laeuft.sender} : radio.laeuft.sender}
@@ -2321,6 +2322,14 @@ export default function Settings({ me }) {
<option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option> <option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option>
))} ))}
</select> </select>
{/* Pause gibt es nur bei YouTube: ein Dauerstrom laeuft
beim Sender weiter, waehrend hier die Leitung volllaeuft --
beim Fortsetzen kaeme eine Konserve, die hinterherhinkt. */}
<button className="btn btn-ghost"
disabled={radioLaedt || radio.laeuft?.art !== 'youtube'}
onClick={() => radioSteuern('pause')}>
{radio.laeuft?.pausiert ? '▶️ Weiter' : '⏸️ Pause'}
</button>
<button className="btn btn-ghost" disabled={radioLaedt || !radio.laeuft} <button className="btn btn-ghost" disabled={radioLaedt || !radio.laeuft}
onClick={() => radioSteuern('skip')}> onClick={() => radioSteuern('skip')}>
Überspringen Überspringen
+76 -17
View File
@@ -28,6 +28,7 @@ import {
} from '../db.js'; } from '../db.js';
import { import {
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle, laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
pausieren, verstrichen,
} from './radio.js'; } from './radio.js';
import { import {
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER, aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
@@ -95,9 +96,10 @@ export function musikEmbed(client, guildId) {
if (jetzt?.quelle?.art === 'youtube') { if (jetzt?.quelle?.art === 'youtube') {
const t = jetzt.quelle.titel; const t = jetzt.quelle.titel;
const verstrichen = Math.floor((Date.now() - jetzt.seit) / 1000); // Pausen abgezogen — sonst behauptet der Balken, ein angehaltener
const balken = fortschritt(verstrichen, t.dauer); // Titel liefe weiter, und läuft beim Fortsetzen voraus.
embed.setDescription(`**${kurz(t.titel, 200)}**\n` const balken = fortschritt(verstrichen(jetzt), t.dauer);
embed.setDescription(`${jetzt.pausiertSeit ? '⏸️ ' : ''}**${kurz(t.titel, 200)}**\n`
+ `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}` + `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}`
+ (balken ? `${balken}\n` : '🔴 _Livestream_\n') + (balken ? `${balken}\n` : '🔴 _Livestream_\n')
+ `in <#${jetzt.channelId}>`); + `in <#${jetzt.channelId}>`);
@@ -107,7 +109,7 @@ export function musikEmbed(client, guildId) {
// auch nicht als einer dargestellt. // auch nicht als einer dargestellt.
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null;
embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n` embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n`
+ 'Was hier eingereiht wird, kommt als Nächstes dran.'); + 'Was hier eingereiht wird, übernimmt sofort — danach geht es zurück auf den Sender.');
} else { } else {
embed.setDescription(liste.length > 0 embed.setDescription(liste.length > 0
? 'Nichts läuft. **Überspringen** startet die Warteschlange — ' ? 'Nichts läuft. **Überspringen** startet die Warteschlange — '
@@ -149,7 +151,7 @@ export function musikKomponenten(guildId) {
const reihen = []; const reihen = [];
if (liste.length > 0) { if (liste.length > 0) {
const optionen = (praefix) => liste.map((e, i) => { const optionen = () => liste.map((e, i) => {
const o = new StringSelectMenuOptionBuilder() const o = new StringSelectMenuOptionBuilder()
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100)) .setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
.setValue(String(e.id)); .setValue(String(e.id));
@@ -171,13 +173,30 @@ export function musikKomponenten(guildId) {
// an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts // an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts
// bewirkt, sieht aus wie ein kaputter Knopf. // bewirkt, sieht aus wie ein kaputter Knopf.
const nichtsDa = liste.length === 0; const nichtsDa = liste.length === 0;
const ytLaeuft = jetzt?.quelle?.art === 'youtube';
const pausiert = Boolean(jetzt?.pausiertSeit);
// Zwei Knopfreihen statt einer: mit der Pause wären es sechs Knöpfe, und
// Discord lässt nur fünf je Reihe zu. Platz ist da — zwei Auswahlmenüs
// plus zwei Knopfreihen sind vier von fünf möglichen Reihen.
//
// Die Trennung ist ausserdem die sinnvollere: oben, was gerade klingt,
// unten, was mit der Liste passiert.
reihen.push(new ActionRowBuilder().addComponents(
// Pause gibt es nur bei YouTube. Warum, steht bei `pausieren()` in
// `radio.js` — kurz: einen Dauerstrom anzuhalten heisst, ihm
// hinterherzuhinken.
pausiert
? knopf('musik_pause', 'Weiter', '▶️', ButtonStyle.Success)
: knopf('musik_pause', 'Pause', '⏸️', ButtonStyle.Primary, !ytLaeuft),
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
nichtsDa && !ytLaeuft),
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
!ytLaeuft || !jetzt.heimStationId),
));
reihen.push(new ActionRowBuilder().addComponents( reihen.push(new ActionRowBuilder().addComponents(
knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success), knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success),
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
nichtsDa && jetzt?.quelle?.art !== 'youtube'),
knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa), knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
!jetzt || jetzt.quelle?.art !== 'youtube' || !jetzt.heimStationId),
knopf('musik_refresh', 'Aktualisieren', '🔄'), knopf('musik_refresh', 'Aktualisieren', '🔄'),
)); ));
return reihen; return reihen;
@@ -229,19 +248,55 @@ export function eintraegeAufnehmen(guildId, eintraege, wunschVon = null) {
} }
/** /**
* Läuft nichts, soll das Einreihen sofort Musik machen. * Nach dem Einreihen: soll das jetzt losgehen?
* *
* Sonst legt jemand drei Titel in eine Liste, es passiert nichts, und der * Drei Fälle, und der mittlere ist der, den ich am 28.08.2026 falsch hatte:
* naheliegende Schluss wäre „kaputt". Läuft dagegen schon etwas — Sender oder *
* Titel —, wird nicht dazwischengefunkt: die Liste kommt dran, wenn sie dran * nichts läuft → die Liste fängt an. Sonst legt jemand drei Titel hinein,
* ist. * es passiert nichts, und der naheliegende Schluss wäre
* „kaputt".
* Sender läuft → **der Titel übernimmt sofort.** „Das Radio ist die
* Grundstellung, YouTube die Unterbrechung" — und eine
* Unterbrechung, die wartet, ist keine. Hier stand vorher
* „die Liste kommt dran, wenn sie dran ist", und das war
* beim Sender schlicht falsch: ein Sender endet nie, also
* gibt es kein `Idle`, das weiterschaltet. Der einzige
* `Idle`, den es gibt, ist ein abgerissener Strom — und der
* startet denselben Sender neu. Der Titel wartete also auf
* einen Moment, der nie kommt. Im Betrieb gemeldet: Link
* eingereiht, Bot blieb beim Radio.
* Der Sender ist nicht verloren: er steht als `heimStation`
* im laufenden Eintrag und kommt zurück, wenn die Liste
* leer ist.
* Titel läuft → nicht dazwischenfunken. Der Neue reiht sich ein, und
* *hier* stimmt „kommt dran, wenn er dran ist" auch.
*/ */
export async function ggfAnwerfen(client, guildId, kanalId) { export async function ggfAnwerfen(client, guildId, kanalId) {
if (laeuft(guildId) || !kanalId) return false; const jetzt = laeuft(guildId);
const q = await weiter(client, guildId, { erzwungen: true, kanalId }); if (!uebernimmtSofort(jetzt)) return false;
// Ohne eigene Angabe der Kanal, in dem der Bot schon steht.
const ziel = jetzt?.channelId ?? kanalId;
if (!ziel) return false;
const q = await weiter(client, guildId, { erzwungen: true, kanalId: ziel });
return Boolean(q); return Boolean(q);
} }
/**
* Die Regel als eigener Satz — weil genau sie falsch war.
*
* Sie steckte als Bedingung mitten in `ggfAnwerfen` (`if (laeuft(guildId))
* return false`) und war von aussen weder zu lesen noch zu prüfen. Hier hat
* sie einen Namen und lässt sich mit allen drei Eingaben durchspielen.
*
* @param {object|null} jetzt Der laufende Eintrag, oder null
* @returns {boolean} true = der neue Titel fängt sofort an
*/
export function uebernimmtSofort(jetzt) {
// Nur ein laufender YouTube-Titel wird nicht unterbrochen. Ein Sender
// schon — er ist die Grundstellung, kein Vorrang.
return jetzt?.quelle?.art !== 'youtube';
}
// ── Suchtreffer zwischenlagern ──────────────────────────────────────────── // ── Suchtreffer zwischenlagern ────────────────────────────────────────────
// //
// Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine // Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine
@@ -300,7 +355,7 @@ async function zielKanal(interaction) {
} }
const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh', const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh',
'musik_vor', 'musik_weg']; 'musik_pause', 'musik_vor', 'musik_weg'];
/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */ /** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */
export async function handleMusikInteraction(interaction) { export async function handleMusikInteraction(interaction) {
@@ -402,6 +457,10 @@ export async function handleMusikInteraction(interaction) {
// und das über den drei Sekunden liegt. // und das über den drei Sekunden liegt.
await interaction.deferUpdate(); await interaction.deferUpdate();
await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id }); await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id });
} else if (customId === 'musik_pause') {
// Kein Aufschub nötig: Anhalten passiert im Speicher, ohne Netz.
pausieren(guildId);
await interaction.deferUpdate();
} else if (customId === 'musik_leeren') { } else if (customId === 'musik_leeren') {
queueClear(guildId); queueClear(guildId);
await interaction.deferUpdate(); await interaction.deferUpdate();
+100 -5
View File
@@ -51,7 +51,7 @@ 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, queueAdd, queueVor, queueNext, queueAdd, queueVor, queueStand,
} 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'; import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.js';
@@ -177,8 +177,14 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
* ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option * ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option
* sinnlos — es gibt nichts, wohin sich neu verbinden liesse. * sinnlos — es gibt nichts, wohin sich neu verbinden liesse.
* *
* `netz` entscheidet ausserdem, ob stdin offen ist, und das ist keine
* Feinheit: bei `-i pipe:0` MUSS stdin eine Rohrleitung sein. Stand hier
* `ignore` (wie es bis zum 28.08.2026 der Fall war), zeigt `pipe:0` auf
* /dev/null — ffmpeg meldet dann „Invalid data found when processing input",
* und `ff.stdin` ist `null`, woran der Aufbau der Kette vorher zerbrach.
*
* @param {string} eingabe Adresse oder `pipe:0` * @param {string} eingabe Adresse oder `pipe:0`
* @param {{netz: boolean}} opts * @param {{netz: boolean}} opts netz=true: ffmpeg holt selbst; false: es liest aus stdin
*/ */
function wandler(eingabe, { netz }) { function wandler(eingabe, { netz }) {
const ff = spawn(FFMPEG, [ const ff = spawn(FFMPEG, [
@@ -195,7 +201,8 @@ function wandler(eingabe, { netz }) {
'-f', 's16le', '-acodec', 'pcm_s16le', '-f', 's16le', '-acodec', 'pcm_s16le',
'-ar', '48000', '-ac', '2', '-ar', '48000', '-ac', '2',
'pipe:1', 'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] }); // Kein stdin bei einer Adresse, eine Rohrleitung bei `pipe:0`.
], { stdio: [netz ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
// Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen // Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen
ff.stderr.on('data', (d) => { ff.stderr.on('data', (d) => {
const text = d.toString().trim(); const text = d.toString().trim();
@@ -223,6 +230,17 @@ function tonquelle(quelle) {
} }
const yt = tonstrom(quelle.titel.url); const yt = tonstrom(quelle.titel.url);
const ff = wandler('pipe:0', { netz: false }); const ff = wandler('pipe:0', { netz: false });
// Startet ein Prozess nicht, sind seine Rohrenden `null`. Ohne diese
// Prüfung wirft schon das Anhängen der Fehlerbehandlung einen
// TypeError — und der reisst die ganze Interaktion mit, statt dass
// jemand „kein Ton" zu sehen bekommt.
if (!yt.stdout || !ff.stdin) {
yt.kill('SIGKILL');
ff.kill('SIGKILL');
throw new Error('Tonkette liess sich nicht aufbauen — '
+ `yt-dlp: ${yt.stdout ? 'ok' : 'kein Ausgang'}, `
+ `ffmpeg: ${ff.stdin ? 'ok' : 'kein Eingang'}.`);
}
yt.stdout.on('error', () => {}); yt.stdout.on('error', () => {});
ff.stdin.on('error', () => {}); ff.stdin.on('error', () => {});
yt.stdout.pipe(ff.stdin); yt.stdout.pipe(ff.stdin);
@@ -485,17 +503,80 @@ export function lautstaerkeSetzen(guildId, wunsch) {
return wert; return wert;
} }
/**
* Wie lange läuft der aktuelle Titel schon — Pausen abgezogen.
*
* An einer Stelle, weil drei Stellen es brauchen: die Musik-Tafel, das Panel
* und der Fortschrittsbalken. Wer das je einzeln rechnet, vergisst an einer
* davon die Pause, und dann behauptet ein Balken, ein angehaltener Titel
* liefe weiter.
*
* @returns {number} Sekunden
*/
export function verstrichen(eintrag) {
if (!eintrag) return 0;
const offen = eintrag.pausiertSeit ? Date.now() - eintrag.pausiertSeit : 0;
const netto = Date.now() - eintrag.seit - (eintrag.pauseGesamt ?? 0) - offen;
return Math.max(0, Math.floor(netto / 1000));
}
/** Läuft gerade etwas, das angehalten ist? */
export const istPausiert = (guildId) => Boolean(laufend.get(guildId)?.pausiertSeit);
/**
* Anhalten und weiterlaufen lassen.
*
* **Nur für YouTube, und das ist kein Vergessen.** Ein Sender ist ein
* Dauerstrom: hält man ihn an, läuft er beim Sender weiter, während sich
* hier die Leitung füllt. Beim Fortsetzen käme dann nicht „dort, wo es
* aufhörte", sondern eine Konserve, die immer weiter hinterherhinkt. Für
* einen Sender heisst Pause deshalb Stopp — und dafür gibt es den
* Stopp-Knopf.
*
* @param {string} guildId
* @param {boolean|null} wunsch true/false, oder null zum Umschalten
* @returns {boolean|null} der neue Zustand, oder null wenn nicht möglich
*/
export function pausieren(guildId, wunsch = null) {
const s = laufend.get(guildId);
if (!s || s.quelle?.art !== 'youtube') return null;
const soll = wunsch === null ? !s.pausiertSeit : Boolean(wunsch);
if (soll === Boolean(s.pausiertSeit)) return soll;
if (soll) {
// `true` schiebt Stille nach, statt die Verbindung stumm hängen zu
// lassen — sonst hält Discord den Sprecher für abgestürzt.
s.player.pause(true);
s.pausiertSeit = Date.now();
} else {
s.pauseGesamt = (s.pauseGesamt ?? 0) + (Date.now() - s.pausiertSeit);
s.pausiertSeit = null;
s.player.unpause();
}
return soll;
}
/** Alles abräumen — Verbindung, Spieler, ffmpeg */ /** Alles abräumen — Verbindung, Spieler, ffmpeg */
export function stoppen(guildId) { export function stoppen(guildId) {
const s = laufend.get(guildId); const s = laufend.get(guildId);
let abgeraeumt; let abgeraeumt;
if (s) { if (s) {
s.player.stop(true); // Reihenfolge ist hier alles: **erst aus der Karte, dann anhalten.**
ketteAbraeumen(s); //
// `player.stop()` meldet den Leerlauf SYNCHRON — gemessen am
// 28.08.2026 mit @discordjs/voice 0.19: der Idle-Melder läuft
// mitten in `stop()`, noch bevor die nächste Zeile dran ist. Stand
// `laufend.delete` danach, fand der Melder den Eintrag noch, hielt
// das Ende für „Titel vorbei" und schaltete weiter — auf den
// nächsten Titel oder zurück auf den Heimsender.
//
// Von aussen sah das so aus: der letzte verlässt den Kanal, der Bot
// geht raus und steht sofort wieder drin. Genau so gemeldet.
laufend.delete(guildId); laufend.delete(guildId);
// Sonst zählt der nächste Start ab dem alten Stand weiter und bricht // Sonst zählt der nächste Start ab dem alten Stand weiter und bricht
// womöglich sofort wieder ab. // womöglich sofort wieder ab.
fehlschlaege.delete(guildId); fehlschlaege.delete(guildId);
s.player.stop(true);
ketteAbraeumen(s);
// 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.
// //
@@ -657,6 +738,11 @@ export async function spielen(kanal, quelle) {
// die Vorgabe ist leise, damit niemand erschrickt. // die Vorgabe ist leise, damit niemand erschrickt.
resource.volume?.setVolume(radioLautstaerke() / 100); resource.volume?.setVolume(radioLautstaerke() / 100);
player.play(resource); player.play(resource);
// Der Spieler wird über Quellenwechsel hinweg wiederverwendet. Stand er
// beim Umschalten auf Pause, bliebe der neue Titel stumm stehen — und der
// neue Eintrag sagt „läuft". Zwei Aussagen, eine Wirklichkeit: hier wird
// sie geradegezogen.
player.unpause();
verbindung.subscribe(player); verbindung.subscribe(player);
// Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts // Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts
@@ -715,6 +801,10 @@ export async function spielen(kanal, quelle) {
heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null), heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null),
titel: quelle.art === 'youtube' ? quelle.titel.titel : null, titel: quelle.art === 'youtube' ? quelle.titel.titel : null,
seit: Date.now(), tonLaeuft: null, client: kanal.client, seit: Date.now(), tonLaeuft: null, client: kanal.client,
// Angehalten seit wann, und wie viel Pause insgesamt schon drin war.
// Ohne das zweite liefe der Fortschrittsbalken nach dem Fortsetzen
// um die Pausenlänge voraus.
pausiertSeit: null, pauseGesamt: 0,
}; };
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
@@ -815,6 +905,11 @@ export async function weiter(client, guildId, { erzwungen = false, kanalId: star
} }
throw error; throw error;
} }
// Der Wechsel gehört ins Log: von aussen sieht „YouTube uebernimmt"
// genauso aus wie „nichts passiert", und der Unterschied ist genau
// der, den es hier zu sehen gibt.
console.log(`[radio] ${quelle.art === 'youtube' ? '▶️' : '📻'} ${quelleName(quelle)}`
+ `${naechster ? ` (noch ${queueStand(guildId).n} in der Liste)` : ' — Warteschlange leer'}`);
const tafel = radioState(guildId); const tafel = radioState(guildId);
setRadioState({ setRadioState({
guildId, guildId,
+17 -4
View File
@@ -28,7 +28,7 @@ import {
createService, updateService, listServices, deleteService, createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage, savePage, getPage, listPages, listPublishedPages, deletePage,
lsState, lsModSizes, setLsModSize, lsState, lsModSizes, setLsModSize,
listStations, createStation, updateStation, deleteStation, listStations, getStation, createStation, updateStation, deleteStation,
queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand, queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand,
setRadioState, clearRadioState, radioState, setRadioState, clearRadioState, radioState,
} from '../db.js'; } from '../db.js';
@@ -40,7 +40,7 @@ import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../
import { istLsServer, modBasis } from '../bot/ls-farm.js'; import { istLsServer, modBasis } from '../bot/ls-farm.js';
import { import {
titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle, titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle,
lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, pausieren, verstrichen,
} from '../bot/radio.js'; } from '../bot/radio.js';
import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js'; import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js';
import { import {
@@ -469,8 +469,10 @@ export function registerApiRoutes(app, client) {
kanal: t?.kanal ?? null, kanal: t?.kanal ?? null,
// Sekunden statt eines Zeitstempels: der Browser muesste sonst // Sekunden statt eines Zeitstempels: der Browser muesste sonst
// gegen die Uhr des Servers rechnen, und die beiden gehen selten // gegen die Uhr des Servers rechnen, und die beiden gehen selten
// gleich. // gleich. `verstrichen()` zieht Pausen ab -- dieselbe Rechnung
verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000), // wie in der Discord-Tafel, nicht eine zweite daneben.
verstrichen: verstrichen(jetzt),
pausiert: Boolean(jetzt.pausiertSeit),
dauer: t ? t.dauer : null, dauer: t ? t.dauer : null,
kanalId: jetzt.channelId, kanalId: jetzt.channelId,
kanalName: kanal?.name ?? null, kanalName: kanal?.name ?? null,
@@ -540,6 +542,17 @@ export function registerApiRoutes(app, client) {
// das am Ergebnis sehen. // das am Ergebnis sehen.
await tafelnMelden(client, guildId); await tafelnMelden(client, guildId);
return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) }; return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) };
} else if (aktion === 'pause') {
// Gibt null zurueck, wenn nichts oder ein Sender laeuft --
// dann ist Pause nicht vorgesehen, und das wird gesagt statt
// still nichts zu tun.
const neu = pausieren(guildId, b.wert === undefined ? null : Boolean(b.wert));
if (neu === null) {
return reply.code(400).send({
error: 'Pause gibt es nur bei YouTube -- ein Sender laesst sich nicht anhalten, '
+ 'nur stoppen.',
});
}
} else if (aktion === 'skip') { } else if (aktion === 'skip') {
const kanalId = zielId(); const kanalId = zielId();
if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' }); if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' });