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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user