Radio: Webradio im Sprachkanal, 44 Sender zum Durchschalten
/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:
+15
-1
@@ -24,6 +24,7 @@ import {
|
||||
handleWunschPick, handleWunschButton, handleWunschModal, registerWishThreads,
|
||||
} from './wishes.js';
|
||||
import { handleGiveawayAdminButton } from './giveaways.js';
|
||||
import { handleRadioInteraction } from './radio.js';
|
||||
import {
|
||||
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
|
||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||
@@ -47,12 +48,13 @@ import * as remind from './commands/remind.js';
|
||||
import * as geburtstag from './commands/geburtstag.js';
|
||||
import * as raid from './commands/raid.js';
|
||||
import * as umfrage from './commands/umfrage.js';
|
||||
import * as radio from './commands/radio.js';
|
||||
|
||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||
const commandModules = [
|
||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
||||
raid, umfrage,
|
||||
raid, umfrage, radio,
|
||||
];
|
||||
|
||||
export async function startBot() {
|
||||
@@ -168,6 +170,18 @@ export async function startBot() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und
|
||||
// antwortet in einem Rutsch — deshalb hier oben und nicht als
|
||||
// weiterer customId-Vergleich zwischen den anderen.
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu()) {
|
||||
try {
|
||||
if (await handleRadioInteraction(interaction)) return;
|
||||
} catch (error) {
|
||||
console.error('[radio] Interaktion:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔔-Button unterm Devlog: Ping-Rolle selbst an-/abmelden
|
||||
if (interaction.isButton() && interaction.customId === 'devlog_ping_toggle') {
|
||||
const roleId = devlogPingRoleId();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// /radio — Steuertafel im Kanal öffnen.
|
||||
//
|
||||
// Der Befehl nimmt bewusst keinen Sendernamen entgegen: getippt werden müsste
|
||||
// er auf dem Handy erst recht, und die Auswahl steht ohnehin im Menü. Ein
|
||||
// Aufruf, eine Tafel, umschalten per Klick.
|
||||
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';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('radio')
|
||||
.setDescription('Radio im Sprachkanal — Sender wählen und umschalten');
|
||||
|
||||
export async function execute(interaction) {
|
||||
if (!moduleEnabled('radio')) {
|
||||
await interaction.reply({
|
||||
content: '❌ Radio ist ausgeschaltet — im Panel unter **Community** einschaltbar.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stationen = listStations();
|
||||
if (stationen.length === 0) {
|
||||
await interaction.reply({
|
||||
content: '❌ Es sind noch keine Sender eingetragen — im Panel unter **Community → Radio**.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const kanal = interaction.member?.voice?.channel;
|
||||
const schonAn = laeuft(interaction.guild.id);
|
||||
if (!kanal && !schonAn) {
|
||||
await interaction.reply({
|
||||
content: '❌ Geh erst in einen Sprachkanal.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Darf der Bot da überhaupt rein? Sonst hängt der Verbindungsaufbau, bis
|
||||
// die Interaktion abgelaufen ist, und der Nutzer sieht nur „Fehler".
|
||||
if (kanal) {
|
||||
const rechte = kanal.permissionsFor(interaction.guild.members.me);
|
||||
if (!rechte?.has('Connect') || !rechte?.has('Speak')) {
|
||||
await interaction.reply({
|
||||
content: `❌ Ich darf in <#${kanal.id}> nicht rein oder nicht sprechen.`,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Verbinden dauert länger als die drei Sekunden, die Discord gibt
|
||||
await interaction.deferReply();
|
||||
|
||||
let station = schonAn ? getStation(schonAn.stationId) : null;
|
||||
let titel = schonAn?.titel ?? null;
|
||||
if (!schonAn) {
|
||||
station = stationen[0];
|
||||
try {
|
||||
await spielen(kanal, station);
|
||||
} catch (error) {
|
||||
console.error('[radio]', error);
|
||||
await interaction.editReply('❌ Verbindung in den Sprachkanal fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
({ titel } = await titelHolen(station.url));
|
||||
const s = laeuft(interaction.guild.id);
|
||||
if (s) s.titel = titel;
|
||||
}
|
||||
|
||||
const nachricht = await interaction.editReply({
|
||||
embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId)],
|
||||
components: tafelKomponenten(stationen, station?.id),
|
||||
});
|
||||
|
||||
// Die Tafel merken, damit der Titel-Melder sie später bearbeiten kann
|
||||
setRadioState({
|
||||
guildId: interaction.guild.id,
|
||||
channelId: kanal?.id ?? schonAn.channelId,
|
||||
stationId: station?.id ?? null,
|
||||
panelChannelId: nachricht.channelId ?? interaction.channelId,
|
||||
panelMessageId: nachricht.id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Die RauteMusik-Sender als Grundausstattung.
|
||||
//
|
||||
// Warum ausgerechnet die: sie stecken auch im Landwirtschafts-Simulator und
|
||||
// im ETS als Bordradio — der Betreiber will also ausdrücklich, dass seine
|
||||
// Ströme in Spielen laufen.
|
||||
//
|
||||
// Diese Liste ist nicht abgeschrieben, sondern gemessen: jede Adresse wurde
|
||||
// einzeln angefragt, der Name kommt aus der `icy-name`-Kopfzeile des Servers.
|
||||
// Doppelgänger sind raus — `deutschrap-charts` und `wackenradio` liefern
|
||||
// denselben Strom wie `deutschrap` beziehungsweise `metal`.
|
||||
//
|
||||
// Eingetragen wird das genau einmal, solange noch kein Sender in der
|
||||
// Datenbank steht. Wer einen löscht, bekommt ihn nicht beim nächsten Start
|
||||
// wieder — die Liste ist ein Startpunkt, keine Vorschrift.
|
||||
import { listStations, createStation } from '../db.js';
|
||||
|
||||
export const RM_FM = [
|
||||
{ name: 'Main', slug: 'main', emoji: '📻' },
|
||||
{ name: 'ChartHits', slug: 'charthits', emoji: '📈' },
|
||||
{ name: 'Top40', slug: 'top40', emoji: '🔝' },
|
||||
{ name: 'Partyhits', slug: 'partyhits', emoji: '🎉' },
|
||||
{ name: 'Happy', slug: 'happy', emoji: '😊' },
|
||||
{ name: 'Club', slug: 'club', emoji: '🪩' },
|
||||
{ name: 'House', slug: 'house', emoji: '🏠' },
|
||||
{ name: 'Techhouse', slug: 'techhouse', emoji: '🎛️' },
|
||||
{ name: 'Trance', slug: 'trance', emoji: '🌀' },
|
||||
{ name: 'BigCityBeats', slug: 'bigcitybeats', emoji: '🌃' },
|
||||
{ name: 'HardeR', slug: 'harder', emoji: '⚡' },
|
||||
{ name: 'Happy Hardcore', slug: 'happyhardcore', emoji: '💥' },
|
||||
{ name: 'Bass', slug: 'bass', emoji: '🔊' },
|
||||
{ name: 'Breakz.FM', slug: 'breakz', emoji: '🎚️' },
|
||||
{ name: 'Rock', slug: 'rock', emoji: '🎸' },
|
||||
{ name: 'Metal', slug: 'metal', emoji: '🤘' },
|
||||
{ name: '12 Punks', slug: '12punks', emoji: '🧷' },
|
||||
{ name: 'Deutschrap Charts', slug: 'deutschrap', emoji: '🎤' },
|
||||
{ name: 'Blackbeats', slug: 'blackbeats', emoji: '🎧' },
|
||||
{ name: 'JaM', slug: 'jam', emoji: '🎶' },
|
||||
{ name: '80s', slug: '80s', emoji: '📼' },
|
||||
{ name: '90s', slug: '90s', emoji: '💿' },
|
||||
{ name: 'Goldies', slug: 'goldies', emoji: '🏆' },
|
||||
{ name: 'Deutsche Hits', slug: 'deutsche-hits', emoji: '🇩🇪' },
|
||||
{ name: 'Schlager', slug: 'schlager', emoji: '🪗' },
|
||||
{ name: 'Best of Schlager', slug: 'best-of-schlager', emoji: '🌟' },
|
||||
{ name: 'Schlager Oldies', slug: 'schlager-oldies', emoji: '📻' },
|
||||
{ name: 'Volksmusik', slug: 'volksmusik', emoji: '🏔️' },
|
||||
{ name: 'Karneval', slug: 'karneval', emoji: '🤡' },
|
||||
{ name: 'Country', slug: 'country', emoji: '🤠' },
|
||||
{ name: 'Salsa', slug: 'salsa', emoji: '💃' },
|
||||
{ name: 'Oriental', slug: 'oriental', emoji: '🕌' },
|
||||
{ name: 'Klassik', slug: 'klassik', emoji: '🎻' },
|
||||
{ name: 'Solopiano', slug: 'solopiano', emoji: '🎹' },
|
||||
{ name: 'Lounge', slug: 'lounge', emoji: '🛋️' },
|
||||
{ name: 'Study', slug: 'study', emoji: '📚' },
|
||||
{ name: 'Workout', slug: 'workout', emoji: '🏋️' },
|
||||
{ name: 'Lovehits', slug: 'lovehits', emoji: '❤️' },
|
||||
{ name: 'Traurig', slug: 'traurig', emoji: '💧' },
|
||||
{ name: 'Sex', slug: 'sex', emoji: '🔥' },
|
||||
{ name: 'Christmas Channel', slug: 'christmas', emoji: '🎄' },
|
||||
{ name: 'Christmas Chor', slug: 'christmas-chor', emoji: '🕯️' },
|
||||
{ name: 'Christmas Schlager', slug: 'christmas-schlager', emoji: '🔔' },
|
||||
{ name: 'Weihnachten', slug: 'weihnachten', emoji: '⛄' },
|
||||
];
|
||||
|
||||
export const rmUrl = (slug) => `https://${slug}-high.rautemusik.fm/`;
|
||||
|
||||
/** Einmalig befüllen — nur solange die Liste leer ist */
|
||||
export function senderGrundstock() {
|
||||
if (listStations().length > 0) return 0;
|
||||
RM_FM.forEach((s, i) => createStation({
|
||||
name: s.name, url: rmUrl(s.slug), emoji: s.emoji, sort: i,
|
||||
}));
|
||||
const n = listStations().length;
|
||||
console.log(`[radio] ${n} Sender als Grundstock eingetragen`);
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Radio im Sprachkanal.
|
||||
//
|
||||
// Bewusst nur Webradio und kein YouTube: genau daran sind Groovy und Rythm
|
||||
// gestorben, und was davon übrig ist, ist ein Dauerlauf gegen kaputte
|
||||
// Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine
|
||||
// Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet.
|
||||
//
|
||||
// Die Tonkette kommt ohne Opus-Bibliothek aus: ffmpeg holt das MP3 und gibt
|
||||
// direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete
|
||||
// in fünf Sekunden. Sonst müsste @discordjs/opus mit ins Image, und das will
|
||||
// gebaut werden.
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags,
|
||||
StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
|
||||
} from 'discord.js';
|
||||
import {
|
||||
joinVoiceChannel, createAudioPlayer, createAudioResource, StreamType,
|
||||
AudioPlayerStatus, VoiceConnectionStatus, NoSubscriberBehavior,
|
||||
entersState, getVoiceConnection,
|
||||
} from '@discordjs/voice';
|
||||
import prism from 'prism-media';
|
||||
import { brandEmbed } from '../embeds.js';
|
||||
import { brandFooter } from '../runtime-settings.js';
|
||||
import { moduleEnabled } from '../modules.js';
|
||||
import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState } from '../db.js';
|
||||
import { senderGrundstock } from './radio-sender.js';
|
||||
|
||||
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
|
||||
// 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 }
|
||||
|
||||
// ── Sender-Titel ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Was läuft gerade? Aus dem Icecast-Datenstrom selbst.
|
||||
*
|
||||
* Mit `Icy-MetaData: 1` schiebt der Server alle `icy-metaint` Bytes einen
|
||||
* Titel-Block in den Ton. Bewusst so und nicht über eine Sender-API: das
|
||||
* funktioniert bei jedem Icecast-Server, den jemand einträgt, und nicht nur
|
||||
* bei den drei, die ich kenne.
|
||||
*
|
||||
* Ein leerer Block heisst „unverändert" — deshalb werden bis zu drei gelesen,
|
||||
* bevor aufgegeben wird. Das sind im schlimmsten Fall rund 48 KB.
|
||||
*/
|
||||
export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
|
||||
const steuer = new AbortController();
|
||||
let leser = null;
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: { 'Icy-MetaData': '1', 'User-Agent': 'd4rkbot-radio/1.0' },
|
||||
signal: AbortSignal.any([steuer.signal, AbortSignal.timeout(timeoutMs)]),
|
||||
});
|
||||
const abstand = Number(r.headers.get('icy-metaint') || 0);
|
||||
const sender = r.headers.get('icy-name') || null;
|
||||
if (!abstand || !r.body) { steuer.abort(); return { titel: null, sender }; }
|
||||
|
||||
leser = r.body.getReader();
|
||||
let puffer = Buffer.alloc(0);
|
||||
let gelesen = 0; // Anfang des nächsten Titel-Blocks
|
||||
for (let i = 0; i < bloecke; i++) {
|
||||
// 4080 ist die grösstmögliche Blocklänge (255 × 16)
|
||||
while (puffer.length < gelesen + abstand + 4081) {
|
||||
const { value, done } = await leser.read();
|
||||
if (done) return { titel: null, sender };
|
||||
puffer = Buffer.concat([puffer, Buffer.from(value)]);
|
||||
}
|
||||
const start = gelesen + abstand;
|
||||
const laenge = puffer[start] * 16;
|
||||
if (laenge > 0) {
|
||||
const roh = puffer.slice(start + 1, start + 1 + laenge).toString('utf8');
|
||||
const titel = roh.match(/StreamTitle='([^']*)'/)?.[1]?.trim();
|
||||
if (titel) return { titel, sender };
|
||||
}
|
||||
gelesen = start + 1 + laenge;
|
||||
}
|
||||
return { titel: null, sender };
|
||||
} catch {
|
||||
// Sender weg, Zeitüberschreitung, kaputte Kopfzeilen — dann steht im
|
||||
// Embed eben nur der Sendername. Das ist kein Grund, etwas abzubrechen.
|
||||
return { titel: null, sender: null };
|
||||
} finally {
|
||||
await leser?.cancel().catch(() => {});
|
||||
steuer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ton ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ffmpeg als Tonquelle.
|
||||
*
|
||||
* `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein
|
||||
* einzelner Netz-Schluckauf würde ihn sonst endgültig beenden.
|
||||
*/
|
||||
function tonquelle(url) {
|
||||
const ff = spawn(FFMPEG, [
|
||||
'-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5',
|
||||
'-i', url,
|
||||
'-analyzeduration', '0', '-loglevel', 'error',
|
||||
'-vn', // manche Sender legen ein Coverbild bei
|
||||
'-acodec', 'libopus', '-f', 'ogg',
|
||||
'-ar', '48000', '-ac', '2', '-b:a', '96k',
|
||||
'pipe:1',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
// Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen
|
||||
ff.stderr.on('data', (d) => {
|
||||
const text = d.toString().trim();
|
||||
if (text) console.error('[radio] ffmpeg:', text.slice(0, 200));
|
||||
});
|
||||
ff.on('error', (e) => console.error('[radio] ffmpeg startet nicht:', e.message));
|
||||
return ff;
|
||||
}
|
||||
|
||||
/** Läuft gerade etwas auf diesem Server? */
|
||||
export const laeuft = (guildId) => laufend.get(guildId) ?? null;
|
||||
|
||||
/** Alles abräumen — Verbindung, Spieler, ffmpeg */
|
||||
export function stoppen(guildId) {
|
||||
const s = laufend.get(guildId);
|
||||
if (s) {
|
||||
s.player.stop(true);
|
||||
s.ffmpeg?.kill('SIGKILL');
|
||||
laufend.delete(guildId);
|
||||
}
|
||||
getVoiceConnection(guildId)?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender im Sprachkanal starten (oder umschalten).
|
||||
*
|
||||
* Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler
|
||||
* bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für
|
||||
* die Zuhörer eine hörbare Pause.
|
||||
*/
|
||||
export async function spielen(kanal, station) {
|
||||
const guildId = kanal.guild.id;
|
||||
const vorher = laufend.get(guildId);
|
||||
|
||||
let verbindung = getVoiceConnection(guildId);
|
||||
if (!verbindung || verbindung.joinConfig.channelId !== kanal.id) {
|
||||
verbindung?.destroy();
|
||||
verbindung = joinVoiceChannel({
|
||||
channelId: kanal.id,
|
||||
guildId,
|
||||
adapterCreator: kanal.guild.voiceAdapterCreator,
|
||||
selfDeaf: true, // der Bot muss nicht hören, nur senden
|
||||
});
|
||||
// Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen
|
||||
// bleibt die Verbindung dann still stehen, statt sich zu erholen.
|
||||
verbindung.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(verbindung, VoiceConnectionStatus.Signalling, 5000),
|
||||
entersState(verbindung, VoiceConnectionStatus.Connecting, 5000),
|
||||
]);
|
||||
} catch {
|
||||
stoppen(guildId);
|
||||
clearRadioState(guildId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// hätte beim Weiterlaufen eine Lücke.
|
||||
behaviors: { noSubscriber: NoSubscriberBehavior.Play },
|
||||
});
|
||||
|
||||
vorher?.ffmpeg?.kill('SIGKILL');
|
||||
const ff = tonquelle(station.url);
|
||||
const resource = createAudioResource(ff.stdout.pipe(new prism.opus.OggDemuxer()), {
|
||||
inputType: StreamType.Opus,
|
||||
});
|
||||
player.play(resource);
|
||||
verbindung.subscribe(player);
|
||||
|
||||
if (!vorher) {
|
||||
player.on('error', (e) => console.error('[radio] Spieler:', e.message));
|
||||
// Ein Radiostream endet nie von selbst. Tut er es doch, ist die
|
||||
// Leitung weg — dann neu aufbauen statt still dazustehen.
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
const jetzt = laufend.get(guildId);
|
||||
if (!jetzt) return;
|
||||
console.error('[radio] Strom abgerissen, neuer Versuch');
|
||||
const s = getStation(jetzt.stationId);
|
||||
if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
laufend.set(guildId, {
|
||||
player, ffmpeg: ff, stationId: station.id, channelId: kanal.id,
|
||||
titel: null, seit: Date.now(),
|
||||
});
|
||||
return verbindung;
|
||||
}
|
||||
|
||||
// ── Steuertafel ───────────────────────────────────────────────────────────
|
||||
|
||||
// Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je
|
||||
// Nachricht. Bei 44 Sendern reicht ein Menü also nicht — und stillschweigend
|
||||
// die letzten 19 wegzulassen wäre die schlechteste aller Lösungen. Deshalb
|
||||
// mehrere Menüs, benannt nach ihrem Anfangsbuchstaben-Bereich.
|
||||
const JE_MENU = 25;
|
||||
const MAX_MENUS = 4; // die fünfte Reihe gehört den Knöpfen
|
||||
|
||||
/**
|
||||
* "Main … Metal" — damit man weiss, in welchem Menü man suchen muss.
|
||||
*
|
||||
* Bewusst die Namen und nicht die Anfangsbuchstaben: die Sender stehen in
|
||||
* der Reihenfolge, die im Panel eingestellt ist, und die ist üblicherweise
|
||||
* nach Musikrichtung sortiert. Ein „Sender B–W" wäre dann schlicht falsch.
|
||||
*/
|
||||
function bereich(gruppe) {
|
||||
const text = `${gruppe[0].name} … ${gruppe[gruppe.length - 1].name}`;
|
||||
return text.length <= 100 ? text : `${text.slice(0, 97)}…`;
|
||||
}
|
||||
|
||||
export function tafelKomponenten(stationen, aktivId) {
|
||||
if (stationen.length === 0) {
|
||||
const leer = new StringSelectMenuBuilder()
|
||||
.setCustomId('radio_select:0')
|
||||
.setPlaceholder('Keine Sender eingetragen')
|
||||
.setDisabled(true)
|
||||
// Ein Menü ohne Eintrag lehnt Discord ab — Platzhalter muss sein
|
||||
.setOptions(new StringSelectMenuOptionBuilder().setLabel('—').setValue('leer'));
|
||||
return [
|
||||
new ActionRowBuilder().addComponents(leer),
|
||||
new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()),
|
||||
];
|
||||
}
|
||||
|
||||
const gruppen = [];
|
||||
for (let i = 0; i < stationen.length && gruppen.length < MAX_MENUS; i += JE_MENU) {
|
||||
gruppen.push(stationen.slice(i, i + JE_MENU));
|
||||
}
|
||||
const reihen = gruppen.map((gruppe, nr) => {
|
||||
const menu = new StringSelectMenuBuilder()
|
||||
.setCustomId(`radio_select:${nr}`)
|
||||
.setPlaceholder(gruppen.length > 1 ? bereich(gruppe) : 'Sender wählen …')
|
||||
.addOptions(gruppe.map((s) => {
|
||||
const o = new StringSelectMenuOptionBuilder()
|
||||
.setLabel(s.name.slice(0, 100))
|
||||
.setValue(String(s.id))
|
||||
.setDefault(s.id === aktivId);
|
||||
if (s.emoji) o.setEmoji(s.emoji);
|
||||
return o;
|
||||
}));
|
||||
return new ActionRowBuilder().addComponents(menu);
|
||||
});
|
||||
reihen.push(new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()));
|
||||
return reihen;
|
||||
}
|
||||
|
||||
const stoppKnopf = () => new ButtonBuilder()
|
||||
.setCustomId('radio_stop').setLabel('Stopp').setEmoji('⏹️').setStyle(ButtonStyle.Danger);
|
||||
const aktualisierenKnopf = () => new ButtonBuilder()
|
||||
.setCustomId('radio_refresh').setLabel('Aktualisieren').setEmoji('🔄').setStyle(ButtonStyle.Secondary);
|
||||
|
||||
/** Wie viele Sender passen überhaupt in eine Tafel? */
|
||||
export const TAFEL_GRENZE = JE_MENU * MAX_MENUS;
|
||||
|
||||
export function tafelEmbed(client, station, titel, kanalId) {
|
||||
const embed = brandEmbed(client, 'RADIO')
|
||||
.setTitle('📻 Radio')
|
||||
.setFooter({ text: brandFooter('RADIO') })
|
||||
.setTimestamp();
|
||||
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_' });
|
||||
return embed;
|
||||
}
|
||||
|
||||
/** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */
|
||||
async function tafelAktualisieren(client, guildId) {
|
||||
const stand = radioState(guildId);
|
||||
if (!stand?.panel_message_id) return;
|
||||
const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null);
|
||||
const nachricht = await kanal?.messages?.fetch(stand.panel_message_id).catch(() => null);
|
||||
if (!nachricht) return;
|
||||
|
||||
const jetzt = laufend.get(guildId);
|
||||
const station = jetzt ? getStation(jetzt.stationId) : null;
|
||||
await nachricht.edit({
|
||||
embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId)],
|
||||
components: tafelKomponenten(listStations(), jetzt?.stationId),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Anbindung ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ist ausser dem Bot noch jemand im Kanal? */
|
||||
function jemandDa(kanal) {
|
||||
return kanal?.members?.filter((m) => !m.user.bot).size > 0;
|
||||
}
|
||||
|
||||
export function startRadio(client) {
|
||||
// Ohne Sender ist das Modul nutzlos, und niemand tippt 44 Adressen ab.
|
||||
// Passiert genau einmal — wer löscht, behält es gelöscht.
|
||||
senderGrundstock();
|
||||
|
||||
// Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert
|
||||
// selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte.
|
||||
setInterval(async () => {
|
||||
if (!moduleEnabled('radio')) return;
|
||||
// Alles einpacken: eine unbehandelte Rejection in einem Timer beendet
|
||||
// seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig.
|
||||
try {
|
||||
for (const [guildId, s] of laufend) {
|
||||
const station = getStation(s.stationId);
|
||||
if (!station) continue;
|
||||
const { titel } = await titelHolen(station.url);
|
||||
if (titel && titel !== s.titel) {
|
||||
s.titel = titel;
|
||||
await tafelAktualisieren(client, guildId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[radio] Titel-Abgleich:', error.message);
|
||||
}
|
||||
}, 30_000).unref();
|
||||
|
||||
// Leert sich der Kanal, geht der Bot. Sonst sendet er stundenlang gegen
|
||||
// eine leere Wand und hält eine ffmpeg-Instanz am Leben.
|
||||
client.on(Events.VoiceStateUpdate, async (vorher) => {
|
||||
try {
|
||||
const s = laufend.get(vorher.guild?.id);
|
||||
if (!s || vorher.channelId !== s.channelId) return;
|
||||
const kanal = await client.channels.fetch(s.channelId).catch(() => null);
|
||||
if (jemandDa(kanal)) return;
|
||||
stoppen(vorher.guild.id);
|
||||
clearRadioState(vorher.guild.id);
|
||||
await tafelAktualisieren(client, vorher.guild.id);
|
||||
} catch (error) {
|
||||
console.error('[radio] Sprachkanal-Wechsel:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Nach einem Neustart zurück in den Kanal — ein Deploy soll die Musik
|
||||
// nicht endgültig beenden, sondern nur kurz unterbrechen.
|
||||
client.once(Events.ClientReady, async () => {
|
||||
if (!moduleEnabled('radio')) return;
|
||||
for (const stand of radioStates()) {
|
||||
const station = stand.station_id ? getStation(stand.station_id) : null;
|
||||
const kanal = station ? await client.channels.fetch(stand.channel_id).catch(() => null) : null;
|
||||
if (!kanal?.isVoiceBased() || !jemandDa(kanal)) {
|
||||
// Niemand mehr da, oder der Sender wurde gelöscht — dann gilt
|
||||
// der Merker als verbraucht statt in einen leeren Kanal zu gehen.
|
||||
clearRadioState(stand.guild_id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await spielen(kanal, station);
|
||||
await tafelAktualisieren(client, stand.guild_id);
|
||||
console.log(`[radio] wieder in ${kanal.name}: ${station.name}`);
|
||||
} catch (e) {
|
||||
console.error('[radio] Rückkehr fehlgeschlagen:', e.message);
|
||||
clearRadioState(stand.guild_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Knöpfe und Auswahlmenü der Steuertafel */
|
||||
export async function handleRadioInteraction(interaction) {
|
||||
const { customId } = interaction;
|
||||
const istAuswahl = customId.startsWith('radio_select:');
|
||||
if (!istAuswahl && !['radio_stop', 'radio_refresh'].includes(customId)) return false;
|
||||
|
||||
const guildId = interaction.guild?.id;
|
||||
const eigenerKanal = interaction.member?.voice?.channel;
|
||||
|
||||
if (customId === 'radio_stop') {
|
||||
stoppen(guildId);
|
||||
clearRadioState(guildId);
|
||||
} else if (istAuswahl) {
|
||||
const station = getStation(Number(interaction.values[0]));
|
||||
// Wer nicht im Sprachkanal sitzt, soll den anderen nicht den Sender
|
||||
// umstellen können — dafür reicht ein Hinweis statt einer Sperre.
|
||||
const ziel = eigenerKanal ?? (laufend.get(guildId)
|
||||
? await interaction.client.channels.fetch(laufend.get(guildId).channelId).catch(() => null)
|
||||
: null);
|
||||
if (!station || !ziel) {
|
||||
await interaction.reply({
|
||||
content: '❌ Geh erst in einen Sprachkanal, dann wähle einen Sender.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await spielen(ziel, station);
|
||||
setRadioState({
|
||||
guildId,
|
||||
channelId: ziel.id,
|
||||
stationId: station.id,
|
||||
panelChannelId: interaction.message.channelId,
|
||||
panelMessageId: interaction.message.id,
|
||||
});
|
||||
// Den Titel gleich mitholen, sonst steht 30 Sekunden „wird abgefragt"
|
||||
const { titel } = await titelHolen(station.url);
|
||||
const s = laufend.get(guildId);
|
||||
if (s) s.titel = titel;
|
||||
}
|
||||
|
||||
const jetzt = laufend.get(guildId);
|
||||
const station = jetzt ? getStation(jetzt.stationId) : null;
|
||||
await interaction.update({
|
||||
embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId)],
|
||||
components: tafelKomponenten(listStations(), jetzt?.stationId),
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { startWatchdog } from './bot/watchdog.js';
|
||||
import { scheduleBackups } from './backup.js';
|
||||
import { startServerMonitor } from './bot/server-monitor.js';
|
||||
import { startLsFarm } from './bot/ls-farm.js';
|
||||
import { startRadio } from './bot/radio.js';
|
||||
import { startGiveaways } from './bot/giveaways.js';
|
||||
import { startScheduledPosts } from './web/scheduled-posts.js';
|
||||
import { startSocialNotify } from './bot/social-notify.js';
|
||||
@@ -24,6 +25,7 @@ try {
|
||||
scheduleBackups(client);
|
||||
startServerMonitor(client);
|
||||
startLsFarm(client);
|
||||
startRadio(client);
|
||||
startGiveaways(client);
|
||||
startScheduledPosts(client);
|
||||
startSocialNotify(client);
|
||||
|
||||
@@ -107,6 +107,14 @@ export const MODULES = [
|
||||
desc: 'Schnelle Abstimmungen als native Discord-Umfrage — /umfrage.',
|
||||
tab: 'community', default: true,
|
||||
},
|
||||
{
|
||||
id: 'radio', group: 'community', name: 'Radio',
|
||||
desc: 'Webradio im Sprachkanal — /radio öffnet eine Tafel mit Sender-Auswahl zum '
|
||||
+ 'Durchschalten. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, '
|
||||
+ 'und kommt nach einem Neustart in den Kanal zurück.',
|
||||
tab: 'community', setting: 'radio_enabled', defaultOn: false,
|
||||
tool: { tab: 'community', label: 'Sender verwalten' },
|
||||
},
|
||||
{
|
||||
id: 'giveaways', group: 'community', name: 'Verlosungen',
|
||||
desc: 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung möglich.',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user