// 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;