// 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) }; }