Radio youtube #2
@@ -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
@@ -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
@@ -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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user