Die zweite Tafel: /musik, Warteschlange, Eingabefenster

Eine eigene Nachricht statt ein paar Knoepfe mehr an der Radio-Tafel.
Discord laesst fuenf Reihen je Nachricht zu, vier davon gehoeren dort den
Sender-Menues -- fuer Ueberspringen, Einreihen, Warteschlange und Leeren
ist kein Platz, und ein Sender-Menue zu opfern hiesse, Sender aus der
Auswahl zu werfen.

Der Vorzug ist groesser als die Notloesung: /radio bleibt genau das, was
es war. Wer nie ein Video einreiht, merkt vom ganzen Umbau nichts.

Bei einem Suchbegriff wird bewusst nicht der erste Treffer genommen,
sondern die Auswahl gezeigt: "Sleeping Sun" hat drei Fassungen, und
welche gemeint war, weiss nur wer es getippt hat. Eine Adresse geht
direkt rein, eine Playlist mit allen Eintraegen auf einmal.

Die Abhaengigkeit zeigt nur in eine Richtung: radio-musik.js kennt das
Radio, das Radio kennt es nicht -- es meldet ueber beiAenderung() nur,
dass sich etwas geaendert hat. Ein gegenseitiger Import zwischen zwei
Dateien beisst beim naechsten Umbau.

Gemessen gegen echtes YouTube am 28.08.2026: Adresse landet in der Liste,
Suchbegriff liefert fuenf Treffer und reiht NICHTS ein (Liste nachweislich
unveraendert), kaputte Adresse meldet "Video unavailable" und laesst die
Liste in Ruhe. Die Tafel zeigt Dauer je Titel, Gesamtdauer, Wunschgeber
und die letzte Fehlerzeile im Klartext.

