Author SHA1 Message Date
D4rkst3r e165402f7d Dokumentation nachziehen -- und einen verlorenen Wunsch retten
Der README behauptete an prominenter Stelle "bewusst kein YouTube".
Das stimmt seit vier Commits nicht mehr, und ein Satz, der das Gegenteil
des Codes sagt, wird geglaubt. Ersetzt samt der PO-Token-Lage und dem
Hinweis auf Cookies und eigene Argumente.

.env.example kennt jetzt FFMPEG_PATH und YTDLP_PATH -- mit dem Hinweis,
dass YTDLP_PATH=/bin/false der Weg ist, den Fehlerpfad zu pruefen.

Beim letzten Durchlesen aufgefallen: `weiter()` holt den naechsten Titel
aus der Liste, BEVOR `spielen()` laeuft. Scheitert der Beitritt in den
Sprachkanal -- fehlendes Recht, voller Kanal, DAVE --, war der Eintrag
weg. Genau im haeufigsten Fall: jemand reiht etwas ein, waehrend der Bot
noch gar nicht im Kanal sitzt, der erste Beitritt ist der, der scheitern
kann, und der Wunsch verschwindet wortlos.

Jetzt wird er wieder ganz vorn eingereiht und der Fehler weitergereicht.
Gegenprobe mit einem Kanal, dessen Beitritt garantiert wirft: drei Titel
vorher, drei Titel nachher, "Erster" wieder an erster Stelle.
2026-08-28 13:52:56 +02:00
D4rkst3r dbec4e9775 Panel: die volle Fernbedienung ueber der Senderliste
Was laeuft, wo, aus welcher Quelle, mit Fortschrittsbalken. Sprachkanal
waehlen, Sender abspielen, Ueberspringen, Zum Radio, Stopp,
Lautstaerke-Schieber. Einreihen per Link oder Suchbegriff, Warteschlange
mit Hoch/Runter/Weg und Leeren. Darunter ein aufklappbarer yt-dlp-Block:
Zustand, Fassung, cookies.txt, eigene Argumente, Aktualisieren -- und die
letzte Fehlerzeile im Klartext.

Steht ueber der Senderliste, weil man oefter etwas abspielt als die Liste
pflegt.

Die Abfrage laeuft nur, solange der Community-Reiter offen ist. Sonst
ginge alle fuenf Sekunden eine Anfrage gegen eine Ansicht raus, die
niemand ansieht.

Der Balken erscheint nur bei bekannter Dauer. Bei einem Livestream gaebe
er einen Stand vor, den niemand kennt -- dort steht stattdessen "live".

Die Kanalauswahl graut aus, wo der Bot nicht rein darf. Ein Kanal, den das
Panel anbietet und in den der Bot dann nicht kommt, sieht aus wie ein
kaputter Knopf.

Geprueft: der Vite-Build laeuft durch, und ein Abgleich stellt alle 30
Feldpfade, die die Oberflaeche vom Stand liest, gegen die echte
API-Antwort -- 0 fehlen. Der Abgleich hat eine Gegenprobe mit bekanntem
Ausgang (ein Feld, das es nicht gibt, muss als fehlend erkannt werden) und
prueft vorher, ob die sonst leeren Zweige (Warteschlange, Cookies, letzter
Fehler) ueberhaupt besetzt sind -- sonst meldet er "ungueltig" statt "in
Ordnung". Seine erste Fassung hatte Fehlalarm geschlagen (?. und .map als
fehlende Felder gelesen); das ist behoben.

NICHT geprueft: das tatsaechliche Rendern im Browser -- hier ist weder
Browser noch DOM-Werkzeug vorhanden.
2026-08-28 13:50:06 +02:00
D4rkst3r 1ac349029f Die Fernbedienung: Radio vom Panel aus steuern
Neun Endpunkte unter /api/radio: Stand, Steuern, Warteschlange (einreihen,
entfernen, leeren, sortieren), Suche und der yt-dlp-Block.

Alles geht durch dieselben Funktionen wie die Discord-Tafeln. Eine zweite
Abspiellogik neben radio.js waere eine zweite Gelegenheit, sich anders zu
verhalten als die erste.

Start, Stopp, Skip, Sender und Lautstaerke liegen als Aktionen auf einem
Endpunkt statt auf fuenf: alle brauchen dieselbe Server-Aufloesung,
dieselbe Rechtepruefung und antworten mit demselben Stand. Fuenf Geruest-
Kopien waeren fuenf Gelegenheiten, eine davon zu vergessen.

Ein Suchbegriff wird auch hier nicht geraten -- die Treffer gehen zurueck,
gewaehlt wird im Panel. Genau wie in Discord.

Die cookies.txt bekommt einen eigenen 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 zurueck kommt nie der Inhalt, nur Groesse und Datum. Nur der Owner
darf ihn setzen, nicht das Team.

listVoiceChannels() prueft die Rechte mit: ein Kanal, den das Panel
anbietet und in den der Bot dann nicht darf, sieht aus wie ein kaputter
Knopf.

Beim Schreiben aufgefallen und behoben: radioState() wurde benutzt, aber
nicht importiert -- das haette beim ersten Senderwechsel aus dem Panel
geknallt. Der Syntaxcheck sieht so etwas nicht, das Laden des Moduls schon.

Geprueft mit echtem Fastify, signiertem Sitzungs-Cookie und gefaelschtem
Discord-Client: 24 Faelle, davon acht Gegenproben, die fehlschlagen
muessen (ohne Anmeldung 401, unbekannte Aktion, Sender ohne ID, Skip ohne
Kanal, leere Eingabe, kaputte Adresse, Sortieren ohne Angabe, Cookies ohne
Netscape-Kennzeile). Alle 24 wie erwartet.
2026-08-28 13:44:28 +02:00
D4rkst3r 2a0cb1905d Die zweite Tafel: /musik, Warteschlange, Eingabefenster
Eine eigene Nachricht statt ein paar Knoepfe mehr an der Radio-Tafel.
Discord laesst fuenf Reihen je Nachricht zu, vier davon gehoeren dort den
Sender-Menues -- fuer Ueberspringen, Einreihen, Warteschlange und Leeren
ist kein Platz, und ein Sender-Menue zu opfern hiesse, Sender aus der
Auswahl zu werfen.

Der Vorzug ist groesser als die Notloesung: /radio bleibt genau das, was
es war. Wer nie ein Video einreiht, merkt vom ganzen Umbau nichts.

Bei einem Suchbegriff wird bewusst nicht der erste Treffer genommen,
sondern die Auswahl gezeigt: "Sleeping Sun" hat drei Fassungen, und
welche gemeint war, weiss nur wer es getippt hat. Eine Adresse geht
direkt rein, eine Playlist mit allen Eintraegen auf einmal.

Die Abhaengigkeit zeigt nur in eine Richtung: radio-musik.js kennt das
Radio, das Radio kennt es nicht -- es meldet ueber beiAenderung() nur,
dass sich etwas geaendert hat. Ein gegenseitiger Import zwischen zwei
Dateien beisst beim naechsten Umbau.

Gemessen gegen echtes YouTube am 28.08.2026: Adresse landet in der Liste,
Suchbegriff liefert fuenf Treffer und reiht NICHTS ein (Liste nachweislich
unveraendert), kaputte Adresse meldet "Video unavailable" und laesst die
Liste in Ruhe. Die Tafel zeigt Dauer je Titel, Gesamtdauer, Wunschgeber
und die letzte Fehlerzeile im Klartext.

Nicht geprueft, weil es dafuer eine echte Discord-Sprachverbindung
braucht: die Darstellung waehrend etwas laeuft, und das Weiterschalten.
2026-08-28 13:39:46 +02:00
D4rkst3r 409b28e9e0 Eine Quelle, zwei Arten -- Sender und Titel gehen denselben Weg
Der ganze Unterschied ist einer: ein Sender endet nie, ein Titel schon.
Also verzweigt genau eine Abspielfunktion und genau ein Idle-Melder nach
der Art der Quelle, statt dass ein zweiter Pfad daneben entsteht -- zwei
Pfade, die dasselbe tun sollen, driften auseinander.

Beim Sender heisst Idle "die Leitung ist weg" (neu aufbauen), bei YouTube
"fertig, der naechste bitte". Danach: Warteschlange, sonst der Sender von
vorher, sonst raus. Das Radio ist die Grundstellung, YouTube die
Unterbrechung.

Bremse gegen das Durchrutschen: ein Eintrag ohne Ton geht sofort wieder
auf Idle. Ohne Zaehlung waere eine Liste mit einem kaputten Eintrag in
Sekundenbruchteilen leer, und niemand haette gesehen warum. Nach drei
Fehlschlaegen in Folge wird angehalten und der Grund gemeldet. Von Hand
Uebersprungenes zaehlt nicht mit.

Zwei Fehler nebenbei, beide vorher schon da:

Der Idle-Melder hielt `kanal` aus dem Abschluss des ERSTEN Aufrufs fest.
Nach einem Kanalwechsel haette der Wiederanlauf in den alten Kanal
gespielt. Er liest den Kanal jetzt frisch aus `laufend`.

`radioLautstaerke()` gab bei unbelegter Einstellung 10 zurueck statt der
50, die der Kommentar zwei Zeilen darueber als Vorgabe begruendet:
`Number(null)` ist 0 und damit endlich, die Pruefung auf "nicht endlich"
hat den unbelegten Fall nie erwischt und auf die Untergrenze geklemmt.
Eine frische Installation startete also sehr leise. Gemessen gegen eine
leere Datenbank, mit Gegenproben fuer leeren String, Unsinn, 0 und 999.
2026-08-28 13:33:42 +02:00
D4rkst3r af16bc1b18 YouTube als zweite Tonquelle: yt-dlp, Warteschlange, Abbild
Das Fundament, noch ohne Anbindung ans Radio.

yt-dlp holt selbst und schiebt rohe Bytes durch ein Rohr an ffmpeg. Der
bequemere Weg waere `-g` und die fertige Adresse -- der ist eine Falle:
googlevideo-Adressen laufen ab und haengen an der IP, die sie geholt hat.
Das schlaegt nach zehn Minuten zu und sieht dann aus wie ein Netzproblem.

Beim Stroemen nach stdout ist die Vorgabe von yt-dlp *mit* Bild; ohne ein
ausdrueckliches `-f bestaudio/best` laedt ein Tonstrom das ganze Video.
Nachgelesen im README der Fassung 2026.08.19.

stderr wird aufgehoben statt weggeworfen. Genau hier entsteht sonst der
Fehler, den niemand deuten kann: YouTube antwortet "Sign in to confirm",
yt-dlp bricht ab, der Bot sitzt still im Kanal. Die letzte ERROR-Zeile
geht ins Panel.

Gemessen am 28.08.2026 gegen echtes YouTube (yt-dlp 2026.08.19, von hier
aus, NICHT auf dem Zielhost): Einzelvideo, Suche und flache Playlist
liefern die erwarteten Felder, eine kaputte Video-ID meldet "Video
unavailable" und wird als Fehler durchgereicht statt als leere Liste.

Die Warteschlange steht in der Datenbank, nicht nur im Speicher -- ein
Deploy soll die Musik unterbrechen, nicht die Wuensche von fuenf Leuten
wegwerfen. Die YouTube-Tafel bekommt eine eigene Tabelle, weil
`radio_state` beim Stoppen geloescht wird und die Tafel das ueberleben
soll.

