Radio youtube #2
+326
-1
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user