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',