Nebenbei: der ffmpeg-Kommentar im Dockerfile beschrieb noch die alte
Ogg/Opus-Kette. Seit der Lautstaerkeregelung laeuft dort PCM.
2026-08-28 13:26:05 +02:00
16 changed files with 2304 additions and 57 deletions
+10
View File
@@ -62,3 +62,13 @@ GITEA_API_TOKEN=
# Optional: Pfad zur SQLite-Datei (Default: ./data/ecobot.db) # Optional: Pfad zur SQLite-Datei (Default: ./data/ecobot.db)
# DB_PATH=./data/ecobot.db # DB_PATH=./data/ecobot.db
# Optional: Pfade zu ffmpeg und yt-dlp fuers Radio (Default: im PATH suchen).
# Beide stecken im Docker-Abbild und muessen dort nicht gesetzt werden. Lokal
# ohne Docker ist das der Ort, an dem man sie bekannt macht.
#
# Zum Pruefen der Fehlerwege taugt ein Pfad, der nicht abspielt:
# YTDLP_PATH=/bin/false laesst die Radiosender weiterlaufen und muss im Panel
# als "yt-dlp fehlt" erscheinen -- nicht als Stille im Sprachkanal.
# FFMPEG_PATH=ffmpeg
# YTDLP_PATH=yt-dlp
+21 -2
View File
@@ -12,11 +12,30 @@ FROM node:22-slim
# Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften), # Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften),
# git für die Repo-Backups (git clone --mirror + bundle), # git für die Repo-Backups (git clone --mirror + bundle),
# ffmpeg fürs Radio: es holt den MP3-Strom und gibt direkt Ogg/Opus aus — damit # ffmpeg fürs Radio: es holt den Strom und gibt rohe Abtastwerte (PCM) aus, an
# braucht es keine Opus-Bibliothek in Node, die erst gebaut werden müsste # denen die Sprachbibliothek die Lautstärke regelt, bevor sie nach Opus packt.
# Bis zur Lautstärkeregelung lief hier Ogg/Opus unverändert durch — an fertigen
# Opus-Paketen lässt sich die Lautstärke nicht drehen.
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core git ffmpeg \ RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core git ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# yt-dlp fürs YouTube-Einreihen im Radio.
#
# Die eigenständige Binärdatei statt `pip install`: sie bringt ihr eigenes
# Python mit, also kein python3-pip im Abbild und kein Compiler. `yt-dlp_linux`
# verlangt glibc 2.17+, node:22-slim (bookworm) hat 2.36.
#
# Bewusst die *jeweils neueste* Fassung und keine feste: YouTube ändert seine
# Sperren schneller, als hier jemand eine Versionsnummer nachzieht, und ein
# veraltetes yt-dlp ist die häufigste Ursache für „geht plötzlich nicht mehr".
# Damit hängt der Bau an GitHub — dieselbe Abhängigkeit, die `apt-get` und
# `npm ci` schon haben.
#
# Der `--version`-Aufruf am Ende ist kein Beiwerk: ohne ihn fällt ein kaputter
# Download erst im Betrieb auf, als stiller Sprachkanal statt als Baufehler.
ADD https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux /usr/local/bin/yt-dlp
RUN chmod 0755 /usr/local/bin/yt-dlp && /usr/local/bin/yt-dlp --version
WORKDIR /app WORKDIR /app
# Erst nur Manifest kopieren → Docker-Layer-Cache für npm ci # Erst nur Manifest kopieren → Docker-Layer-Cache für npm ci
+2 -1
View File
@@ -73,7 +73,8 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) | | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) |
| 💡 **Feature-Voting** | `/wunsch-setup` postet den Ideen-Aufruf: Bereich wählen, Formular ausfüllen → Voting-Post mit 👍 und **Thread zum Reden**. Wünsche haben einen **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung — auf der Roadmap und im Discord-Post. Wer gestimmt hat, bekommt eine DM, sobald es umgesetzt ist. Die Roadmap filtert nach Stand und Bereich, sortiert nach Top / Bewegung / Neu und zeigt die Zahl der Beiträge im Thread. Ein Klick öffnet die **eigene Seite der Idee** mit der Unterhaltung aus dem Discord-Thread — gespiegelt samt Screenshots, geschrieben wird weiter dort. Fehlende Forum-Tags legt das Panel auf Knopfdruck an. Ist der Voting-Kanal ein **Forum**, wird jeder Wunsch ein Forums-Beitrag mit **Tags** für Bereich und Stand — der Stand zieht beim Ändern automatisch mit. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen | | 💡 **Feature-Voting** | `/wunsch-setup` postet den Ideen-Aufruf: Bereich wählen, Formular ausfüllen → Voting-Post mit 👍 und **Thread zum Reden**. Wünsche haben einen **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung — auf der Roadmap und im Discord-Post. Wer gestimmt hat, bekommt eine DM, sobald es umgesetzt ist. Die Roadmap filtert nach Stand und Bereich, sortiert nach Top / Bewegung / Neu und zeigt die Zahl der Beiträge im Thread. Ein Klick öffnet die **eigene Seite der Idee** mit der Unterhaltung aus dem Discord-Thread — gespiegelt samt Screenshots, geschrieben wird weiter dort. Fehlende Forum-Tags legt das Panel auf Knopfdruck an. Ist der Voting-Kanal ein **Forum**, wird jeder Wunsch ein Forums-Beitrag mit **Tags** für Bereich und Stand — der Stand zieht beim Ändern automatisch mit. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen |
| 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst | | 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst |
| 📻 **Radio** | `/radio` holt den Bot in deinen Sprachkanal und öffnet eine Tafel mit Sender-Auswahl zum Durchschalten; der laufende Titel steht im Embed und aktualisiert sich selbst. **44 RauteMusik-Sender** sind ab Werk eingetragen — dieselben, die im LS25 und ETS als Bordradio laufen —, eigene lassen sich im Panel ergänzen. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, und kommt nach einem Neustart zurück. Bewusst **kein YouTube**: daran sind Groovy und Rythm gestorben | | 📻 **Radio** | `/radio` holt den Bot in deinen Sprachkanal und öffnet eine Tafel mit Sender-Auswahl zum Durchschalten; der laufende Titel steht im Embed und aktualisiert sich selbst. **44 RauteMusik-Sender** sind ab Werk eingetragen — dieselben, die im LS25 und ETS als Bordradio laufen —, eigene lassen sich im Panel ergänzen. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, und kommt nach einem Neustart zurück |
| ▶️ **YouTube** | `/musik` öffnet eine zweite Tafel: Link oder Suchbegriff einreihen, Warteschlange umsortieren, überspringen. Ist die Liste leer, kommt der Sender zurück, der vorher lief — das Radio ist die Grundstellung, YouTube die Unterbrechung. Steuern lässt sich alles auch **komplett im Panel** unter *Community → Radio*, inklusive Live-Ansicht. Braucht `yt-dlp` auf dem Server (steckt im Image). ⚠️ YouTube sperrt Server-Adressen inzwischen häufig — dagegen gibt es im Panel ein Feld für eine `cookies.txt` und eines für eigene yt-dlp-Argumente, beides ohne neues Image. Schlägt es fehl, steht der Grund im Klartext im Panel und in der Tafel, statt als Stille im Sprachkanal |
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle | | 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
| 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche Events-Seite | | 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche Events-Seite |
+467
View File
@@ -95,6 +95,21 @@ const WUNSCH_STATUS = [
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig. // Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
const SENDER_LEER = { id: null, name: '', url: '', emoji: '', sort: 0 }; const SENDER_LEER = { id: null, name: '', url: '', emoji: '', sort: 0 };
/**
* 3:07 statt 187. Ohne bekannte Dauer steht live" da und nicht „0:00"
* ein Livestream hat keine Laenge, und eine Null waere eine Behauptung.
*/
function dauerText(sekunden) {
if (sekunden === null || sekunden === undefined) return 'live';
const t = Math.max(0, Math.round(sekunden));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const r = t % 60;
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
: `${m}:${String(r).padStart(2, '0')}`;
}
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']); const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner) // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
@@ -432,6 +447,14 @@ export default function Settings({ me }) {
const [senderDraft, setSenderDraft] = useState({ id: null, name: '', url: '', emoji: '', sort: 0 }); const [senderDraft, setSenderDraft] = useState({ id: null, name: '', url: '', emoji: '', sort: 0 });
// Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft' // Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft'
const [senderProbe, setSenderProbe] = useState(null); const [senderProbe, setSenderProbe] = useState(null);
// Fernbedienung: der Live-Stand vom Bot (null = noch nichts geholt)
const [radio, setRadio] = useState(null);
const [radioKanal, setRadioKanal] = useState('');
const [radioEingabe, setRadioEingabe] = useState('');
const [radioTreffer, setRadioTreffer] = useState(null); // Auswahl nach einer Suche
const [radioLaedt, setRadioLaedt] = useState(false);
const [radioFehler, setRadioFehler] = useState('');
const [ytCookies, setYtCookies] = useState('');
const [owner, setOwner] = useState(null); const [owner, setOwner] = useState(null);
const [auditAlle, setAuditAlle] = useState(false); const [auditAlle, setAuditAlle] = useState(false);
// Module (An/Aus für alle Funktionen) // Module (An/Aus für alle Funktionen)
@@ -472,6 +495,23 @@ export default function Settings({ me }) {
apiGet('/api/radio').then((d) => setSender(d.sender ?? [])).catch(() => {}); apiGet('/api/radio').then((d) => setSender(d.sender ?? [])).catch(() => {});
}, []); }, []);
// Der Live-Stand der Fernbedienung.
//
// Nur solange der Community-Reiter offen ist: sonst liefe im Hintergrund
// alle fuenf Sekunden eine Anfrage gegen eine Ansicht, die niemand sieht.
// Fuenf Sekunden, weil der Fortschrittsbalken sonst ruckelt und ein Titel
// selten kuerzer als zwei Minuten ist.
useEffect(() => {
if (tab !== 'community') return undefined;
let lebt = true;
const holen = () => apiGet('/api/radio/status')
.then((d) => { if (lebt) setRadio(d); })
.catch(() => {});
holen();
const timer = setInterval(holen, 5000);
return () => { lebt = false; clearInterval(timer); };
}, [tab]);
// Portal-Dienste (Kacheln auf der Startseite) // Portal-Dienste (Kacheln auf der Startseite)
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 }; const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
const [services, setServices] = useState([]); const [services, setServices] = useState([]);
@@ -2151,6 +2191,323 @@ export default function Settings({ me }) {
<span className="field-hint">ohne Haken antwortet /radio nur mit einem Hinweis</span> <span className="field-hint">ohne Haken antwortet /radio nur mit einem Hinweis</span>
</label> </label>
</div> </div>
<div className="field" data-setting="radio_youtube_enabled">
<label className="toggle">
<input type="checkbox" checked={Boolean(form.radio_youtube_enabled)}
onChange={(e) => setForm({ ...form, radio_youtube_enabled: e.target.checked })} />
<span>YouTube erlauben</span>
<span className="field-hint">
braucht <code>yt-dlp</code> auf dem Server; ohne Haken laufen nur die Sender
</span>
</label>
</div>
{/* Fernbedienung
Steht ueber der Senderliste, weil man oefter etwas
abspielt als die Liste pflegt. */}
{radio?.mehrdeutig && (
<p className="notice">
Der Bot steht in mehreren Servern und es ist keiner eingestellt
unter <strong>System</strong> die Server-ID eintragen, sonst weiss
das Panel nicht, welchen es steuern soll.
</p>
)}
{radio && !radio.mehrdeutig && (
<div className="radio-remote">
<div className="radio-now">
{radio.laeuft ? (
<>
<div className="radio-now-head">
<span className="radio-badge">
{radio.laeuft.art === 'youtube' ? '▶️' : '📻'}
</span>
<div className="radio-now-text">
<strong>
{radio.laeuft.art === 'youtube'
? radio.laeuft.titel
: radio.laeuft.sender}
</strong>
<span className="radio-now-sub">
{radio.laeuft.art === 'youtube'
? [radio.laeuft.kanal, radio.laeuft.heimStation
&& `danach zurück auf ${radio.laeuft.heimStation.name}`]
.filter(Boolean).join(' · ')
: (radio.laeuft.titel ?? 'Titel wird abgefragt …')}
</span>
<span className="radio-now-sub">
🔊 {radio.laeuft.kanalName ?? radio.laeuft.kanalId}
</span>
</div>
</div>
{/* Der Balken nur, wo er etwas aussagen kann: ohne
bekannte Dauer gaebe er einen Stand vor, den
niemand kennt. */}
{radio.laeuft.dauer ? (
<div className="radio-progress">
<div className="radio-progress-bar">
<span style={{
width: `${Math.min(100, (radio.laeuft.verstrichen
/ radio.laeuft.dauer) * 100)}%`,
}} />
</div>
<span className="radio-progress-text">
{dauerText(radio.laeuft.verstrichen)} / {dauerText(radio.laeuft.dauer)}
</span>
</div>
) : radio.laeuft.art === 'youtube' && (
<p className="radio-now-sub">🔴 Livestream</p>
)}
{radio.laeuft.tonLaeuft === false && (
<p className="notice">
Es kommt kein Ton an.
{radio.werkzeuge.ffmpeg === false
&& ' Auf dem Server fehlt ffmpeg.'}
</p>
)}
</>
) : (
<p className="radio-now-sub">
Nichts läuft gerade.
{radio.stand.n > 0
&& ` ${radio.stand.n} Titel warten — Sprachkanal wählen und abspielen.`}
</p>
)}
</div>
<div className="radio-form-row">
<div className="field">
<label>Sprachkanal <span className="field-hint">wohin der Bot gehen soll</span></label>
<select value={radioKanal} onChange={(e) => setRadioKanal(e.target.value)}>
<option value="">
{radio.laeuft
? `— läuft in ${radio.laeuft.kanalName ?? radio.laeuft.kanalId}`
: '— bitte wählen —'}
</option>
{radio.sprachkanaele.map((k) => (
<option key={k.id} value={k.id} disabled={!k.darfRein}>
{k.name}
{k.besetzt > 0 ? ` (${k.besetzt})` : ''}
{k.darfRein ? '' : ' — darf ich nicht rein'}
</option>
))}
</select>
</div>
<div className="field">
<label>
Lautstärke <span className="field-hint">{radio.lautstaerke} %</span>
</label>
{/* onMouseUp/onChange statt live bei jedem Pixel: sonst
geht je Mausbewegung eine Anfrage raus. */}
<input type="range"
min={radio.grenzen.min} max={radio.grenzen.max}
step={radio.grenzen.schritt}
value={radio.lautstaerke}
onChange={(e) => setRadio({ ...radio, lautstaerke: Number(e.target.value) })}
onMouseUp={(e) => radioSteuern('laut', { wert: Number(e.target.value) })}
onTouchEnd={(e) => radioSteuern('laut', { wert: Number(e.target.value) })} />
</div>
</div>
<div className="settings-actions">
<select className="radio-station-pick" defaultValue=""
onChange={(e) => {
if (!e.target.value) return;
radioSteuern('sender', { stationId: Number(e.target.value) });
e.target.value = '';
}}>
<option value="">Sender abspielen </option>
{sender.map((x) => (
<option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option>
))}
</select>
<button className="btn btn-ghost" disabled={radioLaedt || !radio.laeuft}
onClick={() => radioSteuern('skip')}>
Überspringen
</button>
<button className="btn btn-ghost"
disabled={radioLaedt || radio.laeuft?.art !== 'youtube'
|| !radio.laeuft?.heimStation}
onClick={() => radioSteuern('heim')}>
📻 Zum Radio
</button>
<button className="btn btn-ghost danger" disabled={radioLaedt || !radio.laeuft}
onClick={() => radioSteuern('stopp')}>
Stopp
</button>
</div>
{/* ── Einreihen ── */}
<div className="field">
<label>
YouTube einreihen
<span className="field-hint">Link oder Suchbegriff</span>
</label>
<div className="radio-add">
<input type="text" placeholder="https://youtu.be/… oder: nightwish sleeping sun"
value={radioEingabe}
disabled={!radio.werkzeuge.youtubeAn || radio.werkzeuge.ytdlp === false}
onChange={(e) => { setRadioEingabe(e.target.value); setRadioTreffer(null); }}
onKeyDown={(e) => e.key === 'Enter' && radioEinreihen()} />
<button className="btn btn-save" disabled={radioLaedt || !radioEingabe.trim()}
onClick={() => radioEinreihen()}>
{radioLaedt ? '…' : 'Einreihen'}
</button>
</div>
{!radio.werkzeuge.youtubeAn && (
<span className="field-hint">
YouTube ist ausgeschaltet Haken oben setzen und speichern.
</span>
)}
</div>
{/* Bei einer Suche wird nicht geraten, welcher gemeint war. */}
{radioTreffer && (
<div className="radio-list">
{radioTreffer.map((t) => (
<div className="radio-row" key={t.video_id}>
<span className="radio-emoji"></span>
<div className="radio-info">
<span className="radio-name">{t.titel}</span>
<span className="radio-url">
{[t.kanal, dauerText(t.dauer)].filter(Boolean).join(' · ')}
</span>
</div>
<div className="radio-actions">
<button className="btn btn-ghost" disabled={radioLaedt}
onClick={() => radioEinreihen(t)}>
Nehmen
</button>
</div>
</div>
))}
</div>
)}
{/* ── Warteschlange ── */}
{radio.warteschlange.length > 0 && (
<>
<label>
Warteschlange
<span className="field-hint">
{radio.stand.n} Titel, {dauerText(radio.stand.dauer)} gesamt
</span>
</label>
<div className="radio-list">
{radio.warteschlange.map((e, i) => (
<div className="radio-row" key={e.id}>
<span className="radio-emoji">{i + 1}.</span>
<div className="radio-info">
<span className="radio-name">{e.titel}</span>
<span className="radio-url">
{[e.kanal, dauerText(e.dauer),
e.wunsch_von && `von ${e.wunsch_von}`]
.filter(Boolean).join(' · ')}
</span>
</div>
<div className="radio-actions">
<button className="btn btn-ghost" title="nach oben"
disabled={i === 0}
onClick={() => radioListe('hoch', e.id)}></button>
<button className="btn btn-ghost" title="nach unten"
disabled={i === radio.warteschlange.length - 1}
onClick={() => radioListe('runter', e.id)}></button>
<button className="btn btn-ghost danger" title="entfernen"
onClick={() => radioListe('weg', e.id)}></button>
</div>
</div>
))}
</div>
<div className="settings-actions">
<button className="btn btn-ghost danger"
onClick={() => radioListe('leeren')}>
Warteschlange leeren
</button>
</div>
</>
)}
{radioFehler && <p className="notice"> {radioFehler}</p>}
{/* yt-dlp
Steht sichtbar hier und nicht in einer Fussnote:
wenn YouTube klemmt, ist das fast immer die
Antwort, und Stille erklaert sich nicht selbst. */}
<details className="radio-yt">
<summary>
yt-dlp {radio.werkzeuge.ytdlp === false
? '❌ fehlt'
: radio.werkzeuge.ytdlp === null
? '… noch nicht geprüft'
: `${radio.werkzeuge.ytdlpFassung ?? 'da'}`}
{radio.werkzeuge.cookies ? ' · Cookies gesetzt' : ''}
</summary>
{radio.werkzeuge.letzterFehler && (
<>
<label>Zuletzt schiefgegangen</label>
<pre className="radio-fehler">{radio.werkzeuge.letzterFehler.text}</pre>
</>
)}
<p className="section-intro">
YouTube sperrt Server-Adressen inzwischen häufig
(Sign in to confirm you're not a bot"). Zwei Griffe helfen,
beide ohne neues Image: eine <code>cookies.txt</code> aus dem
Browser eines angemeldeten Kontos, und eigene Argumente
(Feld weiter unten in dieser Sektion).
</p>
<div className="field">
<label>
cookies.txt
<span className="field-hint">
{radio.werkzeuge.cookies
? `gesetzt, ${radio.werkzeuge.cookies.bytes} Bytes — leer speichern löscht sie`
: 'im Netscape-Format; leer = keine'}
</span>
</label>
<textarea rows={4} placeholder="# Netscape HTTP Cookie File&#10;.youtube.com TRUE / TRUE 0 …"
value={ytCookies} onChange={(e) => setYtCookies(e.target.value)} />
</div>
<div className="field" data-setting="ytdlp_args">
<label>
Eigene yt-dlp-Argumente
<span className="field-hint">
z. B. --extractor-args "youtube:player_client=android_vr"
</span>
</label>
<input type="text" value={form.ytdlp_args ?? ''}
onChange={(e) => setForm({ ...form, ytdlp_args: e.target.value })} />
</div>
<label>
Was tatsächlich rausgeht
<span className="field-hint">
aus Grundeinstellung, Cookies und deinen Argumenten
</span>
</label>
<pre className="radio-fehler">yt-dlp {radio.werkzeuge.argumente}</pre>
<div className="settings-actions">
<button className="btn btn-ghost" onClick={ytCookiesSpeichern}>
Cookies speichern
</button>
<button className="btn btn-ghost" disabled={radioLaedt}
onClick={ytAktualisieren}>
yt-dlp aktualisieren
</button>
</div>
<span className="field-hint">
Aktualisieren wirkt sofort, aber nur bis der Container neu
angelegt wird danach gilt wieder die Fassung aus dem Image.
Ein Deploy holt ohnehin die neueste.
</span>
</details>
</div>
)}
<p className="section-intro"> <p className="section-intro">
Adressen von Webradio-Strömen. <code>/radio</code> öffnet im Discord eine Adressen von Webradio-Strömen. <code>/radio</code> öffnet im Discord eine
Tafel mit genau dieser Liste zum Durchschalten. Jeder Icecast- oder Tafel mit genau dieser Liste zum Durchschalten. Jeder Icecast- oder
@@ -4089,6 +4446,116 @@ export default function Settings({ me }) {
// Vor dem Speichern hören, ob überhaupt etwas kommt eine tote Adresse // Vor dem Speichern hören, ob überhaupt etwas kommt eine tote Adresse
// fällt sonst erst auf, wenn jemand im Sprachkanal sitzt und nichts passiert. // fällt sonst erst auf, wenn jemand im Sprachkanal sitzt und nichts passiert.
/**
* Eine Aktion an den Bot schicken und den Stand uebernehmen.
*
* An einer Stelle, weil sonst jeder Knopf seine eigene Fehlerbehandlung
* bekaeme -- und einer davon irgendwann keine.
*/
async function radioSteuern(aktion, mehr = {}) {
setRadioFehler('');
setRadioLaedt(true);
try {
const d = await apiPost('/api/radio/steuern', {
aktion, guildId: radio?.guildId, kanalId: radioKanal || undefined, ...mehr,
});
// Nicht auf die naechste Abfrage warten: ein Knopf, der erst in
// fuenf Sekunden wirkt, fuehlt sich kaputt an.
setRadio((alt) => (alt ? { ...alt, ...d } : alt));
} catch (e) {
setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
} finally {
setRadioLaedt(false);
}
}
/** Einreihen -- Adresse direkt, Suchbegriff erst zur Auswahl */
async function radioEinreihen(treffer = null) {
const eingabe = radioEingabe.trim();
if (!treffer && !eingabe) return;
setRadioFehler('');
setRadioLaedt(true);
try {
const d = await apiPost('/api/radio/warteschlange', {
guildId: radio?.guildId,
kanalId: radioKanal || undefined,
...(treffer ? { treffer } : { eingabe }),
});
if (d.suche) {
// Nicht raten, welcher gemeint war -- fragen.
setRadioTreffer(d.treffer);
return;
}
setRadioTreffer(null);
setRadioEingabe('');
setRadio((alt) => (alt ? { ...alt, ...d } : alt));
setFeedback(d.angeworfen
? '\u25b6\ufe0f L\u00e4uft jetzt'
: `\u2705 ${d.eingereiht.length} eingereiht`);
} catch (e) {
setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
} finally {
setRadioLaedt(false);
}
}
/** Einen Eintrag verschieben oder entfernen */
async function radioListe(was, id) {
setRadioFehler('');
try {
const q = radio?.warteschlange ?? [];
let d;
if (was === 'weg') {
d = await apiDelete(`/api/radio/warteschlange/${id}?guildId=${radio?.guildId ?? ''}`);
} else if (was === 'leeren') {
d = await apiDelete(`/api/radio/warteschlange?guildId=${radio?.guildId ?? ''}`);
} else {
// Der Browser schickt die neue Reihenfolge als IDs; die Zahlen
// vergibt der Server. Was von hier kommt, ist eine Reihenfolge
// und sonst nichts.
const i = q.findIndex((e) => e.id === id);
const j = was === 'hoch' ? i - 1 : i + 1;
if (i < 0 || j < 0 || j >= q.length) return;
const ids = q.map((e) => e.id);
[ids[i], ids[j]] = [ids[j], ids[i]];
d = await apiPost('/api/radio/warteschlange/sortieren',
{ guildId: radio?.guildId, ids });
}
setRadio((alt) => (alt ? { ...alt, ...d } : alt));
} catch (e) {
setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
}
}
/** Die cookies.txt setzen oder loeschen */
async function ytCookiesSpeichern() {
setRadioFehler('');
try {
const d = await apiPut('/api/radio/youtube/cookies', { inhalt: ytCookies });
setYtCookies('');
setRadio((alt) => (alt
? { ...alt, werkzeuge: { ...alt.werkzeuge, cookies: d.cookies } }
: alt));
setFeedback(d.cookies ? '\u2705 Cookies gesetzt' : '\u2705 Cookies gel\u00f6scht');
} catch (e) {
setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
}
}
/** yt-dlp aktualisieren -- wirkt bis zum naechsten Neuanlegen des Containers */
async function ytAktualisieren() {
setRadioFehler('');
setRadioLaedt(true);
try {
const d = await apiPost('/api/radio/youtube/update', {});
setFeedback(`\u2705 yt-dlp ${d.fassung ?? ''}`);
} catch (e) {
setRadioFehler(e.body?.error ?? 'Das hat nicht geklappt.');
} finally {
setRadioLaedt(false);
}
}
async function senderPruefen() { async function senderPruefen() {
const url = senderDraft.url.trim(); const url = senderDraft.url.trim();
if (!url) return; if (!url) return;
+55
View File
@@ -3250,3 +3250,58 @@ button.with-icon, a.with-icon { justify-content: center; }
@media (max-width: 560px) { @media (max-width: 560px) {
.radio-form-row { grid-template-columns: 1fr; } .radio-form-row { grid-template-columns: 1fr; }
} }
/* ── Radio-Fernbedienung ──────────────────────────────────────────────── */
.radio-remote {
border: 1px solid var(--border);
background: var(--bg2);
padding: 1rem;
margin-bottom: 1.2rem;
display: flex;
flex-direction: column;
gap: .9rem;
}
.radio-now { border-left: 3px solid var(--accent); padding-left: .8rem; }
.radio-now-head { display: flex; gap: .7rem; align-items: flex-start; }
.radio-badge { font-size: 1.4rem; line-height: 1.2; }
.radio-now-text { min-width: 0; display: flex; flex-direction: column; gap: .15rem; }
.radio-now-text strong { color: var(--text); overflow-wrap: anywhere; }
.radio-now-sub { color: var(--text-dim); font-size: .72rem; overflow-wrap: anywhere; }
.radio-progress { display: flex; align-items: center; gap: .6rem; margin-top: .5rem; }
.radio-progress-bar { flex: 1; height: 4px; background: var(--bg3); overflow: hidden; }
.radio-progress-bar span {
display: block; height: 100%; background: var(--accent);
/* Der Balken springt sonst bei jeder Abfrage; fuenf Sekunden Abstand
zwischen zwei Staenden sieht ohne das aus wie ein Ruckeln. */
transition: width .6s linear;
}
.radio-progress-text { color: var(--text-dim); font-size: .68rem; font-variant-numeric: tabular-nums; }
.radio-add { display: flex; gap: .5rem; }
.radio-add input { flex: 1; min-width: 0; }
.radio-station-pick { max-width: 16rem; }
.radio-fehler {
background: var(--bg3);
border: 1px solid var(--border);
padding: .55rem .7rem;
font-size: .68rem;
/* Eine Fehlerzeile von yt-dlp ist lang und darf nicht die Seite
breiter machen -- sie bricht um statt zu schieben. */
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0 0 .6rem;
color: var(--text-dim);
}
.radio-yt { border-top: 1px solid var(--border); padding-top: .8rem; }
.radio-yt > summary { cursor: pointer; color: var(--text-dim); font-size: .74rem; }
.radio-yt > summary:hover { color: var(--text); }
.radio-yt textarea { width: 100%; font-family: var(--mono, monospace); font-size: .68rem; }
.radio-yt .field { margin-top: .7rem; }
@media (max-width: 700px) {
.radio-add { flex-direction: column; }
.radio-station-pick { max-width: none; }
}
+19 -2
View File
@@ -25,6 +25,7 @@ import {
} from './wishes.js'; } from './wishes.js';
import { handleGiveawayAdminButton } from './giveaways.js'; import { handleGiveawayAdminButton } from './giveaways.js';
import { handleRadioInteraction } from './radio.js'; import { handleRadioInteraction } from './radio.js';
import { handleMusikInteraction } from './radio-musik.js';
import { import {
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote, removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
getGiveaway, toggleGiveawayEntry, giveawayEntries, getGiveaway, toggleGiveawayEntry, giveawayEntries,
@@ -49,12 +50,13 @@ import * as geburtstag from './commands/geburtstag.js';
import * as raid from './commands/raid.js'; import * as raid from './commands/raid.js';
import * as umfrage from './commands/umfrage.js'; import * as umfrage from './commands/umfrage.js';
import * as radio from './commands/radio.js'; import * as radio from './commands/radio.js';
import * as musik from './commands/musik.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute } // Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [ const commandModules = [
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
raid, umfrage, radio, raid, umfrage, radio, musik,
]; ];
export async function startBot() { export async function startBot() {
@@ -173,11 +175,26 @@ export async function startBot() {
// Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und // Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und
// antwortet in einem Rutsch — deshalb hier oben und nicht als // antwortet in einem Rutsch — deshalb hier oben und nicht als
// weiterer customId-Vergleich zwischen den anderen. // 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 { try {
if (await handleRadioInteraction(interaction)) return; if (await handleRadioInteraction(interaction)) return;
if (await handleMusikInteraction(interaction)) return;
} catch (error) { } catch (error) {
console.error('[radio] Interaktion:', 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; 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);
}
+7 -3
View File
@@ -8,6 +8,7 @@ import { moduleEnabled } from '../../modules.js';
import { listStations, getStation, setRadioState } from '../../db.js'; import { listStations, getStation, setRadioState } from '../../db.js';
import { import {
spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken, spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken,
radioQuelle,
} from '../radio.js'; } from '../radio.js';
export const data = new SlashCommandBuilder() export const data = new SlashCommandBuilder()
@@ -80,12 +81,15 @@ export async function execute(interaction) {
// Verbinden dauert länger als die drei Sekunden, die Discord gibt // Verbinden dauert länger als die drei Sekunden, die Discord gibt
await interaction.deferReply(); await interaction.deferReply();
let station = schonAn ? getStation(schonAn.stationId) : null; // Der Heimsender, nicht die laufende Quelle: läuft gerade ein
// YouTube-Titel, gehört in die Tafel trotzdem der Sender, auf den danach
// zurückgefallen wird.
let station = schonAn?.heimStationId ? getStation(schonAn.heimStationId) : null;
let titel = schonAn?.titel ?? null; let titel = schonAn?.titel ?? null;
if (!schonAn) { if (!schonAn) {
station = stationen[0]; station = stationen[0];
try { try {
await spielen(kanal, station); await spielen(kanal, radioQuelle(station));
} catch (error) { } catch (error) {
console.error('[radio]', error); console.error('[radio]', error);
await interaction.editReply(`${error.message || 'Verbindung in den Sprachkanal fehlgeschlagen.'}`); await interaction.editReply(`${error.message || 'Verbindung in den Sprachkanal fehlgeschlagen.'}`);
@@ -98,7 +102,7 @@ export async function execute(interaction) {
const jetzt = laeuft(interaction.guild.id); const jetzt = laeuft(interaction.guild.id);
const nachricht = await interaction.editReply({ const nachricht = await interaction.editReply({
embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId, embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId,
{ tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden() })], { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegVorhanden(), quelle: jetzt?.quelle })],
components: tafelKomponenten(stationen, station?.id), components: tafelKomponenten(stationen, station?.id),
}); });
+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;
+342 -42
View File
@@ -1,9 +1,21 @@
// Radio im Sprachkanal. // Radio im Sprachkanal — Webradio und, seit dem 28.08.2026, auch YouTube.
// //
// Bewusst nur Webradio und kein YouTube: genau daran sind Groovy und Rythm // Hier stand jahrelang „bewusst nur Webradio und kein YouTube", mit der
// gestorben, und was davon übrig ist, ist ein Dauerlauf gegen kaputte // Begründung, genau daran seien Groovy und Rythm gestorben. Der Einwand ist
// Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine // nicht widerlegt, die Entscheidung ist trotzdem gefallen. Was bleibt, ist
// Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet. // die Konsequenz daraus: YouTube ist der Teil, der von aussen kaputtgehen
// kann, also muss ein Ausfall *sichtbar* sein statt still. Was yt-dlp weiss,
// steht in `youtube.js`, samt Fehlerzeile fürs Panel.
//
// Der ganze Unterschied zwischen den beiden Quellen ist einer: **ein Sender
// endet nie, ein Titel schon.** Daran hängt alles Weitere — die
// Warteschlange, das Überspringen, und die Frage, was ein `Idle` des Spielers
// bedeutet: beim Sender „die Leitung ist weg" (neu aufbauen), bei YouTube
// „fertig, der nächste bitte".
//
// Deshalb gibt es *eine* Abspielfunktion und *einen* `Idle`-Melder, die nach
// der Art der Quelle verzweigen, und keinen zweiten Pfad daneben. Zwei
// Pfade, die dasselbe tun sollen, driften auseinander.
// //
// Die Tonkette: ffmpeg holt den Sender und gibt rohes PCM aus, die // Die Tonkette: ffmpeg holt den Sender und gibt rohes PCM aus, die
// Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief // Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief
@@ -18,6 +30,10 @@
// und kostet gemessene 0,3 % eines Kerns fürs Kodieren eines Dauerstroms. // und kostet gemessene 0,3 % eines Kerns fürs Kodieren eines Dauerstroms.
// Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja // Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja
// schon Abtastwerte. // schon Abtastwerte.
//
// Bei YouTube hängt vor ffmpeg noch yt-dlp: es lädt und schiebt die Bytes
// durch eine Rohrleitung. Warum nicht die fertige Adresse an ffmpeg, steht
// im Kopf von `youtube.js` — kurz: die läuft ab.
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags, ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags,
@@ -35,8 +51,10 @@ import {
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { import {
listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting, listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting,
queueNext, queueAdd, queueVor,
} from '../db.js'; } from '../db.js';
import { senderGrundstock } from './radio-sender.js'; import { senderGrundstock } from './radio-sender.js';
import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.js';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
@@ -61,8 +79,38 @@ export function ffmpegPruefen() {
} }
// Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im // Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im
// Speicher; was davon einen Neustart überlebt, steht in `radio_state`. // Speicher; was davon einen Neustart überlebt, steht in `radio_state` und in
const laufend = new Map(); // guildId → { player, ffmpeg, stationId, channelId, titel } // der Warteschlange.
const laufend = new Map();
// guildId → { player, ffmpeg, ytdlp, resource, quelle, heimStationId,
// channelId, titel, seit, tonLaeuft, client }
// ── Die Quelle ────────────────────────────────────────────────────────────
//
// Zwei Arten, eine Form. Alles, was danach kommt, fragt nur noch `art` —
// damit gibt es genau eine Stelle, an der sich Sender und Titel unterscheiden,
// statt eines zweiten Abspielweges neben dem ersten.
/** @param {object} station Zeile aus `radio_stations` */
export const radioQuelle = (station) => ({ art: 'radio', station });
/** @param {object} titel Eintrag aus der Warteschlange (oder von `aufloesen`) */
export const youtubeQuelle = (titel) => ({ art: 'youtube', titel });
/** Wie die Quelle heisst, wenn man sie hinschreibt */
const quelleName = (q) => (q?.art === 'youtube' ? q.titel?.titel : q?.station?.name) ?? null;
// Wer gerade mitten im Quellenwechsel steckt. Als eigene Menge und nicht als
// Merker am Eintrag: `spielen` ersetzt den Eintrag, ein Merker daran wäre
// nach dem Wechsel weg — und genau während des Wechsels wird er gebraucht.
const wechselt = new Set();
// Wie oft hintereinander ein Titel keinen Ton gebracht hat, je Server.
//
// Ohne diese Zählung rauscht eine Warteschlange mit einem kaputten Eintrag
// in Millisekunden durch: kein Ton → sofort `Idle` → nächster → kein Ton.
// Nach dieser Zahl Fehlschlägen in Folge wird angehalten und gesagt, warum.
const fehlschlaege = new Map();
const FEHLSCHLAG_GRENZE = 3;
// ── Sender-Titel ────────────────────────────────────────────────────────── // ── Sender-Titel ──────────────────────────────────────────────────────────
@@ -122,18 +170,23 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
// ── Ton ─────────────────────────────────────────────────────────────────── // ── Ton ───────────────────────────────────────────────────────────────────
/** /**
* ffmpeg als Tonquelle. * ffmpeg als Wandler.
* *
* `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein * `-reconnect` gilt nur für Adressen aus dem Netz und ist dort kein Beiwerk:
* einzelner Netz-Schluckauf würde ihn sonst endgültig beenden. * ein Radiostream läuft stundenlang, und ein einzelner Netz-Schluckauf würde
* ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option
* sinnlos — es gibt nichts, wohin sich neu verbinden liesse.
*
* @param {string} eingabe Adresse oder `pipe:0`
* @param {{netz: boolean}} opts
*/ */
function tonquelle(url) { function wandler(eingabe, { netz }) {
const ff = spawn(FFMPEG, [ const ff = spawn(FFMPEG, [
// Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher // Alles vor -i gilt fuer die Quelle. `-analyzeduration 0` stand vorher
// dahinter und wurde dort als Ausgabe-Option gelesen, also ignoriert. // dahinter und wurde dort als Ausgabe-Option gelesen, also ignoriert.
'-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', ...(netz ? ['-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5'] : []),
'-analyzeduration', '0', '-loglevel', 'error', '-analyzeduration', '0', '-loglevel', 'error',
'-i', url, '-i', eingabe,
'-vn', // manche Sender legen ein Coverbild bei '-vn', // manche Sender legen ein Coverbild bei
// Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke // Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke
// drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet — // drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet —
@@ -152,6 +205,38 @@ function tonquelle(url) {
return ff; return ff;
} }
/**
* Die Prozesskette für eine Quelle.
*
* Sender: ffmpeg holt selbst. YouTube: yt-dlp holt und schiebt durch ein Rohr.
*
* Die Fehlerbehandlung an den Rohrenden ist Pflicht und kein Übereifer: wird
* ffmpeg abgeräumt (Stopp, Überspringen, Wechsel), während yt-dlp noch
* schreibt, kommt ein EPIPE — und ein Strom ohne 'error'-Hörer reisst den
* ganzen Prozess mit.
*
* @returns {{ffmpeg: object, ytdlp: object|null}}
*/
function tonquelle(quelle) {
if (quelle.art !== 'youtube') {
return { ffmpeg: wandler(quelle.station.url, { netz: true }), ytdlp: null };
}
const yt = tonstrom(quelle.titel.url);
const ff = wandler('pipe:0', { netz: false });
yt.stdout.on('error', () => {});
ff.stdin.on('error', () => {});
yt.stdout.pipe(ff.stdin);
return { ffmpeg: ff, ytdlp: yt };
}
/** Beide Prozesse einer Kette abräumen — an einer Stelle, damit keiner stehenbleibt */
function ketteAbraeumen(eintrag) {
eintrag?.ffmpeg?.kill('SIGKILL');
// Ohne das lädt ein übersprungener Titel munter weiter: yt-dlp merkt vom
// Ende der Rohrleitung nichts, solange niemand es ihm sagt.
eintrag?.ytdlp?.kill('SIGKILL');
}
/** /**
* Der Adapter zwischen Discord-Verbindung und Sprachbibliothek — mit Protokoll. * Der Adapter zwischen Discord-Verbindung und Sprachbibliothek — mit Protokoll.
* *
@@ -406,8 +491,11 @@ export function stoppen(guildId) {
let abgeraeumt; let abgeraeumt;
if (s) { if (s) {
s.player.stop(true); s.player.stop(true);
s.ffmpeg?.kill('SIGKILL'); ketteAbraeumen(s);
laufend.delete(guildId); laufend.delete(guildId);
// Sonst zählt der nächste Start ab dem alten Stand weiter und bricht
// womöglich sofort wieder ab.
fehlschlaege.delete(guildId);
// Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es
// liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab. // liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab.
// //
@@ -433,13 +521,21 @@ export async function radioBeenden() {
} }
/** /**
* Sender im Sprachkanal starten (oder umschalten). * Eine Quelle im Sprachkanal starten (oder umschalten).
* *
* Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler * Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler
* bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für * bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für
* die Zuhörer eine hörbare Pause. * die Zuhörer eine hörbare Pause.
*
* Dass hier eine *Quelle* ankommt und kein Sender, ist der Kern des Umbaus
* vom 28.08.2026: Sender und YouTube-Titel gehen denselben Weg, und der
* Unterschied lebt in `tonquelle()` und im `Idle`-Melder — nicht in zwei
* Abspielfunktionen, die auseinanderdriften.
*
* @param {object} kanal Sprachkanal
* @param {{art: string}} quelle aus `radioQuelle()` oder `youtubeQuelle()`
*/ */
export async function spielen(kanal, station) { export async function spielen(kanal, quelle) {
const guildId = kanal.guild.id; const guildId = kanal.guild.id;
const vorher = laufend.get(guildId); const vorher = laufend.get(guildId);
@@ -551,8 +647,8 @@ export async function spielen(kanal, station) {
behaviors: { noSubscriber: NoSubscriberBehavior.Play }, behaviors: { noSubscriber: NoSubscriberBehavior.Play },
}); });
vorher?.ffmpeg?.kill('SIGKILL'); ketteAbraeumen(vorher);
const ff = tonquelle(station.url); const { ffmpeg: ff, ytdlp } = tonquelle(quelle);
const resource = createAudioResource(ff.stdout, { const resource = createAudioResource(ff.stdout, {
inputType: StreamType.Raw, inputType: StreamType.Raw,
inlineVolume: true, // ohne das gibt es kein `resource.volume` inlineVolume: true, // ohne das gibt es kein `resource.volume`
@@ -579,34 +675,167 @@ export async function spielen(kanal, station) {
if (!vorher) { if (!vorher) {
player.on('error', (e) => console.error('[radio] Spieler:', e.message)); player.on('error', (e) => console.error('[radio] Spieler:', e.message));
// Ein Radiostream endet nie von selbst. Tut er es doch, ist die // Hier entscheidet sich, was ein Ende bedeutet — und das ist bei den
// Leitung weg — dann neu aufbauen statt still dazustehen. // beiden Quellen genau gegensätzlich:
//
// Sender → ein Radiostream endet nie von selbst. Tut er es doch,
// ist die Leitung weg: neu aufbauen statt dazustehen.
// YouTube → ein Titel endet immer. Das ist kein Fehler, sondern das
// Stichwort für den nächsten.
//
// Der Melder wird nur einmal je Spieler gehängt und überlebt jeden
// Quellenwechsel. Deshalb wird der aktuelle Stand hier frisch aus
// `laufend` geholt und nicht aus dem Abschluss: `kanal` und `quelle`
// von damals sind nach dem ersten Umschalten veraltet. (Vorher hing
// an dieser Stelle `kanal` fest — nach einem Kanalwechsel hätte der
// Wiederanlauf in den alten Kanal gespielt.)
player.on(AudioPlayerStatus.Idle, () => { player.on(AudioPlayerStatus.Idle, () => {
const jetzt = laufend.get(guildId); const jetzt = laufend.get(guildId);
if (!jetzt) return; if (!jetzt) return;
if (jetzt.quelle?.art === 'youtube') {
weiter(jetzt.client, guildId).catch((e) =>
console.error('[radio] Weiterschalten:', e.message));
return;
}
console.error('[radio] Strom abgerissen, neuer Versuch'); console.error('[radio] Strom abgerissen, neuer Versuch');
const s = getStation(jetzt.stationId); const s = getStation(jetzt.quelle?.station?.id);
if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000); if (!s) return;
setTimeout(async () => {
const k = await jetzt.client.channels.fetch(jetzt.channelId).catch(() => null);
if (k) spielen(k, radioQuelle(s)).catch(() => {});
}, 3000);
}); });
} }
const eintrag = { const eintrag = {
player, ffmpeg: ff, resource, stationId: station.id, channelId: kanal.id, player, ffmpeg: ff, ytdlp, resource, quelle, channelId: kanal.id,
titel: null, seit: Date.now(), tonLaeuft: null, client: kanal.client, // Der Sender, auf den nach der Warteschlange zurückgefallen wird.
// Bei einem Sender ist das er selbst; bei einem YouTube-Titel bleibt
// stehen, was vorher lief — sonst wüsste niemand mehr, wohin zurück.
heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null),
titel: quelle.art === 'youtube' ? quelle.titel.titel : null,
seit: Date.now(), tonLaeuft: null, client: kanal.client,
}; };
laufend.set(guildId, eintrag); laufend.set(guildId, eintrag);
// Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen // Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen
// erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort — // erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort —
// besser als eine leere Zeile, während schon Musik läuft. // besser als eine leere Zeile, während schon Musik läuft.
statusAus = null; statusAus = null;
kanalStatusSetzen(kanal.client, kanal.id, `📻 ${station.name}`); kanalStatusSetzen(kanal.client, kanal.id,
quelle.art === 'youtube' ? `▶️ ${quelle.titel.titel}` : `📻 ${quelle.station.name}`);
beobachten.then((ok) => { beobachten.then((ok) => {
eintrag.tonLaeuft = ok; eintrag.tonLaeuft = ok;
if (!ok) console.error(`[radio] kein Ton von ${station.name} — ffmpeg da? ${ffmpegDa}`); // Ein Titel, der wirklich klingt, macht die Zählung der Fehlschläge
// gegenstandslos — sonst hielte ein alter Ausrutscher die Bremse an.
if (ok) fehlschlaege.delete(guildId);
if (!ok) {
console.error(`[radio] kein Ton von ${quelleName(quelle)}`
+ `ffmpeg da? ${ffmpegDa}${quelle.art === 'youtube' ? `, yt-dlp da? ${ytdlpVorhanden()}` : ''}`);
}
}); });
return verbindung; return verbindung;
} }
/**
* Der nächste Titel — oder zurück aufs Radio, oder raus.
*
* Die Reihenfolge ist die Entscheidung vom 28.08.2026: erst die
* Warteschlange, dann der Sender, der vor dem ersten Titel lief, und erst
* wenn es auch den nicht gibt, geht der Bot. Das Radio ist die Grundstellung,
* YouTube die Unterbrechung.
*
* Die Bremse davor ist kein Übereifer: ein Eintrag, der keinen Ton bringt,
* geht sofort wieder auf `Idle`. Ohne Zählung wäre eine Warteschlange mit
* einem kaputten Eintrag in Sekundenbruchteilen leer, und niemand hätte
* gesehen, warum.
*/
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);
// 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) {
const n = (fehlschlaege.get(guildId) ?? 0) + 1;
fehlschlaege.set(guildId, n);
console.error(`[radio] ohne Ton geblieben: ${quelleName(jetzt.quelle)} (${n}. in Folge)`);
if (n >= FEHLSCHLAG_GRENZE) {
console.error(`[radio] ${n} Titel hintereinander ohne Ton — angehalten. `
+ 'Ursache steht im Panel unter Radio → yt-dlp.');
fehlschlaege.delete(guildId);
stoppen(guildId);
clearRadioState(guildId);
await tafelnMelden(client, guildId);
return null;
}
}
const kanal = await client.channels.fetch(kanalId).catch(() => null);
if (!kanal?.isVoiceBased()) return null;
const naechster = queueNext(guildId);
const quelle = naechster
? youtubeQuelle(naechster)
: (() => {
// 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;
})();
if (!quelle) {
// Nichts mehr da und kein Sender, auf den zurückzufallen wäre.
stoppen(guildId);
clearRadioState(guildId);
await tafelnMelden(client, guildId);
return null;
}
try {
await spielen(kanal, quelle);
} catch (error) {
// Der Eintrag ist oben schon aus der Liste geholt worden. Kommt
// die Sprachverbindung nicht zustande, waere er damit weg —
// jemand reiht etwas ein, der Beitritt scheitert an einem Recht,
// und der Wunsch ist verschwunden. Also zurück an dieselbe
// Stelle: anhängen und nach vorn holen.
if (naechster) {
const neu = queueAdd(guildId, naechster);
queueVor(guildId, neu);
}
throw error;
}
const tafel = radioState(guildId);
setRadioState({
guildId,
channelId: kanalId,
stationId: laufend.get(guildId)?.heimStationId ?? null,
panelChannelId: tafel?.panel_channel_id,
panelMessageId: tafel?.panel_message_id,
});
// Beim Rückfall auf den Sender steht sonst noch der letzte Liedtitel
// in der Statuszeile und behauptet, er liefe.
if (quelle.art === 'radio') {
const { titel } = await titelHolen(quelle.station.url);
titelMerken(client, guildId, titel);
}
await tafelnMelden(client, guildId);
return quelle;
} finally {
wechselt.delete(guildId);
}
}
// ── Steuertafel ─────────────────────────────────────────────────────────── // ── Steuertafel ───────────────────────────────────────────────────────────
// Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je // Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je
@@ -703,10 +932,22 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) {
.setFooter({ text: brandFooter('RADIO') }) .setFooter({ text: brandFooter('RADIO') })
.setTimestamp(); .setTimestamp();
const laut = radioLautstaerke(); const laut = radioLautstaerke();
if (!station) { // Läuft ein YouTube-Titel, ist der Sender nur pausiert und nicht aus.
// Das gehört hierhin, sonst behauptet die Radio-Tafel „nichts läuft",
// während im Kanal hörbar etwas läuft.
const ytLaeuft = stand.quelle?.art === 'youtube';
if (!station && !ytLaeuft) {
return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n' return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n'
+ `Startet mit ${lautText(laut)} — vorab einstellbar.`); + `Startet mit ${lautText(laut)} — vorab einstellbar.`);
} }
if (ytLaeuft) {
embed.setDescription(`▶️ **${stand.quelle.titel.titel}**${kanalId ? ` in <#${kanalId}>` : ''}\n`
+ (station
? `_danach zurück auf **${station.name}**_`
: '_aus der Warteschlange — `/musik` zeigt sie_'));
embed.addFields({ name: 'Lautstärke', value: lautText(laut), inline: true });
return embed;
}
embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`); embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`);
// Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier, // Stille ist kein Zustand, den man raten soll. Kommt kein Ton, steht hier,
@@ -731,7 +972,7 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) {
} }
/** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */ /** 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); const stand = radioState(guildId);
if (!stand?.panel_message_id) return; if (!stand?.panel_message_id) return;
const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null); const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null);
@@ -739,14 +980,36 @@ async function tafelAktualisieren(client, guildId) {
if (!nachricht) return; if (!nachricht) return;
const jetzt = laufend.get(guildId); const jetzt = laufend.get(guildId);
const station = jetzt ? getStation(jetzt.stationId) : null; // Welcher Sender in der Radio-Tafel angehakt ist, richtet sich nach dem
// Heimsender und nicht nach der laufenden Quelle: während ein
// YouTube-Titel läuft, soll dort weiter stehen, wohin zurückgefallen wird.
const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null;
await nachricht.edit({ await nachricht.edit({
embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId, embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId,
{ tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })],
components: tafelKomponenten(listStations(), jetzt?.stationId), components: tafelKomponenten(listStations(), station?.id),
}).catch(() => {}); }).catch(() => {});
} }
// Wer sonst noch neu gezeichnet werden will, wenn sich etwas geändert hat.
//
// Eine Anmeldung statt eines Imports, weil die YouTube-Tafel `radio.js`
// braucht und `radio.js` sie sonst zurück — ein Ringschluss zwischen zwei
// Dateien, den niemand beim Lesen erwartet. So zeigt die Abhängigkeit in eine
// Richtung: `radio-musik.js` kennt das Radio, das Radio kennt nur „jemand
// möchte Bescheid".
const melder = [];
export function beiAenderung(fn) { melder.push(fn); }
/** Alle Tafeln neu zeichnen — die Radio-Tafel und alles Angemeldete */
export async function tafelnMelden(client, guildId) {
await tafelAktualisieren(client, guildId).catch(() => {});
for (const fn of melder) {
await fn(client, guildId).catch((e) =>
console.error('[radio] Tafel zeichnen:', e.message));
}
}
// ── Anbindung ───────────────────────────────────────────────────────────── // ── Anbindung ─────────────────────────────────────────────────────────────
/** Ist ausser dem Bot noch jemand im Kanal? */ /** Ist ausser dem Bot noch jemand im Kanal? */
@@ -765,6 +1028,16 @@ export function startRadio(client) {
+ 'Im Container nachinstallieren oder das Image neu bauen.'); + 'Im Container nachinstallieren oder das Image neu bauen.');
}); });
// Dieselbe Frage für yt-dlp, und aus demselben Grund: ohne die Antwort
// sähe ein fehlendes yt-dlp aus wie „YouTube geht nicht mehr". Ein
// fehlendes yt-dlp lässt das Radio unberührt — deshalb ist das eine
// Meldung und kein Abbruch.
ytdlpPruefen().then((da) => {
if (da) return;
console.error('[radio] yt-dlp fehlt — Sender laufen weiter, YouTube nicht. '
+ 'Im Panel unter Radio → yt-dlp steht, was fehlt.');
});
// Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert // Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert
// selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte. // selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte.
setInterval(async () => { setInterval(async () => {
@@ -773,13 +1046,17 @@ export function startRadio(client) {
// seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig. // seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig.
try { try {
for (const [guildId, s] of laufend) { for (const [guildId, s] of laufend) {
const station = getStation(s.stationId); // YouTube hat keine Icy-Metadaten und braucht sie nicht: der
// Titel stand schon fest, als er eingereiht wurde. Eine
// Abfrage dagegen wäre eine Anfrage ins Leere.
if (s.quelle?.art !== 'radio') continue;
const station = getStation(s.quelle.station.id);
if (!station) continue; if (!station) continue;
const { titel } = await titelHolen(station.url); const { titel } = await titelHolen(station.url);
// Nur bei echter Änderung neu zeichnen — und nur dann den // Nur bei echter Änderung neu zeichnen — und nur dann den
// Kanal-Status anfassen. Discord begrenzt beides. // Kanal-Status anfassen. Discord begrenzt beides.
if (titelMerken(client, guildId, titel)) { if (titelMerken(client, guildId, titel)) {
await tafelAktualisieren(client, guildId); await tafelnMelden(client, guildId);
} }
} }
} catch (error) { } catch (error) {
@@ -797,7 +1074,10 @@ export function startRadio(client) {
if (jemandDa(kanal)) return; if (jemandDa(kanal)) return;
stoppen(vorher.guild.id); stoppen(vorher.guild.id);
clearRadioState(vorher.guild.id); clearRadioState(vorher.guild.id);
await tafelAktualisieren(client, vorher.guild.id); // Die Warteschlange bleibt absichtlich stehen: der Kanal hat sich
// geleert, die Wünsche sind deshalb nicht hinfällig. Wer sie los
// sein will, drückt „Leeren".
await tafelnMelden(client, vorher.guild.id);
} catch (error) { } catch (error) {
console.error('[radio] Sprachkanal-Wechsel:', error.message); console.error('[radio] Sprachkanal-Wechsel:', error.message);
} }
@@ -808,8 +1088,16 @@ export function startRadio(client) {
client.once(Events.ClientReady, async () => { client.once(Events.ClientReady, async () => {
if (!moduleEnabled('radio')) return; if (!moduleEnabled('radio')) return;
for (const stand of radioStates()) { for (const stand of radioStates()) {
// Erst die Warteschlange, dann der Sender.
//
// Mitten in einem Titel wieder einzusteigen geht nicht — es gibt
// keine gespeicherte Position. Und andersherum käme die Liste nie
// dran: ein Sender endet nicht, also gäbe es kein `Idle`, das
// weiterschaltet. Deshalb diese Reihenfolge und nicht die andere.
const naechster = queueNext(stand.guild_id);
const station = stand.station_id ? getStation(stand.station_id) : null; const station = stand.station_id ? getStation(stand.station_id) : null;
const kanal = station ? await client.channels.fetch(stand.channel_id).catch(() => null) : null; const quelle = naechster ? youtubeQuelle(naechster) : (station ? radioQuelle(station) : null);
const kanal = quelle ? await client.channels.fetch(stand.channel_id).catch(() => null) : null;
if (!kanal?.isVoiceBased() || !jemandDa(kanal)) { if (!kanal?.isVoiceBased() || !jemandDa(kanal)) {
// Niemand mehr da, oder der Sender wurde gelöscht — dann gilt // Niemand mehr da, oder der Sender wurde gelöscht — dann gilt
// der Merker als verbraucht statt in einen leeren Kanal zu gehen. // der Merker als verbraucht statt in einen leeren Kanal zu gehen.
@@ -817,9 +1105,13 @@ export function startRadio(client) {
continue; continue;
} }
try { try {
await spielen(kanal, station); await spielen(kanal, quelle);
await tafelAktualisieren(client, stand.guild_id); // Der Heimsender überlebt den Neustart nur über `radio_state`;
console.log(`[radio] wieder in ${kanal.name}: ${station.name}`); // ohne diese Zeile fiele die Liste danach ins Leere.
const s = laufend.get(stand.guild_id);
if (s && station) s.heimStationId = station.id;
await tafelnMelden(client, stand.guild_id);
console.log(`[radio] wieder in ${kanal.name}: ${quelleName(quelle)}`);
} catch (e) { } catch (e) {
console.error('[radio] Rückkehr fehlgeschlagen:', e.message); console.error('[radio] Rückkehr fehlgeschlagen:', e.message);
clearRadioState(stand.guild_id); clearRadioState(stand.guild_id);
@@ -860,7 +1152,10 @@ export async function handleRadioInteraction(interaction) {
}); });
return true; return true;
} }
await spielen(ziel, station); // Ein Sender von Hand gewählt schlägt die Warteschlange: wer hier
// klickt, will jetzt diesen Sender hören und nicht erst nach drei
// eingereihten Titeln. Die Liste bleibt stehen und kommt danach dran.
await spielen(ziel, radioQuelle(station));
setRadioState({ setRadioState({
guildId, guildId,
channelId: ziel.id, channelId: ziel.id,
@@ -874,11 +1169,16 @@ export async function handleRadioInteraction(interaction) {
} }
const jetzt = laufend.get(guildId); const jetzt = laufend.get(guildId);
const station = jetzt ? getStation(jetzt.stationId) : null; const station = jetzt?.heimStationId ? getStation(jetzt.heimStationId) : null;
await interaction.update({ await interaction.update({
embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId, embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId,
{ tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa })], { tonLaeuft: jetzt?.tonLaeuft, ffmpeg: ffmpegDa, quelle: jetzt?.quelle })],
components: tafelKomponenten(listStations(), jetzt?.stationId), components: tafelKomponenten(listStations(), station?.id),
}).catch(() => {}); }).catch(() => {});
// Die zweite Tafel zeigt dieselbe Wirklichkeit und muss mitgezogen
// werden — sonst behauptet sie nach einem Stopp weiter, es liefe etwas.
for (const fn of melder) {
await fn(interaction.client, guildId).catch(() => {});
}
return true; return true;
} }
+378
View File
@@ -0,0 +1,378 @@
// YouTube als zweite Tonquelle — alles, was yt-dlp kennt, steht hier.
//
// Der Kopf von `radio.js` hat YouTube jahrelang ausgeschlossen, und die
// Begründung war nicht falsch: Groovy und Rythm sind daran gestorben, und ein
// Extraktor ist ein bewegliches Ziel. Die Entscheidung ist am 28.08.2026
// gefallen, YouTube kommt trotzdem rein — dann aber so gebaut, dass ein
// Ausfall *sichtbar* ist statt Stille im Sprachkanal.
//
// Drei Bauentscheidungen, die man sonst später rät:
//
// 1. **yt-dlp holt selbst, ffmpeg bekommt ein Rohr.**
// Der bequeme Weg wäre `yt-dlp -g` und die fertige Adresse an ffmpeg. Der
// ist eine Falle: googlevideo-Adressen laufen ab und hängen an der IP, die
// sie geholt hat. Das schlägt nach zehn Minuten zu und sieht dann aus wie
// ein Netzproblem. Also `-o -` in eine Rohrleitung — Fragmente, Drosselung
// und Ablauf bleiben yt-dlps Problem, und dafür ist es gebaut.
//
// 2. **Das Format wird ausdrücklich gesetzt.**
// Nachgelesen im yt-dlp-README (Fassung 2026.08.19, gelesen am 28.08.2026):
// beim Strömen nach stdout ändert sich die Vorgabe auf
// `best/bestvideo+bestaudio` — also mit Bild. Wer das nicht überschreibt,
// lädt für einen Tonstrom das ganze Video. `bestaudio/best` ist gemeint;
// `bestaudio*` steht im README ausdrücklich als „Do not use!".
//
// 3. **stderr wird aufgehoben, nicht weggeworfen.**
// Genau hier entsteht sonst der Fehler, den niemand deuten kann: YouTube
// antwortet „Sign in to confirm you're not a bot" oder „HTTP Error 403",
// yt-dlp bricht ab, der Bot sitzt still im Kanal. Die letzte Fehlerzeile
// wird deshalb festgehalten und im Panel angezeigt.
//
// Zur Lage, damit sie niemand für einen Baufehler hier hält: YouTube verlangt
// inzwischen für die meisten Clients einen PO-Token (nachgelesen im
// yt-dlp-Wiki „PO Token Guide", 28.08.2026 — NICHT auf diesem Server
// gemessen). Rechenzentrums-Adressen werden zusätzlich gern geblockt. Dagegen
// gibt es hier zwei Griffe, beide ohne Neubau des Abbilds: eine `cookies.txt`
// und ein Feld für eigene Argumente.
import { spawn } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { bildOrdner } from './bilder.js';
import { ytdlpArgumente } from '../runtime-settings.js';
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
/** Neben der SQLite, also auf dem Volume — ein Deploy soll die Kekse behalten */
export const RADIO_ORDNER = bildOrdner('radio');
export const COOKIE_DATEI = join(RADIO_ORDNER, 'yt-cookies.txt');
// Wie viele Treffer eine Suche zurückgibt. Fünf, weil ein Discord-Auswahlmenü
// mit fünf Zeilen noch lesbar ist und die Suche danach spürbar länger dauert.
export const TREFFER = 5;
// Obergrenze beim Einreihen einer Playlist. Wer 400 Titel einreiht, meint es
// nicht so — und die Tafel könnte sie ohnehin nicht zeigen.
export const PLAYLIST_MAX = 50;
// ── Ist yt-dlp überhaupt da? ──────────────────────────────────────────────
//
// Dieselbe Frage wie bei ffmpeg und aus demselben Grund: ohne die Antwort
// setzt sich der Bot hin und schweigt. Einmal beim Start geprüft, danach im
// Panel angesagt statt nur ins Log geschrieben.
let ytdlpDa = null; // null = noch nicht geprüft
let fassung = null;
export const ytdlpVorhanden = () => ytdlpDa;
export const ytdlpFassung = () => fassung;
export function ytdlpPruefen() {
return new Promise((fertig) => {
let text = '';
const p = spawn(YTDLP, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
p.stdout.on('data', (d) => { text += d.toString(); });
p.on('error', () => { ytdlpDa = false; fassung = null; fertig(false); });
p.on('close', (code) => {
ytdlpDa = code === 0;
fassung = ytdlpDa ? text.trim().split('\n')[0] || null : null;
fertig(ytdlpDa);
});
});
}
// ── Der letzte Fehler, im Klartext ────────────────────────────────────────
//
// Eine einzelne Zeile, absichtlich global und nicht je Server: wenn YouTube
// blockt, blockt es für alle, und die Ursache ist dieselbe. Sie steht im
// Panel, damit niemand aus Stille auf einen Grund schliessen muss.
let letzteMeldung = null; // { text, zeit }
export const letzterFehler = () => letzteMeldung;
export const fehlerVergessen = () => { letzteMeldung = null; };
/**
* Aus yt-dlps stderr die eine Zeile ziehen, die etwas aussagt.
*
* yt-dlp schreibt Warnungen und den eigentlichen Fehler in denselben Strom.
* Die letzte `ERROR:`-Zeile ist die brauchbare; gibt es keine, tut es die
* letzte nicht-leere Zeile. Ohne diese Auswahl steht im Panel gern eine
* Warnung über Untertitel, während der Abbruch drei Zeilen höher stand.
*/
export function fehlerZeile(stderr) {
const zeilen = String(stderr ?? '').split('\n').map((z) => z.trim()).filter(Boolean);
if (zeilen.length === 0) return null;
const echt = zeilen.filter((z) => /^ERROR:/i.test(z));
return (echt.at(-1) ?? zeilen.at(-1)).slice(0, 400);
}
function fehlerMerken(text) {
if (!text) return;
letzteMeldung = { text, zeit: new Date().toISOString() };
console.error('[youtube]', text);
}
// ── Cookies ───────────────────────────────────────────────────────────────
export const cookiesVorhanden = () => existsSync(COOKIE_DATEI);
/** Wie gross und wie alt — damit das Panel mehr sagen kann als „ja" */
export function cookiesStand() {
if (!cookiesVorhanden()) return null;
try {
const s = statSync(COOKIE_DATEI);
return { bytes: s.size, geaendert: s.mtime.toISOString() };
} catch {
return null;
}
}
/**
* Cookies setzen oder löschen. Leerer Text = löschen.
*
* Geprüft wird nur die Form, nicht der Inhalt: eine Netscape-Cookie-Datei
* beginnt mit einer Kennzeile. Wer eine JSON-Ausgabe aus einer Erweiterung
* hineinkopiert, bekommt sonst eine Datei, die yt-dlp wortlos ignoriert —
* und sucht den Fehler dann bei YouTube.
*/
export async function cookiesSchreiben(text) {
const inhalt = String(text ?? '').trim();
if (!inhalt) {
await rm(COOKIE_DATEI, { force: true });
return { gesetzt: false };
}
if (!/^#\s*(Netscape|HTTP Cookie File)/i.test(inhalt)) {
throw new Error('Das sieht nicht nach einer Netscape-Cookie-Datei aus — '
+ 'die erste Zeile muss „# Netscape HTTP Cookie File" sein.');
}
await writeFile(COOKIE_DATEI, `${inhalt}\n`, { mode: 0o600 });
return { gesetzt: true };
}
// ── Argumente ─────────────────────────────────────────────────────────────
/**
* Eine Kommandozeile in einzelne Argumente zerlegen, mit Anführungszeichen.
*
* Gebraucht, weil `--extractor-args "youtube:player_client=android_vr"` ein
* Argument ist und nicht zwei. Bewusst kein Umweg über eine Shell: das wäre
* eine Kommandozeile, die aus dem Panel kommt, und die soll nichts anderes
* starten können als yt-dlp mit Argumenten.
*/
export function argumenteZerlegen(text) {
const raus = [];
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
let treffer;
while ((treffer = re.exec(String(text ?? ''))) !== null) {
raus.push(treffer[1] ?? treffer[2] ?? treffer[3]);
}
return raus;
}
/**
* Was bei jedem Aufruf mitgeht.
*
* `--ignore-config` steht bewusst dabei: yt-dlp liest sonst Konfigurationen
* aus dem Dateisystem, und im Container wäre das eine unsichtbare Quelle für
* Verhalten, das hier niemand geschrieben hat.
*/
function grundArgumente() {
const args = [
'--ignore-config',
'--no-warnings',
'--no-colors',
'--socket-timeout', '15',
];
if (cookiesVorhanden()) args.push('--cookies', COOKIE_DATEI);
args.push(...argumenteZerlegen(ytdlpArgumente()));
return args;
}
/** Die Argumentliste, wie sie tatsächlich rausgeht — fürs Panel, ohne Raten */
export function argumenteVorschau() {
return grundArgumente().join(' ');
}
// ── Auskunft holen ────────────────────────────────────────────────────────
/**
* yt-dlp aufrufen und auf das Ende warten.
*
* Mit eigener Frist: ein Aufruf, der auf eine Sperre läuft, kann sehr lange
* hängen, und eine Discord-Interaktion ist nach drei Sekunden weg. Lieber
* abbrechen und sagen, dass abgebrochen wurde.
*/
function laufen(args, { timeoutMs = 25_000 } = {}) {
return new Promise((fertig) => {
let aus = '';
let fehler = '';
let abgebrochen = false;
const p = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const frist = setTimeout(() => { abgebrochen = true; p.kill('SIGKILL'); }, timeoutMs);
p.stdout.on('data', (d) => { aus += d.toString(); });
p.stderr.on('data', (d) => { fehler += d.toString(); });
p.on('error', (e) => {
clearTimeout(frist);
fertig({ code: -1, aus: '', fehler: `yt-dlp startet nicht: ${e.message}`, abgebrochen });
});
p.on('close', (code) => {
clearTimeout(frist);
fertig({
code,
aus,
fehler: abgebrochen ? `Zeitüberschreitung nach ${timeoutMs / 1000} s` : fehler,
abgebrochen,
});
});
});
}
// Eine Zeile Ausgabe je Eintrag, als JSON. Das ist die Form, die yt-dlp selbst
// für Programme empfiehlt („avoid parsing the normal stdout"), und sie ist
// deutlich sparsamer als `-J`: die volle Auskunft zu einem Video enthält jede
// Formatvariante und geht in die Hunderttausende Zeichen.
const SPALTEN = '%(.{id,title,duration,webpage_url,url,uploader,thumbnail,live_status})j';
/** Eine Ausgabezeile in einen Eintrag übersetzen; null, wenn unbrauchbar */
function eintragLesen(zeile) {
let d;
try {
d = JSON.parse(zeile);
} catch {
return null;
}
if (!d?.id) return null;
// Bei `--flat-playlist` fehlt `webpage_url` und stattdessen steht die
// Adresse in `url`. Ohne diesen Rückfall wären Playlist-Einträge lauter
// Titel ohne Ziel — sie liessen sich einreihen und nicht abspielen.
const url = d.webpage_url || d.url || `https://www.youtube.com/watch?v=${d.id}`;
return {
video_id: String(d.id),
url,
titel: String(d.title ?? 'Ohne Titel').slice(0, 200),
// Livestreams haben keine Dauer. `null` heisst hier genau das und wird
// in der Tafel als „live" gezeigt — nicht als „0:00".
dauer: Number.isFinite(d.duration) ? Math.round(d.duration) : null,
kanal: d.uploader ? String(d.uploader).slice(0, 120) : null,
thumb: typeof d.thumbnail === 'string' ? d.thumbnail : null,
live: d.live_status === 'is_live',
};
}
/** Sieht das nach einer Adresse aus — oder ist es ein Suchbegriff? */
export const istAdresse = (text) => /^https?:\/\//i.test(String(text ?? '').trim());
/** Zeigt diese YouTube-Adresse auf eine Playlist? */
export const istPlaylist = (text) => /[?&]list=/.test(String(text ?? ''));
/**
* Eingabe auflösen: Adresse, Playlist oder Suchbegriff.
*
* Eine Funktion für alle drei, weil das Panel und die Discord-Tafel dieselbe
* Frage stellen. Zwei Auflöser nebeneinander wären zwei Gelegenheiten, sich
* beim Format zu unterscheiden.
*
* @returns {Promise<{treffer: object[], suche: boolean, fehler: string|null}>}
*/
export async function aufloesen(eingabe, { max = TREFFER } = {}) {
const text = String(eingabe ?? '').trim();
if (!text) return { treffer: [], suche: false, fehler: 'Nichts eingegeben.' };
if (ytdlpDa === false) {
return { treffer: [], suche: false, fehler: 'Auf dem Server fehlt yt-dlp.' };
}
const suche = !istAdresse(text);
const args = [...grundArgumente(), '--print', SPALTEN];
if (suche) {
args.push(`ytsearch${Math.max(1, Math.min(25, max))}:${text}`);
} else if (istPlaylist(text)) {
// Flach und gedeckelt: eine Playlist einzeln aufzulösen dauert je
// Eintrag eine Anfrage — bei fünfzig Titeln ist die Interaktion längst
// abgelaufen, bevor der erste Ton kommt.
args.push('--flat-playlist', '-I', `1:${PLAYLIST_MAX}`, text);
} else {
args.push('--no-playlist', text);
}
const { code, aus, fehler } = await laufen(args, { timeoutMs: suche ? 25_000 : 20_000 });
const treffer = aus.split('\n').map((z) => z.trim()).filter(Boolean)
.map(eintragLesen).filter(Boolean);
if (treffer.length > 0) return { treffer, suche, fehler: null };
// Kein Treffer *und* kein Fehlercode heisst wirklich „nichts gefunden".
// Alles andere ist ein Fehler und wird als solcher gemeldet — eine leere
// Liste, die in Wahrheit ein Abbruch war, ist die schlimmste Auskunft.
const meldung = fehlerZeile(fehler);
if (code === 0 && !meldung) {
return {
treffer: [], suche,
fehler: suche ? 'Nichts gefunden.' : 'Dazu gibt es nichts abzuspielen.',
};
}
fehlerMerken(meldung ?? `yt-dlp endete mit Code ${code}`);
return { treffer: [], suche, fehler: meldung ?? `yt-dlp endete mit Code ${code}` };
}
// ── Ton ───────────────────────────────────────────────────────────────────
/**
* Der Tonstrom eines Titels — rohe Bytes auf stdout, für ffmpeg.
*
* Der Prozess gehört dem Aufrufer: er muss ihn abräumen. `radio.js` tut das
* in `stoppen()` und bei jedem Quellenwechsel, sonst bliebe je übersprungenem
* Titel ein yt-dlp stehen und lüde weiter.
*/
export function tonstrom(url) {
const p = spawn(YTDLP, [
...grundArgumente(),
'--no-playlist',
// Siehe Kopf: beim Strömen nach stdout ist die Vorgabe *mit* Bild.
'-f', 'bestaudio/best',
// Ein Fortschrittsbalken in eine Rohrleitung hilft niemandem und
// füllt nur stderr.
'--no-progress',
'-o', '-',
url,
], { stdio: ['ignore', 'pipe', 'pipe'] });
// stderr muss gelesen werden, sonst läuft die Rohrleitung voll und der
// Prozess bleibt stehen — derselbe Grund wie bei ffmpeg in `radio.js`.
// Gesammelt statt sofort gedruckt: erst beim Abbruch weiss man, ob eine
// Zeile eine Warnung war oder die Ursache.
let gesammelt = '';
p.stderr.on('data', (d) => {
gesammelt = `${gesammelt}${d.toString()}`.slice(-4000);
});
p.on('error', (e) => fehlerMerken(`yt-dlp startet nicht: ${e.message}`));
p.on('close', (code) => {
// 0 = fertig, null/SIGKILL = wir haben ihn selbst abgeräumt (Skip,
// Stopp, Wechsel). Beides ist kein Fehler und darf die Anzeige im
// Panel nicht mit einer Falschmeldung überschreiben.
if (code === 0 || code === null) return;
fehlerMerken(fehlerZeile(gesammelt) ?? `yt-dlp endete mit Code ${code}`);
});
return p;
}
// ── Aktualisieren ─────────────────────────────────────────────────────────
/**
* `yt-dlp -U` — die eingebaute Selbstaktualisierung.
*
* Ehrlich zur Reichweite: das schreibt die Binärdatei im *laufenden* Container
* neu. Wird der Container neu angelegt, gilt wieder die Fassung aus dem
* Abbild. Das ist kein Fehler, sondern die Bauweise — und es steht genau so
* im Panel, damit niemand ein Rätsel daraus macht.
*/
export async function selbstAktualisieren() {
const { code, aus, fehler } = await laufen(['--update'], { timeoutMs: 120_000 });
await ytdlpPruefen();
const text = `${aus}\n${fehler}`.trim();
if (code !== 0) {
const meldung = fehlerZeile(fehler) ?? `yt-dlp endete mit Code ${code}`;
fehlerMerken(meldung);
throw new Error(meldung);
}
return { fassung, ausgabe: text.slice(-1000) };
}
+139
View File
@@ -1968,3 +1968,142 @@ export const setRadioState = (s) => setRadioStateStmt.run({
panel_message_id: s.panelMessageId ? String(s.panelMessageId) : null, panel_message_id: s.panelMessageId ? String(s.panelMessageId) : null,
}); });
export const clearRadioState = (guildId) => clearRadioStateStmt.run(guildId).changes > 0; export const clearRadioState = (guildId) => clearRadioStateStmt.run(guildId).changes > 0;
// ── Warteschlange (YouTube) ───────────────────────────────────────────────
//
// Der ganze Unterschied zwischen einem Sender und einem YouTube-Titel ist,
// dass der Titel endet. Daraus folgt alles Weitere: es muss etwas geben, das
// danach kommt, und das ist diese Liste.
//
// Sie steht in der Datenbank und nicht nur im Speicher, aus demselben Grund
// wie `radio_state`: ein Deploy soll die Musik unterbrechen, nicht die
// Wünsche von fünf Leuten wegwerfen.
//
// `sort` statt einer verketteten Liste, weil das Panel umsortieren können soll
// — und weil eine Liste, deren Reihenfolge nur aus `id` folgt, sich genau
// einmal umsortieren lässt: gar nicht.
db.exec(`
CREATE TABLE IF NOT EXISTS radio_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
video_id TEXT NOT NULL,
url TEXT NOT NULL,
titel TEXT NOT NULL,
dauer INTEGER,
kanal TEXT,
thumb TEXT,
wunsch_von TEXT,
sort INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS radio_queue_guild ON radio_queue (guild_id, sort, id);
`);
const queueListStmt = db.prepare(
'SELECT * FROM radio_queue WHERE guild_id = ? ORDER BY sort, id'
);
const queueGetStmt = db.prepare('SELECT * FROM radio_queue WHERE id = ?');
const queueCountStmt = db.prepare(
'SELECT COUNT(*) AS n, COALESCE(SUM(dauer), 0) AS dauer FROM radio_queue WHERE guild_id = ?'
);
const queueAddStmt = db.prepare(`
INSERT INTO radio_queue (guild_id, video_id, url, titel, dauer, kanal, thumb, wunsch_von, sort)
VALUES (@guild_id, @video_id, @url, @titel, @dauer, @kanal, @thumb, @wunsch_von,
(SELECT COALESCE(MAX(sort), 0) + 1 FROM radio_queue WHERE guild_id = @guild_id))
`);
const queueHeadStmt = db.prepare(
'SELECT * FROM radio_queue WHERE guild_id = ? ORDER BY sort, id LIMIT 1'
);
const queueDeleteStmt = db.prepare('DELETE FROM radio_queue WHERE id = ?');
const queueClearStmt = db.prepare('DELETE FROM radio_queue WHERE guild_id = ?');
const queueVorStmt = db.prepare(`
UPDATE radio_queue
SET sort = (SELECT COALESCE(MIN(sort), 0) - 1 FROM radio_queue WHERE guild_id = @guild_id)
WHERE id = @id AND guild_id = @guild_id
`);
const queueSortStmt = db.prepare(
'UPDATE radio_queue SET sort = @sort WHERE id = @id AND guild_id = @guild_id'
);
export const queueList = (guildId) => queueListStmt.all(String(guildId));
export const queueGet = (id) => queueGetStmt.get(Number(id)) ?? null;
export const queuePeek = (guildId) => queueHeadStmt.get(String(guildId)) ?? null;
/** Wie viele und wie lange — für die Tafel, ohne die ganze Liste zu holen */
export const queueStand = (guildId) => queueCountStmt.get(String(guildId));
export const queueAdd = (guildId, e) => queueAddStmt.run({
guild_id: String(guildId),
video_id: String(e.video_id ?? ''),
url: String(e.url ?? ''),
titel: String(e.titel ?? 'Ohne Titel').slice(0, 200),
// Livestreams haben keine Dauer. NULL heisst genau das — eine 0 würde in
// der Gesamtdauer mitgezählt und behauptete, der Titel sei sofort vorbei.
dauer: Number.isFinite(Number(e.dauer)) && e.dauer !== null ? Number(e.dauer) : null,
kanal: e.kanal ? String(e.kanal).slice(0, 120) : null,
thumb: e.thumb ? String(e.thumb).slice(0, 500) : null,
wunsch_von: e.wunsch_von ? String(e.wunsch_von).slice(0, 80) : null,
}).lastInsertRowid;
export const queueRemove = (id) => queueDeleteStmt.run(Number(id)).changes > 0;
export const queueClear = (guildId) => queueClearStmt.run(String(guildId)).changes;
export const queueVor = (guildId, id) =>
queueVorStmt.run({ guild_id: String(guildId), id: Number(id) }).changes > 0;
/**
* Den nächsten Titel nehmen — holen und entfernen in einem Zug.
*
* Als Transaktion, weil sonst zwei gleichzeitige Auslöser (der Titel endet
* *und* jemand drückt „Überspringen") denselben Eintrag bekommen und einer
* davon doppelt läuft.
*/
export const queueNext = db.transaction((guildId) => {
const e = queueHeadStmt.get(String(guildId));
if (e) queueDeleteStmt.run(e.id);
return e ?? null;
});
/**
* Neue Reihenfolge setzen. Nur Einträge dieses Servers, nur bekannte IDs.
*
* Der Server bestimmt die Zahlen, nicht der Browser: was von aussen kommt,
* ist eine Reihenfolge von IDs und sonst nichts.
*/
export const queueReorder = db.transaction((guildId, ids) => {
let n = 0;
ids.forEach((id, i) => {
n += queueSortStmt.run({ guild_id: String(guildId), id: Number(id), sort: i }).changes;
});
return n;
});
// ── Die zweite Tafel (YouTube) ────────────────────────────────────────────
//
// Eigene Tabelle statt zweier Spalten in `radio_state`, und das ist kein
// Geschmack: `radio_state` wird beim Stoppen gelöscht. Die YouTube-Tafel soll
// das überleben — sie steht ja weiter im Kanal, und wer nach dem Stoppen
// wieder etwas einreiht, erwartet, dass sich genau diese Nachricht aktualisiert
// statt eine zweite danebenzustellen.
db.exec(`
CREATE TABLE IF NOT EXISTS radio_yt_panel (
guild_id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL
);
`);
const ytPanelStmt = db.prepare('SELECT * FROM radio_yt_panel WHERE guild_id = ?');
const ytPanelsStmt = db.prepare('SELECT * FROM radio_yt_panel');
const setYtPanelStmt = db.prepare(`
INSERT INTO radio_yt_panel (guild_id, channel_id, message_id)
VALUES (@guild_id, @channel_id, @message_id)
ON CONFLICT(guild_id) DO UPDATE SET
channel_id = excluded.channel_id, message_id = excluded.message_id
`);
const clearYtPanelStmt = db.prepare('DELETE FROM radio_yt_panel WHERE guild_id = ?');
export const ytPanel = (guildId) => ytPanelStmt.get(String(guildId)) ?? null;
export const ytPanels = () => ytPanelsStmt.all();
export const setYtPanel = (guildId, channelId, messageId) => setYtPanelStmt.run({
guild_id: String(guildId),
channel_id: String(channelId),
message_id: String(messageId),
});
export const clearYtPanel = (guildId) => clearYtPanelStmt.run(String(guildId)).changes > 0;
+2
View File
@@ -7,6 +7,7 @@ import { scheduleBackups } from './backup.js';
import { startServerMonitor } from './bot/server-monitor.js'; import { startServerMonitor } from './bot/server-monitor.js';
import { startLsFarm } from './bot/ls-farm.js'; import { startLsFarm } from './bot/ls-farm.js';
import { startRadio, radioBeenden } from './bot/radio.js'; import { startRadio, radioBeenden } from './bot/radio.js';
import { startMusik } from './bot/radio-musik.js';
import { startGiveaways } from './bot/giveaways.js'; import { startGiveaways } from './bot/giveaways.js';
import { startScheduledPosts } from './web/scheduled-posts.js'; import { startScheduledPosts } from './web/scheduled-posts.js';
import { startSocialNotify } from './bot/social-notify.js'; import { startSocialNotify } from './bot/social-notify.js';
@@ -76,6 +77,7 @@ try {
startServerMonitor(client); startServerMonitor(client);
startLsFarm(client); startLsFarm(client);
startRadio(client); startRadio(client);
startMusik();
startGiveaways(client); startGiveaways(client);
startScheduledPosts(client); startScheduledPosts(client);
startSocialNotify(client); startSocialNotify(client);
+14 -3
View File
@@ -109,10 +109,21 @@ export const MODULES = [
}, },
{ {
id: 'radio', group: 'community', name: 'Radio', id: 'radio', group: 'community', name: 'Radio',
desc: 'Webradio im Sprachkanal — /radio öffnet eine Tafel mit Sender-Auswahl zum ' desc: 'Webradio und YouTube im Sprachkanal — /radio öffnet eine Tafel mit '
+ 'Durchschalten. Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, ' + 'Sender-Auswahl, /musik eine zweite fürs Einreihen von YouTube-Titeln. '
+ 'und kommt nach einem Neustart in den Kanal zurück.', + 'Der Bot geht von selbst wieder raus, wenn der Kanal leer ist, und kommt '
+ 'nach einem Neustart in den Kanal zurück.',
tab: 'community', setting: 'radio_enabled', defaultOn: false, tab: 'community', setting: 'radio_enabled', defaultOn: false,
fields: [
{
setting: 'radio_youtube_enabled', label: 'YouTube erlauben', art: 'schalter',
hint: 'braucht yt-dlp auf dem Server; ohne Haken laufen nur die Sender',
},
{
setting: 'ytdlp_args', label: 'Eigene yt-dlp-Argumente', art: 'text',
hint: 'nur nötig, wenn YouTube sperrt — z. B. --extractor-args "youtube:player_client=android_vr"',
},
],
tool: { tab: 'community', label: 'Sender verwalten' }, tool: { tab: 'community', label: 'Sender verwalten' },
}, },
{ {
+37 -3
View File
@@ -380,7 +380,41 @@ export const LAUT_SCHRITT = 10;
export const LAUT_VORGABE = 50; export const LAUT_VORGABE = 50;
export function radioLautstaerke() { export function radioLautstaerke() {
const roh = Number(getSetting('radio_lautstaerke')); const roh = getSetting('radio_lautstaerke');
if (!Number.isFinite(roh)) return LAUT_VORGABE; // Der unbelegte Fall muss VOR der Umwandlung abgefangen werden.
return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(roh))); //
// `Number(null)` ist 0, und 0 ist endlich — die Prüfung unten hat „noch
// nie eingestellt" deshalb nie erwischt, sondern auf LAUT_MIN geklemmt.
// Ergebnis: eine frische Installation startete mit 10 % statt mit den 50,
// die zwei Zeilen weiter oben als Vorgabe begründet werden. Gemessen am
// 28.08.2026 gegen eine leere Datenbank.
if (roh === null || roh === undefined || roh === '') return LAUT_VORGABE;
const wert = Number(roh);
if (!Number.isFinite(wert)) return LAUT_VORGABE;
return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(wert)));
}
/**
* Darf das Radio auch YouTube abspielen?
*
* Getrennt vom Modul-Schalter, und zwar mit Absicht: YouTube ist der Teil,
* der von aussen kaputtgehen kann (gesperrte Adressen, PO-Token, ein yt-dlp,
* das seit drei Wochen nicht aktualisiert wurde). Wenn das klemmt, soll man
* genau das abschalten können und nicht das Radio mit, das davon nichts hat.
*
* Vorgabe: aus. Wer YouTube will, sagt es einmal.
*/
export function youtubeAn() {
return getSetting('radio_youtube_enabled') === '1';
}
/**
* Eigene Argumente für yt-dlp, roh wie eingetippt.
*
* Das Feld gibt es, weil YouTube seine Sperren schneller ändert, als hier ein
* Abbild neu gebaut wird: `--extractor-args`, ein anderer Client, eine
* Bandbreitenbremse. Ohne dieses Feld hiesse jede solche Anpassung Deploy.
*/
export function ytdlpArgumente() {
return getSetting('ytdlp_args') ?? '';
} }
+326 -1
View File
@@ -29,6 +29,8 @@ import {
savePage, getPage, listPages, listPublishedPages, deletePage, savePage, getPage, listPages, listPublishedPages, deletePage,
lsState, lsModSizes, setLsModSize, lsState, lsModSizes, setLsModSize,
listStations, createStation, updateStation, deleteStation, listStations, createStation, updateStation, deleteStation,
queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand,
setRadioState, clearRadioState, radioState,
} from '../db.js'; } from '../db.js';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; 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 { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js'; import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js';
import { istLsServer, modBasis } from '../bot/ls-farm.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 { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js'; import { listRules, setRuleEnabled } from '../bot/automod.js';
import { import {
@@ -51,6 +61,9 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
import { computeNextRun } from './scheduled-posts.js'; import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.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 { 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 { xpForLevel } from '../bot/levels.js';
import { getMilestones } from '../gitea-api.js'; import { getMilestones } from '../gitea-api.js';
@@ -418,6 +431,291 @@ export function registerApiRoutes(app, client) {
return { erreichbar: sender != null || titel != null, sender, titel }; 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 // Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist
// eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server. // eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server.
app.get('/api/ticket-categories', async (request, reply) => { app.get('/api/ticket-categories', async (request, reply) => {
@@ -938,6 +1236,33 @@ ${rssItems}
return channels.sort((a, b) => a.name.localeCompare(b.name)); 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) */ /** Rollen, die der Bot vergeben kann (unter seiner höchsten Rolle, nicht managed) */
function listRoles() { function listRoles() {
const roles = []; const roles = [];