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 }) {
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',