Radio: Webradio im Sprachkanal, 44 Sender zum Durchschalten
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

/radio holt den Bot in den Sprachkanal und postet eine Tafel: Auswahlmenue
mit allen Sendern, Stopp, und ein Embed mit dem laufenden Titel, das sich
alle 30 Sekunden selbst nachfuehrt.

Der Titel kommt aus dem Icecast-Strom selbst (Icy-MetaData). Bewusst so und
nicht ueber eine Sender-API: das funktioniert bei jedem Strom, den jemand
eintraegt, und nicht nur bei den drei, die ich kenne. Leere Titel-Bloecke
heissen "unveraendert", deshalb werden bis zu drei gelesen.

Die Tonkette braucht keine Opus-Bibliothek: ffmpeg holt das MP3 und gibt
direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete in
fuenf Sekunden. Sonst muesste @discordjs/opus mit ins Image, und das will
gebaut werden. Neu im Image ist nur ffmpeg.

44 RauteMusik-Sender sind ab Werk eingetragen — dieselben, die im LS25 und
ETS als Bordradio laufen. Die Liste ist nicht abgeschrieben, sondern
gemessen: jede Adresse einzeln angefragt, der Name kommt aus icy-name.
Doppelgaenger sind raus (deutschrap-charts und wackenradio liefern denselben
Strom wie deutschrap und metal). Eingetragen wird einmal, solange die Liste
leer ist — wer loescht, behaelt es geloescht.

Ein Auswahlmenue fasst nur 25 Eintraege. Bei 44 Sendern haette ein Deckel
die letzten 19 stillschweigend verschluckt; stattdessen mehrere Menues,
benannt nach ihrem ersten und letzten Sender.

Der Bot geht raus, wenn der Kanal leer ist, und kommt nach einem Neustart in
den Kanal zurueck — sonst beendet jeder Deploy die Musik endgueltig.

Kein YouTube. Genau daran sind Groovy und Rythm gestorben, und was davon
uebrig ist, ist ein Dauerlauf gegen kaputte Extraktoren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:40:15 +02:00
co-authored by Claude Opus 5
parent b7f54185f7
commit 89f6dfcd29
17 changed files with 994 additions and 4 deletions
+75
View File
@@ -1882,3 +1882,78 @@ export function archiveStats() {
lastCommit: lastCommitStmt.get().ts ?? null,
};
}
// ── Radio ─────────────────────────────────────────────────────────────────
//
// Sender sind frei eintragbar, nicht fest verdrahtet: es gibt keinen Grund,
// warum jemand nur die Sender hören dürfen sollte, die ich kenne. Die URL ist
// eindeutig — denselben Sender zweimal in der Liste hilft niemandem.
db.exec(`
CREATE TABLE IF NOT EXISTS radio_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
emoji TEXT,
sort INTEGER NOT NULL DEFAULT 0
);
`);
const listStationsStmt = db.prepare('SELECT * FROM radio_stations ORDER BY sort, name');
const getStationStmt = db.prepare('SELECT * FROM radio_stations WHERE id = ?');
const insertStationStmt = db.prepare(`
INSERT INTO radio_stations (name, url, emoji, sort) VALUES (@name, @url, @emoji, @sort)
ON CONFLICT(url) DO NOTHING
`);
const updateStationStmt = db.prepare(`
UPDATE radio_stations SET name = @name, url = @url, emoji = @emoji, sort = @sort WHERE id = @id
`);
const deleteStationStmt = db.prepare('DELETE FROM radio_stations WHERE id = ?');
export const listStations = () => listStationsStmt.all();
export const getStation = (id) => getStationStmt.get(id) ?? null;
export const createStation = (s) => insertStationStmt.run({
name: String(s.name ?? '').slice(0, 60),
url: String(s.url ?? ''),
emoji: s.emoji ? String(s.emoji).slice(0, 8) : null,
sort: Number(s.sort) || 0,
}).lastInsertRowid;
export const updateStation = (s) => updateStationStmt.run({
id: Number(s.id),
name: String(s.name ?? '').slice(0, 60),
url: String(s.url ?? ''),
emoji: s.emoji ? String(s.emoji).slice(0, 8) : null,
sort: Number(s.sort) || 0,
});
export const deleteStation = (id) => deleteStationStmt.run(id).changes > 0;
// Was gerade wo läuft. Steht in der Datenbank und nicht nur im Speicher, damit
// der Bot nach einem Neustart von selbst zurückkommt — ein Deploy wirft ihn
// sonst aus dem Sprachkanal und die Musik ist einfach weg.
db.exec(`
CREATE TABLE IF NOT EXISTS radio_state (
guild_id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
station_id INTEGER,
panel_channel_id TEXT,
panel_message_id TEXT,
seit TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const radioStateStmt = db.prepare('SELECT * FROM radio_state WHERE guild_id = ?');
const radioStatesStmt = db.prepare('SELECT * FROM radio_state');
const setRadioStateStmt = db.prepare(`
INSERT INTO radio_state (guild_id, channel_id, station_id, panel_channel_id, panel_message_id, seit)
VALUES (@guild_id, @channel_id, @station_id, @panel_channel_id, @panel_message_id, datetime('now'))
ON CONFLICT(guild_id) DO UPDATE SET
channel_id = excluded.channel_id, station_id = excluded.station_id,
panel_channel_id = excluded.panel_channel_id, panel_message_id = excluded.panel_message_id
`);
const clearRadioStateStmt = db.prepare('DELETE FROM radio_state WHERE guild_id = ?');
export const radioState = (guildId) => radioStateStmt.get(guildId) ?? null;
export const radioStates = () => radioStatesStmt.all();
export const setRadioState = (s) => setRadioStateStmt.run({
guild_id: String(s.guildId),
channel_id: String(s.channelId),
station_id: s.stationId != null ? Number(s.stationId) : null,
panel_channel_id: s.panelChannelId ? String(s.panelChannelId) : null,
panel_message_id: s.panelMessageId ? String(s.panelMessageId) : null,
});
export const clearRadioState = (guildId) => clearRadioStateStmt.run(guildId).changes > 0;