Radio youtube #2

Merged
D4rkst3r merged 6 commits from radio-youtube into main 2026-08-28 14:02:24 +02:00
Showing only changes of commit 1ac349029f - Show all commits
+326 -1
View File
@@ -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 = [];