Nicht geprueft, weil es dafuer eine echte Discord-Sprachverbindung
braucht: die Darstellung waehrend etwas laeuft, und das Weiterschalten.
This commit is contained in:
2026-08-28 13:39:46 +02:00
parent 409b28e9e0
commit 2a0cb1905d
5 changed files with 516 additions and 7 deletions
+19 -2
View File
@@ -25,6 +25,7 @@ import {
} from './wishes.js'; } from './wishes.js';
import { handleGiveawayAdminButton } from './giveaways.js'; import { handleGiveawayAdminButton } from './giveaways.js';
import { handleRadioInteraction } from './radio.js'; import { handleRadioInteraction } from './radio.js';
import { handleMusikInteraction } from './radio-musik.js';
import { import {
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote, removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
getGiveaway, toggleGiveawayEntry, giveawayEntries, getGiveaway, toggleGiveawayEntry, giveawayEntries,
@@ -49,12 +50,13 @@ import * as geburtstag from './commands/geburtstag.js';
import * as raid from './commands/raid.js'; import * as raid from './commands/raid.js';
import * as umfrage from './commands/umfrage.js'; import * as umfrage from './commands/umfrage.js';
import * as radio from './commands/radio.js'; import * as radio from './commands/radio.js';
import * as musik from './commands/musik.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute } // Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [ const commandModules = [
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
raid, umfrage, radio, raid, umfrage, radio, musik,
]; ];
export async function startBot() { export async function startBot() {
@@ -173,11 +175,26 @@ export async function startBot() {
// Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und // Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und
// antwortet in einem Rutsch — deshalb hier oben und nicht als // antwortet in einem Rutsch — deshalb hier oben und nicht als
// weiterer customId-Vergleich zwischen den anderen. // weiterer customId-Vergleich zwischen den anderen.
if (interaction.isButton() || interaction.isStringSelectMenu()) { // Das Eingabefenster der Musik-Tafel gehoert mit hierher: es kommt als
// ModalSubmit zurueck und wuerde sonst weiter unten bei den
// Bug-/Wunsch-Fenstern landen, die es nicht kennen.
if (interaction.isButton() || interaction.isStringSelectMenu()
|| interaction.isModalSubmit()) {
try { try {
if (await handleRadioInteraction(interaction)) return; if (await handleRadioInteraction(interaction)) return;
if (await handleMusikInteraction(interaction)) return;
} catch (error) { } catch (error) {
console.error('[radio] Interaktion:', error); console.error('[radio] Interaktion:', error);
// Ohne Rueckmeldung steht bei dem, der geklickt hat, nur
// "Interaktion fehlgeschlagen" -- ohne jeden Hinweis darauf,
// dass der Grund im Serverlog steht.
const meldung = {
content: '❌ Da ist etwas schiefgelaufen — Genaueres steht im Serverlog.',
flags: MessageFlags.Ephemeral,
};
await (interaction.deferred || interaction.replied
? interaction.followUp(meldung)
: interaction.reply(meldung)).catch(() => {});
return; return;
} }
} }
+22
View File
@@ -0,0 +1,22 @@
// /musik — die YouTube-Tafel im Kanal öffnen.
//
// Ein eigener Befehl und kein Unterbefehl von `/radio`: `/radio` funktioniert
// heute, wie es ist, und daran soll sich für niemanden etwas ändern. Aus
// `/radio` ein `/radio tafel` zu machen, um Platz für `/radio musik` zu haben,
// wäre eine geänderte Bedienung für alle — als Preis dafür, dass ein paar
// Leute Videos einreihen wollen.
//
// Wie bei `/radio` nimmt der Befehl selbst nichts entgegen: eingereiht wird
// über das Eingabefenster an der Tafel. Ein Link ist auf dem Handy nichts,
// was man in eine Befehlszeile tippt — man fügt ihn ein, und dafür ist ein
// Textfeld der bessere Ort.
import { SlashCommandBuilder } from 'discord.js';
import { musikTafelOeffnen } from '../radio-musik.js';
export const data = new SlashCommandBuilder()
.setName('musik')
.setDescription('YouTube einreihen und die Warteschlange verwalten');
export async function execute(interaction) {
await musikTafelOeffnen(interaction);
}
+463
View File
@@ -0,0 +1,463 @@
// 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,
} 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;
const verstrichen = Math.floor((Date.now() - jetzt.seit) / 1000);
const balken = fortschritt(verstrichen, t.dauer);
embed.setDescription(`**${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, kommt als Nächstes dran.');
} 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 = (praefix) => 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;
reihen.push(new ActionRowBuilder().addComponents(
knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success),
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
nichtsDa && jetzt?.quelle?.art !== 'youtube'),
knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
!jetzt || jetzt.quelle?.art !== 'youtube' || !jetzt.heimStationId),
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;
}
/**
* Läuft nichts, soll das Einreihen sofort Musik machen.
*
* Sonst legt jemand drei Titel in eine Liste, es passiert nichts, und der
* naheliegende Schluss wäre „kaputt". Läuft dagegen schon etwas — Sender oder
* Titel —, wird nicht dazwischengefunkt: die Liste kommt dran, wenn sie dran
* ist.
*/
export async function ggfAnwerfen(client, guildId, kanalId) {
if (laeuft(guildId) || !kanalId) return false;
const q = await weiter(client, guildId, { erzwungen: true, kanalId });
return Boolean(q);
}
// ── 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_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_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;
+10 -5
View File
@@ -749,20 +749,23 @@ export async function spielen(kanal, quelle) {
* einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte * einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte
* gesehen, warum. * gesehen, warum.
*/ */
export async function weiter(client, guildId, { erzwungen = false } = {}) { export async function weiter(client, guildId, { erzwungen = false, kanalId: start = null } = {}) {
// Zwei Auslöser gleichzeitig — der Titel endet, *und* jemand drückt // Zwei Auslöser gleichzeitig — der Titel endet, *und* jemand drückt
// „Überspringen" — dürfen nicht zwei Titel gleichzeitig starten. // „Überspringen" — dürfen nicht zwei Titel gleichzeitig starten.
if (wechselt.has(guildId)) return null; if (wechselt.has(guildId)) return null;
wechselt.add(guildId); wechselt.add(guildId);
try { try {
const jetzt = laufend.get(guildId); const jetzt = laufend.get(guildId);
const kanalId = jetzt?.channelId; // Läuft schon etwas, gilt dessen Kanal; sonst der mitgegebene. Der
// zweite Fall ist „aus der Stille heraus": jemand reiht etwas ein,
// während der Bot gar nicht im Kanal sitzt.
const kanalId = jetzt?.channelId ?? start;
if (!kanalId) return null; if (!kanalId) return null;
// Kam vom laufenden Titel je ein Ton? Wenn nicht, war er kaputt. // Kam vom laufenden Titel je ein Ton? Wenn nicht, war er kaputt.
// Beim Überspringen von Hand zählt das nicht mit: wer weiterdrückt, // Beim Überspringen von Hand zählt das nicht mit: wer weiterdrückt,
// hat den Titel abgebrochen, nicht der Titel sich selbst. // hat den Titel abgebrochen, nicht der Titel sich selbst.
if (!erzwungen && jetzt.quelle?.art === 'youtube' && jetzt.tonLaeuft !== true) { if (!erzwungen && jetzt?.quelle?.art === 'youtube' && jetzt.tonLaeuft !== true) {
const n = (fehlschlaege.get(guildId) ?? 0) + 1; const n = (fehlschlaege.get(guildId) ?? 0) + 1;
fehlschlaege.set(guildId, n); fehlschlaege.set(guildId, n);
console.error(`[radio] ohne Ton geblieben: ${quelleName(jetzt.quelle)} (${n}. in Folge)`); console.error(`[radio] ohne Ton geblieben: ${quelleName(jetzt.quelle)} (${n}. in Folge)`);
@@ -784,7 +787,9 @@ export async function weiter(client, guildId, { erzwungen = false } = {}) {
const quelle = naechster const quelle = naechster
? youtubeQuelle(naechster) ? youtubeQuelle(naechster)
: (() => { : (() => {
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; // Kein `jetzt` heisst „aus der Stille gestartet" — dann gibt
// es auch keinen Sender, auf den zurückzufallen wäre.
const heim = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null;
return heim ? radioQuelle(heim) : null; return heim ? radioQuelle(heim) : null;
})(); })();
@@ -953,7 +958,7 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) {
} }
/** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */ /** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */
async function tafelAktualisieren(client, guildId) { export async function tafelAktualisieren(client, guildId) {
const stand = radioState(guildId); const stand = radioState(guildId);
if (!stand?.panel_message_id) return; if (!stand?.panel_message_id) return;
const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null); const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null);
+2
View File
@@ -7,6 +7,7 @@ import { scheduleBackups } from './backup.js';
import { startServerMonitor } from './bot/server-monitor.js'; import { startServerMonitor } from './bot/server-monitor.js';
import { startLsFarm } from './bot/ls-farm.js'; import { startLsFarm } from './bot/ls-farm.js';
import { startRadio, radioBeenden } from './bot/radio.js'; import { startRadio, radioBeenden } from './bot/radio.js';
import { startMusik } from './bot/radio-musik.js';
import { startGiveaways } from './bot/giveaways.js'; import { startGiveaways } from './bot/giveaways.js';
import { startScheduledPosts } from './web/scheduled-posts.js'; import { startScheduledPosts } from './web/scheduled-posts.js';
import { startSocialNotify } from './bot/social-notify.js'; import { startSocialNotify } from './bot/social-notify.js';
@@ -76,6 +77,7 @@ try {
startServerMonitor(client); startServerMonitor(client);
startLsFarm(client); startLsFarm(client);
startRadio(client); startRadio(client);
startMusik();
startGiveaways(client); startGiveaways(client);
startScheduledPosts(client); startScheduledPosts(client);
startSocialNotify(client); startSocialNotify(client);