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