Radio youtube #2

Merged
D4rkst3r merged 6 commits from radio-youtube into main 2026-08-28 14:02:24 +02:00
4 changed files with 563 additions and 2 deletions
Showing only changes of commit af16bc1b18 - Show all commits
+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') ?? '';
}