diff --git a/src/bot/client.js b/src/bot/client.js index b34ea00..ef80671 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -25,6 +25,7 @@ import { } from './wishes.js'; import { handleGiveawayAdminButton } from './giveaways.js'; import { handleRadioInteraction } from './radio.js'; +import { handleMusikInteraction } from './radio-musik.js'; import { removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote, getGiveaway, toggleGiveawayEntry, giveawayEntries, @@ -49,12 +50,13 @@ 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'; +import * as musik from './commands/musik.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, radio, + raid, umfrage, radio, musik, ]; export async function startBot() { @@ -173,11 +175,26 @@ export async function startBot() { // 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()) { + // 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 { if (await handleRadioInteraction(interaction)) return; + if (await handleMusikInteraction(interaction)) return; } catch (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; } } diff --git a/src/bot/commands/musik.js b/src/bot/commands/musik.js new file mode 100644 index 0000000..a7ae709 --- /dev/null +++ b/src/bot/commands/musik.js @@ -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); +} diff --git a/src/bot/radio-musik.js b/src/bot/radio-musik.js new file mode 100644 index 0000000..bd0c3d0 --- /dev/null +++ b/src/bot/radio-musik.js @@ -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; diff --git a/src/bot/radio.js b/src/bot/radio.js index 32a3780..358db18 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -749,20 +749,23 @@ export async function spielen(kanal, quelle) { * einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte * 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 // „Überspringen" — dürfen nicht zwei Titel gleichzeitig starten. if (wechselt.has(guildId)) return null; wechselt.add(guildId); try { 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; // Kam vom laufenden Titel je ein Ton? Wenn nicht, war er kaputt. // Beim Überspringen von Hand zählt das nicht mit: wer weiterdrückt, // 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; fehlschlaege.set(guildId, n); 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 ? 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; })(); @@ -953,7 +958,7 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) { } /** 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); if (!stand?.panel_message_id) return; const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null); diff --git a/src/index.js b/src/index.js index 0ec5fa2..239c77a 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { scheduleBackups } from './backup.js'; import { startServerMonitor } from './bot/server-monitor.js'; import { startLsFarm } from './bot/ls-farm.js'; import { startRadio, radioBeenden } from './bot/radio.js'; +import { startMusik } from './bot/radio-musik.js'; import { startGiveaways } from './bot/giveaways.js'; import { startScheduledPosts } from './web/scheduled-posts.js'; import { startSocialNotify } from './bot/social-notify.js'; @@ -76,6 +77,7 @@ try { startServerMonitor(client); startLsFarm(client); startRadio(client); + startMusik(); startGiveaways(client); startScheduledPosts(client); startSocialNotify(client);