diff --git a/src/web/api.js b/src/web/api.js index e54a911..48f7d99 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -29,6 +29,8 @@ import { savePage, getPage, listPages, listPublishedPages, deletePage, lsState, lsModSizes, setLsModSize, listStations, createStation, updateStation, deleteStation, + queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand, + setRadioState, clearRadioState, radioState, } from '../db.js'; import crypto from 'node:crypto'; import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; @@ -36,7 +38,15 @@ import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js'; import { istLsServer, modBasis } from '../bot/ls-farm.js'; -import { titelHolen } from '../bot/radio.js'; +import { + titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle, + lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, +} from '../bot/radio.js'; +import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js'; +import { + ytdlpVorhanden, ytdlpFassung, cookiesStand, cookiesSchreiben, + selbstAktualisieren, letzterFehler, argumenteVorschau, aufloesen, TREFFER, +} from '../bot/youtube.js'; import { watchdogState, watchedServices } from '../bot/watchdog.js'; import { listRules, setRuleEnabled } from '../bot/automod.js'; import { @@ -51,6 +61,9 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men import { computeNextRun } from './scheduled-posts.js'; import { config } from '../config.js'; import { removeDevlog } from '../bot/devlog-archive.js'; +import { + radioLautstaerke, LAUT_MIN, LAUT_MAX, LAUT_SCHRITT, youtubeAn, +} from '../runtime-settings.js'; import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard, playtesterRoleId } from '../runtime-settings.js'; import { xpForLevel } from '../bot/levels.js'; import { getMilestones } from '../gitea-api.js'; @@ -418,6 +431,291 @@ export function registerApiRoutes(app, client) { return { erreichbar: sender != null || titel != null, sender, titel }; }); + // --- Radio-Fernbedienung --- + // + // Was die Discord-Tafeln koennen, kann das Panel auch — und zwar ueber + // dieselben Funktionen. Eine zweite Abspiellogik neben `radio.js` waere + // eine zweite Gelegenheit, sich anders zu verhalten als die erste. + + /** + * Welcher Server ist gemeint? + * + * Der Bot kann in mehreren stehen; das Panel hat aber nur eine Ansicht. + * Reihenfolge: ausdruecklich mitgegeben, sonst der eingestellte, sonst — + * wenn es nur einen gibt — eben der. Bei mehreren ohne Einstellung wird + * *nicht* geraten, sondern gesagt, dass die Angabe fehlt. + */ + function radioGuild(request) { + const gewuenscht = String(request.body?.guildId ?? request.query?.guildId ?? '').trim(); + if (gewuenscht) return client.guilds.cache.get(gewuenscht) ?? null; + const eingestellt = discordGuildId(); + if (eingestellt) return client.guilds.cache.get(eingestellt) ?? null; + return client.guilds.cache.size === 1 ? [...client.guilds.cache.values()][0] : null; + } + + /** Der laufende Stand, so wie ihn das Panel braucht */ + function radioStand(guild) { + const jetzt = guild ? laeuft(guild.id) : null; + if (!jetzt) return null; + const kanal = guild.channels.cache.get(jetzt.channelId); + const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null; + const t = jetzt.quelle?.art === 'youtube' ? jetzt.quelle.titel : null; + return { + art: jetzt.quelle?.art ?? null, + titel: t ? t.titel : (jetzt.titel ?? null), + sender: jetzt.quelle?.art === 'radio' ? jetzt.quelle.station.name : null, + url: t?.url ?? null, + thumb: t?.thumb ?? null, + kanal: t?.kanal ?? null, + // Sekunden statt eines Zeitstempels: der Browser muesste sonst + // gegen die Uhr des Servers rechnen, und die beiden gehen selten + // gleich. + verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000), + dauer: t ? t.dauer : null, + kanalId: jetzt.channelId, + kanalName: kanal?.name ?? null, + tonLaeuft: jetzt.tonLaeuft, + heimStation: heim ? { id: heim.id, name: heim.name } : null, + }; + } + + app.get('/api/radio/status', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + const guildId = guild?.id ?? null; + return { + guildId, + guildName: guild?.name ?? null, + // Mehrere Server und keine Einstellung: dann fehlt die Angabe, + // und das steht hier statt eines stillen leeren Standes. + mehrdeutig: !guild && client.guilds.cache.size > 1, + laeuft: radioStand(guild), + lautstaerke: radioLautstaerke(), + grenzen: { min: LAUT_MIN, max: LAUT_MAX, schritt: LAUT_SCHRITT }, + warteschlange: guildId ? queueList(guildId) : [], + stand: guildId ? queueStand(guildId) : { n: 0, dauer: 0 }, + sprachkanaele: listVoiceChannels(), + werkzeuge: { + ffmpeg: ffmpegVorhanden(), + ytdlp: ytdlpVorhanden(), + ytdlpFassung: ytdlpFassung(), + youtubeAn: youtubeAn(), + cookies: cookiesStand(), + argumente: argumenteVorschau(), + letzterFehler: letzterFehler(), + }, + }; + }); + + /** + * Start, Stopp, Ueberspringen, Sender, Lautstaerke. + * + * Ein Endpunkt mit einer Aktion statt fuenf Endpunkten: alle brauchen + * dieselbe Server-Aufloesung, dieselbe Rechtepruefung und antworten mit + * demselben Stand. Fuenfmal dasselbe Gerüst waere fuenfmal die Gelegenheit, + * eines davon zu vergessen. + */ + app.post('/api/radio/steuern', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const guildId = guild.id; + const b = request.body ?? {}; + const aktion = String(b.aktion ?? ''); + + /** Der Zielkanal: mitgegeben, sonst der laufende */ + const zielId = () => String(b.kanalId ?? '') || laeuft(guildId)?.channelId || null; + + try { + if (aktion === 'stopp') { + stoppen(guildId); + clearRadioState(guildId); + } else if (aktion === 'laut') { + const wert = lautstaerkeSetzen(guildId, Number(b.wert)); + // Frueh zurueck, und zwar an der Protokollierung vorbei: am + // Schieber entstehen viele Werte hintereinander, und ein + // Protokoll, das zu neunzig Prozent aus Lautstaerke besteht, + // ist keines mehr. Zurueck kommt der tatsaechlich gesetzte + // Wert, nicht der gewuenschte — wer am Anschlag dreht, soll + // das am Ergebnis sehen. + await tafelnMelden(client, guildId); + return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) }; + } else if (aktion === 'skip') { + const kanalId = zielId(); + if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' }); + await weiter(client, guildId, { erzwungen: true, kanalId }); + } else if (aktion === 'sender' || aktion === 'heim') { + const stationId = aktion === 'heim' + ? laeuft(guildId)?.heimStationId + : Number(b.stationId); + const station = stationId ? getStation(Number(stationId)) : null; + if (!station) return reply.code(400).send({ error: 'Sender nicht gefunden.' }); + const kanalId = zielId(); + if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal gewaehlt.' }); + const kanal = await client.channels.fetch(kanalId).catch(() => null); + if (!kanal?.isVoiceBased()) return reply.code(400).send({ error: 'Das ist kein Sprachkanal.' }); + await spielen(kanal, radioQuelle(station)); + // Ohne diesen Merker kaeme der Bot nach einem Neustart nicht + // zurueck — und die Radio-Tafel wuesste nicht, was laeuft. + const vorhanden = radioState(guildId); + setRadioState({ + guildId, + channelId: kanalId, + stationId: station.id, + panelChannelId: vorhanden?.panel_channel_id, + panelMessageId: vorhanden?.panel_message_id, + }); + // Den Titel gleich mitholen: `titelMerken` schreibt ihn auch + // in die Statuszeile am Sprachkanal, sonst steht dort bis zum + // naechsten Abgleich der Titel des vorigen Senders. + const { titel } = await titelHolen(station.url); + titelMerken(client, guildId, titel); + } else { + return reply.code(400).send({ error: `Unbekannte Aktion: ${aktion}` }); + } + } catch (error) { + // Ein gescheiterter Beitritt bringt die Diagnose aus `radio.js` + // mit — die ist hier deutlich mehr wert als ein 500er. + return reply.code(400).send({ error: error.message }); + } + + logAudit(getSessionUser(request), 'radio gesteuert', aktion); + await tafelnMelden(client, guildId); + return { ok: true, laeuft: radioStand(guild), warteschlange: queueList(guildId) }; + }); + + // --- Warteschlange --- + + app.post('/api/radio/warteschlange', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const guildId = guild.id; + const b = request.body ?? {}; + const wunschVon = getSessionUser(request)?.username ?? null; + + // Zwei Wege hier hinein: eine Eingabe (Adresse oder Suchbegriff) oder + // ein fertiger Treffer, den das Panel vorher ausgewaehlt hat. + let eingereiht; + if (b.treffer) { + eingereiht = eintraegeAufnehmen(guildId, [{ + video_id: String(b.treffer.video_id ?? ''), + url: String(b.treffer.url ?? ''), + titel: String(b.treffer.titel ?? ''), + dauer: b.treffer.dauer ?? null, + kanal: b.treffer.kanal ?? null, + thumb: b.treffer.thumb ?? null, + }], wunschVon); + } else { + const r = await einreihen(guildId, String(b.eingabe ?? ''), { wunschVon }); + if (r.fehler) return reply.code(400).send({ error: r.fehler }); + // Ein Suchbegriff wird nicht geraten: die Treffer gehen zurueck, + // gewaehlt wird im Panel. Genau wie in Discord. + if (r.treffer) return { treffer: r.treffer, suche: true }; + eingereiht = r.eingereiht; + } + + const angeworfen = await ggfAnwerfen(client, guildId, + String(b.kanalId ?? '') || null); + logAudit(getSessionUser(request), 'radio eingereiht', + eingereiht.map((e) => e.titel).join(', ').slice(0, 200)); + await tafelnMelden(client, guildId); + return { + ok: true, eingereiht, angeworfen, + warteschlange: queueList(guildId), laeuft: radioStand(guild), + }; + }); + + app.post('/api/radio/suche', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const text = String(request.body?.text ?? '').trim(); + if (!text) return reply.code(400).send({ error: 'Nichts eingegeben.' }); + const r = await aufloesen(text, { max: TREFFER }); + if (r.fehler) return reply.code(400).send({ error: r.fehler }); + return { treffer: r.treffer, suche: r.suche }; + }); + + app.delete('/api/radio/warteschlange/:id', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const weg = queueRemove(Number(request.params.id)); + await tafelnMelden(client, guild.id); + return { deleted: weg, warteschlange: queueList(guild.id) }; + }); + + app.delete('/api/radio/warteschlange', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const n = queueClear(guild.id); + logAudit(getSessionUser(request), 'warteschlange geleert', `${n} Titel`); + await tafelnMelden(client, guild.id); + return { geleert: n, warteschlange: [] }; + }); + + app.post('/api/radio/warteschlange/sortieren', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + const guild = radioGuild(request); + if (!guild) return reply.code(400).send({ error: 'Kein Server gewaehlt.' }); + const b = request.body ?? {}; + // Zwei Formen, weil das Panel umsortiert und Discord nur „nach vorn" + // kennt — beide landen in derselben Pruefung in der Datenbank, die + // fremde IDs nicht anfasst. + if (b.vor) queueVor(guild.id, Number(b.vor)); + else if (Array.isArray(b.ids)) queueReorder(guild.id, b.ids); + else return reply.code(400).send({ error: 'Weder `ids` noch `vor` mitgegeben.' }); + await tafelnMelden(client, guild.id); + return { ok: true, warteschlange: queueList(guild.id) }; + }); + + // --- yt-dlp --- + + app.get('/api/radio/youtube', async (request, reply) => { + if (requireScope(request, reply, 'settings')) return; + return { + ytdlp: ytdlpVorhanden(), + fassung: ytdlpFassung(), + an: youtubeAn(), + cookies: cookiesStand(), + argumente: argumenteVorschau(), + letzterFehler: letzterFehler(), + }; + }); + + /** + * Die `cookies.txt` setzen oder loeschen. + * + * Eigener Endpunkt statt eines Feldes in den Einstellungen: der Inhalt ist + * mehrzeilig und enthaelt Sitzungsschluessel eines echten Kontos. Er + * gehoert in eine Datei mit 0600 neben die Datenbank und nicht in eine + * Tabelle, die das Panel andernorts auch anzeigt. + * + * Zurueck kommt nie der Inhalt, nur Groesse und Datum. + */ + app.put('/api/radio/youtube/cookies', async (request, reply) => { + if (requireAdmin(request, reply)) return; + try { + const r = await cookiesSchreiben(request.body?.inhalt ?? ''); + logAudit(getSessionUser(request), 'yt-cookies', + r.gesetzt ? 'gesetzt' : 'geloescht'); + return { ok: true, cookies: cookiesStand() }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } + }); + + app.post('/api/radio/youtube/update', async (request, reply) => { + if (requireAdmin(request, reply)) return; + try { + const r = await selbstAktualisieren(); + logAudit(getSessionUser(request), 'yt-dlp aktualisiert', r.fassung ?? '?'); + return { ok: true, ...r }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } + }); + // Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist // eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server. app.get('/api/ticket-categories', async (request, reply) => { @@ -938,6 +1236,33 @@ ${rssItems} return channels.sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Sprachkanäle, in die der Bot auch wirklich hineinkommt. + * + * Bewusst mit Rechteprüfung und nicht bloss nach Kanaltyp: ein Kanal, den + * das Panel anbietet und in den der Bot dann nicht darf, sieht aus wie ein + * kaputter Knopf. Was hier fehlt, fehlt aus einem Grund — deshalb steht + * der auch dabei. + */ + function listVoiceChannels() { + const channels = []; + for (const guild of client.guilds.cache.values()) { + for (const ch of guild.channels.cache.values()) { + if (!ch.isVoiceBased?.() || ch.viewable === false) continue; + const rechte = guild.members.me ? ch.permissionsFor(guild.members.me) : null; + channels.push({ + id: ch.id, + name: ch.name, + guild: guild.name, + guildId: guild.id, + besetzt: ch.members?.filter?.((m) => !m.user.bot).size ?? 0, + darfRein: Boolean(rechte?.has('Connect') && rechte?.has('Speak')), + }); + } + } + return channels.sort((a, b) => a.name.localeCompare(b.name)); + } + /** Rollen, die der Bot vergeben kann (unter seiner höchsten Rolle, nicht managed) */ function listRoles() { const roles = [];