Radio youtube #2
+21
-2
@@ -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
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user