diff --git a/.env.example b/.env.example index 4bee08b..9f6659c 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,13 @@ GITEA_API_TOKEN= # Optional: Pfad zur SQLite-Datei (Default: ./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 diff --git a/Dockerfile b/Dockerfile index e6109eb..941b6ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,30 @@ FROM node:22-slim # Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften), # 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 -# braucht es keine Opus-Bibliothek in Node, die erst gebaut werden müsste +# ffmpeg fürs Radio: es holt den Strom und gibt rohe Abtastwerte (PCM) aus, an +# 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 \ && 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 # Erst nur Manifest kopieren → Docker-Layer-Cache für npm ci diff --git a/README.md b/README.md index 676e745..d925867 100644 --- a/README.md +++ b/README.md @@ -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) | | 💡 **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 | -| 📻 **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 | | 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle | | 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche Events-Seite | diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fefbf26..ca9fd88 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -95,6 +95,21 @@ const WUNSCH_STATUS = [ // Alles mit Editor, Vorschau oder Liste bleibt einspaltig. 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']); // 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 }); // Ergebnis der Sender-Probe: { erreichbar, sender, titel } oder 'laeuft' 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 [auditAlle, setAuditAlle] = useState(false); // 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(() => {}); }, []); + // 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) const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 }; const [services, setServices] = useState([]); @@ -2151,6 +2191,323 @@ export default function Settings({ me }) { ohne Haken antwortet /radio nur mit einem Hinweis +
+ +
+ + {/* ── Fernbedienung ────────────────────────────────────── + Steht ueber der Senderliste, weil man oefter etwas + abspielt als die Liste pflegt. */} + {radio?.mehrdeutig && ( +

+ Der Bot steht in mehreren Servern und es ist keiner eingestellt — + unter System die Server-ID eintragen, sonst weiss + das Panel nicht, welchen es steuern soll. +

+ )} + + {radio && !radio.mehrdeutig && ( +
+
+ {radio.laeuft ? ( + <> +
+ + {radio.laeuft.art === 'youtube' ? '▶️' : '📻'} + +
+ + {radio.laeuft.art === 'youtube' + ? radio.laeuft.titel + : radio.laeuft.sender} + + + {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 …')} + + + 🔊 {radio.laeuft.kanalName ?? radio.laeuft.kanalId} + +
+
+ {/* Der Balken nur, wo er etwas aussagen kann: ohne + bekannte Dauer gaebe er einen Stand vor, den + niemand kennt. */} + {radio.laeuft.dauer ? ( +
+
+ +
+ + {dauerText(radio.laeuft.verstrichen)} / {dauerText(radio.laeuft.dauer)} + +
+ ) : radio.laeuft.art === 'youtube' && ( +

🔴 Livestream

+ )} + {radio.laeuft.tonLaeuft === false && ( +

+ ⚠️ Es kommt kein Ton an. + {radio.werkzeuge.ffmpeg === false + && ' Auf dem Server fehlt ffmpeg.'} +

+ )} + + ) : ( +

+ Nichts läuft gerade. + {radio.stand.n > 0 + && ` ${radio.stand.n} Titel warten — Sprachkanal wählen und abspielen.`} +

+ )} +
+ +
+
+ + +
+
+ + {/* onMouseUp/onChange statt live bei jedem Pixel: sonst + geht je Mausbewegung eine Anfrage raus. */} + 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) })} /> +
+
+ +
+ + + + +
+ + {/* ── Einreihen ── */} +
+ +
+ { setRadioEingabe(e.target.value); setRadioTreffer(null); }} + onKeyDown={(e) => e.key === 'Enter' && radioEinreihen()} /> + +
+ {!radio.werkzeuge.youtubeAn && ( + + YouTube ist ausgeschaltet — Haken oben setzen und speichern. + + )} +
+ + {/* Bei einer Suche wird nicht geraten, welcher gemeint war. */} + {radioTreffer && ( +
+ {radioTreffer.map((t) => ( +
+ ▶️ +
+ {t.titel} + + {[t.kanal, dauerText(t.dauer)].filter(Boolean).join(' · ')} + +
+
+ +
+
+ ))} +
+ )} + + {/* ── Warteschlange ── */} + {radio.warteschlange.length > 0 && ( + <> + +
+ {radio.warteschlange.map((e, i) => ( +
+ {i + 1}. +
+ {e.titel} + + {[e.kanal, dauerText(e.dauer), + e.wunsch_von && `von ${e.wunsch_von}`] + .filter(Boolean).join(' · ')} + +
+
+ + + +
+
+ ))} +
+
+ +
+ + )} + + {radioFehler &&

❌ {radioFehler}

} + + {/* ── 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. */} +
+ + yt-dlp — {radio.werkzeuge.ytdlp === false + ? '❌ fehlt' + : radio.werkzeuge.ytdlp === null + ? '… noch nicht geprüft' + : `✅ ${radio.werkzeuge.ytdlpFassung ?? 'da'}`} + {radio.werkzeuge.cookies ? ' · Cookies gesetzt' : ''} + + + {radio.werkzeuge.letzterFehler && ( + <> + +
{radio.werkzeuge.letzterFehler.text}
+ + )} + +

+ YouTube sperrt Server-Adressen inzwischen häufig + („Sign in to confirm you're not a bot"). Zwei Griffe helfen, + beide ohne neues Image: eine cookies.txt aus dem + Browser eines angemeldeten Kontos, und eigene Argumente + (Feld weiter unten in dieser Sektion). +

+ +
+ +