Files
d4rkbot/src/bot/radio-musik.js
T
D4rkst3r 7e7d04378a
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Pause fuer YouTube -- und der Bot bleibt draussen, wenn er rausgeht
Zwei Befunde aus dem Betrieb.

1. Der Bot ging aus dem leeren Kanal und kam sofort wieder rein.

`stoppen()` hielt erst den Spieler an und loeschte dann aus `laufend`.
`player.stop()` meldet den Leerlauf aber SYNCHRON -- gemessen mit
@discordjs/voice 0.19: der Idle-Melder laeuft mitten in stop(), noch vor
der naechsten Zeile. Er fand den Eintrag also noch, hielt das Ende fuer
"Titel vorbei" und schaltete weiter: naechster Titel oder zurueck auf den
Heimsender. Von aussen genau das gemeldete Bild.

Jetzt wird erst geloescht, dann angehalten. Nachgestellt mit einem echten
AudioPlayer und beiden Reihenfolgen -- die Kontrolle ist dabei die alte
Reihenfolge: sie schaltet nachweislich weiter (1x), die neue nicht (0x).
Ohne diese Gegenprobe koennte der Test etwas ganz anderes messen.

2. Im YouTube-Modus fehlte die Pause.

`pausieren()` gibt es jetzt, ausdruecklich nur fuer YouTube. Ein Sender
laesst sich nicht sinnvoll anhalten: er laeuft beim Betreiber weiter,
waehrend hier die Leitung volllaeuft, und beim Fortsetzen kaeme eine
Konserve, die immer weiter hinterherhinkt. Fuer einen Sender heisst Pause
Stopp -- dafuer gibt es den Stopp-Knopf, und der Pause-Knopf graut aus.

Die verstrichene Zeit rechnet Pausen heraus, sonst liefe der
Fortschrittsbalken nach dem Fortsetzen um die Pausenlaenge voraus.
`verstrichen()` steht an EINER Stelle, die sich Discord-Tafel und Panel
teilen -- zwei Rechnungen waeren zwei Gelegenheiten, die Pause zu
vergessen.

Die Musik-Tafel bekommt eine zweite Knopfreihe: mit der Pause waeren es
sechs Knoepfe, Discord laesst fuenf je Reihe zu. Platz ist reichlich (vier
von fuenf Reihen), und die Trennung ist ohnehin sinnvoller -- oben, was
gerade klingt, unten, was mit der Liste passiert. Nachgezaehlt: 2 Reihen
ohne Liste, 4 mit.

Beim Quellenwechsel wird ausdruecklich fortgesetzt: der Spieler ueberlebt
den Wechsel, und stand er auf Pause, waere der neue Titel stumm gestartet
waehrend der Eintrag "laeuft" sagt.

Nebenbei: mein Feld-Abgleich hielt eine zweite Kopie der Feldliste von
`radioStand()` und driftete beim ersten neuen Feld prompt ab -- er meldete
`pausiert` als fehlend, obwohl die API es liefert. Er liest die Form jetzt
aus der Quelle, statt sie doppelt zu pflegen.
2026-08-28 21:46:24 +02:00

523 lines
23 KiB
JavaScript

