From af16bc1b185c431ece0cb0eb9f7e6e2533ae03ec Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 28 Aug 2026 13:26:05 +0200 Subject: [PATCH 1/6] YouTube als zweite Tonquelle: yt-dlp, Warteschlange, Abbild Das Fundament, noch ohne Anbindung ans Radio. yt-dlp holt selbst und schiebt rohe Bytes durch ein Rohr an ffmpeg. Der bequemere Weg waere `-g` und die fertige Adresse -- der ist eine Falle: googlevideo-Adressen laufen ab und haengen an der IP, die sie geholt hat. Das schlaegt nach zehn Minuten zu und sieht dann aus wie ein Netzproblem. Beim Stroemen nach stdout ist die Vorgabe von yt-dlp *mit* Bild; ohne ein ausdrueckliches `-f bestaudio/best` laedt ein Tonstrom das ganze Video. Nachgelesen im README der Fassung 2026.08.19. stderr wird aufgehoben statt weggeworfen. Genau hier entsteht sonst der Fehler, den niemand deuten kann: YouTube antwortet "Sign in to confirm", yt-dlp bricht ab, der Bot sitzt still im Kanal. Die letzte ERROR-Zeile geht ins Panel. Gemessen am 28.08.2026 gegen echtes YouTube (yt-dlp 2026.08.19, von hier aus, NICHT auf dem Zielhost): Einzelvideo, Suche und flache Playlist liefern die erwarteten Felder, eine kaputte Video-ID meldet "Video unavailable" und wird als Fehler durchgereicht statt als leere Liste. Die Warteschlange steht in der Datenbank, nicht nur im Speicher -- ein Deploy soll die Musik unterbrechen, nicht die Wuensche von fuenf Leuten wegwerfen. Die YouTube-Tafel bekommt eine eigene Tabelle, weil `radio_state` beim Stoppen geloescht wird und die Tafel das ueberleben soll. Nebenbei: der ffmpeg-Kommentar im Dockerfile beschrieb noch die alte Ogg/Opus-Kette. Seit der Lautstaerkeregelung laeuft dort PCM. --- Dockerfile | 23 ++- src/bot/youtube.js | 378 ++++++++++++++++++++++++++++++++++++++++ src/db.js | 139 +++++++++++++++ src/runtime-settings.js | 25 +++ 4 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 src/bot/youtube.js diff --git a/Dockerfile b/Dockerfile index e6109eb..941b6ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,30 @@ FROM node:22-slim # Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften), # git für die Repo-Backups (git clone --mirror + bundle), -# ffmpeg fürs Radio: es holt den MP3-Strom und gibt direkt Ogg/Opus aus — damit -# braucht es keine Opus-Bibliothek in Node, die erst gebaut werden müsste +# ffmpeg fürs Radio: es holt den Strom und gibt rohe Abtastwerte (PCM) aus, an +# denen die Sprachbibliothek die Lautstärke regelt, bevor sie nach Opus packt. +# Bis zur Lautstärkeregelung lief hier Ogg/Opus unverändert durch — an fertigen +# Opus-Paketen lässt sich die Lautstärke nicht drehen. RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core git ffmpeg \ && rm -rf /var/lib/apt/lists/* +# yt-dlp fürs YouTube-Einreihen im Radio. +# +# Die eigenständige Binärdatei statt `pip install`: sie bringt ihr eigenes +# Python mit, also kein python3-pip im Abbild und kein Compiler. `yt-dlp_linux` +# verlangt glibc 2.17+, node:22-slim (bookworm) hat 2.36. +# +# Bewusst die *jeweils neueste* Fassung und keine feste: YouTube ändert seine +# Sperren schneller, als hier jemand eine Versionsnummer nachzieht, und ein +# veraltetes yt-dlp ist die häufigste Ursache für „geht plötzlich nicht mehr". +# Damit hängt der Bau an GitHub — dieselbe Abhängigkeit, die `apt-get` und +# `npm ci` schon haben. +# +# Der `--version`-Aufruf am Ende ist kein Beiwerk: ohne ihn fällt ein kaputter +# Download erst im Betrieb auf, als stiller Sprachkanal statt als Baufehler. +ADD https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux /usr/local/bin/yt-dlp +RUN chmod 0755 /usr/local/bin/yt-dlp && /usr/local/bin/yt-dlp --version + WORKDIR /app # Erst nur Manifest kopieren → Docker-Layer-Cache für npm ci diff --git a/src/bot/youtube.js b/src/bot/youtube.js new file mode 100644 index 0000000..1d5c553 --- /dev/null +++ b/src/bot/youtube.js @@ -0,0 +1,378 @@ +// YouTube als zweite Tonquelle — alles, was yt-dlp kennt, steht hier. +// +// Der Kopf von `radio.js` hat YouTube jahrelang ausgeschlossen, und die +// Begründung war nicht falsch: Groovy und Rythm sind daran gestorben, und ein +// Extraktor ist ein bewegliches Ziel. Die Entscheidung ist am 28.08.2026 +// gefallen, YouTube kommt trotzdem rein — dann aber so gebaut, dass ein +// Ausfall *sichtbar* ist statt Stille im Sprachkanal. +// +// Drei Bauentscheidungen, die man sonst später rät: +// +// 1. **yt-dlp holt selbst, ffmpeg bekommt ein Rohr.** +// Der bequeme Weg wäre `yt-dlp -g` und die fertige Adresse an ffmpeg. Der +// ist eine Falle: googlevideo-Adressen laufen ab und hängen an der IP, die +// sie geholt hat. Das schlägt nach zehn Minuten zu und sieht dann aus wie +// ein Netzproblem. Also `-o -` in eine Rohrleitung — Fragmente, Drosselung +// und Ablauf bleiben yt-dlps Problem, und dafür ist es gebaut. +// +// 2. **Das Format wird ausdrücklich gesetzt.** +// Nachgelesen im yt-dlp-README (Fassung 2026.08.19, gelesen am 28.08.2026): +// beim Strömen nach stdout ändert sich die Vorgabe auf +// `best/bestvideo+bestaudio` — also mit Bild. Wer das nicht überschreibt, +// lädt für einen Tonstrom das ganze Video. `bestaudio/best` ist gemeint; +// `bestaudio*` steht im README ausdrücklich als „Do not use!". +// +// 3. **stderr wird aufgehoben, nicht weggeworfen.** +// Genau hier entsteht sonst der Fehler, den niemand deuten kann: YouTube +// antwortet „Sign in to confirm you're not a bot" oder „HTTP Error 403", +// yt-dlp bricht ab, der Bot sitzt still im Kanal. Die letzte Fehlerzeile +// wird deshalb festgehalten und im Panel angezeigt. +// +// Zur Lage, damit sie niemand für einen Baufehler hier hält: YouTube verlangt +// inzwischen für die meisten Clients einen PO-Token (nachgelesen im +// yt-dlp-Wiki „PO Token Guide", 28.08.2026 — NICHT auf diesem Server +// gemessen). Rechenzentrums-Adressen werden zusätzlich gern geblockt. Dagegen +// gibt es hier zwei Griffe, beide ohne Neubau des Abbilds: eine `cookies.txt` +// und ein Feld für eigene Argumente. +import { spawn } from 'node:child_process'; +import { existsSync, statSync } from 'node:fs'; +import { writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { bildOrdner } from './bilder.js'; +import { ytdlpArgumente } from '../runtime-settings.js'; + +const YTDLP = process.env.YTDLP_PATH || 'yt-dlp'; + +/** Neben der SQLite, also auf dem Volume — ein Deploy soll die Kekse behalten */ +export const RADIO_ORDNER = bildOrdner('radio'); +export const COOKIE_DATEI = join(RADIO_ORDNER, 'yt-cookies.txt'); + +// Wie viele Treffer eine Suche zurückgibt. Fünf, weil ein Discord-Auswahlmenü +// mit fünf Zeilen noch lesbar ist und die Suche danach spürbar länger dauert. +export const TREFFER = 5; +// Obergrenze beim Einreihen einer Playlist. Wer 400 Titel einreiht, meint es +// nicht so — und die Tafel könnte sie ohnehin nicht zeigen. +export const PLAYLIST_MAX = 50; + +// ── Ist yt-dlp überhaupt da? ────────────────────────────────────────────── +// +// Dieselbe Frage wie bei ffmpeg und aus demselben Grund: ohne die Antwort +// setzt sich der Bot hin und schweigt. Einmal beim Start geprüft, danach im +// Panel angesagt statt nur ins Log geschrieben. + +let ytdlpDa = null; // null = noch nicht geprüft +let fassung = null; + +export const ytdlpVorhanden = () => ytdlpDa; +export const ytdlpFassung = () => fassung; + +export function ytdlpPruefen() { + return new Promise((fertig) => { + let text = ''; + const p = spawn(YTDLP, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }); + p.stdout.on('data', (d) => { text += d.toString(); }); + p.on('error', () => { ytdlpDa = false; fassung = null; fertig(false); }); + p.on('close', (code) => { + ytdlpDa = code === 0; + fassung = ytdlpDa ? text.trim().split('\n')[0] || null : null; + fertig(ytdlpDa); + }); + }); +} + +// ── Der letzte Fehler, im Klartext ──────────────────────────────────────── +// +// Eine einzelne Zeile, absichtlich global und nicht je Server: wenn YouTube +// blockt, blockt es für alle, und die Ursache ist dieselbe. Sie steht im +// Panel, damit niemand aus Stille auf einen Grund schliessen muss. + +let letzteMeldung = null; // { text, zeit } + +export const letzterFehler = () => letzteMeldung; +export const fehlerVergessen = () => { letzteMeldung = null; }; + +/** + * Aus yt-dlps stderr die eine Zeile ziehen, die etwas aussagt. + * + * yt-dlp schreibt Warnungen und den eigentlichen Fehler in denselben Strom. + * Die letzte `ERROR:`-Zeile ist die brauchbare; gibt es keine, tut es die + * letzte nicht-leere Zeile. Ohne diese Auswahl steht im Panel gern eine + * Warnung über Untertitel, während der Abbruch drei Zeilen höher stand. + */ +export function fehlerZeile(stderr) { + const zeilen = String(stderr ?? '').split('\n').map((z) => z.trim()).filter(Boolean); + if (zeilen.length === 0) return null; + const echt = zeilen.filter((z) => /^ERROR:/i.test(z)); + return (echt.at(-1) ?? zeilen.at(-1)).slice(0, 400); +} + +function fehlerMerken(text) { + if (!text) return; + letzteMeldung = { text, zeit: new Date().toISOString() }; + console.error('[youtube]', text); +} + +// ── Cookies ─────────────────────────────────────────────────────────────── + +export const cookiesVorhanden = () => existsSync(COOKIE_DATEI); + +/** Wie gross und wie alt — damit das Panel mehr sagen kann als „ja" */ +export function cookiesStand() { + if (!cookiesVorhanden()) return null; + try { + const s = statSync(COOKIE_DATEI); + return { bytes: s.size, geaendert: s.mtime.toISOString() }; + } catch { + return null; + } +} + +/** + * Cookies setzen oder löschen. Leerer Text = löschen. + * + * Geprüft wird nur die Form, nicht der Inhalt: eine Netscape-Cookie-Datei + * beginnt mit einer Kennzeile. Wer eine JSON-Ausgabe aus einer Erweiterung + * hineinkopiert, bekommt sonst eine Datei, die yt-dlp wortlos ignoriert — + * und sucht den Fehler dann bei YouTube. + */ +export async function cookiesSchreiben(text) { + const inhalt = String(text ?? '').trim(); + if (!inhalt) { + await rm(COOKIE_DATEI, { force: true }); + return { gesetzt: false }; + } + if (!/^#\s*(Netscape|HTTP Cookie File)/i.test(inhalt)) { + throw new Error('Das sieht nicht nach einer Netscape-Cookie-Datei aus — ' + + 'die erste Zeile muss „# Netscape HTTP Cookie File" sein.'); + } + await writeFile(COOKIE_DATEI, `${inhalt}\n`, { mode: 0o600 }); + return { gesetzt: true }; +} + +// ── Argumente ───────────────────────────────────────────────────────────── + +/** + * Eine Kommandozeile in einzelne Argumente zerlegen, mit Anführungszeichen. + * + * Gebraucht, weil `--extractor-args "youtube:player_client=android_vr"` ein + * Argument ist und nicht zwei. Bewusst kein Umweg über eine Shell: das wäre + * eine Kommandozeile, die aus dem Panel kommt, und die soll nichts anderes + * starten können als yt-dlp mit Argumenten. + */ +export function argumenteZerlegen(text) { + const raus = []; + const re = /"([^"]*)"|'([^']*)'|(\S+)/g; + let treffer; + while ((treffer = re.exec(String(text ?? ''))) !== null) { + raus.push(treffer[1] ?? treffer[2] ?? treffer[3]); + } + return raus; +} + +/** + * Was bei jedem Aufruf mitgeht. + * + * `--ignore-config` steht bewusst dabei: yt-dlp liest sonst Konfigurationen + * aus dem Dateisystem, und im Container wäre das eine unsichtbare Quelle für + * Verhalten, das hier niemand geschrieben hat. + */ +function grundArgumente() { + const args = [ + '--ignore-config', + '--no-warnings', + '--no-colors', + '--socket-timeout', '15', + ]; + if (cookiesVorhanden()) args.push('--cookies', COOKIE_DATEI); + args.push(...argumenteZerlegen(ytdlpArgumente())); + return args; +} + +/** Die Argumentliste, wie sie tatsächlich rausgeht — fürs Panel, ohne Raten */ +export function argumenteVorschau() { + return grundArgumente().join(' '); +} + +// ── Auskunft holen ──────────────────────────────────────────────────────── + +/** + * yt-dlp aufrufen und auf das Ende warten. + * + * Mit eigener Frist: ein Aufruf, der auf eine Sperre läuft, kann sehr lange + * hängen, und eine Discord-Interaktion ist nach drei Sekunden weg. Lieber + * abbrechen und sagen, dass abgebrochen wurde. + */ +function laufen(args, { timeoutMs = 25_000 } = {}) { + return new Promise((fertig) => { + let aus = ''; + let fehler = ''; + let abgebrochen = false; + const p = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const frist = setTimeout(() => { abgebrochen = true; p.kill('SIGKILL'); }, timeoutMs); + p.stdout.on('data', (d) => { aus += d.toString(); }); + p.stderr.on('data', (d) => { fehler += d.toString(); }); + p.on('error', (e) => { + clearTimeout(frist); + fertig({ code: -1, aus: '', fehler: `yt-dlp startet nicht: ${e.message}`, abgebrochen }); + }); + p.on('close', (code) => { + clearTimeout(frist); + fertig({ + code, + aus, + fehler: abgebrochen ? `Zeitüberschreitung nach ${timeoutMs / 1000} s` : fehler, + abgebrochen, + }); + }); + }); +} + +// Eine Zeile Ausgabe je Eintrag, als JSON. Das ist die Form, die yt-dlp selbst +// für Programme empfiehlt („avoid parsing the normal stdout"), und sie ist +// deutlich sparsamer als `-J`: die volle Auskunft zu einem Video enthält jede +// Formatvariante und geht in die Hunderttausende Zeichen. +const SPALTEN = '%(.{id,title,duration,webpage_url,url,uploader,thumbnail,live_status})j'; + +/** Eine Ausgabezeile in einen Eintrag übersetzen; null, wenn unbrauchbar */ +function eintragLesen(zeile) { + let d; + try { + d = JSON.parse(zeile); + } catch { + return null; + } + if (!d?.id) return null; + // Bei `--flat-playlist` fehlt `webpage_url` und stattdessen steht die + // Adresse in `url`. Ohne diesen Rückfall wären Playlist-Einträge lauter + // Titel ohne Ziel — sie liessen sich einreihen und nicht abspielen. + const url = d.webpage_url || d.url || `https://www.youtube.com/watch?v=${d.id}`; + return { + video_id: String(d.id), + url, + titel: String(d.title ?? 'Ohne Titel').slice(0, 200), + // Livestreams haben keine Dauer. `null` heisst hier genau das und wird + // in der Tafel als „live" gezeigt — nicht als „0:00". + dauer: Number.isFinite(d.duration) ? Math.round(d.duration) : null, + kanal: d.uploader ? String(d.uploader).slice(0, 120) : null, + thumb: typeof d.thumbnail === 'string' ? d.thumbnail : null, + live: d.live_status === 'is_live', + }; +} + +/** Sieht das nach einer Adresse aus — oder ist es ein Suchbegriff? */ +export const istAdresse = (text) => /^https?:\/\//i.test(String(text ?? '').trim()); + +/** Zeigt diese YouTube-Adresse auf eine Playlist? */ +export const istPlaylist = (text) => /[?&]list=/.test(String(text ?? '')); + +/** + * Eingabe auflösen: Adresse, Playlist oder Suchbegriff. + * + * Eine Funktion für alle drei, weil das Panel und die Discord-Tafel dieselbe + * Frage stellen. Zwei Auflöser nebeneinander wären zwei Gelegenheiten, sich + * beim Format zu unterscheiden. + * + * @returns {Promise<{treffer: object[], suche: boolean, fehler: string|null}>} + */ +export async function aufloesen(eingabe, { max = TREFFER } = {}) { + const text = String(eingabe ?? '').trim(); + if (!text) return { treffer: [], suche: false, fehler: 'Nichts eingegeben.' }; + if (ytdlpDa === false) { + return { treffer: [], suche: false, fehler: 'Auf dem Server fehlt yt-dlp.' }; + } + + const suche = !istAdresse(text); + const args = [...grundArgumente(), '--print', SPALTEN]; + if (suche) { + args.push(`ytsearch${Math.max(1, Math.min(25, max))}:${text}`); + } else if (istPlaylist(text)) { + // Flach und gedeckelt: eine Playlist einzeln aufzulösen dauert je + // Eintrag eine Anfrage — bei fünfzig Titeln ist die Interaktion längst + // abgelaufen, bevor der erste Ton kommt. + args.push('--flat-playlist', '-I', `1:${PLAYLIST_MAX}`, text); + } else { + args.push('--no-playlist', text); + } + + const { code, aus, fehler } = await laufen(args, { timeoutMs: suche ? 25_000 : 20_000 }); + const treffer = aus.split('\n').map((z) => z.trim()).filter(Boolean) + .map(eintragLesen).filter(Boolean); + + if (treffer.length > 0) return { treffer, suche, fehler: null }; + + // Kein Treffer *und* kein Fehlercode heisst wirklich „nichts gefunden". + // Alles andere ist ein Fehler und wird als solcher gemeldet — eine leere + // Liste, die in Wahrheit ein Abbruch war, ist die schlimmste Auskunft. + const meldung = fehlerZeile(fehler); + if (code === 0 && !meldung) { + return { + treffer: [], suche, + fehler: suche ? 'Nichts gefunden.' : 'Dazu gibt es nichts abzuspielen.', + }; + } + fehlerMerken(meldung ?? `yt-dlp endete mit Code ${code}`); + return { treffer: [], suche, fehler: meldung ?? `yt-dlp endete mit Code ${code}` }; +} + +// ── Ton ─────────────────────────────────────────────────────────────────── + +/** + * Der Tonstrom eines Titels — rohe Bytes auf stdout, für ffmpeg. + * + * Der Prozess gehört dem Aufrufer: er muss ihn abräumen. `radio.js` tut das + * in `stoppen()` und bei jedem Quellenwechsel, sonst bliebe je übersprungenem + * Titel ein yt-dlp stehen und lüde weiter. + */ +export function tonstrom(url) { + const p = spawn(YTDLP, [ + ...grundArgumente(), + '--no-playlist', + // Siehe Kopf: beim Strömen nach stdout ist die Vorgabe *mit* Bild. + '-f', 'bestaudio/best', + // Ein Fortschrittsbalken in eine Rohrleitung hilft niemandem und + // füllt nur stderr. + '--no-progress', + '-o', '-', + url, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + // stderr muss gelesen werden, sonst läuft die Rohrleitung voll und der + // Prozess bleibt stehen — derselbe Grund wie bei ffmpeg in `radio.js`. + // Gesammelt statt sofort gedruckt: erst beim Abbruch weiss man, ob eine + // Zeile eine Warnung war oder die Ursache. + let gesammelt = ''; + p.stderr.on('data', (d) => { + gesammelt = `${gesammelt}${d.toString()}`.slice(-4000); + }); + p.on('error', (e) => fehlerMerken(`yt-dlp startet nicht: ${e.message}`)); + p.on('close', (code) => { + // 0 = fertig, null/SIGKILL = wir haben ihn selbst abgeräumt (Skip, + // Stopp, Wechsel). Beides ist kein Fehler und darf die Anzeige im + // Panel nicht mit einer Falschmeldung überschreiben. + if (code === 0 || code === null) return; + fehlerMerken(fehlerZeile(gesammelt) ?? `yt-dlp endete mit Code ${code}`); + }); + return p; +} + +// ── Aktualisieren ───────────────────────────────────────────────────────── + +/** + * `yt-dlp -U` — die eingebaute Selbstaktualisierung. + * + * Ehrlich zur Reichweite: das schreibt die Binärdatei im *laufenden* Container + * neu. Wird der Container neu angelegt, gilt wieder die Fassung aus dem + * Abbild. Das ist kein Fehler, sondern die Bauweise — und es steht genau so + * im Panel, damit niemand ein Rätsel daraus macht. + */ +export async function selbstAktualisieren() { + const { code, aus, fehler } = await laufen(['--update'], { timeoutMs: 120_000 }); + await ytdlpPruefen(); + const text = `${aus}\n${fehler}`.trim(); + if (code !== 0) { + const meldung = fehlerZeile(fehler) ?? `yt-dlp endete mit Code ${code}`; + fehlerMerken(meldung); + throw new Error(meldung); + } + return { fassung, ausgabe: text.slice(-1000) }; +} diff --git a/src/db.js b/src/db.js index 8115b89..1e0dd63 100644 --- a/src/db.js +++ b/src/db.js @@ -1968,3 +1968,142 @@ export const setRadioState = (s) => setRadioStateStmt.run({ panel_message_id: s.panelMessageId ? String(s.panelMessageId) : null, }); export const clearRadioState = (guildId) => clearRadioStateStmt.run(guildId).changes > 0; + +// ── Warteschlange (YouTube) ─────────────────────────────────────────────── +// +// Der ganze Unterschied zwischen einem Sender und einem YouTube-Titel ist, +// dass der Titel endet. Daraus folgt alles Weitere: es muss etwas geben, das +// danach kommt, und das ist diese Liste. +// +// Sie steht in der Datenbank und nicht nur im Speicher, aus demselben Grund +// wie `radio_state`: ein Deploy soll die Musik unterbrechen, nicht die +// Wünsche von fünf Leuten wegwerfen. +// +// `sort` statt einer verketteten Liste, weil das Panel umsortieren können soll +// — und weil eine Liste, deren Reihenfolge nur aus `id` folgt, sich genau +// einmal umsortieren lässt: gar nicht. +db.exec(` + CREATE TABLE IF NOT EXISTS radio_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + video_id TEXT NOT NULL, + url TEXT NOT NULL, + titel TEXT NOT NULL, + dauer INTEGER, + kanal TEXT, + thumb TEXT, + wunsch_von TEXT, + sort INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS radio_queue_guild ON radio_queue (guild_id, sort, id); +`); + +const queueListStmt = db.prepare( + 'SELECT * FROM radio_queue WHERE guild_id = ? ORDER BY sort, id' +); +const queueGetStmt = db.prepare('SELECT * FROM radio_queue WHERE id = ?'); +const queueCountStmt = db.prepare( + 'SELECT COUNT(*) AS n, COALESCE(SUM(dauer), 0) AS dauer FROM radio_queue WHERE guild_id = ?' +); +const queueAddStmt = db.prepare(` + INSERT INTO radio_queue (guild_id, video_id, url, titel, dauer, kanal, thumb, wunsch_von, sort) + VALUES (@guild_id, @video_id, @url, @titel, @dauer, @kanal, @thumb, @wunsch_von, + (SELECT COALESCE(MAX(sort), 0) + 1 FROM radio_queue WHERE guild_id = @guild_id)) +`); +const queueHeadStmt = db.prepare( + 'SELECT * FROM radio_queue WHERE guild_id = ? ORDER BY sort, id LIMIT 1' +); +const queueDeleteStmt = db.prepare('DELETE FROM radio_queue WHERE id = ?'); +const queueClearStmt = db.prepare('DELETE FROM radio_queue WHERE guild_id = ?'); +const queueVorStmt = db.prepare(` + UPDATE radio_queue + SET sort = (SELECT COALESCE(MIN(sort), 0) - 1 FROM radio_queue WHERE guild_id = @guild_id) + WHERE id = @id AND guild_id = @guild_id +`); +const queueSortStmt = db.prepare( + 'UPDATE radio_queue SET sort = @sort WHERE id = @id AND guild_id = @guild_id' +); + +export const queueList = (guildId) => queueListStmt.all(String(guildId)); +export const queueGet = (id) => queueGetStmt.get(Number(id)) ?? null; +export const queuePeek = (guildId) => queueHeadStmt.get(String(guildId)) ?? null; +/** Wie viele und wie lange — für die Tafel, ohne die ganze Liste zu holen */ +export const queueStand = (guildId) => queueCountStmt.get(String(guildId)); + +export const queueAdd = (guildId, e) => queueAddStmt.run({ + guild_id: String(guildId), + video_id: String(e.video_id ?? ''), + url: String(e.url ?? ''), + titel: String(e.titel ?? 'Ohne Titel').slice(0, 200), + // Livestreams haben keine Dauer. NULL heisst genau das — eine 0 würde in + // der Gesamtdauer mitgezählt und behauptete, der Titel sei sofort vorbei. + dauer: Number.isFinite(Number(e.dauer)) && e.dauer !== null ? Number(e.dauer) : null, + kanal: e.kanal ? String(e.kanal).slice(0, 120) : null, + thumb: e.thumb ? String(e.thumb).slice(0, 500) : null, + wunsch_von: e.wunsch_von ? String(e.wunsch_von).slice(0, 80) : null, +}).lastInsertRowid; + +export const queueRemove = (id) => queueDeleteStmt.run(Number(id)).changes > 0; +export const queueClear = (guildId) => queueClearStmt.run(String(guildId)).changes; +export const queueVor = (guildId, id) => + queueVorStmt.run({ guild_id: String(guildId), id: Number(id) }).changes > 0; + +/** + * Den nächsten Titel nehmen — holen und entfernen in einem Zug. + * + * Als Transaktion, weil sonst zwei gleichzeitige Auslöser (der Titel endet + * *und* jemand drückt „Überspringen") denselben Eintrag bekommen und einer + * davon doppelt läuft. + */ +export const queueNext = db.transaction((guildId) => { + const e = queueHeadStmt.get(String(guildId)); + if (e) queueDeleteStmt.run(e.id); + return e ?? null; +}); + +/** + * Neue Reihenfolge setzen. Nur Einträge dieses Servers, nur bekannte IDs. + * + * Der Server bestimmt die Zahlen, nicht der Browser: was von aussen kommt, + * ist eine Reihenfolge von IDs und sonst nichts. + */ +export const queueReorder = db.transaction((guildId, ids) => { + let n = 0; + ids.forEach((id, i) => { + n += queueSortStmt.run({ guild_id: String(guildId), id: Number(id), sort: i }).changes; + }); + return n; +}); + +// ── Die zweite Tafel (YouTube) ──────────────────────────────────────────── +// +// Eigene Tabelle statt zweier Spalten in `radio_state`, und das ist kein +// Geschmack: `radio_state` wird beim Stoppen gelöscht. Die YouTube-Tafel soll +// das überleben — sie steht ja weiter im Kanal, und wer nach dem Stoppen +// wieder etwas einreiht, erwartet, dass sich genau diese Nachricht aktualisiert +// statt eine zweite danebenzustellen. +db.exec(` + CREATE TABLE IF NOT EXISTS radio_yt_panel ( + guild_id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL + ); +`); +const ytPanelStmt = db.prepare('SELECT * FROM radio_yt_panel WHERE guild_id = ?'); +const ytPanelsStmt = db.prepare('SELECT * FROM radio_yt_panel'); +const setYtPanelStmt = db.prepare(` + INSERT INTO radio_yt_panel (guild_id, channel_id, message_id) + VALUES (@guild_id, @channel_id, @message_id) + ON CONFLICT(guild_id) DO UPDATE SET + channel_id = excluded.channel_id, message_id = excluded.message_id +`); +const clearYtPanelStmt = db.prepare('DELETE FROM radio_yt_panel WHERE guild_id = ?'); +export const ytPanel = (guildId) => ytPanelStmt.get(String(guildId)) ?? null; +export const ytPanels = () => ytPanelsStmt.all(); +export const setYtPanel = (guildId, channelId, messageId) => setYtPanelStmt.run({ + guild_id: String(guildId), + channel_id: String(channelId), + message_id: String(messageId), +}); +export const clearYtPanel = (guildId) => clearYtPanelStmt.run(String(guildId)).changes > 0; diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 09e5859..eec6d00 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -384,3 +384,28 @@ export function radioLautstaerke() { if (!Number.isFinite(roh)) return LAUT_VORGABE; return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(roh))); } + +/** + * Darf das Radio auch YouTube abspielen? + * + * Getrennt vom Modul-Schalter, und zwar mit Absicht: YouTube ist der Teil, + * der von aussen kaputtgehen kann (gesperrte Adressen, PO-Token, ein yt-dlp, + * das seit drei Wochen nicht aktualisiert wurde). Wenn das klemmt, soll man + * genau das abschalten können — und nicht das Radio mit, das davon nichts hat. + * + * Vorgabe: aus. Wer YouTube will, sagt es einmal. + */ +export function youtubeAn() { + return getSetting('radio_youtube_enabled') === '1'; +} + +/** + * Eigene Argumente für yt-dlp, roh wie eingetippt. + * + * Das Feld gibt es, weil YouTube seine Sperren schneller ändert, als hier ein + * Abbild neu gebaut wird: `--extractor-args`, ein anderer Client, eine + * Bandbreitenbremse. Ohne dieses Feld hiesse jede solche Anpassung Deploy. + */ +export function ytdlpArgumente() { + return getSetting('ytdlp_args') ?? ''; +} From 409b28e9e0eb08857b9283ff9f4bc031cd611027 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 28 Aug 2026 13:33:42 +0200 Subject: [PATCH 2/6] Eine Quelle, zwei Arten -- Sender und Titel gehen denselben Weg Der ganze Unterschied ist einer: ein Sender endet nie, ein Titel schon. Also verzweigt genau eine Abspielfunktion und genau ein Idle-Melder nach der Art der Quelle, statt dass ein zweiter Pfad daneben entsteht -- zwei Pfade, die dasselbe tun sollen, driften auseinander. Beim Sender heisst Idle "die Leitung ist weg" (neu aufbauen), bei YouTube "fertig, der naechste bitte". Danach: Warteschlange, sonst der Sender von vorher, sonst raus. Das Radio ist die Grundstellung, YouTube die Unterbrechung. Bremse gegen das Durchrutschen: ein Eintrag ohne Ton geht sofort wieder auf Idle. Ohne Zaehlung waere eine Liste mit einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand haette gesehen warum. Nach drei Fehlschlaegen in Folge wird angehalten und der Grund gemeldet. Von Hand Uebersprungenes zaehlt nicht mit. Zwei Fehler nebenbei, beide vorher schon da: Der Idle-Melder hielt `kanal` aus dem Abschluss des ERSTEN Aufrufs fest. Nach einem Kanalwechsel haette der Wiederanlauf in den alten Kanal gespielt. Er liest den Kanal jetzt frisch aus `laufend`. `radioLautstaerke()` gab bei unbelegter Einstellung 10 zurueck statt der 50, die der Kommentar zwei Zeilen darueber als Vorgabe begruendet: `Number(null)` ist 0 und damit endlich, die Pruefung auf "nicht endlich" hat den unbelegten Fall nie erwischt und auf die Untergrenze geklemmt. Eine frische Installation startete also sehr leise. Gemessen gegen eine leere Datenbank, mit Gegenproben fuer leeren String, Unsinn, 0 und 999. --- src/bot/commands/radio.js | 10 +- src/bot/radio.js | 363 +++++++++++++++++++++++++++++++++----- src/runtime-settings.js | 15 +- 3 files changed, 341 insertions(+), 47 deletions(-) diff --git a/src/bot/commands/radio.js b/src/bot/commands/radio.js index 2e940d9..e264fb8 100644 --- a/src/bot/commands/radio.js +++ b/src/bot/commands/radio.js @@ -8,6 +8,7 @@ import { moduleEnabled } from '../../modules.js'; import { listStations, getStation, setRadioState } from '../../db.js'; import { spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken, + radioQuelle, } from '../radio.js'; export const data = new SlashCommandBuilder() @@ -80,12 +81,15 @@ export async function execute(interaction) { // Verbinden dauert länger als die drei Sekunden, die Discord gibt await interaction.deferReply(); - let station = schonAn ? getStation(schonAn.stationId) : null; + // Der Heimsender, nicht die laufende Quelle: läuft gerade ein + // YouTube-Titel, gehört in die Tafel trotzdem der Sender, auf den danach + // zurückgefallen wird. + let station = schonAn?.heimStationId ? getStation(schonAn.heimStationId) : null; let titel = schonAn?.titel ?? null; if (!schonAn) { station = stationen[0]; try { - await spielen(kanal, station); + await spielen(kanal, radioQuelle(station)); } catch (error) { console.error('[radio]', error); await interaction.editReply(`❌ ${error.message || 'Verbindung in den Sprachkanal fehlgeschlagen.'}`); @@ -98,7 +102,7 @@ export async function execute(interaction) { const jetzt = laeuft(interaction.guild.id); const nachricht = await interaction.editReply({ embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId, - { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden() })], + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden(), quelle: jetzt?.quelle })], components: tafelKomponenten(stationen, station?.id), }); diff --git a/src/bot/radio.js b/src/bot/radio.js index 8c3d433..32a3780 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -1,9 +1,21 @@ -// Radio im Sprachkanal. +// Radio im Sprachkanal — Webradio und, seit dem 28.08.2026, auch YouTube. // -// Bewusst nur Webradio und kein YouTube: genau daran sind Groovy und Rythm -// gestorben, und was davon übrig ist, ist ein Dauerlauf gegen kaputte -// Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine -// Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet. +// Hier stand jahrelang „bewusst nur Webradio und kein YouTube", mit der +// Begründung, genau daran seien Groovy und Rythm gestorben. Der Einwand ist +// nicht widerlegt, die Entscheidung ist trotzdem gefallen. Was bleibt, ist +// die Konsequenz daraus: YouTube ist der Teil, der von aussen kaputtgehen +// kann, also muss ein Ausfall *sichtbar* sein statt still. Was yt-dlp weiss, +// steht in `youtube.js`, samt Fehlerzeile fürs Panel. +// +// Der ganze Unterschied zwischen den beiden Quellen ist einer: **ein Sender +// endet nie, ein Titel schon.** Daran hängt alles Weitere — die +// Warteschlange, das Überspringen, und die Frage, was ein `Idle` des Spielers +// bedeutet: beim Sender „die Leitung ist weg" (neu aufbauen), bei YouTube +// „fertig, der nächste bitte". +// +// Deshalb gibt es *eine* Abspielfunktion und *einen* `Idle`-Melder, die nach +// der Art der Quelle verzweigen, und keinen zweiten Pfad daneben. Zwei +// Pfade, die dasselbe tun sollen, driften auseinander. // // Die Tonkette: ffmpeg holt den Sender und gibt rohes PCM aus, die // Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief @@ -18,6 +30,10 @@ // und kostet gemessene 0,3 % eines Kerns fürs Kodieren eines Dauerstroms. // Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja // schon Abtastwerte. +// +// Bei YouTube hängt vor ffmpeg noch yt-dlp: es lädt und schiebt die Bytes +// durch eine Rohrleitung. Warum nicht die fertige Adresse an ffmpeg, steht +// im Kopf von `youtube.js` — kurz: die läuft ab. import { spawn } from 'node:child_process'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags, @@ -35,8 +51,10 @@ import { import { moduleEnabled } from '../modules.js'; import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting, + queueNext, } from '../db.js'; import { senderGrundstock } from './radio-sender.js'; +import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.js'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; @@ -61,8 +79,38 @@ export function ffmpegPruefen() { } // Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im -// Speicher; was davon einen Neustart überlebt, steht in `radio_state`. -const laufend = new Map(); // guildId → { player, ffmpeg, stationId, channelId, titel } +// Speicher; was davon einen Neustart überlebt, steht in `radio_state` und in +// der Warteschlange. +const laufend = new Map(); +// guildId → { player, ffmpeg, ytdlp, resource, quelle, heimStationId, +// channelId, titel, seit, tonLaeuft, client } + +// ── Die Quelle ──────────────────────────────────────────────────────────── +// +// Zwei Arten, eine Form. Alles, was danach kommt, fragt nur noch `art` — +// damit gibt es genau eine Stelle, an der sich Sender und Titel unterscheiden, +// statt eines zweiten Abspielweges neben dem ersten. + +/** @param {object} station Zeile aus `radio_stations` */ +export const radioQuelle = (station) => ({ art: 'radio', station }); +/** @param {object} titel Eintrag aus der Warteschlange (oder von `aufloesen`) */ +export const youtubeQuelle = (titel) => ({ art: 'youtube', titel }); + +/** Wie die Quelle heisst, wenn man sie hinschreibt */ +const quelleName = (q) => (q?.art === 'youtube' ? q.titel?.titel : q?.station?.name) ?? null; + +// Wer gerade mitten im Quellenwechsel steckt. Als eigene Menge und nicht als +// Merker am Eintrag: `spielen` ersetzt den Eintrag, ein Merker daran wäre +// nach dem Wechsel weg — und genau während des Wechsels wird er gebraucht. +const wechselt = new Set(); + +// Wie oft hintereinander ein Titel keinen Ton gebracht hat, je Server. +// +// Ohne diese Zählung rauscht eine Warteschlange mit einem kaputten Eintrag +// in Millisekunden durch: kein Ton → sofort `Idle` → nächster → kein Ton. +// Nach dieser Zahl Fehlschlägen in Folge wird angehalten und gesagt, warum. +const fehlschlaege = new Map(); +const FEHLSCHLAG_GRENZE = 3; // ── Sender-Titel ────────────────────────────────────────────────────────── @@ -122,18 +170,23 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) { // ── Ton ─────────────────────────────────────────────────────────────────── /** - * ffmpeg als Tonquelle. + * ffmpeg als Wandler. * - * `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein - * einzelner Netz-Schluckauf würde ihn sonst endgültig beenden. + * `-reconnect` gilt nur für Adressen aus dem Netz und ist dort kein Beiwerk: + * ein Radiostream läuft stundenlang, und ein einzelner Netz-Schluckauf würde + * ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option + * sinnlos — es gibt nichts, wohin sich neu verbinden liesse. + * + * @param {string} eingabe Adresse oder `pipe:0` + * @param {{netz: boolean}} opts */ -function tonquelle(url) { +function wandler(eingabe, { netz }) { const ff = spawn(FFMPEG, [ // Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher // dahinter und wurde dort als Ausgabe-Option gelesen, also ignoriert. - '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', + ...(netz ? ['-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'] : []), '-analyzeduration', '0', '-loglevel', 'error', - '-i', url, + '-i', eingabe, '-vn', // manche Sender legen ein Coverbild bei // Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke // drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet — @@ -152,6 +205,38 @@ function tonquelle(url) { return ff; } +/** + * Die Prozesskette für eine Quelle. + * + * Sender: ffmpeg holt selbst. YouTube: yt-dlp holt und schiebt durch ein Rohr. + * + * Die Fehlerbehandlung an den Rohrenden ist Pflicht und kein Übereifer: wird + * ffmpeg abgeräumt (Stopp, Überspringen, Wechsel), während yt-dlp noch + * schreibt, kommt ein EPIPE — und ein Strom ohne 'error'-Hörer reisst den + * ganzen Prozess mit. + * + * @returns {{ffmpeg: object, ytdlp: object|null}} + */ +function tonquelle(quelle) { + if (quelle.art !== 'youtube') { + return { ffmpeg: wandler(quelle.station.url, { netz: true }), ytdlp: null }; + } + const yt = tonstrom(quelle.titel.url); + const ff = wandler('pipe:0', { netz: false }); + yt.stdout.on('error', () => {}); + ff.stdin.on('error', () => {}); + yt.stdout.pipe(ff.stdin); + return { ffmpeg: ff, ytdlp: yt }; +} + +/** Beide Prozesse einer Kette abräumen — an einer Stelle, damit keiner stehenbleibt */ +function ketteAbraeumen(eintrag) { + eintrag?.ffmpeg?.kill('SIGKILL'); + // Ohne das lädt ein übersprungener Titel munter weiter: yt-dlp merkt vom + // Ende der Rohrleitung nichts, solange niemand es ihm sagt. + eintrag?.ytdlp?.kill('SIGKILL'); +} + /** * Der Adapter zwischen Discord-Verbindung und Sprachbibliothek — mit Protokoll. * @@ -406,8 +491,11 @@ export function stoppen(guildId) { let abgeraeumt; if (s) { s.player.stop(true); - s.ffmpeg?.kill('SIGKILL'); + ketteAbraeumen(s); laufend.delete(guildId); + // Sonst zählt der nächste Start ab dem alten Stand weiter und bricht + // womöglich sofort wieder ab. + fehlschlaege.delete(guildId); // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es // liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab. // @@ -433,13 +521,21 @@ export async function radioBeenden() { } /** - * Sender im Sprachkanal starten (oder umschalten). + * Eine Quelle im Sprachkanal starten (oder umschalten). * * Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler * bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für * die Zuhörer eine hörbare Pause. + * + * Dass hier eine *Quelle* ankommt und kein Sender, ist der Kern des Umbaus + * vom 28.08.2026: Sender und YouTube-Titel gehen denselben Weg, und der + * Unterschied lebt in `tonquelle()` und im `Idle`-Melder — nicht in zwei + * Abspielfunktionen, die auseinanderdriften. + * + * @param {object} kanal Sprachkanal + * @param {{art: string}} quelle aus `radioQuelle()` oder `youtubeQuelle()` */ -export async function spielen(kanal, station) { +export async function spielen(kanal, quelle) { const guildId = kanal.guild.id; const vorher = laufend.get(guildId); @@ -551,8 +647,8 @@ export async function spielen(kanal, station) { behaviors: { noSubscriber: NoSubscriberBehavior.Play }, }); - vorher?.ffmpeg?.kill('SIGKILL'); - const ff = tonquelle(station.url); + ketteAbraeumen(vorher); + const { ffmpeg: ff, ytdlp } = tonquelle(quelle); const resource = createAudioResource(ff.stdout, { inputType: StreamType.Raw, inlineVolume: true, // ohne das gibt es kein `resource.volume` @@ -579,34 +675,148 @@ export async function spielen(kanal, station) { if (!vorher) { player.on('error', (e) => console.error('[radio] Spieler:', e.message)); - // Ein Radiostream endet nie von selbst. Tut er es doch, ist die - // Leitung weg — dann neu aufbauen statt still dazustehen. + // Hier entscheidet sich, was ein Ende bedeutet — und das ist bei den + // beiden Quellen genau gegensätzlich: + // + // Sender → ein Radiostream endet nie von selbst. Tut er es doch, + // ist die Leitung weg: neu aufbauen statt dazustehen. + // YouTube → ein Titel endet immer. Das ist kein Fehler, sondern das + // Stichwort für den nächsten. + // + // Der Melder wird nur einmal je Spieler gehängt und überlebt jeden + // Quellenwechsel. Deshalb wird der aktuelle Stand hier frisch aus + // `laufend` geholt und nicht aus dem Abschluss: `kanal` und `quelle` + // von damals sind nach dem ersten Umschalten veraltet. (Vorher hing + // an dieser Stelle `kanal` fest — nach einem Kanalwechsel hätte der + // Wiederanlauf in den alten Kanal gespielt.) player.on(AudioPlayerStatus.Idle, () => { const jetzt = laufend.get(guildId); if (!jetzt) return; + if (jetzt.quelle?.art === 'youtube') { + weiter(jetzt.client, guildId).catch((e) => + console.error('[radio] Weiterschalten:', e.message)); + return; + } console.error('[radio] Strom abgerissen, neuer Versuch'); - const s = getStation(jetzt.stationId); - if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000); + const s = getStation(jetzt.quelle?.station?.id); + if (!s) return; + setTimeout(async () => { + const k = await jetzt.client.channels.fetch(jetzt.channelId).catch(() => null); + if (k) spielen(k, radioQuelle(s)).catch(() => {}); + }, 3000); }); } const eintrag = { - player, ffmpeg: ff, resource, stationId: station.id, channelId: kanal.id, - titel: null, seit: Date.now(), tonLaeuft: null, client: kanal.client, + player, ffmpeg: ff, ytdlp, resource, quelle, channelId: kanal.id, + // Der Sender, auf den nach der Warteschlange zurückgefallen wird. + // Bei einem Sender ist das er selbst; bei einem YouTube-Titel bleibt + // stehen, was vorher lief — sonst wüsste niemand mehr, wohin zurück. + heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null), + titel: quelle.art === 'youtube' ? quelle.titel.titel : null, + seit: Date.now(), tonLaeuft: null, client: kanal.client, }; laufend.set(guildId, eintrag); // Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen // erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort — // besser als eine leere Zeile, während schon Musik läuft. statusAus = null; - kanalStatusSetzen(kanal.client, kanal.id, `📻 ${station.name}`); + kanalStatusSetzen(kanal.client, kanal.id, + quelle.art === 'youtube' ? `▶️ ${quelle.titel.titel}` : `📻 ${quelle.station.name}`); beobachten.then((ok) => { eintrag.tonLaeuft = ok; - if (!ok) console.error(`[radio] kein Ton von ${station.name} — ffmpeg da? ${ffmpegDa}`); + // Ein Titel, der wirklich klingt, macht die Zählung der Fehlschläge + // gegenstandslos — sonst hielte ein alter Ausrutscher die Bremse an. + if (ok) fehlschlaege.delete(guildId); + if (!ok) { + console.error(`[radio] kein Ton von ${quelleName(quelle)} — ` + + `ffmpeg da? ${ffmpegDa}${quelle.art === 'youtube' ? `, yt-dlp da? ${ytdlpVorhanden()}` : ''}`); + } }); return verbindung; } +/** + * Der nächste Titel — oder zurück aufs Radio, oder raus. + * + * Die Reihenfolge ist die Entscheidung vom 28.08.2026: erst die + * Warteschlange, dann der Sender, der vor dem ersten Titel lief, und erst + * wenn es auch den nicht gibt, geht der Bot. Das Radio ist die Grundstellung, + * YouTube die Unterbrechung. + * + * Die Bremse davor ist kein Übereifer: ein Eintrag, der keinen Ton bringt, + * geht sofort wieder auf `Idle`. Ohne Zählung wäre eine Warteschlange mit + * einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte + * gesehen, warum. + */ +export async function weiter(client, guildId, { erzwungen = false } = {}) { + // Zwei Auslöser gleichzeitig — der Titel endet, *und* jemand drückt + // „Überspringen" — dürfen nicht zwei Titel gleichzeitig starten. + if (wechselt.has(guildId)) return null; + wechselt.add(guildId); + try { + const jetzt = laufend.get(guildId); + const kanalId = jetzt?.channelId; + if (!kanalId) return null; + + // Kam vom laufenden Titel je ein Ton? Wenn nicht, war er kaputt. + // Beim Überspringen von Hand zählt das nicht mit: wer weiterdrückt, + // hat den Titel abgebrochen, nicht der Titel sich selbst. + if (!erzwungen && jetzt.quelle?.art === 'youtube' && jetzt.tonLaeuft !== true) { + const n = (fehlschlaege.get(guildId) ?? 0) + 1; + fehlschlaege.set(guildId, n); + console.error(`[radio] ohne Ton geblieben: ${quelleName(jetzt.quelle)} (${n}. in Folge)`); + if (n >= FEHLSCHLAG_GRENZE) { + console.error(`[radio] ${n} Titel hintereinander ohne Ton — angehalten. ` + + 'Ursache steht im Panel unter Radio → yt-dlp.'); + fehlschlaege.delete(guildId); + stoppen(guildId); + clearRadioState(guildId); + await tafelnMelden(client, guildId); + return null; + } + } + + const kanal = await client.channels.fetch(kanalId).catch(() => null); + if (!kanal?.isVoiceBased()) return null; + + const naechster = queueNext(guildId); + const quelle = naechster + ? youtubeQuelle(naechster) + : (() => { + const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; + return heim ? radioQuelle(heim) : null; + })(); + + if (!quelle) { + // Nichts mehr da und kein Sender, auf den zurückzufallen wäre. + stoppen(guildId); + clearRadioState(guildId); + await tafelnMelden(client, guildId); + return null; + } + + await spielen(kanal, quelle); + setRadioState({ + guildId, + channelId: kanalId, + stationId: laufend.get(guildId)?.heimStationId ?? null, + panelChannelId: radioState(guildId)?.panel_channel_id, + panelMessageId: radioState(guildId)?.panel_message_id, + }); + // Beim Rückfall auf den Sender steht sonst noch der letzte Liedtitel + // in der Statuszeile und behauptet, er liefe. + if (quelle.art === 'radio') { + const { titel } = await titelHolen(quelle.station.url); + titelMerken(client, guildId, titel); + } + await tafelnMelden(client, guildId); + return quelle; + } finally { + wechselt.delete(guildId); + } +} + // ── Steuertafel ─────────────────────────────────────────────────────────── // Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je @@ -703,10 +913,22 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) { .setFooter({ text: brandFooter('RADIO') }) .setTimestamp(); const laut = radioLautstaerke(); - if (!station) { + // Läuft ein YouTube-Titel, ist der Sender nur pausiert und nicht aus. + // Das gehört hierhin, sonst behauptet die Radio-Tafel „nichts läuft", + // während im Kanal hörbar etwas läuft. + const ytLaeuft = stand.quelle?.art === 'youtube'; + if (!station && !ytLaeuft) { return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n' + `Startet mit ${lautText(laut)} — vorab einstellbar.`); } + if (ytLaeuft) { + embed.setDescription(`▶️ **${stand.quelle.titel.titel}**${kanalId ? ` in <#${kanalId}>` : ''}\n` + + (station + ? `_danach zurück auf **${station.name}**_` + : '_aus der Warteschlange — `/musik` zeigt sie_')); + embed.addFields({ name: 'Lautstärke', value: lautText(laut), inline: true }); + return embed; + } embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`); // Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier, @@ -739,14 +961,36 @@ async function tafelAktualisieren(client, guildId) { if (!nachricht) return; const jetzt = laufend.get(guildId); - const station = jetzt ? getStation(jetzt.stationId) : null; + // Welcher Sender in der Radio-Tafel angehakt ist, richtet sich nach dem + // Heimsender und nicht nach der laufenden Quelle: während ein + // YouTube-Titel läuft, soll dort weiter stehen, wohin zurückgefallen wird. + const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null; await nachricht.edit({ embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId, - { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], - components: tafelKomponenten(listStations(), jetzt?.stationId), + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })], + components: tafelKomponenten(listStations(), station?.id), }).catch(() => {}); } +// Wer sonst noch neu gezeichnet werden will, wenn sich etwas geändert hat. +// +// Eine Anmeldung statt eines Imports, weil die YouTube-Tafel `radio.js` +// braucht und `radio.js` sie sonst zurück — ein Ringschluss zwischen zwei +// Dateien, den niemand beim Lesen erwartet. So zeigt die Abhängigkeit in eine +// Richtung: `radio-musik.js` kennt das Radio, das Radio kennt nur „jemand +// möchte Bescheid". +const melder = []; +export function beiAenderung(fn) { melder.push(fn); } + +/** Alle Tafeln neu zeichnen — die Radio-Tafel und alles Angemeldete */ +export async function tafelnMelden(client, guildId) { + await tafelAktualisieren(client, guildId).catch(() => {}); + for (const fn of melder) { + await fn(client, guildId).catch((e) => + console.error('[radio] Tafel zeichnen:', e.message)); + } +} + // ── Anbindung ───────────────────────────────────────────────────────────── /** Ist ausser dem Bot noch jemand im Kanal? */ @@ -765,6 +1009,16 @@ export function startRadio(client) { + 'Im Container nachinstallieren oder das Image neu bauen.'); }); + // Dieselbe Frage für yt-dlp, und aus demselben Grund: ohne die Antwort + // sähe ein fehlendes yt-dlp aus wie „YouTube geht nicht mehr". Ein + // fehlendes yt-dlp lässt das Radio unberührt — deshalb ist das eine + // Meldung und kein Abbruch. + ytdlpPruefen().then((da) => { + if (da) return; + console.error('[radio] yt-dlp fehlt — Sender laufen weiter, YouTube nicht. ' + + 'Im Panel unter Radio → yt-dlp steht, was fehlt.'); + }); + // Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert // selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte. setInterval(async () => { @@ -773,13 +1027,17 @@ export function startRadio(client) { // seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig. try { for (const [guildId, s] of laufend) { - const station = getStation(s.stationId); + // YouTube hat keine Icy-Metadaten und braucht sie nicht: der + // Titel stand schon fest, als er eingereiht wurde. Eine + // Abfrage dagegen wäre eine Anfrage ins Leere. + if (s.quelle?.art !== 'radio') continue; + const station = getStation(s.quelle.station.id); if (!station) continue; const { titel } = await titelHolen(station.url); // Nur bei echter Änderung neu zeichnen — und nur dann den // Kanal-Status anfassen. Discord begrenzt beides. if (titelMerken(client, guildId, titel)) { - await tafelAktualisieren(client, guildId); + await tafelnMelden(client, guildId); } } } catch (error) { @@ -797,7 +1055,10 @@ export function startRadio(client) { if (jemandDa(kanal)) return; stoppen(vorher.guild.id); clearRadioState(vorher.guild.id); - await tafelAktualisieren(client, vorher.guild.id); + // Die Warteschlange bleibt absichtlich stehen: der Kanal hat sich + // geleert, die Wünsche sind deshalb nicht hinfällig. Wer sie los + // sein will, drückt „Leeren". + await tafelnMelden(client, vorher.guild.id); } catch (error) { console.error('[radio] Sprachkanal-Wechsel:', error.message); } @@ -808,8 +1069,16 @@ export function startRadio(client) { client.once(Events.ClientReady, async () => { if (!moduleEnabled('radio')) return; for (const stand of radioStates()) { + // Erst die Warteschlange, dann der Sender. + // + // Mitten in einem Titel wieder einzusteigen geht nicht — es gibt + // keine gespeicherte Position. Und andersherum käme die Liste nie + // dran: ein Sender endet nicht, also gäbe es kein `Idle`, das + // weiterschaltet. Deshalb diese Reihenfolge und nicht die andere. + const naechster = queueNext(stand.guild_id); const station = stand.station_id ? getStation(stand.station_id) : null; - const kanal = station ? await client.channels.fetch(stand.channel_id).catch(() => null) : null; + const quelle = naechster ? youtubeQuelle(naechster) : (station ? radioQuelle(station) : null); + const kanal = quelle ? await client.channels.fetch(stand.channel_id).catch(() => null) : null; if (!kanal?.isVoiceBased() || !jemandDa(kanal)) { // Niemand mehr da, oder der Sender wurde gelöscht — dann gilt // der Merker als verbraucht statt in einen leeren Kanal zu gehen. @@ -817,9 +1086,13 @@ export function startRadio(client) { continue; } try { - await spielen(kanal, station); - await tafelAktualisieren(client, stand.guild_id); - console.log(`[radio] wieder in ${kanal.name}: ${station.name}`); + await spielen(kanal, quelle); + // Der Heimsender überlebt den Neustart nur über `radio_state`; + // ohne diese Zeile fiele die Liste danach ins Leere. + const s = laufend.get(stand.guild_id); + if (s && station) s.heimStationId = station.id; + await tafelnMelden(client, stand.guild_id); + console.log(`[radio] wieder in ${kanal.name}: ${quelleName(quelle)}`); } catch (e) { console.error('[radio] Rückkehr fehlgeschlagen:', e.message); clearRadioState(stand.guild_id); @@ -860,7 +1133,10 @@ export async function handleRadioInteraction(interaction) { }); return true; } - await spielen(ziel, station); + // Ein Sender von Hand gewählt schlägt die Warteschlange: wer hier + // klickt, will jetzt diesen Sender hören und nicht erst nach drei + // eingereihten Titeln. Die Liste bleibt stehen und kommt danach dran. + await spielen(ziel, radioQuelle(station)); setRadioState({ guildId, channelId: ziel.id, @@ -874,11 +1150,16 @@ export async function handleRadioInteraction(interaction) { } const jetzt = laufend.get(guildId); - const station = jetzt ? getStation(jetzt.stationId) : null; + const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null; await interaction.update({ embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId, - { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], - components: tafelKomponenten(listStations(), jetzt?.stationId), + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })], + components: tafelKomponenten(listStations(), station?.id), }).catch(() => {}); + // Die zweite Tafel zeigt dieselbe Wirklichkeit und muss mitgezogen + // werden — sonst behauptet sie nach einem Stopp weiter, es liefe etwas. + for (const fn of melder) { + await fn(interaction.client, guildId).catch(() => {}); + } return true; } diff --git a/src/runtime-settings.js b/src/runtime-settings.js index eec6d00..76caac6 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -380,9 +380,18 @@ export const LAUT_SCHRITT = 10; export const LAUT_VORGABE = 50; export function radioLautstaerke() { - const roh = Number(getSetting('radio_lautstaerke')); - if (!Number.isFinite(roh)) return LAUT_VORGABE; - return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(roh))); + const roh = getSetting('radio_lautstaerke'); + // Der unbelegte Fall muss VOR der Umwandlung abgefangen werden. + // + // `Number(null)` ist 0, und 0 ist endlich — die Prüfung unten hat „noch + // nie eingestellt" deshalb nie erwischt, sondern auf LAUT_MIN geklemmt. + // Ergebnis: eine frische Installation startete mit 10 % statt mit den 50, + // die zwei Zeilen weiter oben als Vorgabe begründet werden. Gemessen am + // 28.08.2026 gegen eine leere Datenbank. + if (roh === null || roh === undefined || roh === '') return LAUT_VORGABE; + const wert = Number(roh); + if (!Number.isFinite(wert)) return LAUT_VORGABE; + return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(wert))); } /** From 2a0cb1905decb429fd7d30392364d32c16d5923c Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 28 Aug 2026 13:39:46 +0200 Subject: [PATCH 3/6] Die zweite Tafel: /musik, Warteschlange, Eingabefenster Eine eigene Nachricht statt ein paar Knoepfe mehr an der Radio-Tafel. Discord laesst fuenf Reihen je Nachricht zu, vier davon gehoeren dort den Sender-Menues -- fuer Ueberspringen, Einreihen, Warteschlange und Leeren ist kein Platz, und ein Sender-Menue zu opfern hiesse, Sender aus der Auswahl zu werfen. Der Vorzug ist groesser als die Notloesung: /radio bleibt genau das, was es war. Wer nie ein Video einreiht, merkt vom ganzen Umbau nichts. Bei einem Suchbegriff wird bewusst nicht der erste Treffer genommen, sondern die Auswahl gezeigt: "Sleeping Sun" hat drei Fassungen, und welche gemeint war, weiss nur wer es getippt hat. Eine Adresse geht direkt rein, eine Playlist mit allen Eintraegen auf einmal. Die Abhaengigkeit zeigt nur in eine Richtung: radio-musik.js kennt das Radio, das Radio kennt es nicht -- es meldet ueber beiAenderung() nur, dass sich etwas geaendert hat. Ein gegenseitiger Import zwischen zwei Dateien beisst beim naechsten Umbau. Gemessen gegen echtes YouTube am 28.08.2026: Adresse landet in der Liste, Suchbegriff liefert fuenf Treffer und reiht NICHTS ein (Liste nachweislich unveraendert), kaputte Adresse meldet "Video unavailable" und laesst die Liste in Ruhe. Die Tafel zeigt Dauer je Titel, Gesamtdauer, Wunschgeber und die letzte Fehlerzeile im Klartext. Nicht geprueft, weil es dafuer eine echte Discord-Sprachverbindung braucht: die Darstellung waehrend etwas laeuft, und das Weiterschalten. --- src/bot/client.js | 21 +- src/bot/commands/musik.js | 22 ++ src/bot/radio-musik.js | 463 ++++++++++++++++++++++++++++++++++++++ src/bot/radio.js | 15 +- src/index.js | 2 + 5 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 src/bot/commands/musik.js create mode 100644 src/bot/radio-musik.js diff --git a/src/bot/client.js b/src/bot/client.js index b34ea00..ef80671 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -25,6 +25,7 @@ import { } from './wishes.js'; import { handleGiveawayAdminButton } from './giveaways.js'; import { handleRadioInteraction } from './radio.js'; +import { handleMusikInteraction } from './radio-musik.js'; import { removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote, getGiveaway, toggleGiveawayEntry, giveawayEntries, @@ -49,12 +50,13 @@ import * as geburtstag from './commands/geburtstag.js'; import * as raid from './commands/raid.js'; import * as umfrage from './commands/umfrage.js'; import * as radio from './commands/radio.js'; +import * as musik from './commands/musik.js'; // Alle Commands hier eintragen — jedes Modul exportiert { data, execute } const commandModules = [ ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, - raid, umfrage, radio, + raid, umfrage, radio, musik, ]; export async function startBot() { @@ -173,11 +175,26 @@ export async function startBot() { // Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und // antwortet in einem Rutsch — deshalb hier oben und nicht als // weiterer customId-Vergleich zwischen den anderen. - if (interaction.isButton() || interaction.isStringSelectMenu()) { + // Das Eingabefenster der Musik-Tafel gehoert mit hierher: es kommt als + // ModalSubmit zurueck und wuerde sonst weiter unten bei den + // Bug-/Wunsch-Fenstern landen, die es nicht kennen. + if (interaction.isButton() || interaction.isStringSelectMenu() + || interaction.isModalSubmit()) { try { if (await handleRadioInteraction(interaction)) return; + if (await handleMusikInteraction(interaction)) return; } catch (error) { console.error('[radio] Interaktion:', error); + // Ohne Rueckmeldung steht bei dem, der geklickt hat, nur + // "Interaktion fehlgeschlagen" -- ohne jeden Hinweis darauf, + // dass der Grund im Serverlog steht. + const meldung = { + content: '❌ Da ist etwas schiefgelaufen — Genaueres steht im Serverlog.', + flags: MessageFlags.Ephemeral, + }; + await (interaction.deferred || interaction.replied + ? interaction.followUp(meldung) + : interaction.reply(meldung)).catch(() => {}); return; } } diff --git a/src/bot/commands/musik.js b/src/bot/commands/musik.js new file mode 100644 index 0000000..a7ae709 --- /dev/null +++ b/src/bot/commands/musik.js @@ -0,0 +1,22 @@ +// /musik — die YouTube-Tafel im Kanal öffnen. +// +// Ein eigener Befehl und kein Unterbefehl von `/radio`: `/radio` funktioniert +// heute, wie es ist, und daran soll sich für niemanden etwas ändern. Aus +// `/radio` ein `/radio tafel` zu machen, um Platz für `/radio musik` zu haben, +// wäre eine geänderte Bedienung für alle — als Preis dafür, dass ein paar +// Leute Videos einreihen wollen. +// +// Wie bei `/radio` nimmt der Befehl selbst nichts entgegen: eingereiht wird +// über das Eingabefenster an der Tafel. Ein Link ist auf dem Handy nichts, +// was man in eine Befehlszeile tippt — man fügt ihn ein, und dafür ist ein +// Textfeld der bessere Ort. +import { SlashCommandBuilder } from 'discord.js'; +import { musikTafelOeffnen } from '../radio-musik.js'; + +export const data = new SlashCommandBuilder() + .setName('musik') + .setDescription('YouTube einreihen und die Warteschlange verwalten'); + +export async function execute(interaction) { + await musikTafelOeffnen(interaction); +} diff --git a/src/bot/radio-musik.js b/src/bot/radio-musik.js new file mode 100644 index 0000000..bd0c3d0 --- /dev/null +++ b/src/bot/radio-musik.js @@ -0,0 +1,463 @@ +// Die zweite Tafel: YouTube und die Warteschlange. +// +// Warum eine eigene Nachricht und nicht ein paar Knöpfe mehr an der +// Radio-Tafel: Discord lässt fünf Reihen je Nachricht zu, und vier davon +// gehören dort den Sender-Menüs. Für „Überspringen", „Einreihen", +// „Warteschlange" und „Leeren" ist da schlicht kein Platz — und ein +// Sender-Menü zu opfern hiesse, Sender aus der Auswahl zu werfen. +// +// Zwei Tafeln haben ausserdem einen Vorzug, der nicht bloss Notlösung ist: +// `/radio` bleibt genau das, was es war. Wer nie ein Video einreiht, merkt +// von diesem ganzen Umbau nichts. +// +// Die Abhängigkeit zeigt bewusst nur in eine Richtung: diese Datei kennt das +// Radio, das Radio kennt sie nicht. Es meldet über `beiAenderung()` nur, dass +// sich etwas geändert hat. Ein gegenseitiger Import zwischen zwei Dateien ist +// etwas, das beim Lesen niemand erwartet und beim Umbauen jeden beisst. +import { + ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, + ModalBuilder, TextInputBuilder, TextInputStyle, + StringSelectMenuBuilder, StringSelectMenuOptionBuilder, +} from 'discord.js'; +import { brandEmbed } from '../embeds.js'; +import { brandFooter, youtubeAn } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; +import { + queueList, queueAdd, queueRemove, queueClear, queueVor, queueStand, + ytPanel, setYtPanel, clearYtPanel, getStation, +} from '../db.js'; +import { + laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle, +} from './radio.js'; +import { + aufloesen, ytdlpVorhanden, letzterFehler, TREFFER, +} from './youtube.js'; + +// Discord lässt 25 Einträge je Auswahlmenü zu. Die Warteschlange kann länger +// sein — dann zeigen die Menüs die ersten 25, und der Rest wird über das +// Web-Panel verwaltet. Das steht auch so in der Tafel, statt stillschweigend +// zu unterschlagen, dass da noch etwas ist. +const JE_MENU = 25; + +// ── Darstellung ─────────────────────────────────────────────────────────── + +/** 3:07 statt 187 */ +export function dauerText(sekunden) { + if (sekunden === null || sekunden === undefined) return 'live'; + const s = Math.max(0, Math.round(sekunden)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const rest = s % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, '0')}:${String(rest).padStart(2, '0')}` + : `${m}:${String(rest).padStart(2, '0')}`; +} + +/** + * Ein Balken für den Fortschritt. + * + * Nur wo er etwas aussagen kann: ohne bekannte Dauer (Livestream) gäbe er + * einen Stand vor, den niemand kennt. Dann steht dort lieber nichts. + */ +function fortschritt(verstrichen, dauer) { + if (!dauer || dauer <= 0) return null; + const anteil = Math.min(1, Math.max(0, verstrichen / dauer)); + const voll = Math.round(anteil * 12); + return `\`${'━'.repeat(voll)}${'─'.repeat(12 - voll)}\` ${dauerText(verstrichen)} / ${dauerText(dauer)}`; +} + +/** Titel kürzen, damit Discord die Nachricht nicht ablehnt */ +const kurz = (text, n) => (String(text ?? '').length > n + ? `${String(text).slice(0, n - 1)}…` + : String(text ?? '')); + +export function musikEmbed(client, guildId) { + const embed = brandEmbed(client, 'RADIO') + .setTitle('▶️ Musik') + .setFooter({ text: brandFooter('RADIO') }) + .setTimestamp(); + + // Ein fehlendes yt-dlp ist die häufigste Ursache und die am schlechtesten + // zu erratende: alles sieht normal aus, es passiert nur nichts. Deshalb + // steht sie ganz oben und nicht in einer Fussnote. + if (ytdlpVorhanden() === false) { + return embed.setDescription('⚠️ Auf dem Server fehlt **yt-dlp** — ohne das kann der Bot ' + + 'kein YouTube abspielen. Die Radiosender laufen davon unberührt weiter.'); + } + if (!youtubeAn()) { + return embed.setDescription('YouTube ist ausgeschaltet — im Panel unter ' + + '**Community → Radio** einschalten.'); + } + + const jetzt = laeuft(guildId); + const liste = queueList(guildId); + const stand = queueStand(guildId); + + if (jetzt?.quelle?.art === 'youtube') { + const t = jetzt.quelle.titel; + const verstrichen = Math.floor((Date.now() - jetzt.seit) / 1000); + const balken = fortschritt(verstrichen, t.dauer); + embed.setDescription(`**${kurz(t.titel, 200)}**\n` + + `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}` + + (balken ? `${balken}\n` : '🔴 _Livestream_\n') + + `in <#${jetzt.channelId}>`); + if (t.thumb) embed.setThumbnail(t.thumb); + } else if (jetzt) { + // Es läuft etwas, nur eben ein Sender. Das ist kein Fehler und wird + // auch nicht als einer dargestellt. + const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; + embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n` + + 'Was hier eingereiht wird, kommt als Nächstes dran.'); + } else { + embed.setDescription(liste.length > 0 + ? 'Nichts läuft. **Überspringen** startet die Warteschlange — ' + + 'geh vorher in einen Sprachkanal.' + : 'Nichts läuft und nichts eingereiht. **Einreihen…** nimmt einen ' + + 'YouTube-Link oder einen Suchbegriff.'); + } + + if (liste.length > 0) { + const zeilen = liste.slice(0, 10).map((e, i) => + `\`${String(i + 1).padStart(2)}.\` ${kurz(e.titel, 60)} \`${dauerText(e.dauer)}\`` + + (e.wunsch_von ? ` — _${kurz(e.wunsch_von, 24)}_` : '')); + if (liste.length > 10) zeilen.push(`_… und ${liste.length - 10} weitere_`); + embed.addFields({ + name: `Warteschlange — ${stand.n} Titel, ${dauerText(stand.dauer)} gesamt`, + value: zeilen.join('\n').slice(0, 1024), + }); + } + + // Der letzte Fehlschlag, im Klartext. Genau das ist der Unterschied + // zwischen „YouTube geht nicht" und „YouTube verlangt eine Anmeldung". + const fehler = letzterFehler(); + if (fehler && !jetzt) { + embed.addFields({ + name: '⚠️ Zuletzt schiefgegangen', + value: `\`\`\`${kurz(fehler.text, 300)}\`\`\``, + }); + } + return embed; +} + +const knopf = (id, label, emoji, stil = ButtonStyle.Secondary, aus = false) => + new ButtonBuilder().setCustomId(id).setLabel(label).setEmoji(emoji) + .setStyle(stil).setDisabled(aus); + +export function musikKomponenten(guildId) { + const liste = queueList(guildId).slice(0, JE_MENU); + const jetzt = laeuft(guildId); + const reihen = []; + + if (liste.length > 0) { + const optionen = (praefix) => liste.map((e, i) => { + const o = new StringSelectMenuOptionBuilder() + .setLabel(kurz(`${i + 1}. ${e.titel}`, 100)) + .setValue(String(e.id)); + const unten = [dauerText(e.dauer), e.kanal].filter(Boolean).join(' · '); + if (unten) o.setDescription(kurz(unten, 100)); + return o; + }); + reihen.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder().setCustomId('musik_vor') + .setPlaceholder('⏭️ Als Nächstes spielen …').addOptions(optionen()) + )); + reihen.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder().setCustomId('musik_weg') + .setPlaceholder('🗑️ Aus der Liste nehmen …').addOptions(optionen()) + )); + } + + // Am Anschlag ausgrauen statt stumm nichts zu tun — dieselbe Regel wie + // an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts + // bewirkt, sieht aus wie ein kaputter Knopf. + const nichtsDa = liste.length === 0; + reihen.push(new ActionRowBuilder().addComponents( + 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_heim', 'Zum Radio', '📻', ButtonStyle.Secondary, + !jetzt || jetzt.quelle?.art !== 'youtube' || !jetzt.heimStationId), + knopf('musik_refresh', 'Aktualisieren', '🔄'), + )); + return reihen; +} + +/** Die gespeicherte Tafel neu zeichnen — für den Melder aus `radio.js` */ +export async function musikTafelAktualisieren(client, guildId) { + const p = ytPanel(guildId); + if (!p) return; + const kanal = await client.channels.fetch(p.channel_id).catch(() => null); + const nachricht = await kanal?.messages?.fetch(p.message_id).catch(() => null); + if (!nachricht) { + // Die Nachricht ist weg (gelöscht, Kanal aufgeräumt). Den Verweis + // stehenzulassen hiesse, es bei jeder Änderung erneut zu versuchen. + clearYtPanel(guildId); + return; + } + await nachricht.edit({ + embeds: [musikEmbed(client, guildId)], + components: musikKomponenten(guildId), + }).catch(() => {}); +} + +// ── Einreihen ───────────────────────────────────────────────────────────── + +/** + * Eingabe auflösen und einreihen — die eine Stelle für Discord *und* Panel. + * + * Bei einem Suchbegriff wird bewusst nicht der erste Treffer genommen, + * sondern die Auswahl zurückgegeben: „Sleeping Sun" hat drei Fassungen, und + * welche gemeint war, weiss nur der, der es getippt hat. Bei einer Adresse + * gibt es nichts zu wählen — die geht direkt rein, eine Playlist mit allen + * Einträgen auf einmal. + * + * @returns {Promise<{eingereiht?: object[], treffer?: object[], fehler?: string}>} + */ +export async function einreihen(guildId, eingabe, { wunschVon = null } = {}) { + if (!youtubeAn()) return { fehler: 'YouTube ist ausgeschaltet.' }; + const r = await aufloesen(eingabe, { max: TREFFER }); + if (r.fehler) return { fehler: r.fehler }; + if (r.suche) return { treffer: r.treffer }; + return { eingereiht: eintraegeAufnehmen(guildId, r.treffer, wunschVon) }; +} + +/** Fertige Einträge in die Liste schreiben */ +export function eintraegeAufnehmen(guildId, eintraege, wunschVon = null) { + for (const e of eintraege) queueAdd(guildId, { ...e, wunsch_von: wunschVon }); + return eintraege; +} + +/** + * Läuft nichts, soll das Einreihen sofort Musik machen. + * + * Sonst legt jemand drei Titel in eine Liste, es passiert nichts, und der + * naheliegende Schluss wäre „kaputt". Läuft dagegen schon etwas — Sender oder + * Titel —, wird nicht dazwischengefunkt: die Liste kommt dran, wenn sie dran + * ist. + */ +export async function ggfAnwerfen(client, guildId, kanalId) { + if (laeuft(guildId) || !kanalId) return false; + const q = await weiter(client, guildId, { erzwungen: true, kanalId }); + return Boolean(q); +} + +// ── Suchtreffer zwischenlagern ──────────────────────────────────────────── +// +// Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine +// vollständige Auskunft hinein. Also bleiben die Treffer kurz hier liegen und +// die Kennung wandert in die customId. +// +// Mit Verfallszeit, weil sonst jede Suche für immer Speicher belegt — bei +// einem Bot, der monatelang läuft, ist das kein theoretischer Einwand. +const treffermerker = new Map(); // token → { treffer, zeit } +const MERK_DAUER = 5 * 60_000; + +function trefferMerken(token, treffer) { + const grenze = Date.now() - MERK_DAUER; + for (const [k, v] of treffermerker) if (v.zeit < grenze) treffermerker.delete(k); + treffermerker.set(token, { treffer, zeit: Date.now() }); +} + +function trefferHolen(token) { + const e = treffermerker.get(token); + if (!e || e.zeit < Date.now() - MERK_DAUER) return null; + return e.treffer; +} + +/** Die flüchtige Auswahl nach einer Suche */ +function trefferAuswahl(token, treffer) { + return new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`musik_treffer:${token}`) + .setPlaceholder('Welchen davon?') + .addOptions(treffer.map((t, i) => { + const o = new StringSelectMenuOptionBuilder() + .setLabel(kurz(t.titel, 100)) + .setValue(String(i)); + const unten = [dauerText(t.dauer), t.kanal].filter(Boolean).join(' · '); + if (unten) o.setDescription(kurz(unten, 100)); + return o; + })) + ); +} + +// ── Anbindung ───────────────────────────────────────────────────────────── + +export function startMusik() { + // Damit ein Titelwechsel, ein Stopp oder ein Überspringen auch hier + // ankommt — ohne dass `radio.js` diese Datei kennen muss. + beiAenderung(musikTafelAktualisieren); +} + +/** Wo darf gespielt werden: der eigene Kanal, sonst der laufende */ +async function zielKanal(interaction) { + const eigener = interaction.member?.voice?.channel; + if (eigener) return eigener; + const jetzt = laeuft(interaction.guild?.id); + if (!jetzt) return null; + return interaction.client.channels.fetch(jetzt.channelId).catch(() => null); +} + +const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh', + 'musik_vor', 'musik_weg']; + +/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */ +export async function handleMusikInteraction(interaction) { + const { customId } = interaction; + const istTreffer = customId.startsWith('musik_treffer:'); + if (customId !== 'musik_modal' && !istTreffer && !IDS.includes(customId)) return false; + + const guildId = interaction.guild?.id; + + // Das Eingabefenster muss *vor* jedem Aufschub kommen: Discord lässt ein + // Modal nur als erste Antwort auf eine Interaktion zu. + if (customId === 'musik_add') { + await interaction.showModal(new ModalBuilder() + .setCustomId('musik_modal') + .setTitle('Einreihen') + .addComponents(new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('eingabe') + .setLabel('YouTube-Link oder Suchbegriff') + .setPlaceholder('https://youtu.be/… oder: nightwish sleeping sun') + .setStyle(TextInputStyle.Short) + .setMaxLength(300) + .setRequired(true) + ))); + return true; + } + + // Auflösen dauert länger als die drei Sekunden, die Discord gibt. + if (customId === 'musik_modal' || istTreffer) { + // Beim Eingabefenster gibt es noch keine Nachricht, also eine neue + // (flüchtige). Bei der Trefferauswahl gibt es sie — dann wird sie + // ersetzt, statt eine zweite daneben zu stellen und die Auswahl + // stehen zu lassen, als könnte man noch einmal wählen. + await (istTreffer + ? interaction.deferUpdate() + : interaction.deferReply({ flags: MessageFlags.Ephemeral })); + const kanal = await zielKanal(interaction); + + let eintraege = null; + if (istTreffer) { + const treffer = trefferHolen(customId.split(':')[1]); + if (!treffer) { + await interaction.editReply('❌ Die Auswahl ist abgelaufen — bitte neu suchen.'); + return true; + } + const gewaehlt = treffer[Number(interaction.values[0])]; + if (!gewaehlt) { + await interaction.editReply('❌ Diesen Treffer gibt es nicht mehr.'); + return true; + } + eintraege = eintraegeAufnehmen(guildId, [gewaehlt], + interaction.member?.displayName ?? interaction.user?.username); + } else { + const eingabe = interaction.fields.getTextInputValue('eingabe'); + const r = await einreihen(guildId, eingabe, { + wunschVon: interaction.member?.displayName ?? interaction.user?.username, + }); + if (r.fehler) { + await interaction.editReply(`❌ ${r.fehler}`); + return true; + } + if (r.treffer) { + const token = interaction.id; + trefferMerken(token, r.treffer); + await interaction.editReply({ + content: `🔎 ${r.treffer.length} Treffer:`, + components: [trefferAuswahl(token, r.treffer)], + }); + return true; + } + eintraege = r.eingereiht; + } + + const angeworfen = await ggfAnwerfen(interaction.client, guildId, kanal?.id); + await interaction.editReply({ + content: eintraege.length === 1 + ? `✅ **${kurz(eintraege[0].titel, 120)}** ${angeworfen ? 'läuft jetzt' : 'eingereiht'}.` + : `✅ ${eintraege.length} Titel eingereiht${angeworfen ? ' — der erste läuft' : ''}.` + + (kanal ? '' : '\n_Geh in einen Sprachkanal, damit es losgeht._'), + components: [], + }); + // Hier zeichnet `tafelnMelden` beide Tafeln: die Antwort oben war + // flüchtig und ist keine von ihnen. + await tafelnMelden(interaction.client, guildId); + return true; + } + + // Ab hier: alles, was die Tafel an Ort und Stelle neu zeichnet. + if (customId === 'musik_skip') { + const kanal = await zielKanal(interaction); + if (!kanal) { + await interaction.reply({ + content: '❌ Geh erst in einen Sprachkanal.', + flags: MessageFlags.Ephemeral, + }); + return true; + } + // Aufgeschoben, weil ein Quellenwechsel eine Verbindung aufbauen kann + // und das über den drei Sekunden liegt. + await interaction.deferUpdate(); + await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id }); + } else if (customId === 'musik_leeren') { + queueClear(guildId); + await interaction.deferUpdate(); + } else if (customId === 'musik_heim') { + const jetzt = laeuft(guildId); + const heim = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null; + await interaction.deferUpdate(); + if (heim) { + const kanal = await interaction.client.channels.fetch(jetzt.channelId).catch(() => null); + // Die Warteschlange bleibt stehen: „zum Radio" heisst „jetzt + // wieder Sender", nicht „alles wegwerfen". + if (kanal) await spielen(kanal, radioQuelle(heim)); + } + } else if (customId === 'musik_vor') { + queueVor(guildId, Number(interaction.values[0])); + await interaction.deferUpdate(); + } else if (customId === 'musik_weg') { + queueRemove(Number(interaction.values[0])); + await interaction.deferUpdate(); + } else { + await interaction.deferUpdate(); // musik_refresh + } + + await interaction.editReply({ + embeds: [musikEmbed(interaction.client, guildId)], + components: musikKomponenten(guildId), + }).catch(() => {}); + // Die Radio-Tafel zeigt dieselbe Wirklichkeit und muss mitgezogen werden. + // Nur die Radio-Tafel: die Musik-Tafel ist die Nachricht, auf der gerade + // geklickt wurde, und die ist eine Zeile weiter oben schon neu gezeichnet. + // `tafelnMelden` würde sie ein zweites Mal anfassen — eine Anfrage an + // Discord für nichts. + await tafelAktualisieren(interaction.client, guildId).catch(() => {}); + return true; +} + +/** Die Tafel im Kanal öffnen — von `/musik` aufgerufen */ +export async function musikTafelOeffnen(interaction) { + if (!moduleEnabled('radio')) { + await interaction.reply({ + content: '❌ Radio ist ausgeschaltet — im Panel unter **Community → Radio** einschalten.', + flags: MessageFlags.Ephemeral, + }); + return; + } + await interaction.deferReply(); + const guildId = interaction.guild.id; + const nachricht = await interaction.editReply({ + embeds: [musikEmbed(interaction.client, guildId)], + components: musikKomponenten(guildId), + }); + // Die alte Tafel verliert ihre Knöpfe nicht, aber ihren Anspruch: von hier + // an wird die neue nachgeführt. Zwei Tafeln, von denen eine einfriert, + // wären schlechter als eine, die umzieht. + setYtPanel(guildId, nachricht.channelId ?? interaction.channelId, nachricht.id); +} + +// Nur zum Prüfen von aussen — die Merker-Verfallszeit ohne Wartezeit testbar. +export const _treffermerker = treffermerker; diff --git a/src/bot/radio.js b/src/bot/radio.js index 32a3780..358db18 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -749,20 +749,23 @@ export async function spielen(kanal, quelle) { * einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte * gesehen, warum. */ -export async function weiter(client, guildId, { erzwungen = false } = {}) { +export async function weiter(client, guildId, { erzwungen = false, kanalId: start = null } = {}) { // 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; + // Läuft schon etwas, gilt dessen Kanal; sonst der mitgegebene. Der + // zweite Fall ist „aus der Stille heraus": jemand reiht etwas ein, + // während der Bot gar nicht im Kanal sitzt. + const kanalId = jetzt?.channelId ?? start; 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) { + 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)`); @@ -784,7 +787,9 @@ export async function weiter(client, guildId, { erzwungen = false } = {}) { const quelle = naechster ? youtubeQuelle(naechster) : (() => { - const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; + // Kein `jetzt` heisst „aus der Stille gestartet" — dann gibt + // es auch keinen Sender, auf den zurückzufallen wäre. + const heim = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null; return heim ? radioQuelle(heim) : null; })(); @@ -953,7 +958,7 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) { } /** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */ -async function tafelAktualisieren(client, guildId) { +export async function tafelAktualisieren(client, guildId) { const stand = radioState(guildId); if (!stand?.panel_message_id) return; const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null); diff --git a/src/index.js b/src/index.js index 0ec5fa2..239c77a 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { scheduleBackups } from './backup.js'; import { startServerMonitor } from './bot/server-monitor.js'; import { startLsFarm } from './bot/ls-farm.js'; import { startRadio, radioBeenden } from './bot/radio.js'; +import { startMusik } from './bot/radio-musik.js'; import { startGiveaways } from './bot/giveaways.js'; import { startScheduledPosts } from './web/scheduled-posts.js'; import { startSocialNotify } from './bot/social-notify.js'; @@ -76,6 +77,7 @@ try { startServerMonitor(client); startLsFarm(client); startRadio(client); + startMusik(); startGiveaways(client); startScheduledPosts(client); startSocialNotify(client); From 1ac349029fc14f41f0369f170bbf5a06734ea84d Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 28 Aug 2026 13:44:28 +0200 Subject: [PATCH 4/6] Die Fernbedienung: Radio vom Panel aus steuern Neun Endpunkte unter /api/radio: Stand, Steuern, Warteschlange (einreihen, entfernen, leeren, sortieren), Suche und der yt-dlp-Block. Alles geht durch dieselben Funktionen wie die Discord-Tafeln. Eine zweite Abspiellogik neben radio.js waere eine zweite Gelegenheit, sich anders zu verhalten als die erste. Start, Stopp, Skip, Sender und Lautstaerke liegen als Aktionen auf einem Endpunkt statt auf fuenf: alle brauchen dieselbe Server-Aufloesung, dieselbe Rechtepruefung und antworten mit demselben Stand. Fuenf Geruest- Kopien waeren fuenf Gelegenheiten, eine davon zu vergessen. Ein Suchbegriff wird auch hier nicht geraten -- die Treffer gehen zurueck, gewaehlt wird im Panel. Genau wie in Discord. Die cookies.txt bekommt einen eigenen Endpunkt statt eines Feldes in den Einstellungen: der Inhalt ist mehrzeilig und enthaelt Sitzungsschluessel eines echten Kontos. Er gehoert in eine Datei mit 0600 neben die Datenbank, und zurueck kommt nie der Inhalt, nur Groesse und Datum. Nur der Owner darf ihn setzen, nicht das Team. listVoiceChannels() prueft die Rechte mit: ein Kanal, den das Panel anbietet und in den der Bot dann nicht darf, sieht aus wie ein kaputter Knopf. Beim Schreiben aufgefallen und behoben: radioState() wurde benutzt, aber nicht importiert -- das haette beim ersten Senderwechsel aus dem Panel geknallt. Der Syntaxcheck sieht so etwas nicht, das Laden des Moduls schon. Geprueft mit echtem Fastify, signiertem Sitzungs-Cookie und gefaelschtem Discord-Client: 24 Faelle, davon acht Gegenproben, die fehlschlagen muessen (ohne Anmeldung 401, unbekannte Aktion, Sender ohne ID, Skip ohne Kanal, leere Eingabe, kaputte Adresse, Sortieren ohne Angabe, Cookies ohne Netscape-Kennzeile). Alle 24 wie erwartet. --- src/web/api.js | 327 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 326 insertions(+), 1 deletion(-) diff --git a/src/web/api.js b/src/web/api.js index e54a911..48f7d99 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -29,6 +29,8 @@ import { savePage, getPage, listPages, listPublishedPages, deletePage, lsState, lsModSizes, setLsModSize, listStations, createStation, updateStation, deleteStation, + queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand, + setRadioState, clearRadioState, radioState, } from '../db.js'; import crypto from 'node:crypto'; import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; @@ -36,7 +38,15 @@ import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js'; import { istLsServer, modBasis } from '../bot/ls-farm.js'; -import { titelHolen } from '../bot/radio.js'; +import { + titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle, + lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, +} from '../bot/radio.js'; +import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js'; +import { + ytdlpVorhanden, ytdlpFassung, cookiesStand, cookiesSchreiben, + selbstAktualisieren, letzterFehler, argumenteVorschau, aufloesen, TREFFER, +} from '../bot/youtube.js'; import { watchdogState, watchedServices } from '../bot/watchdog.js'; import { listRules, setRuleEnabled } from '../bot/automod.js'; import { @@ -51,6 +61,9 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men import { computeNextRun } from './scheduled-posts.js'; import { config } from '../config.js'; import { removeDevlog } from '../bot/devlog-archive.js'; +import { + radioLautstaerke, LAUT_MIN, LAUT_MAX, LAUT_SCHRITT, youtubeAn, +} from '../runtime-settings.js'; import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard, playtesterRoleId } from '../runtime-settings.js'; import { xpForLevel } from '../bot/levels.js'; import { getMilestones } from '../gitea-api.js'; @@ -418,6 +431,291 @@ export function registerApiRoutes(app, client) { return { erreichbar: sender != null || titel != null, sender, titel }; }); + // --- Radio-Fernbedienung --- + // + // Was die Discord-Tafeln koennen, kann das Panel auch — und zwar ueber + // dieselben Funktionen. Eine zweite Abspiellogik neben `radio.js` waere + // eine zweite Gelegenheit, sich anders zu verhalten als die erste. + + /** + * Welcher Server ist gemeint? + * + * Der Bot kann in mehreren stehen; das Panel hat aber nur eine Ansicht. + * Reihenfolge: ausdruecklich mitgegeben, sonst der eingestellte, sonst — + * wenn es nur einen gibt — eben der. Bei mehreren ohne Einstellung wird + * *nicht* geraten, sondern gesagt, dass die Angabe fehlt. + */ + function radioGuild(request) { + const gewuenscht = String(request.body?.guildId ?? request.query?.guildId ?? '').trim(); + if (gewuenscht) return client.guilds.cache.get(gewuenscht) ?? null; + const eingestellt = discordGuildId(); + if (eingestellt) return client.guilds.cache.get(eingestellt) ?? null; + return client.guilds.cache.size === 1 ? [...client.guilds.cache.values()][0] : null; + } + + /** Der laufende Stand, so wie ihn das Panel braucht */ + function radioStand(guild) { + const jetzt = guild ? laeuft(guild.id) : null; + if (!jetzt) return null; + const kanal = guild.channels.cache.get(jetzt.channelId); + const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; + const t = jetzt.quelle?.art === 'youtube' ? jetzt.quelle.titel : null; + return { + art: jetzt.quelle?.art ?? null, + titel: t ? t.titel : (jetzt.titel ?? null), + sender: jetzt.quelle?.art === 'radio' ? jetzt.quelle.station.name : null, + url: t?.url ?? null, + thumb: t?.thumb ?? null, + kanal: t?.kanal ?? null, + // Sekunden statt eines Zeitstempels: der Browser muesste sonst + // gegen die Uhr des Servers rechnen, und die beiden gehen selten + // gleich. + verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000), + dauer: t ? t.dauer : null, + kanalId: jetzt.channelId, + kanalName: kanal?.name ?? null, + tonLaeuft: jetzt.tonLaeuft, + heimStation: heim ? { id: heim.id, name: heim.name } : null, + }; + } + + app.get('/api/radio/status', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + const guildId = guild?.id ?? null; + return { + guildId, + guildName: guild?.name ?? null, + // Mehrere Server und keine Einstellung: dann fehlt die Angabe, + // und das steht hier statt eines stillen leeren Standes. + mehrdeutig: !guild && client.guilds.cache.size > 1, + laeuft: radioStand(guild), + lautstaerke: radioLautstaerke(), + grenzen: { min: LAUT_MIN, max: LAUT_MAX, schritt: LAUT_SCHRITT }, + warteschlange: guildId ? queueList(guildId) : [], + stand: guildId ? queueStand(guildId) : { n: 0, dauer: 0 }, + sprachkanaele: listVoiceChannels(), + werkzeuge: { + ffmpeg: ffmpegVorhanden(), + ytdlp: ytdlpVorhanden(), + ytdlpFassung: ytdlpFassung(), + youtubeAn: youtubeAn(), + cookies: cookiesStand(), + argumente: argumenteVorschau(), + letzterFehler: letzterFehler(), + }, + }; + }); + + /** + * Start, Stopp, Ueberspringen, Sender, Lautstaerke. + * + * Ein Endpunkt mit einer Aktion statt fuenf Endpunkten: alle brauchen + * dieselbe Server-Aufloesung, dieselbe Rechtepruefung und antworten mit + * demselben Stand. Fuenfmal dasselbe Gerüst waere fuenfmal die Gelegenheit, + * eines davon zu vergessen. + */ + app.post('/api/radio/steuern', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const guildId = guild.id; + const b = request.body ?? {}; + const aktion = String(b.aktion ?? ''); + + /** Der Zielkanal: mitgegeben, sonst der laufende */ + const zielId = () => String(b.kanalId ?? '') || laeuft(guildId)?.channelId || null; + + try { + if (aktion === 'stopp') { + stoppen(guildId); + clearRadioState(guildId); + } else if (aktion === 'laut') { + const wert = lautstaerkeSetzen(guildId, Number(b.wert)); + // Frueh zurueck, und zwar an der Protokollierung vorbei: am + // Schieber entstehen viele Werte hintereinander, und ein + // Protokoll, das zu neunzig Prozent aus Lautstaerke besteht, + // ist keines mehr. Zurueck kommt der tatsaechlich gesetzte + // Wert, nicht der gewuenschte — wer am Anschlag dreht, soll + // das am Ergebnis sehen. + await tafelnMelden(client, guildId); + return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) }; + } else if (aktion === 'skip') { + const kanalId = zielId(); + if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' }); + await weiter(client, guildId, { erzwungen: true, kanalId }); + } else if (aktion === 'sender' || aktion === 'heim') { + const stationId = aktion === 'heim' + ? laeuft(guildId)?.heimStationId + : Number(b.stationId); + const station = stationId ? getStation(Number(stationId)) : null; + if (!station) return reply.code(400).send({ error: 'Sender nicht gefunden.' }); + const kanalId = zielId(); + if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal gewaehlt.' }); + const kanal = await client.channels.fetch(kanalId).catch(() => null); + if (!kanal?.isVoiceBased()) return reply.code(400).send({ error: 'Das ist kein Sprachkanal.' }); + await spielen(kanal, radioQuelle(station)); + // Ohne diesen Merker kaeme der Bot nach einem Neustart nicht + // zurueck — und die Radio-Tafel wuesste nicht, was laeuft. + const vorhanden = radioState(guildId); + setRadioState({ + guildId, + channelId: kanalId, + stationId: station.id, + panelChannelId: vorhanden?.panel_channel_id, + panelMessageId: vorhanden?.panel_message_id, + }); + // Den Titel gleich mitholen: `titelMerken` schreibt ihn auch + // in die Statuszeile am Sprachkanal, sonst steht dort bis zum + // naechsten Abgleich der Titel des vorigen Senders. + const { titel } = await titelHolen(station.url); + titelMerken(client, guildId, titel); + } else { + return reply.code(400).send({ error: `Unbekannte Aktion: ${aktion}` }); + } + } catch (error) { + // Ein gescheiterter Beitritt bringt die Diagnose aus `radio.js` + // mit — die ist hier deutlich mehr wert als ein 500er. + return reply.code(400).send({ error: error.message }); + } + + logAudit(getSessionUser(request), 'radio gesteuert', aktion); + await tafelnMelden(client, guildId); + return { ok: true, laeuft: radioStand(guild), warteschlange: queueList(guildId) }; + }); + + // --- Warteschlange --- + + app.post('/api/radio/warteschlange', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const guildId = guild.id; + const b = request.body ?? {}; + const wunschVon = getSessionUser(request)?.username ?? null; + + // Zwei Wege hier hinein: eine Eingabe (Adresse oder Suchbegriff) oder + // ein fertiger Treffer, den das Panel vorher ausgewaehlt hat. + let eingereiht; + if (b.treffer) { + eingereiht = eintraegeAufnehmen(guildId, [{ + video_id: String(b.treffer.video_id ?? ''), + url: String(b.treffer.url ?? ''), + titel: String(b.treffer.titel ?? ''), + dauer: b.treffer.dauer ?? null, + kanal: b.treffer.kanal ?? null, + thumb: b.treffer.thumb ?? null, + }], wunschVon); + } else { + const r = await einreihen(guildId, String(b.eingabe ?? ''), { wunschVon }); + if (r.fehler) return reply.code(400).send({ error: r.fehler }); + // Ein Suchbegriff wird nicht geraten: die Treffer gehen zurueck, + // gewaehlt wird im Panel. Genau wie in Discord. + if (r.treffer) return { treffer: r.treffer, suche: true }; + eingereiht = r.eingereiht; + } + + const angeworfen = await ggfAnwerfen(client, guildId, + String(b.kanalId ?? '') || null); + logAudit(getSessionUser(request), 'radio eingereiht', + eingereiht.map((e) => e.titel).join(', ').slice(0, 200)); + await tafelnMelden(client, guildId); + return { + ok: true, eingereiht, angeworfen, + warteschlange: queueList(guildId), laeuft: radioStand(guild), + }; + }); + + app.post('/api/radio/suche', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const text = String(request.body?.text ?? '').trim(); + if (!text) return reply.code(400).send({ error: 'Nichts eingegeben.' }); + const r = await aufloesen(text, { max: TREFFER }); + if (r.fehler) return reply.code(400).send({ error: r.fehler }); + return { treffer: r.treffer, suche: r.suche }; + }); + + app.delete('/api/radio/warteschlange/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const weg = queueRemove(Number(request.params.id)); + await tafelnMelden(client, guild.id); + return { deleted: weg, warteschlange: queueList(guild.id) }; + }); + + app.delete('/api/radio/warteschlange', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const n = queueClear(guild.id); + logAudit(getSessionUser(request), 'warteschlange geleert', `${n} Titel`); + await tafelnMelden(client, guild.id); + return { geleert: n, warteschlange: [] }; + }); + + app.post('/api/radio/warteschlange/sortieren', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const b = request.body ?? {}; + // Zwei Formen, weil das Panel umsortiert und Discord nur „nach vorn" + // kennt — beide landen in derselben Pruefung in der Datenbank, die + // fremde IDs nicht anfasst. + if (b.vor) queueVor(guild.id, Number(b.vor)); + else if (Array.isArray(b.ids)) queueReorder(guild.id, b.ids); + else return reply.code(400).send({ error: 'Weder `ids` noch `vor` mitgegeben.' }); + await tafelnMelden(client, guild.id); + return { ok: true, warteschlange: queueList(guild.id) }; + }); + + // --- yt-dlp --- + + app.get('/api/radio/youtube', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + return { + ytdlp: ytdlpVorhanden(), + fassung: ytdlpFassung(), + an: youtubeAn(), + cookies: cookiesStand(), + argumente: argumenteVorschau(), + letzterFehler: letzterFehler(), + }; + }); + + /** + * Die `cookies.txt` setzen oder loeschen. + * + * Eigener Endpunkt statt eines Feldes in den Einstellungen: der Inhalt ist + * mehrzeilig und enthaelt Sitzungsschluessel eines echten Kontos. Er + * gehoert in eine Datei mit 0600 neben die Datenbank und nicht in eine + * Tabelle, die das Panel andernorts auch anzeigt. + * + * Zurueck kommt nie der Inhalt, nur Groesse und Datum. + */ + app.put('/api/radio/youtube/cookies', async (request, reply) => { + if (requireAdmin(request, reply)) return; + try { + const r = await cookiesSchreiben(request.body?.inhalt ?? ''); + logAudit(getSessionUser(request), 'yt-cookies', + r.gesetzt ? 'gesetzt' : 'geloescht'); + return { ok: true, cookies: cookiesStand() }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } + }); + + app.post('/api/radio/youtube/update', async (request, reply) => { + if (requireAdmin(request, reply)) return; + try { + const r = await selbstAktualisieren(); + logAudit(getSessionUser(request), 'yt-dlp aktualisiert', r.fassung ?? '?'); + return { ok: true, ...r }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } + }); + // Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist // eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server. app.get('/api/ticket-categories', async (request, reply) => { @@ -938,6 +1236,33 @@ ${rssItems} return channels.sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Sprachkanäle, in die der Bot auch wirklich hineinkommt. + * + * Bewusst mit Rechteprüfung und nicht bloss nach Kanaltyp: ein Kanal, den + * das Panel anbietet und in den der Bot dann nicht darf, sieht aus wie ein + * kaputter Knopf. Was hier fehlt, fehlt aus einem Grund — deshalb steht + * der auch dabei. + */ + function listVoiceChannels() { + const channels = []; + for (const guild of client.guilds.cache.values()) { + for (const ch of guild.channels.cache.values()) { + if (!ch.isVoiceBased?.() || ch.viewable === false) continue; + const rechte = guild.members.me ? ch.permissionsFor(guild.members.me) : null; + channels.push({ + id: ch.id, + name: ch.name, + guild: guild.name, + guildId: guild.id, + besetzt: ch.members?.filter?.((m) => !m.user.bot).size ?? 0, + darfRein: Boolean(rechte?.has('Connect') && rechte?.has('Speak')), + }); + } + } + return channels.sort((a, b) => a.name.localeCompare(b.name)); + } + /** Rollen, die der Bot vergeben kann (unter seiner höchsten Rolle, nicht managed) */ function listRoles() { const roles = []; From dbec4e9775bf96bc3212e232b7706ed4e02fceed Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 28 Aug 2026 13:50:06 +0200 Subject: [PATCH 5/6] Panel: die volle Fernbedienung ueber der Senderliste Was laeuft, wo, aus welcher Quelle, mit Fortschrittsbalken. Sprachkanal waehlen, Sender abspielen, Ueberspringen, Zum Radio, Stopp, Lautstaerke-Schieber. Einreihen per Link oder Suchbegriff, Warteschlange mit Hoch/Runter/Weg und Leeren. Darunter ein aufklappbarer yt-dlp-Block: Zustand, Fassung, cookies.txt, eigene Argumente, Aktualisieren -- und die letzte Fehlerzeile im Klartext. Steht ueber der Senderliste, weil man oefter etwas abspielt als die Liste pflegt. Die Abfrage laeuft nur, solange der Community-Reiter offen ist. Sonst ginge alle fuenf Sekunden eine Anfrage gegen eine Ansicht raus, die niemand ansieht. Der Balken erscheint nur bei bekannter Dauer. Bei einem Livestream gaebe er einen Stand vor, den niemand kennt -- dort steht stattdessen "live". Die Kanalauswahl graut aus, wo der Bot nicht rein darf. Ein Kanal, den das Panel anbietet und in den der Bot dann nicht kommt, sieht aus wie ein kaputter Knopf. Geprueft: der Vite-Build laeuft durch, und ein Abgleich stellt alle 30 Feldpfade, die die Oberflaeche vom Stand liest, gegen die echte API-Antwort -- 0 fehlen. Der Abgleich hat eine Gegenprobe mit bekanntem Ausgang (ein Feld, das es nicht gibt, muss als fehlend erkannt werden) und prueft vorher, ob die sonst leeren Zweige (Warteschlange, Cookies, letzter Fehler) ueberhaupt besetzt sind -- sonst meldet er "ungueltig" statt "in Ordnung". Seine erste Fassung hatte Fehlalarm geschlagen (?. und .map als fehlende Felder gelesen); das ist behoben. NICHT geprueft: das tatsaechliche Rendern im Browser -- hier ist weder Browser noch DOM-Werkzeug vorhanden. --- frontend/src/pages/Settings.jsx | 467 ++++++++++++++++++++++++++++++++ frontend/src/style.css | 55 ++++ src/modules.js | 17 +- 3 files changed, 536 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fefbf26..ca9fd88 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -95,6 +95,21 @@ const WUNSCH_STATUS = [ // Alles mit Editor, Vorschau oder Liste bleibt einspaltig. const SENDER_LEER = { id: null, name: '', url: '', emoji: '', sort: 0 }; +/** + * 3:07 statt 187. Ohne bekannte Dauer steht „live" da und nicht „0:00" — + * ein Livestream hat keine Laenge, und eine Null waere eine Behauptung. + */ +function dauerText(sekunden) { + if (sekunden === null || sekunden === undefined) return 'live'; + const t = Math.max(0, Math.round(sekunden)); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const r = t % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` + : `${m}:${String(r).padStart(2, '0')}`; +} + const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']); // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) @@ -432,6 +447,14 @@ export default function Settings({ me }) { const [senderDraft, setSenderDraft] = useState({ id: null, name: '', url: '', emoji: '', sort: 0 }); // Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft' const [senderProbe, setSenderProbe] = useState(null); + // Fernbedienung: der Live-Stand vom Bot (null = noch nichts geholt) + const [radio, setRadio] = useState(null); + const [radioKanal, setRadioKanal] = useState(''); + const [radioEingabe, setRadioEingabe] = useState(''); + const [radioTreffer, setRadioTreffer] = useState(null); // Auswahl nach einer Suche + const [radioLaedt, setRadioLaedt] = useState(false); + const [radioFehler, setRadioFehler] = useState(''); + const [ytCookies, setYtCookies] = useState(''); const [owner, setOwner] = useState(null); const [auditAlle, setAuditAlle] = useState(false); // Module (An/Aus für alle Funktionen) @@ -472,6 +495,23 @@ export default function Settings({ me }) { apiGet('/api/radio').then((d) => setSender(d.sender ?? [])).catch(() => {}); }, []); + // Der Live-Stand der Fernbedienung. + // + // Nur solange der Community-Reiter offen ist: sonst liefe im Hintergrund + // alle fuenf Sekunden eine Anfrage gegen eine Ansicht, die niemand sieht. + // Fuenf Sekunden, weil der Fortschrittsbalken sonst ruckelt und ein Titel + // selten kuerzer als zwei Minuten ist. + useEffect(() => { + if (tab !== 'community') return undefined; + let lebt = true; + const holen = () => apiGet('/api/radio/status') + .then((d) => { if (lebt) setRadio(d); }) + .catch(() => {}); + holen(); + const timer = setInterval(holen, 5000); + return () => { lebt = false; clearInterval(timer); }; + }, [tab]); + // Portal-Dienste (Kacheln auf der Startseite) const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 }; const [services, setServices] = useState([]); @@ -2151,6 +2191,323 @@ export default function Settings({ me }) { ohne Haken antwortet /radio nur mit einem Hinweis +
+ +
+ + {/* ── Fernbedienung ────────────────────────────────────── + Steht ueber der Senderliste, weil man oefter etwas + abspielt als die Liste pflegt. */} + {radio?.mehrdeutig && ( +

+ Der Bot steht in mehreren Servern und es ist keiner eingestellt — + unter System die Server-ID eintragen, sonst weiss + das Panel nicht, welchen es steuern soll. +

+ )} + + {radio && !radio.mehrdeutig && ( +
+
+ {radio.laeuft ? ( + <> +
+ + {radio.laeuft.art === 'youtube' ? '▶️' : '📻'} + +
+ + {radio.laeuft.art === 'youtube' + ? radio.laeuft.titel + : radio.laeuft.sender} + + + {radio.laeuft.art === 'youtube' + ? [radio.laeuft.kanal, radio.laeuft.heimStation + && `danach zurück auf ${radio.laeuft.heimStation.name}`] + .filter(Boolean).join(' · ') + : (radio.laeuft.titel ?? 'Titel wird abgefragt …')} + + + 🔊 {radio.laeuft.kanalName ?? radio.laeuft.kanalId} + +
+
+ {/* Der Balken nur, wo er etwas aussagen kann: ohne + bekannte Dauer gaebe er einen Stand vor, den + niemand kennt. */} + {radio.laeuft.dauer ? ( +
+
+ +
+ + {dauerText(radio.laeuft.verstrichen)} / {dauerText(radio.laeuft.dauer)} + +
+ ) : radio.laeuft.art === 'youtube' && ( +

🔴 Livestream

+ )} + {radio.laeuft.tonLaeuft === false && ( +

+ ⚠️ Es kommt kein Ton an. + {radio.werkzeuge.ffmpeg === false + && ' Auf dem Server fehlt ffmpeg.'} +

+ )} + + ) : ( +

+ Nichts läuft gerade. + {radio.stand.n > 0 + && ` ${radio.stand.n} Titel warten — Sprachkanal wählen und abspielen.`} +

+ )} +
+ +
+
+ + +
+
+ + {/* onMouseUp/onChange statt live bei jedem Pixel: sonst + geht je Mausbewegung eine Anfrage raus. */} + setRadio({ ...radio, lautstaerke: Number(e.target.value) })} + onMouseUp={(e) => radioSteuern('laut', { wert: Number(e.target.value) })} + onTouchEnd={(e) => radioSteuern('laut', { wert: Number(e.target.value) })} /> +
+
+ +
+ + + + +
+ + {/* ── Einreihen ── */} +
+ +
+ { setRadioEingabe(e.target.value); setRadioTreffer(null); }} + onKeyDown={(e) => e.key === 'Enter' && radioEinreihen()} /> + +
+ {!radio.werkzeuge.youtubeAn && ( + + YouTube ist ausgeschaltet — Haken oben setzen und speichern. + + )} +
+ + {/* Bei einer Suche wird nicht geraten, welcher gemeint war. */} + {radioTreffer && ( +
+ {radioTreffer.map((t) => ( +
+ ▶️ +
+ {t.titel} + + {[t.kanal, dauerText(t.dauer)].filter(Boolean).join(' · ')} + +
+
+ +
+
+ ))} +
+ )} + + {/* ── Warteschlange ── */} + {radio.warteschlange.length > 0 && ( + <> + +
+ {radio.warteschlange.map((e, i) => ( +
+ {i + 1}. +
+ {e.titel} + + {[e.kanal, dauerText(e.dauer), + e.wunsch_von && `von ${e.wunsch_von}`] + .filter(Boolean).join(' · ')} + +
+
+ + + +
+
+ ))} +
+
+ +
+ + )} + + {radioFehler &&

❌ {radioFehler}

} + + {/* ── yt-dlp ────────────────────────────────────── + Steht sichtbar hier und nicht in einer Fussnote: + wenn YouTube klemmt, ist das fast immer die + Antwort, und Stille erklaert sich nicht selbst. */} +
+ + yt-dlp — {radio.werkzeuge.ytdlp === false + ? '❌ fehlt' + : radio.werkzeuge.ytdlp === null + ? '… noch nicht geprüft' + : `✅ ${radio.werkzeuge.ytdlpFassung ?? 'da'}`} + {radio.werkzeuge.cookies ? ' · Cookies gesetzt' : ''} + + + {radio.werkzeuge.letzterFehler && ( + <> + +
{radio.werkzeuge.letzterFehler.text}
+ + )} + +

+ YouTube sperrt Server-Adressen inzwischen häufig + („Sign in to confirm you're not a bot"). Zwei Griffe helfen, + beide ohne neues Image: eine cookies.txt aus dem + Browser eines angemeldeten Kontos, und eigene Argumente + (Feld weiter unten in dieser Sektion). +

+ +
+ +