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') ?? ''; +}