// Die zweite Tafel: YouTube und die Warteschlange.
//
// Warum eine eigene Nachricht und nicht ein paar Knöpfe mehr an der
// Radio-Tafel: Discord lässt fünf Reihen je Nachricht zu, und vier davon
// gehören dort den Sender-Menüs. Für „Überspringen", „Einreihen",
// „Warteschlange" und „Leeren" ist da schlicht kein Platz — und ein
// Sender-Menü zu opfern hiesse, Sender aus der Auswahl zu werfen.
//
// Zwei Tafeln haben ausserdem einen Vorzug, der nicht bloss Notlösung ist:
// `/radio` bleibt genau das, was es war. Wer nie ein Video einreiht, merkt
// von diesem ganzen Umbau nichts.
//
// Die Abhängigkeit zeigt bewusst nur in eine Richtung: diese Datei kennt das
// Radio, das Radio kennt sie nicht. Es meldet über `beiAenderung()` nur, dass
// sich etwas geändert hat. Ein gegenseitiger Import zwischen zwei Dateien ist
// etwas, das beim Lesen niemand erwartet und beim Umbauen jeden beisst.
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags,
ModalBuilder, TextInputBuilder, TextInputStyle,
StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
} from 'discord.js';
import { brandEmbed } from '../embeds.js';
import { brandFooter, youtubeAn } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
import {
queueList, queueAdd, queueRemove, queueClear, queueVor, queueStand,
ytPanel, setYtPanel, clearYtPanel, getStation,
} from '../db.js';
import {
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
pausieren, verstrichen,
} from './radio.js';
import {
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
} from './youtube.js';
// Discord lässt 25 Einträge je Auswahlmenü zu. Die Warteschlange kann länger
// sein — dann zeigen die Menüs die ersten 25, und der Rest wird über das
// Web-Panel verwaltet. Das steht auch so in der Tafel, statt stillschweigend
// zu unterschlagen, dass da noch etwas ist.
const JE_MENU = 25;
// ── Darstellung ───────────────────────────────────────────────────────────
/** 3:07 statt 187 */
export function dauerText(sekunden) {
if (sekunden === null || sekunden === undefined) return 'live';
const s = Math.max(0, Math.round(sekunden));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const rest = s % 60;
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
: `${m}:${String(rest).padStart(2, '0')}`;
}
/**
* Ein Balken für den Fortschritt.
*
* Nur wo er etwas aussagen kann: ohne bekannte Dauer (Livestream) gäbe er
* einen Stand vor, den niemand kennt. Dann steht dort lieber nichts.
*/
function fortschritt(verstrichen, dauer) {
if (!dauer || dauer <= 0) return null;
const anteil = Math.min(1, Math.max(0, verstrichen / dauer));
const voll = Math.round(anteil * 12);
return `\`${'━'.repeat(voll)}${'─'.repeat(12 - voll)}\` ${dauerText(verstrichen)} / ${dauerText(dauer)}`;
}
/** Titel kürzen, damit Discord die Nachricht nicht ablehnt */
const kurz = (text, n) => (String(text ?? '').length > n
? `${String(text).slice(0, n - 1)}…`
: String(text ?? ''));
export function musikEmbed(client, guildId) {
const embed = brandEmbed(client, 'RADIO')
.setTitle('▶️ Musik')
.setFooter({ text: brandFooter('RADIO') })
.setTimestamp();
// Ein fehlendes yt-dlp ist die häufigste Ursache und die am schlechtesten
// zu erratende: alles sieht normal aus, es passiert nur nichts. Deshalb
// steht sie ganz oben und nicht in einer Fussnote.
if (ytdlpVorhanden() === false) {
return embed.setDescription('⚠️ Auf dem Server fehlt **yt-dlp** — ohne das kann der Bot '
+ 'kein YouTube abspielen. Die Radiosender laufen davon unberührt weiter.');
}
if (!youtubeAn()) {
return embed.setDescription('YouTube ist ausgeschaltet — im Panel unter '
+ '**Community → Radio** einschalten.');
}
const jetzt = laeuft(guildId);
const liste = queueList(guildId);
const stand = queueStand(guildId);
if (jetzt?.quelle?.art === 'youtube') {
const t = jetzt.quelle.titel;
// Pausen abgezogen — sonst behauptet der Balken, ein angehaltener
// Titel liefe weiter, und läuft beim Fortsetzen voraus.
const balken = fortschritt(verstrichen(jetzt), t.dauer);
embed.setDescription(`${jetzt.pausiertSeit ? '⏸️ ' : ''}**${kurz(t.titel, 200)}**\n`
+ `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}`
+ (balken ? `${balken}\n` : '🔴 _Livestream_\n')
+ `in <#${jetzt.channelId}>`);
if (t.thumb) embed.setThumbnail(t.thumb);
} else if (jetzt) {
// Es läuft etwas, nur eben ein Sender. Das ist kein Fehler und wird
// auch nicht als einer dargestellt.
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null;
embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n`
+ 'Was hier eingereiht wird, übernimmt sofort — danach geht es zurück auf den Sender.');
} else {
embed.setDescription(liste.length > 0
? 'Nichts läuft. **Überspringen** startet die Warteschlange — '
+ 'geh vorher in einen Sprachkanal.'
: 'Nichts läuft und nichts eingereiht. **Einreihen…** nimmt einen '
+ 'YouTube-Link oder einen Suchbegriff.');
}
if (liste.length > 0) {
const zeilen = liste.slice(0, 10).map((e, i) =>
`\`${String(i + 1).padStart(2)}.\` ${kurz(e.titel, 60)} \`${dauerText(e.dauer)}\``
+ (e.wunsch_von ? ` — _${kurz(e.wunsch_von, 24)}_` : ''));
if (liste.length > 10) zeilen.push(`_… und ${liste.length - 10} weitere_`);
embed.addFields({
name: `Warteschlange — ${stand.n} Titel, ${dauerText(stand.dauer)} gesamt`,
value: zeilen.join('\n').slice(0, 1024),
});
}
// Der letzte Fehlschlag, im Klartext. Genau das ist der Unterschied
// zwischen „YouTube geht nicht" und „YouTube verlangt eine Anmeldung".
const fehler = letzterFehler();
if (fehler && !jetzt) {
embed.addFields({
name: '⚠️ Zuletzt schiefgegangen',
value: `\`\`\`${kurz(fehler.text, 300)}\`\`\``,
});
}
return embed;
}
const knopf = (id, label, emoji, stil = ButtonStyle.Secondary, aus = false) =>
new ButtonBuilder().setCustomId(id).setLabel(label).setEmoji(emoji)
.setStyle(stil).setDisabled(aus);
export function musikKomponenten(guildId) {
const liste = queueList(guildId).slice(0, JE_MENU);
const jetzt = laeuft(guildId);
const reihen = [];
if (liste.length > 0) {
const optionen = () => liste.map((e, i) => {
const o = new StringSelectMenuOptionBuilder()
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
.setValue(String(e.id));
const unten = [dauerText(e.dauer), e.kanal].filter(Boolean).join(' · ');
if (unten) o.setDescription(kurz(unten, 100));
return o;
});
reihen.push(new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder().setCustomId('musik_vor')
.setPlaceholder('⏭️ Als Nächstes spielen …').addOptions(optionen())
));
reihen.push(new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder().setCustomId('musik_weg')
.setPlaceholder('🗑️ Aus der Liste nehmen …').addOptions(optionen())
));
}
// Am Anschlag ausgrauen statt stumm nichts zu tun — dieselbe Regel wie
// an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts
// bewirkt, sieht aus wie ein kaputter Knopf.
const nichtsDa = liste.length === 0;
const ytLaeuft = jetzt?.quelle?.art === 'youtube';
const pausiert = Boolean(jetzt?.pausiertSeit);
// Zwei Knopfreihen statt einer: mit der Pause wären es sechs Knöpfe, und
// Discord lässt nur fünf je Reihe zu. Platz ist da — zwei Auswahlmenüs
// plus zwei Knopfreihen sind vier von fünf möglichen Reihen.
//
// Die Trennung ist ausserdem die sinnvollere: oben, was gerade klingt,
// unten, was mit der Liste passiert.
reihen.push(new ActionRowBuilder().addComponents(
// Pause gibt es nur bei YouTube. Warum, steht bei `pausieren()` in
// `radio.js` — kurz: einen Dauerstrom anzuhalten heisst, ihm
// hinterherzuhinken.
pausiert
? knopf('musik_pause', 'Weiter', '▶️', ButtonStyle.Success)
: knopf('musik_pause', 'Pause', '⏸️', ButtonStyle.Primary, !ytLaeuft),
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
nichtsDa && !ytLaeuft),
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
!ytLaeuft || !jetzt.heimStationId),
));
reihen.push(new ActionRowBuilder().addComponents(
knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success),
knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
knopf('musik_refresh', 'Aktualisieren', '🔄'),
));
return reihen;
}
/** Die gespeicherte Tafel neu zeichnen — für den Melder aus `radio.js` */
export async function musikTafelAktualisieren(client, guildId) {
const p = ytPanel(guildId);
if (!p) return;
const kanal = await client.channels.fetch(p.channel_id).catch(() => null);
const nachricht = await kanal?.messages?.fetch(p.message_id).catch(() => null);
if (!nachricht) {
// Die Nachricht ist weg (gelöscht, Kanal aufgeräumt). Den Verweis
// stehenzulassen hiesse, es bei jeder Änderung erneut zu versuchen.
clearYtPanel(guildId);
return;
}
await nachricht.edit({
embeds: [musikEmbed(client, guildId)],
components: musikKomponenten(guildId),
}).catch(() => {});
}
// ── Einreihen ─────────────────────────────────────────────────────────────
/**
* Eingabe auflösen und einreihen — die eine Stelle für Discord *und* Panel.
*
* Bei einem Suchbegriff wird bewusst nicht der erste Treffer genommen,
* sondern die Auswahl zurückgegeben: „Sleeping Sun" hat drei Fassungen, und
* welche gemeint war, weiss nur der, der es getippt hat. Bei einer Adresse
* gibt es nichts zu wählen — die geht direkt rein, eine Playlist mit allen
* Einträgen auf einmal.
*
* @returns {Promise<{eingereiht?: object[], treffer?: object[], fehler?: string}>}
*/
export async function einreihen(guildId, eingabe, { wunschVon = null } = {}) {
if (!youtubeAn()) return { fehler: 'YouTube ist ausgeschaltet.' };
const r = await aufloesen(eingabe, { max: TREFFER });
if (r.fehler) return { fehler: r.fehler };
if (r.suche) return { treffer: r.treffer };
return { eingereiht: eintraegeAufnehmen(guildId, r.treffer, wunschVon) };
}
/** Fertige Einträge in die Liste schreiben */
export function eintraegeAufnehmen(guildId, eintraege, wunschVon = null) {
for (const e of eintraege) queueAdd(guildId, { ...e, wunsch_von: wunschVon });
return eintraege;
}
/**
* Nach dem Einreihen: soll das jetzt losgehen?
*
* Drei Fälle, und der mittlere ist der, den ich am 28.08.2026 falsch hatte:
*
* nichts läuft → die Liste fängt an. Sonst legt jemand drei Titel hinein,
* es passiert nichts, und der naheliegende Schluss wäre
* „kaputt".
* Sender läuft → **der Titel übernimmt sofort.** „Das Radio ist die
* Grundstellung, YouTube die Unterbrechung" — und eine
* Unterbrechung, die wartet, ist keine. Hier stand vorher
* „die Liste kommt dran, wenn sie dran ist", und das war
* beim Sender schlicht falsch: ein Sender endet nie, also
* gibt es kein `Idle`, das weiterschaltet. Der einzige
* `Idle`, den es gibt, ist ein abgerissener Strom — und der
* startet denselben Sender neu. Der Titel wartete also auf
* einen Moment, der nie kommt. Im Betrieb gemeldet: Link
* eingereiht, Bot blieb beim Radio.
* Der Sender ist nicht verloren: er steht als `heimStation`
* im laufenden Eintrag und kommt zurück, wenn die Liste
* leer ist.
* Titel läuft → nicht dazwischenfunken. Der Neue reiht sich ein, und
* *hier* stimmt „kommt dran, wenn er dran ist" auch.
*/
export async function ggfAnwerfen(client, guildId, kanalId) {
const jetzt = laeuft(guildId);
if (!uebernimmtSofort(jetzt)) return false;
// Ohne eigene Angabe der Kanal, in dem der Bot schon steht.
const ziel = jetzt?.channelId ?? kanalId;
if (!ziel) return false;
const q = await weiter(client, guildId, { erzwungen: true, kanalId: ziel });
return Boolean(q);
}
/**
* Die Regel als eigener Satz — weil genau sie falsch war.
*
* Sie steckte als Bedingung mitten in `ggfAnwerfen` (`if (laeuft(guildId))
* return false`) und war von aussen weder zu lesen noch zu prüfen. Hier hat
* sie einen Namen und lässt sich mit allen drei Eingaben durchspielen.
*
* @param {object|null} jetzt Der laufende Eintrag, oder null
* @returns {boolean} true = der neue Titel fängt sofort an
*/
export function uebernimmtSofort(jetzt) {
// Nur ein laufender YouTube-Titel wird nicht unterbrochen. Ein Sender
// schon — er ist die Grundstellung, kein Vorrang.
return jetzt?.quelle?.art !== 'youtube';
}
// ── Suchtreffer zwischenlagern ────────────────────────────────────────────
//
// Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine
// vollständige Auskunft hinein. Also bleiben die Treffer kurz hier liegen und
// die Kennung wandert in die customId.
//
// Mit Verfallszeit, weil sonst jede Suche für immer Speicher belegt — bei
// einem Bot, der monatelang läuft, ist das kein theoretischer Einwand.
const treffermerker = new Map(); // token → { treffer, zeit }
const MERK_DAUER = 5 * 60_000;
function trefferMerken(token, treffer) {
const grenze = Date.now() - MERK_DAUER;
for (const [k, v] of treffermerker) if (v.zeit < grenze) treffermerker.delete(k);
treffermerker.set(token, { treffer, zeit: Date.now() });
}
function trefferHolen(token) {
const e = treffermerker.get(token);
if (!e || e.zeit < Date.now() - MERK_DAUER) return null;
return e.treffer;
}
/** Die flüchtige Auswahl nach einer Suche */
function trefferAuswahl(token, treffer) {
return new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder()
.setCustomId(`musik_treffer:${token}`)
.setPlaceholder('Welchen davon?')
.addOptions(treffer.map((t, i) => {
const o = new StringSelectMenuOptionBuilder()
.setLabel(kurz(t.titel, 100))
.setValue(String(i));
const unten = [dauerText(t.dauer), t.kanal].filter(Boolean).join(' · ');
if (unten) o.setDescription(kurz(unten, 100));
return o;
}))
);
}
// ── Anbindung ─────────────────────────────────────────────────────────────
export function startMusik() {
// Damit ein Titelwechsel, ein Stopp oder ein Überspringen auch hier
// ankommt — ohne dass `radio.js` diese Datei kennen muss.
beiAenderung(musikTafelAktualisieren);
}
/** Wo darf gespielt werden: der eigene Kanal, sonst der laufende */
async function zielKanal(interaction) {
const eigener = interaction.member?.voice?.channel;
if (eigener) return eigener;
const jetzt = laeuft(interaction.guild?.id);
if (!jetzt) return null;
return interaction.client.channels.fetch(jetzt.channelId).catch(() => null);
}
const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh',
'musik_pause', 'musik_vor', 'musik_weg'];
/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */
export async function handleMusikInteraction(interaction) {
const { customId } = interaction;
const istTreffer = customId.startsWith('musik_treffer:');
if (customId !== 'musik_modal' && !istTreffer && !IDS.includes(customId)) return false;
const guildId = interaction.guild?.id;
// Das Eingabefenster muss *vor* jedem Aufschub kommen: Discord lässt ein
// Modal nur als erste Antwort auf eine Interaktion zu.
if (customId === 'musik_add') {
await interaction.showModal(new ModalBuilder()
.setCustomId('musik_modal')
.setTitle('Einreihen')
.addComponents(new ActionRowBuilder().addComponents(
new TextInputBuilder()
.setCustomId('eingabe')
.setLabel('YouTube-Link oder Suchbegriff')
.setPlaceholder('https://youtu.be/… oder: nightwish sleeping sun')
.setStyle(TextInputStyle.Short)
.setMaxLength(300)
.setRequired(true)
)));
return true;
}
// Auflösen dauert länger als die drei Sekunden, die Discord gibt.
if (customId === 'musik_modal' || istTreffer) {
// Beim Eingabefenster gibt es noch keine Nachricht, also eine neue
// (flüchtige). Bei der Trefferauswahl gibt es sie — dann wird sie
// ersetzt, statt eine zweite daneben zu stellen und die Auswahl
// stehen zu lassen, als könnte man noch einmal wählen.
await (istTreffer
? interaction.deferUpdate()
: interaction.deferReply({ flags: MessageFlags.Ephemeral }));
const kanal = await zielKanal(interaction);
let eintraege = null;
if (istTreffer) {
const treffer = trefferHolen(customId.split(':')[1]);
if (!treffer) {
await interaction.editReply('❌ Die Auswahl ist abgelaufen — bitte neu suchen.');
return true;
}
const gewaehlt = treffer[Number(interaction.values[0])];
if (!gewaehlt) {
await interaction.editReply('❌ Diesen Treffer gibt es nicht mehr.');
return true;
}
eintraege = eintraegeAufnehmen(guildId, [gewaehlt],
interaction.member?.displayName ?? interaction.user?.username);
} else {
const eingabe = interaction.fields.getTextInputValue('eingabe');
const r = await einreihen(guildId, eingabe, {
wunschVon: interaction.member?.displayName ?? interaction.user?.username,
});
if (r.fehler) {
await interaction.editReply(`❌ ${r.fehler}`);
return true;
}
if (r.treffer) {
const token = interaction.id;
trefferMerken(token, r.treffer);
await interaction.editReply({
content: `🔎 ${r.treffer.length} Treffer:`,
components: [trefferAuswahl(token, r.treffer)],
});
return true;
}
eintraege = r.eingereiht;
}
const angeworfen = await ggfAnwerfen(interaction.client, guildId, kanal?.id);
await interaction.editReply({
content: eintraege.length === 1
? `✅ **${kurz(eintraege[0].titel, 120)}** ${angeworfen ? 'läuft jetzt' : 'eingereiht'}.`
: `✅ ${eintraege.length} Titel eingereiht${angeworfen ? ' — der erste läuft' : ''}.`
+ (kanal ? '' : '\n_Geh in einen Sprachkanal, damit es losgeht._'),
components: [],
});
// Hier zeichnet `tafelnMelden` beide Tafeln: die Antwort oben war
// flüchtig und ist keine von ihnen.
await tafelnMelden(interaction.client, guildId);
return true;
}
// Ab hier: alles, was die Tafel an Ort und Stelle neu zeichnet.
if (customId === 'musik_skip') {
const kanal = await zielKanal(interaction);
if (!kanal) {
await interaction.reply({
content: '❌ Geh erst in einen Sprachkanal.',
flags: MessageFlags.Ephemeral,
});
return true;
}
// Aufgeschoben, weil ein Quellenwechsel eine Verbindung aufbauen kann
// und das über den drei Sekunden liegt.
await interaction.deferUpdate();
await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id });
} else if (customId === 'musik_pause') {
// Kein Aufschub nötig: Anhalten passiert im Speicher, ohne Netz.
pausieren(guildId);
await interaction.deferUpdate();
} else if (customId === 'musik_leeren') {
queueClear(guildId);
await interaction.deferUpdate();
} else if (customId === 'musik_heim') {
const jetzt = laeuft(guildId);
const heim = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null;
await interaction.deferUpdate();
if (heim) {
const kanal = await interaction.client.channels.fetch(jetzt.channelId).catch(() => null);
// Die Warteschlange bleibt stehen: „zum Radio" heisst „jetzt
// wieder Sender", nicht „alles wegwerfen".
if (kanal) await spielen(kanal, radioQuelle(heim));
}
} else if (customId === 'musik_vor') {
queueVor(guildId, Number(interaction.values[0]));
await interaction.deferUpdate();
} else if (customId === 'musik_weg') {
queueRemove(Number(interaction.values[0]));
await interaction.deferUpdate();
} else {
await interaction.deferUpdate(); // musik_refresh
}
await interaction.editReply({
embeds: [musikEmbed(interaction.client, guildId)],
components: musikKomponenten(guildId),
}).catch(() => {});
// Die Radio-Tafel zeigt dieselbe Wirklichkeit und muss mitgezogen werden.
// Nur die Radio-Tafel: die Musik-Tafel ist die Nachricht, auf der gerade
// geklickt wurde, und die ist eine Zeile weiter oben schon neu gezeichnet.
// `tafelnMelden` würde sie ein zweites Mal anfassen — eine Anfrage an
// Discord für nichts.
await tafelAktualisieren(interaction.client, guildId).catch(() => {});
return true;
}
/** Die Tafel im Kanal öffnen — von `/musik` aufgerufen */
export async function musikTafelOeffnen(interaction) {
if (!moduleEnabled('radio')) {
await interaction.reply({
content: '❌ Radio ist ausgeschaltet — im Panel unter **Community → Radio** einschalten.',
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.deferReply();
const guildId = interaction.guild.id;
const nachricht = await interaction.editReply({
embeds: [musikEmbed(interaction.client, guildId)],
components: musikKomponenten(guildId),
});
// Die alte Tafel verliert ihre Knöpfe nicht, aber ihren Anspruch: von hier
// an wird die neue nachgeführt. Zwei Tafeln, von denen eine einfriert,
// wären schlechter als eine, die umzieht.
setYtPanel(guildId, nachricht.channelId ?? interaction.channelId, nachricht.id);
}
// Nur zum Prüfen von aussen — die Merker-Verfallszeit ohne Wartezeit testbar.
export const _treffermerker = treffermerker;