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
+61
View File
@@ -28,6 +28,7 @@ import {
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
lsState, lsModSizes, setLsModSize,
listStations, createStation, updateStation, deleteStation,
} from '../db.js';
import crypto from 'node:crypto';
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
@@ -35,6 +36,7 @@ import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js';
import { istLsServer, modBasis } from '../bot/ls-farm.js';
import { titelHolen } from '../bot/radio.js';
import { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js';
import {
@@ -357,6 +359,65 @@ export function registerApiRoutes(app, client) {
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
});
// --- Radio-Sender ---
//
// Frei eintragbar statt fest verdrahtet: es gibt keinen Grund, warum
// jemand nur die Sender hören dürfen sollte, die im Code stehen.
/** Eingaben prüfen; antwortet bei Fehlern selbst */
function senderBody(request, reply) {
const b = request.body ?? {};
const name = String(b.name ?? '').trim().slice(0, 60);
const url = String(b.url ?? '').trim();
if (!name) { reply.code(400).send({ error: 'Name fehlt' }); return null; }
// Kein http:// — der Strom liefe sonst unverschlüsselt durch den Server
if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; }
return {
name, url,
emoji: String(b.emoji ?? '').trim().slice(0, 8) || null,
sort: Number(b.sort) || 0,
};
}
app.get('/api/radio', async (request, reply) => {
if (requireAnyScope(request, reply)) return;
return { sender: listStations() };
});
app.post('/api/radio', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const s = senderBody(request, reply);
if (!s) return;
createStation(s);
logAudit(getSessionUser(request), 'radiosender angelegt', `${s.name} (${s.url})`);
return { ok: true, sender: listStations() };
});
app.put('/api/radio/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const s = senderBody(request, reply);
if (!s) return;
updateStation({ ...s, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'radiosender geändert', s.name);
return { ok: true, sender: listStations() };
});
app.delete('/api/radio/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
logAudit(getSessionUser(request), 'radiosender entfernt', String(request.params.id));
return { deleted: deleteStation(Number(request.params.id)), sender: listStations() };
});
// Was der Sender gerade spielt — zum Prüfen beim Eintragen. Der Titel
// kommt aus dem Datenstrom selbst, es braucht also keine Sender-API.
app.post('/api/radio/pruefen', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const url = String(request.body?.url ?? '').trim();
if (!/^https?:\/\/.+/.test(url)) return reply.code(400).send({ error: 'URL mit http(s) erwartet' });
const { titel, sender } = await titelHolen(url);
return { erreichbar: sender != null || titel != null, sender, titel };
});
// Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist
// eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server.
app.get('/api/ticket-categories', async (request, reply) => {