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.
This commit is contained in:
2026-08-28 13:26:05 +02:00
parent 19010e9fde
commit af16bc1b18
4 changed files with 563 additions and 2 deletions
+378
View File
@@ -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) };
}