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
+21 -2
View File
@@ -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
+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) };
}
+139
View File
@@ -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;
+25
View File
@@ -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') ?? '';
}