From 4d711228203db979132cc80a96f966d0ae455e7e Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 14 Aug 2026 11:54:49 +0200 Subject: [PATCH] =?UTF-8?q?Radio:=20Schalter=20dorthin,=20wo=20man=20ihn?= =?UTF-8?q?=20sucht=20=E2=80=94=20und=20Stille=20sagt=20jetzt=20warum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Sachen, alle vom selben Bericht ausgeloest. 1. Der Schalter war nur im Modul-Reiter zu finden, die Meldung schickte aber nach "Community". Beim Nachsehen kam heraus: Modul-Schalter haben in /api/settings gar keinen Weg — jeder stand von Hand in api.js, und fuenf fehlten dort (radio, automod, anti_raid, linked_roles, ls_farm). Die liessen sich nur ueber den Modulreiter umlegen. Jetzt kommen sie wie Kanaele, Rollen und Texte aus dem Register; jeder neue ist damit von allein speicherbar. Dazu steht der Haken jetzt oben im Radio-Abschnitt, wo auch die Sender stehen. 2. Der Bot sass im Sprachkanal und schwieg. Ursache im Code: nach joinVoiceChannel wurde sofort losgespielt. Der Aufruf kommt zurueck, sobald der Bot sichtbar im Kanal steht — der UDP-Weg wird danach erst ausgehandelt. Wer vorher sendet, sendet ins Leere. Jetzt wird auf VoiceConnectionStatus.Ready gewartet, und schlaegt das fehl, sagt der Befehl es statt still dazustehen. 3. Stille erklaert sich. Fehlt ffmpeg im Container, setzte sich der Bot bisher hin und schwieg — nichts stuerzte ab, nichts fehlte sichtbar. ffmpeg wird jetzt beim Start geprueft, /radio verweigert mit klarer Ansage, und kommt binnen acht Sekunden kein Ton, steht der Grund in der Tafel: fehlendes ffmpeg oder ein Sender, der nichts liefert. Nebenbei: -analyzeduration stand hinter -i und wurde damit als Ausgabe-Option gelesen, also ignoriert. Geprueft: die Tonkette laeuft lokal gegen einen echten Sender durch bis zum Abspieler (4,1 s Ton in 6 s Laufzeit, Status bleibt playing). Alle 16 Modul-Schalter gehen angemeldet durch /api/settings hin und zurueck. Co-Authored-By: Claude Opus 5 --- frontend/src/pages/Settings.jsx | 12 ++++- src/bot/commands/radio.js | 21 ++++++-- src/bot/commands/umfrage.js | 2 +- src/bot/radio.js | 95 +++++++++++++++++++++++++++++---- src/web/api.js | 21 +++++++- 5 files changed, 135 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 0c7ecaf..5747358 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -2125,7 +2125,17 @@ export default function Settings({ me }) {
-

// Radio-Sender

+

// Radio

+ {/* Der Schalter steht hier und nicht nur im Modul-Reiter: + wer die Sender pflegt, sucht ihn genau an dieser Stelle. */} +
+ +

Adressen von Webradio-Strömen. /radio öffnet im Discord eine Tafel mit genau dieser Liste zum Durchschalten. Jeder Icecast- oder diff --git a/src/bot/commands/radio.js b/src/bot/commands/radio.js index f214c38..43f741b 100644 --- a/src/bot/commands/radio.js +++ b/src/bot/commands/radio.js @@ -6,7 +6,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { moduleEnabled } from '../../modules.js'; import { listStations, getStation, setRadioState } from '../../db.js'; -import { spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen } from '../radio.js'; +import { spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden } from '../radio.js'; export const data = new SlashCommandBuilder() .setName('radio') @@ -15,7 +15,7 @@ export const data = new SlashCommandBuilder() export async function execute(interaction) { if (!moduleEnabled('radio')) { await interaction.reply({ - content: '❌ Radio ist ausgeschaltet — im Panel unter **Community** einschaltbar.', + content: '❌ Radio ist ausgeschaltet — im Panel unter **Community → Radio** einschalten.', flags: MessageFlags.Ephemeral, }); return; @@ -53,6 +53,17 @@ export async function execute(interaction) { } } + // Ohne ffmpeg wuerde der Bot sich hinsetzen und schweigen. Lieber gleich + // sagen, woran es liegt, als jemanden im stillen Kanal warten lassen. + if (ffmpegVorhanden() === false) { + await interaction.reply({ + content: '❌ Auf dem Server fehlt **ffmpeg** — ohne das kann der Bot keinen Ton ' + + 'umwandeln. Das Image muss neu gebaut werden.', + flags: MessageFlags.Ephemeral, + }); + return; + } + // Verbinden dauert länger als die drei Sekunden, die Discord gibt await interaction.deferReply(); @@ -64,7 +75,7 @@ export async function execute(interaction) { await spielen(kanal, station); } catch (error) { console.error('[radio]', error); - await interaction.editReply('❌ Verbindung in den Sprachkanal fehlgeschlagen.'); + await interaction.editReply(`❌ ${error.message || 'Verbindung in den Sprachkanal fehlgeschlagen.'}`); return; } ({ titel } = await titelHolen(station.url)); @@ -72,8 +83,10 @@ export async function execute(interaction) { if (s) s.titel = titel; } + const jetzt = laeuft(interaction.guild.id); const nachricht = await interaction.editReply({ - embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId)], + embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId, + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden() })], components: tafelKomponenten(stationen, station?.id), }); diff --git a/src/bot/commands/umfrage.js b/src/bot/commands/umfrage.js index 6928ab1..29a28fe 100644 --- a/src/bot/commands/umfrage.js +++ b/src/bot/commands/umfrage.js @@ -37,7 +37,7 @@ export const data = new SlashCommandBuilder() export async function execute(interaction) { if (!moduleEnabled('polls')) { await interaction.reply({ - content: '❌ Umfragen sind ausgeschaltet — im Panel unter **Community** einschaltbar.', + content: '❌ Umfragen sind ausgeschaltet — im Panel unter **Module → Community** einschalten.', flags: MessageFlags.Ephemeral, }); return; diff --git a/src/bot/radio.js b/src/bot/radio.js index d7b6b3b..e1a2d99 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -28,6 +28,26 @@ import { senderGrundstock } from './radio-sender.js'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; +/** + * Ist ffmpeg überhaupt da? + * + * Ohne ffmpeg setzt sich der Bot in den Sprachkanal und schweigt — nichts + * stürzt ab, nichts fehlt sichtbar, es kommt nur kein Ton. Genau diese Sorte + * Fehler soll niemand raten müssen, also wird sie einmal beim Start geprüft + * und danach im Discord angesagt statt nur ins Log geschrieben. + */ +let ffmpegDa = null; // null = noch nicht geprüft +export function ffmpegVorhanden() { + return ffmpegDa; +} +export function ffmpegPruefen() { + return new Promise((fertig) => { + const p = spawn(FFMPEG, ['-version'], { stdio: 'ignore' }); + p.on('error', () => { ffmpegDa = false; fertig(false); }); + p.on('close', (code) => { ffmpegDa = code === 0; fertig(ffmpegDa); }); + }); +} + // Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im // Speicher; was davon einen Neustart überlebt, steht in `radio_state`. const laufend = new Map(); // guildId → { player, ffmpeg, stationId, channelId, titel } @@ -97,9 +117,11 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) { */ function tonquelle(url) { const ff = spawn(FFMPEG, [ + // Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher + // dahinter und wurde dort als Ausgabe-Option gelesen, also ignoriert. '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', - '-i', url, '-analyzeduration', '0', '-loglevel', 'error', + '-i', url, '-vn', // manche Sender legen ein Coverbild bei '-acodec', 'libopus', '-f', 'ogg', '-ar', '48000', '-ac', '2', '-b:a', '96k', @@ -163,6 +185,20 @@ export async function spielen(kanal, station) { }); } + // Auf die fertige Verbindung warten, bevor gesendet wird. + // + // `joinVoiceChannel` kommt sofort zurueck — der Bot steht dann schon im + // Kanal, aber der UDP-Weg wird erst ausgehandelt. Wer sofort losspielt, + // schickt gegen eine Verbindung, die noch nicht steht: der Bot sitzt + // sichtbar drin und es kommt kein Ton. Genau so sah der Fehler aus. + try { + await entersState(verbindung, VoiceConnectionStatus.Ready, 20_000); + } catch { + verbindung.destroy(); + laufend.delete(guildId); + throw new Error('Die Sprachverbindung kam nicht zustande.'); + } + const player = vorher?.player ?? createAudioPlayer({ // Niemand mehr im Kanal? Trotzdem weiterlaufen lassen statt zu pausieren // — die Aufräumung macht der Leer-Melder, und ein pausierter Stream @@ -178,6 +214,20 @@ export async function spielen(kanal, station) { player.play(resource); verbindung.subscribe(player); + // Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts + // liefert, bleibt der Spieler in `buffering` haengen — ohne Fehler, ohne + // Ton. Das wird gemerkt, damit die Tafel es sagen kann. + const beobachten = new Promise((fertig) => { + const timer = setTimeout(() => fertig(false), 8000); + const beiWechsel = (_, neuerStand) => { + if (neuerStand.status !== AudioPlayerStatus.Playing) return; + clearTimeout(timer); + player.off('stateChange', beiWechsel); + fertig(true); + }; + player.on('stateChange', beiWechsel); + }); + if (!vorher) { player.on('error', (e) => console.error('[radio] Spieler:', e.message)); // Ein Radiostream endet nie von selbst. Tut er es doch, ist die @@ -191,9 +241,14 @@ export async function spielen(kanal, station) { }); } - laufend.set(guildId, { + const eintrag = { player, ffmpeg: ff, stationId: station.id, channelId: kanal.id, - titel: null, seit: Date.now(), + titel: null, seit: Date.now(), tonLaeuft: null, + }; + laufend.set(guildId, eintrag); + beobachten.then((ok) => { + eintrag.tonLaeuft = ok; + if (!ok) console.error(`[radio] kein Ton von ${station.name} — ffmpeg da? ${ffmpegDa}`); }); return verbindung; } @@ -263,7 +318,7 @@ const aktualisierenKnopf = () => new ButtonBuilder() /** Wie viele Sender passen überhaupt in eine Tafel? */ export const TAFEL_GRENZE = JE_MENU * MAX_MENUS; -export function tafelEmbed(client, station, titel, kanalId) { +export function tafelEmbed(client, station, titel, kanalId, stand = {}) { const embed = brandEmbed(client, 'RADIO') .setTitle('📻 Radio') .setFooter({ text: brandFooter('RADIO') }) @@ -271,9 +326,23 @@ export function tafelEmbed(client, station, titel, kanalId) { if (!station) { return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.'); } - embed - .setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`) - .addFields({ name: 'Läuft gerade', value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_' }); + embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`); + + // Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier, + // woran es liegt — statt dass jemand im leeren Kanal sitzt und wartet. + if (stand.tonLaeuft === false) { + embed.addFields({ + name: '⚠️ Kein Ton', + value: stand.ffmpeg === false + ? 'Auf dem Server fehlt **ffmpeg**. Ohne das kann der Bot keinen Ton umwandeln.' + : 'Der Sender liefert nichts. Adresse im Panel mit **Reinhören** prüfen.', + }); + return embed; + } + embed.addFields({ + name: 'Läuft gerade', + value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_', + }); return embed; } @@ -288,7 +357,8 @@ async function tafelAktualisieren(client, guildId) { const jetzt = laufend.get(guildId); const station = jetzt ? getStation(jetzt.stationId) : null; await nachricht.edit({ - embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId)], + embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId, + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], components: tafelKomponenten(listStations(), jetzt?.stationId), }).catch(() => {}); } @@ -305,6 +375,12 @@ export function startRadio(client) { // Passiert genau einmal — wer löscht, behält es gelöscht. senderGrundstock(); + ffmpegPruefen().then((da) => { + if (da) return; + console.error('[radio] ffmpeg fehlt — der Bot kann keinen Ton umwandeln. ' + + 'Im Container nachinstallieren oder das Image neu bauen.'); + }); + // Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert // selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte. setInterval(async () => { @@ -410,7 +486,8 @@ export async function handleRadioInteraction(interaction) { const jetzt = laufend.get(guildId); const station = jetzt ? getStation(jetzt.stationId) : null; await interaction.update({ - embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId)], + embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId, + { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], components: tafelKomponenten(listStations(), jetzt?.stationId), }).catch(() => {}); return true; diff --git a/src/web/api.js b/src/web/api.js index b771858..6cd84ea 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -961,9 +961,20 @@ ${rssItems} // liegt, und der Haken „einsatzbereit" widerspricht dem Formular. ...Object.fromEntries( Object.entries(moduleSettingKeys()) - .filter(([art]) => art !== 'schalter') // Schalter sind Ja/Nein, siehe unten + .filter(([art]) => art !== 'schalter') // Schalter sind Ja/Nein, siehe direkt darunter .flatMap(([, keys]) => keys.map((k) => [k, getSetting(k) ?? ''])) ), + // Schalter ebenfalls aus dem Register. Vorher stand jeder einzeln + // weiter unten in dieser Datei — und fünf fehlten dort schlicht + // (radio, automod, anti_raid, linked_roles, ls_farm). Die liessen + // sich dann nur über den Modul-Reiter umlegen, nirgends sonst. + // + // Der Vorgabewert steckt im Register, nicht hier: manche Module + // sind ab Werk an, andere aus. `moduleEnabled` weiss das. + ...Object.fromEntries(moduleSettingKeys().schalter.map((k) => { + const mod = MODULES.find((m) => m.setting === k); + return [k, mod ? moduleEnabled(mod.id) : getSetting(k) === '1']; + })), commit_channel_id: commitChannelId(), devlog_channel_id: devlogChannelId(), release_channel_id: releaseChannelId() ?? '', @@ -1092,6 +1103,14 @@ ${rssItems} // Panel steht, aber nicht in dieser Datei, verfiel sonst stumm. const ausRegister = moduleSettingKeys(); + // Schalter aus dem Register — jeder neue Modul-Schalter ist damit + // automatisch speicherbar. Die handgeschriebenen Zweige weiter unten + // machen dasselbe noch einmal; das schadet nicht und bleibt stehen, + // solange sie zusätzliche Nebenwirkungen haben. + for (const key of ausRegister.schalter) { + if (body[key] !== undefined) setSetting(key, body[key] ? '1' : '0'); + } + // Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus) const OPTIONAL_CHANNELS = [...new Set([ 'release_channel_id', 'backup_channel_id', 'starboard_channel_id',