Radio youtube #2

Merged
D4rkst3r merged 6 commits from radio-youtube into main 2026-08-28 14:02:24 +02:00
5 changed files with 516 additions and 7 deletions
Showing only changes of commit 2a0cb1905d - Show all commits
+19 -2
View File
@@ -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;
}
}
+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
* 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);
+2
View File
@@ -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);