Radio: Webradio im Sprachkanal, 44 Sender zum Durchschalten
/radio holt den Bot in den Sprachkanal und postet eine Tafel: Auswahlmenue mit allen Sendern, Stopp, und ein Embed mit dem laufenden Titel, das sich alle 30 Sekunden selbst nachfuehrt. Der Titel kommt aus dem Icecast-Strom selbst (Icy-MetaData). Bewusst so und nicht ueber eine Sender-API: das funktioniert bei jedem Strom, den jemand eintraegt, und nicht nur bei den drei, die ich kenne. Leere Titel-Bloecke heissen "unveraendert", deshalb werden bis zu drei gelesen. Die Tonkette braucht keine Opus-Bibliothek: ffmpeg holt das MP3 und gibt direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete in fuenf Sekunden. Sonst muesste @discordjs/opus mit ins Image, und das will gebaut werden. Neu im Image ist nur ffmpeg. 44 RauteMusik-Sender sind ab Werk eingetragen — dieselben, die im LS25 und ETS als Bordradio laufen. Die Liste ist nicht abgeschrieben, sondern gemessen: jede Adresse einzeln angefragt, der Name kommt aus icy-name. Doppelgaenger sind raus (deutschrap-charts und wackenradio liefern denselben Strom wie deutschrap und metal). Eingetragen wird einmal, solange die Liste leer ist — wer loescht, behaelt es geloescht. Ein Auswahlmenue fasst nur 25 Eintraege. Bei 44 Sendern haette ein Deckel die letzten 19 stillschweigend verschluckt; stattdessen mehrere Menues, benannt nach ihrem ersten und letzten Sender. Der Bot geht raus, wenn der Kanal leer ist, und kommt nach einem Neustart in den Kanal zurueck — sonst beendet jeder Deploy die Musik endgueltig. Kein YouTube. Genau daran sind Groovy und Rythm gestorben, und was davon uebrig ist, ist ein Dauerlauf gegen kaputte Extraktoren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+4
-2
@@ -11,8 +11,10 @@ RUN npm run build
|
||||
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)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core git \
|
||||
# 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
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core git ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -47,7 +47,7 @@ bleibt bewusst deutsch — sie sieht nur das Team.
|
||||
|
||||
## Funktionen
|
||||
|
||||
**39 Module**, einzeln an- und abschaltbar. Ein ausgeschaltetes Modul reagiert
|
||||
**40 Module**, einzeln an- und abschaltbar. Ein ausgeschaltetes Modul reagiert
|
||||
auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
|
||||
|
||||
### Inhalte & Feeds
|
||||
@@ -73,6 +73,7 @@ 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 |
|
||||
| 🎉 **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 |
|
||||
|
||||
@@ -321,6 +321,7 @@ export default {
|
||||
'commands.remind': 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".',
|
||||
'commands.cmdTag': 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.',
|
||||
'commands.ping': 'Lebenszeichen des Bots.',
|
||||
'commands.radio': 'Holt den Bot in deinen Sprachkanal und öffnet die Sender-Auswahl.',
|
||||
'commands.umfrage': 'Startet eine Abstimmung mit bis zu vier Antworten — Discord zählt selbst aus.',
|
||||
'commands.warn': 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.',
|
||||
'commands.warns': 'Zeigt die Verwarnungs-Historie.',
|
||||
|
||||
@@ -410,6 +410,7 @@ export default {
|
||||
'commands.remind': 'Reminds you by direct message — something like “2h” or “30m”.',
|
||||
'commands.cmdTag': 'Fetches a saved text snippet, with suggestions as you type.',
|
||||
'commands.ping': 'A sign of life from the bot.',
|
||||
'commands.radio': 'Brings the bot into your voice channel and opens the station picker.',
|
||||
'commands.umfrage': 'Starts a vote with up to four answers — Discord does the counting.',
|
||||
'commands.warn': 'Warns someone, sends them a message and writes it to the log.',
|
||||
'commands.warns': 'Shows the warning history.',
|
||||
|
||||
@@ -15,6 +15,7 @@ const GROUPS = [
|
||||
['/tag name:…', 'commands.cmdTag'],
|
||||
['/ping', 'commands.ping'],
|
||||
['/umfrage frage:… a:… b:…', 'commands.umfrage'],
|
||||
['/radio', 'commands.radio'],
|
||||
]],
|
||||
['commands.g.mod', true, [
|
||||
['/warn user:… grund:…', 'commands.warn'],
|
||||
|
||||
@@ -93,6 +93,8 @@ const WUNSCH_STATUS = [
|
||||
|
||||
// Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen.
|
||||
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
|
||||
const SENDER_LEER = { id: null, name: '', url: '', emoji: '', sort: 0 };
|
||||
|
||||
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
|
||||
|
||||
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
|
||||
@@ -426,6 +428,10 @@ export default function Settings({ me }) {
|
||||
const [bereichDraft, setBereichDraft] = useState({ id: null, name: '', emoji: '' });
|
||||
const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
|
||||
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||||
const [sender, setSender] = useState([]);
|
||||
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);
|
||||
const [owner, setOwner] = useState(null);
|
||||
const [auditAlle, setAuditAlle] = useState(false);
|
||||
// Module (An/Aus für alle Funktionen)
|
||||
@@ -461,6 +467,11 @@ export default function Settings({ me }) {
|
||||
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
|
||||
const [pages, setPages] = useState([]);
|
||||
const [pageDraft, setPageDraft] = useState(emptyPage);
|
||||
// Radio-Sender
|
||||
useEffect(() => {
|
||||
apiGet('/api/radio').then((d) => setSender(d.sender ?? [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Portal-Dienste (Kacheln auf der Startseite)
|
||||
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
|
||||
const [services, setServices] = useState([]);
|
||||
@@ -2112,6 +2123,91 @@ export default function Settings({ me }) {
|
||||
<input type="text" placeholder="5=RollenID, 10=RollenID" value={form.level_rewards} onChange={(e) => setForm({ ...form, level_rewards: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Radio-Sender</h2>
|
||||
<p className="section-intro">
|
||||
Adressen von Webradio-Strömen. <code>/radio</code> öffnet im Discord eine
|
||||
Tafel mit genau dieser Liste zum Durchschalten. Jeder Icecast- oder
|
||||
Shoutcast-Strom funktioniert — die Sender von <strong>rm.fm</strong> etwa
|
||||
stecken auch im LS25 und im ETS.
|
||||
</p>
|
||||
|
||||
{sender.length === 0
|
||||
? <p className="notice">Noch kein Sender eingetragen.</p>
|
||||
: (
|
||||
<div className="radio-list">
|
||||
{sender.map((s) => (
|
||||
<div className="radio-row" key={s.id}>
|
||||
<span className="radio-emoji">{s.emoji || '📻'}</span>
|
||||
<div className="radio-info">
|
||||
<span className="radio-name">{s.name}</span>
|
||||
<span className="radio-url">{s.url}</span>
|
||||
</div>
|
||||
<div className="radio-actions">
|
||||
<button className="btn btn-ghost" onClick={() => { setSenderDraft({ ...s, emoji: s.emoji ?? '' }); setSenderProbe(null); }}>
|
||||
Ändern
|
||||
</button>
|
||||
<button className="btn btn-ghost danger" onClick={() => senderLoeschen(s.id)}>
|
||||
Weg
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="radio-form">
|
||||
<div className="field">
|
||||
<label>Name <span className="field-hint">steht im Auswahlmenü</span></label>
|
||||
<input type="text" maxLength={60} placeholder="z. B. Rock"
|
||||
value={senderDraft.name}
|
||||
onChange={(e) => setSenderDraft({ ...senderDraft, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Stream-Adresse <span className="field-hint">direkter Strom, keine Playlist-Datei (.m3u/.pls)</span></label>
|
||||
<input type="text" placeholder="https://rock-high.rautemusik.fm/"
|
||||
value={senderDraft.url}
|
||||
onChange={(e) => { setSenderDraft({ ...senderDraft, url: e.target.value }); setSenderProbe(null); }} />
|
||||
</div>
|
||||
<div className="radio-form-row">
|
||||
<div className="field">
|
||||
<label>Emoji <span className="field-hint">optional</span></label>
|
||||
<input type="text" maxLength={8} placeholder="🎸"
|
||||
value={senderDraft.emoji}
|
||||
onChange={(e) => setSenderDraft({ ...senderDraft, emoji: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Reihenfolge <span className="field-hint">kleiner = weiter oben</span></label>
|
||||
<input type="number" value={senderDraft.sort}
|
||||
onChange={(e) => setSenderDraft({ ...senderDraft, sort: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{senderProbe && (
|
||||
<p className="notice">
|
||||
{senderProbe === 'laeuft' && 'Höre kurz rein …'}
|
||||
{senderProbe !== 'laeuft' && (senderProbe.erreichbar
|
||||
? `✅ ${senderProbe.sender ?? 'Erreichbar'}${senderProbe.titel ? ` — läuft gerade: ${senderProbe.titel}` : ''}`
|
||||
: `❌ ${senderProbe.fehler ?? 'Keine Antwort von dieser Adresse'}`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-save" onClick={senderSpeichern}>
|
||||
{senderDraft.id ? 'Änderungen speichern' : 'Hinzufügen'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={senderPruefen} disabled={!senderDraft.url.trim()}>
|
||||
Reinhören
|
||||
</button>
|
||||
{senderDraft.id && (
|
||||
<button className="btn btn-ghost" onClick={() => { setSenderDraft(SENDER_LEER); setSenderProbe(null); }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3977,6 +4073,47 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
async function senderSpeichern() {
|
||||
const body = {
|
||||
name: senderDraft.name.trim(),
|
||||
url: senderDraft.url.trim(),
|
||||
emoji: senderDraft.emoji.trim(),
|
||||
sort: Number(senderDraft.sort) || 0,
|
||||
};
|
||||
if (!body.name || !body.url) return;
|
||||
try {
|
||||
const d = senderDraft.id
|
||||
? await apiPut(`/api/radio/${senderDraft.id}`, body)
|
||||
: await apiPost('/api/radio', body);
|
||||
setSender(d.sender ?? []);
|
||||
setSenderDraft(SENDER_LEER);
|
||||
setSenderProbe(null);
|
||||
setFeedback('✅ Sender gespeichert');
|
||||
} catch (e) {
|
||||
setFeedback(`❌ ${e.body?.error ?? 'Speichern fehlgeschlagen'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function senderLoeschen(id) {
|
||||
const d = await apiDelete(`/api/radio/${id}`).catch(() => null);
|
||||
if (d) setSender(d.sender ?? []);
|
||||
if (senderDraft.id === id) setSenderDraft(SENDER_LEER);
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function senderPruefen() {
|
||||
const url = senderDraft.url.trim();
|
||||
if (!url) return;
|
||||
setSenderProbe('laeuft');
|
||||
try {
|
||||
setSenderProbe(await apiPost('/api/radio/pruefen', { url }));
|
||||
} catch (e) {
|
||||
setSenderProbe({ erreichbar: false, fehler: e.body?.error ?? 'nicht erreichbar' });
|
||||
}
|
||||
}
|
||||
|
||||
const auditSichtbar = auditAlle ? audit : audit.slice(0, 12);
|
||||
const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id);
|
||||
|
||||
|
||||
@@ -2961,3 +2961,40 @@ button.with-icon, a.with-icon { justify-content: center; }
|
||||
.mod-version, .mod-size { grid-area: meta; text-align: left; min-width: 0; }
|
||||
.mod-version::after { content: ' ·'; }
|
||||
}
|
||||
|
||||
/* ── RADIO-SENDER (Panel) ──────────────────────────── */
|
||||
/* 44 Sender sind 3300 px — ohne Deckel schiebt die Liste den halben
|
||||
Community-Bereich aus dem Bild. Gemessen im Panel, nicht geschätzt. */
|
||||
.radio-list {
|
||||
display: flex; flex-direction: column; gap: 1px; margin-bottom: 1.5rem;
|
||||
max-height: 26rem; overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.radio-list::-webkit-scrollbar { width: 8px; }
|
||||
.radio-list::-webkit-scrollbar-thumb { background: var(--border); }
|
||||
/* Die Config-Seite ist zweispaltig — ein Abschnitt ist rund 480 px breit,
|
||||
nicht die volle Seite. Mit vier Spalten blieben für Name und Adresse
|
||||
96 px übrig; gemessen, nicht geschätzt. Deshalb schmale Knöpfe. */
|
||||
.radio-row {
|
||||
display: grid; align-items: center; gap: .6rem;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
padding: .6rem .7rem; background: var(--bg2);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.radio-actions { display: flex; gap: .35rem; }
|
||||
.radio-actions .btn { padding: .3rem .55rem; font-size: .6rem; }
|
||||
.radio-row:hover { background: var(--bg3); border-left-color: var(--neon); }
|
||||
.radio-emoji { font-size: 1.2rem; line-height: 1; }
|
||||
.radio-info { min-width: 0; }
|
||||
.radio-name { display: block; color: var(--text); }
|
||||
.radio-url {
|
||||
display: block; font-family: var(--mono); font-size: .62rem; color: var(--muted2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.radio-form { border-top: 1px solid var(--border); padding-top: 1.2rem; }
|
||||
/* Emoji und Reihenfolge sind beide schmal — nebeneinander statt zwei
|
||||
halbleere Zeilen, aber auf dem Handy untereinander. */
|
||||
.radio-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 560px) {
|
||||
.radio-form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Generated
+68
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/multipart": "^10.1.0",
|
||||
"@fastify/static": "^10.1.2",
|
||||
@@ -17,6 +18,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.10.0",
|
||||
"gamedig": "^5.3.3",
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -118,6 +120,31 @@
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/voice": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.18.0.tgz",
|
||||
"integrity": "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/ws": "^8.5.12",
|
||||
"discord-api-types": "^0.37.103",
|
||||
"prism-media": "^1.3.5",
|
||||
"tslib": "^2.6.3",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/voice/node_modules/discord-api-types": {
|
||||
"version": "0.37.120",
|
||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz",
|
||||
"integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@discordjs/ws": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
||||
@@ -1990,6 +2017,21 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libsodium": {
|
||||
"version": "0.8.4",
|
||||
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz",
|
||||
"integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/libsodium-wrappers": {
|
||||
"version": "0.8.4",
|
||||
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz",
|
||||
"integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libsodium": "^0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -2293,6 +2335,32 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prism-media": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
|
||||
"integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@discordjs/opus": ">=0.8.0 <1.0.0",
|
||||
"ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0",
|
||||
"node-opus": "^0.3.3",
|
||||
"opusscript": "^0.0.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@discordjs/opus": {
|
||||
"optional": true
|
||||
},
|
||||
"ffmpeg-static": {
|
||||
"optional": true
|
||||
},
|
||||
"node-opus": {
|
||||
"optional": true
|
||||
},
|
||||
"opusscript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/multipart": "^10.1.0",
|
||||
"@fastify/static": "^10.1.2",
|
||||
@@ -22,6 +23,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.10.0",
|
||||
"gamedig": "^5.3.3",
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -24,6 +24,7 @@ import {
|
||||
handleWunschPick, handleWunschButton, handleWunschModal, registerWishThreads,
|
||||
} from './wishes.js';
|
||||
import { handleGiveawayAdminButton } from './giveaways.js';
|
||||
import { handleRadioInteraction } from './radio.js';
|
||||
import {
|
||||
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
|
||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||
@@ -47,12 +48,13 @@ import * as remind from './commands/remind.js';
|
||||
import * as geburtstag from './commands/geburtstag.js';
|
||||
import * as raid from './commands/raid.js';
|
||||
import * as umfrage from './commands/umfrage.js';
|
||||
import * as radio from './commands/radio.js';
|
||||
|
||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||
const commandModules = [
|
||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
||||
raid, umfrage,
|
||||
raid, umfrage, radio,
|
||||
];
|
||||
|
||||
export async function startBot() {
|
||||
@@ -168,6 +170,18 @@ export async function startBot() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Radio-Steuertafel. Sie prüft selbst, ob sie gemeint ist, und
|
||||
// antwortet in einem Rutsch — deshalb hier oben und nicht als
|
||||
// weiterer customId-Vergleich zwischen den anderen.
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu()) {
|
||||
try {
|
||||
if (await handleRadioInteraction(interaction)) return;
|
||||
} catch (error) {
|
||||
console.error('[radio] Interaktion:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔔-Button unterm Devlog: Ping-Rolle selbst an-/abmelden
|
||||
if (interaction.isButton() && interaction.customId === 'devlog_ping_toggle') {
|
||||
const roleId = devlogPingRoleId();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// /radio — Steuertafel im Kanal öffnen.
|
||||
//
|
||||
// Der Befehl nimmt bewusst keinen Sendernamen entgegen: getippt werden müsste
|
||||
// er auf dem Handy erst recht, und die Auswahl steht ohnehin im Menü. Ein
|
||||
// Aufruf, eine Tafel, umschalten per Klick.
|
||||
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
||||
import { moduleEnabled } from '../../modules.js';
|
||||
import { listStations, getStation, setRadioState } from '../../db.js';
|
||||
import { spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen } from '../radio.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('radio')
|
||||
.setDescription('Radio im Sprachkanal — Sender wählen und umschalten');
|
||||
|
||||
export async function execute(interaction) {
|
||||
if (!moduleEnabled('radio')) {
|
||||
await interaction.reply({
|
||||
content: '❌ Radio ist ausgeschaltet — im Panel unter **Community** einschaltbar.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stationen = listStations();
|
||||
if (stationen.length === 0) {
|
||||
await interaction.reply({
|
||||
content: '❌ Es sind noch keine Sender eingetragen — im Panel unter **Community → Radio**.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const kanal = interaction.member?.voice?.channel;
|
||||
const schonAn = laeuft(interaction.guild.id);
|
||||
if (!kanal && !schonAn) {
|
||||
await interaction.reply({
|
||||
content: '❌ Geh erst in einen Sprachkanal.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Darf der Bot da überhaupt rein? Sonst hängt der Verbindungsaufbau, bis
|
||||
// die Interaktion abgelaufen ist, und der Nutzer sieht nur „Fehler".
|
||||
if (kanal) {
|
||||
const rechte = kanal.permissionsFor(interaction.guild.members.me);
|
||||
if (!rechte?.has('Connect') || !rechte?.has('Speak')) {
|
||||
await interaction.reply({
|
||||
content: `❌ Ich darf in <#${kanal.id}> nicht rein oder nicht sprechen.`,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Verbinden dauert länger als die drei Sekunden, die Discord gibt
|
||||
await interaction.deferReply();
|
||||
|
||||
let station = schonAn ? getStation(schonAn.stationId) : null;
|
||||
let titel = schonAn?.titel ?? null;
|
||||
if (!schonAn) {
|
||||
station = stationen[0];
|
||||
try {
|
||||
await spielen(kanal, station);
|
||||
} catch (error) {
|
||||
console.error('[radio]', error);
|
||||
await interaction.editReply('❌ Verbindung in den Sprachkanal fehlgeschlagen.');
|
||||
return;
|
||||
}
|
||||
({ titel } = await titelHolen(station.url));
|
||||
const s = laeuft(interaction.guild.id);
|
||||
if (s) s.titel = titel;
|
||||
}
|
||||
|
||||
const nachricht = await interaction.editReply({
|
||||
embeds: [tafelEmbed(interaction.client, station, titel, kanal?.id ?? schonAn?.channelId)],
|
||||
components: tafelKomponenten(stationen, station?.id),
|
||||
});
|
||||
|
||||
// Die Tafel merken, damit der Titel-Melder sie später bearbeiten kann
|
||||
setRadioState({
|
||||
guildId: interaction.guild.id,
|
||||
channelId: kanal?.id ?? schonAn.channelId,
|
||||
stationId: station?.id ?? null,
|
||||
panelChannelId: nachricht.channelId ?? interaction.channelId,
|
||||
panelMessageId: nachricht.id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Die RauteMusik-Sender als Grundausstattung.
|
||||
//
|
||||
// Warum ausgerechnet die: sie stecken auch im Landwirtschafts-Simulator und
|
||||
// im ETS als Bordradio — der Betreiber will also ausdrücklich, dass seine
|
||||
// Ströme in Spielen laufen.
|
||||
//
|
||||
// Diese Liste ist nicht abgeschrieben, sondern gemessen: jede Adresse wurde
|
||||
// einzeln angefragt, der Name kommt aus der `icy-name`-Kopfzeile des Servers.
|
||||
// Doppelgänger sind raus — `deutschrap-charts` und `wackenradio` liefern
|
||||
// denselben Strom wie `deutschrap` beziehungsweise `metal`.
|
||||
//
|
||||
// Eingetragen wird das genau einmal, solange noch kein Sender in der
|
||||
// Datenbank steht. Wer einen löscht, bekommt ihn nicht beim nächsten Start
|
||||
// wieder — die Liste ist ein Startpunkt, keine Vorschrift.
|
||||
import { listStations, createStation } from '../db.js';
|
||||
|
||||
export const RM_FM = [
|
||||
{ name: 'Main', slug: 'main', emoji: '📻' },
|
||||
{ name: 'ChartHits', slug: 'charthits', emoji: '📈' },
|
||||
{ name: 'Top40', slug: 'top40', emoji: '🔝' },
|
||||
{ name: 'Partyhits', slug: 'partyhits', emoji: '🎉' },
|
||||
{ name: 'Happy', slug: 'happy', emoji: '😊' },
|
||||
{ name: 'Club', slug: 'club', emoji: '🪩' },
|
||||
{ name: 'House', slug: 'house', emoji: '🏠' },
|
||||
{ name: 'Techhouse', slug: 'techhouse', emoji: '🎛️' },
|
||||
{ name: 'Trance', slug: 'trance', emoji: '🌀' },
|
||||
{ name: 'BigCityBeats', slug: 'bigcitybeats', emoji: '🌃' },
|
||||
{ name: 'HardeR', slug: 'harder', emoji: '⚡' },
|
||||
{ name: 'Happy Hardcore', slug: 'happyhardcore', emoji: '💥' },
|
||||
{ name: 'Bass', slug: 'bass', emoji: '🔊' },
|
||||
{ name: 'Breakz.FM', slug: 'breakz', emoji: '🎚️' },
|
||||
{ name: 'Rock', slug: 'rock', emoji: '🎸' },
|
||||
{ name: 'Metal', slug: 'metal', emoji: '🤘' },
|
||||
{ name: '12 Punks', slug: '12punks', emoji: '🧷' },
|
||||
{ name: 'Deutschrap Charts', slug: 'deutschrap', emoji: '🎤' },
|
||||
{ name: 'Blackbeats', slug: 'blackbeats', emoji: '🎧' },
|
||||
{ name: 'JaM', slug: 'jam', emoji: '🎶' },
|
||||
{ name: '80s', slug: '80s', emoji: '📼' },
|
||||
{ name: '90s', slug: '90s', emoji: '💿' },
|
||||
{ name: 'Goldies', slug: 'goldies', emoji: '🏆' },
|
||||
{ name: 'Deutsche Hits', slug: 'deutsche-hits', emoji: '🇩🇪' },
|
||||
{ name: 'Schlager', slug: 'schlager', emoji: '🪗' },
|
||||
{ name: 'Best of Schlager', slug: 'best-of-schlager', emoji: '🌟' },
|
||||
{ name: 'Schlager Oldies', slug: 'schlager-oldies', emoji: '📻' },
|
||||
{ name: 'Volksmusik', slug: 'volksmusik', emoji: '🏔️' },
|
||||
{ name: 'Karneval', slug: 'karneval', emoji: '🤡' },
|
||||
{ name: 'Country', slug: 'country', emoji: '🤠' },
|
||||
{ name: 'Salsa', slug: 'salsa', emoji: '💃' },
|
||||
{ name: 'Oriental', slug: 'oriental', emoji: '🕌' },
|
||||
{ name: 'Klassik', slug: 'klassik', emoji: '🎻' },
|
||||
{ name: 'Solopiano', slug: 'solopiano', emoji: '🎹' },
|
||||
{ name: 'Lounge', slug: 'lounge', emoji: '🛋️' },
|
||||
{ name: 'Study', slug: 'study', emoji: '📚' },
|
||||
{ name: 'Workout', slug: 'workout', emoji: '🏋️' },
|
||||
{ name: 'Lovehits', slug: 'lovehits', emoji: '❤️' },
|
||||
{ name: 'Traurig', slug: 'traurig', emoji: '💧' },
|
||||
{ name: 'Sex', slug: 'sex', emoji: '🔥' },
|
||||
{ name: 'Christmas Channel', slug: 'christmas', emoji: '🎄' },
|
||||
{ name: 'Christmas Chor', slug: 'christmas-chor', emoji: '🕯️' },
|
||||
{ name: 'Christmas Schlager', slug: 'christmas-schlager', emoji: '🔔' },
|
||||
{ name: 'Weihnachten', slug: 'weihnachten', emoji: '⛄' },
|
||||
];
|
||||
|
||||
export const rmUrl = (slug) => `https://${slug}-high.rautemusik.fm/`;
|
||||
|
||||
/** Einmalig befüllen — nur solange die Liste leer ist */
|
||||
export function senderGrundstock() {
|
||||
if (listStations().length > 0) return 0;
|
||||
RM_FM.forEach((s, i) => createStation({
|
||||
name: s.name, url: rmUrl(s.slug), emoji: s.emoji, sort: i,
|
||||
}));
|
||||
const n = listStations().length;
|
||||
console.log(`[radio] ${n} Sender als Grundstock eingetragen`);
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Radio im Sprachkanal.
|
||||
//
|
||||
// Bewusst nur Webradio und kein YouTube: genau daran sind Groovy und Rythm
|
||||
// gestorben, und was davon übrig ist, ist ein Dauerlauf gegen kaputte
|
||||
// Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine
|
||||
// Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet.
|
||||
//
|
||||
// Die Tonkette kommt ohne Opus-Bibliothek aus: ffmpeg holt das MP3 und gibt
|
||||
// direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete
|
||||
// in fünf Sekunden. Sonst müsste @discordjs/opus mit ins Image, und das will
|
||||
// gebaut werden.
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags,
|
||||
StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
|
||||
} from 'discord.js';
|
||||
import {
|
||||
joinVoiceChannel, createAudioPlayer, createAudioResource, StreamType,
|
||||
AudioPlayerStatus, VoiceConnectionStatus, NoSubscriberBehavior,
|
||||
entersState, getVoiceConnection,
|
||||
} from '@discordjs/voice';
|
||||
import prism from 'prism-media';
|
||||
import { brandEmbed } from '../embeds.js';
|
||||
import { brandFooter } from '../runtime-settings.js';
|
||||
import { moduleEnabled } from '../modules.js';
|
||||
import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState } from '../db.js';
|
||||
import { senderGrundstock } from './radio-sender.js';
|
||||
|
||||
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
|
||||
// Was gerade läuft, je Server. Der Spieler und die Verbindung leben nur im
|
||||
// Speicher; was davon einen Neustart überlebt, steht in `radio_state`.
|
||||
const laufend = new Map(); // guildId → { player, ffmpeg, stationId, channelId, titel }
|
||||
|
||||
// ── Sender-Titel ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Was läuft gerade? Aus dem Icecast-Datenstrom selbst.
|
||||
*
|
||||
* Mit `Icy-MetaData: 1` schiebt der Server alle `icy-metaint` Bytes einen
|
||||
* Titel-Block in den Ton. Bewusst so und nicht über eine Sender-API: das
|
||||
* funktioniert bei jedem Icecast-Server, den jemand einträgt, und nicht nur
|
||||
* bei den drei, die ich kenne.
|
||||
*
|
||||
* Ein leerer Block heisst „unverändert" — deshalb werden bis zu drei gelesen,
|
||||
* bevor aufgegeben wird. Das sind im schlimmsten Fall rund 48 KB.
|
||||
*/
|
||||
export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
|
||||
const steuer = new AbortController();
|
||||
let leser = null;
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: { 'Icy-MetaData': '1', 'User-Agent': 'd4rkbot-radio/1.0' },
|
||||
signal: AbortSignal.any([steuer.signal, AbortSignal.timeout(timeoutMs)]),
|
||||
});
|
||||
const abstand = Number(r.headers.get('icy-metaint') || 0);
|
||||
const sender = r.headers.get('icy-name') || null;
|
||||
if (!abstand || !r.body) { steuer.abort(); return { titel: null, sender }; }
|
||||
|
||||
leser = r.body.getReader();
|
||||
let puffer = Buffer.alloc(0);
|
||||
let gelesen = 0; // Anfang des nächsten Titel-Blocks
|
||||
for (let i = 0; i < bloecke; i++) {
|
||||
// 4080 ist die grösstmögliche Blocklänge (255 × 16)
|
||||
while (puffer.length < gelesen + abstand + 4081) {
|
||||
const { value, done } = await leser.read();
|
||||
if (done) return { titel: null, sender };
|
||||
puffer = Buffer.concat([puffer, Buffer.from(value)]);
|
||||
}
|
||||
const start = gelesen + abstand;
|
||||
const laenge = puffer[start] * 16;
|
||||
if (laenge > 0) {
|
||||
const roh = puffer.slice(start + 1, start + 1 + laenge).toString('utf8');
|
||||
const titel = roh.match(/StreamTitle='([^']*)'/)?.[1]?.trim();
|
||||
if (titel) return { titel, sender };
|
||||
}
|
||||
gelesen = start + 1 + laenge;
|
||||
}
|
||||
return { titel: null, sender };
|
||||
} catch {
|
||||
// Sender weg, Zeitüberschreitung, kaputte Kopfzeilen — dann steht im
|
||||
// Embed eben nur der Sendername. Das ist kein Grund, etwas abzubrechen.
|
||||
return { titel: null, sender: null };
|
||||
} finally {
|
||||
await leser?.cancel().catch(() => {});
|
||||
steuer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ton ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ffmpeg als Tonquelle.
|
||||
*
|
||||
* `-reconnect` ist kein Beiwerk: ein Radiostream läuft stundenlang, und ein
|
||||
* einzelner Netz-Schluckauf würde ihn sonst endgültig beenden.
|
||||
*/
|
||||
function tonquelle(url) {
|
||||
const ff = spawn(FFMPEG, [
|
||||
'-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5',
|
||||
'-i', url,
|
||||
'-analyzeduration', '0', '-loglevel', 'error',
|
||||
'-vn', // manche Sender legen ein Coverbild bei
|
||||
'-acodec', 'libopus', '-f', 'ogg',
|
||||
'-ar', '48000', '-ac', '2', '-b:a', '96k',
|
||||
'pipe:1',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
// Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen
|
||||
ff.stderr.on('data', (d) => {
|
||||
const text = d.toString().trim();
|
||||
if (text) console.error('[radio] ffmpeg:', text.slice(0, 200));
|
||||
});
|
||||
ff.on('error', (e) => console.error('[radio] ffmpeg startet nicht:', e.message));
|
||||
return ff;
|
||||
}
|
||||
|
||||
/** Läuft gerade etwas auf diesem Server? */
|
||||
export const laeuft = (guildId) => laufend.get(guildId) ?? null;
|
||||
|
||||
/** Alles abräumen — Verbindung, Spieler, ffmpeg */
|
||||
export function stoppen(guildId) {
|
||||
const s = laufend.get(guildId);
|
||||
if (s) {
|
||||
s.player.stop(true);
|
||||
s.ffmpeg?.kill('SIGKILL');
|
||||
laufend.delete(guildId);
|
||||
}
|
||||
getVoiceConnection(guildId)?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender im Sprachkanal starten (oder umschalten).
|
||||
*
|
||||
* Umschalten läuft bewusst über dieselbe Funktion: Verbindung und Spieler
|
||||
* bleiben stehen, nur die Quelle wird getauscht. Ein Neuverbinden wäre für
|
||||
* die Zuhörer eine hörbare Pause.
|
||||
*/
|
||||
export async function spielen(kanal, station) {
|
||||
const guildId = kanal.guild.id;
|
||||
const vorher = laufend.get(guildId);
|
||||
|
||||
let verbindung = getVoiceConnection(guildId);
|
||||
if (!verbindung || verbindung.joinConfig.channelId !== kanal.id) {
|
||||
verbindung?.destroy();
|
||||
verbindung = joinVoiceChannel({
|
||||
channelId: kanal.id,
|
||||
guildId,
|
||||
adapterCreator: kanal.guild.voiceAdapterCreator,
|
||||
selfDeaf: true, // der Bot muss nicht hören, nur senden
|
||||
});
|
||||
// Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen
|
||||
// bleibt die Verbindung dann still stehen, statt sich zu erholen.
|
||||
verbindung.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(verbindung, VoiceConnectionStatus.Signalling, 5000),
|
||||
entersState(verbindung, VoiceConnectionStatus.Connecting, 5000),
|
||||
]);
|
||||
} catch {
|
||||
stoppen(guildId);
|
||||
clearRadioState(guildId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const player = vorher?.player ?? createAudioPlayer({
|
||||
// Niemand mehr im Kanal? Trotzdem weiterlaufen lassen statt zu pausieren
|
||||
// — die Aufräumung macht der Leer-Melder, und ein pausierter Stream
|
||||
// hätte beim Weiterlaufen eine Lücke.
|
||||
behaviors: { noSubscriber: NoSubscriberBehavior.Play },
|
||||
});
|
||||
|
||||
vorher?.ffmpeg?.kill('SIGKILL');
|
||||
const ff = tonquelle(station.url);
|
||||
const resource = createAudioResource(ff.stdout.pipe(new prism.opus.OggDemuxer()), {
|
||||
inputType: StreamType.Opus,
|
||||
});
|
||||
player.play(resource);
|
||||
verbindung.subscribe(player);
|
||||
|
||||
if (!vorher) {
|
||||
player.on('error', (e) => console.error('[radio] Spieler:', e.message));
|
||||
// Ein Radiostream endet nie von selbst. Tut er es doch, ist die
|
||||
// Leitung weg — dann neu aufbauen statt still dazustehen.
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
const jetzt = laufend.get(guildId);
|
||||
if (!jetzt) return;
|
||||
console.error('[radio] Strom abgerissen, neuer Versuch');
|
||||
const s = getStation(jetzt.stationId);
|
||||
if (s) setTimeout(() => spielen(kanal, s).catch(() => {}), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
laufend.set(guildId, {
|
||||
player, ffmpeg: ff, stationId: station.id, channelId: kanal.id,
|
||||
titel: null, seit: Date.now(),
|
||||
});
|
||||
return verbindung;
|
||||
}
|
||||
|
||||
// ── Steuertafel ───────────────────────────────────────────────────────────
|
||||
|
||||
// Discord lässt 25 Einträge je Auswahlmenü zu und höchstens fünf Reihen je
|
||||
// Nachricht. Bei 44 Sendern reicht ein Menü also nicht — und stillschweigend
|
||||
// die letzten 19 wegzulassen wäre die schlechteste aller Lösungen. Deshalb
|
||||
// mehrere Menüs, benannt nach ihrem Anfangsbuchstaben-Bereich.
|
||||
const JE_MENU = 25;
|
||||
const MAX_MENUS = 4; // die fünfte Reihe gehört den Knöpfen
|
||||
|
||||
/**
|
||||
* "Main … Metal" — damit man weiss, in welchem Menü man suchen muss.
|
||||
*
|
||||
* Bewusst die Namen und nicht die Anfangsbuchstaben: die Sender stehen in
|
||||
* der Reihenfolge, die im Panel eingestellt ist, und die ist üblicherweise
|
||||
* nach Musikrichtung sortiert. Ein „Sender B–W" wäre dann schlicht falsch.
|
||||
*/
|
||||
function bereich(gruppe) {
|
||||
const text = `${gruppe[0].name} … ${gruppe[gruppe.length - 1].name}`;
|
||||
return text.length <= 100 ? text : `${text.slice(0, 97)}…`;
|
||||
}
|
||||
|
||||
export function tafelKomponenten(stationen, aktivId) {
|
||||
if (stationen.length === 0) {
|
||||
const leer = new StringSelectMenuBuilder()
|
||||
.setCustomId('radio_select:0')
|
||||
.setPlaceholder('Keine Sender eingetragen')
|
||||
.setDisabled(true)
|
||||
// Ein Menü ohne Eintrag lehnt Discord ab — Platzhalter muss sein
|
||||
.setOptions(new StringSelectMenuOptionBuilder().setLabel('—').setValue('leer'));
|
||||
return [
|
||||
new ActionRowBuilder().addComponents(leer),
|
||||
new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()),
|
||||
];
|
||||
}
|
||||
|
||||
const gruppen = [];
|
||||
for (let i = 0; i < stationen.length && gruppen.length < MAX_MENUS; i += JE_MENU) {
|
||||
gruppen.push(stationen.slice(i, i + JE_MENU));
|
||||
}
|
||||
const reihen = gruppen.map((gruppe, nr) => {
|
||||
const menu = new StringSelectMenuBuilder()
|
||||
.setCustomId(`radio_select:${nr}`)
|
||||
.setPlaceholder(gruppen.length > 1 ? bereich(gruppe) : 'Sender wählen …')
|
||||
.addOptions(gruppe.map((s) => {
|
||||
const o = new StringSelectMenuOptionBuilder()
|
||||
.setLabel(s.name.slice(0, 100))
|
||||
.setValue(String(s.id))
|
||||
.setDefault(s.id === aktivId);
|
||||
if (s.emoji) o.setEmoji(s.emoji);
|
||||
return o;
|
||||
}));
|
||||
return new ActionRowBuilder().addComponents(menu);
|
||||
});
|
||||
reihen.push(new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()));
|
||||
return reihen;
|
||||
}
|
||||
|
||||
const stoppKnopf = () => new ButtonBuilder()
|
||||
.setCustomId('radio_stop').setLabel('Stopp').setEmoji('⏹️').setStyle(ButtonStyle.Danger);
|
||||
const aktualisierenKnopf = () => new ButtonBuilder()
|
||||
.setCustomId('radio_refresh').setLabel('Aktualisieren').setEmoji('🔄').setStyle(ButtonStyle.Secondary);
|
||||
|
||||
/** Wie viele Sender passen überhaupt in eine Tafel? */
|
||||
export const TAFEL_GRENZE = JE_MENU * MAX_MENUS;
|
||||
|
||||
export function tafelEmbed(client, station, titel, kanalId) {
|
||||
const embed = brandEmbed(client, 'RADIO')
|
||||
.setTitle('📻 Radio')
|
||||
.setFooter({ text: brandFooter('RADIO') })
|
||||
.setTimestamp();
|
||||
if (!station) {
|
||||
return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.');
|
||||
}
|
||||
embed
|
||||
.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`)
|
||||
.addFields({ name: 'Läuft gerade', value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_' });
|
||||
return embed;
|
||||
}
|
||||
|
||||
/** Steuertafel neu zeichnen — der Sender-Titel ändert sich ja ständig */
|
||||
async function tafelAktualisieren(client, guildId) {
|
||||
const stand = radioState(guildId);
|
||||
if (!stand?.panel_message_id) return;
|
||||
const kanal = await client.channels.fetch(stand.panel_channel_id).catch(() => null);
|
||||
const nachricht = await kanal?.messages?.fetch(stand.panel_message_id).catch(() => null);
|
||||
if (!nachricht) return;
|
||||
|
||||
const jetzt = laufend.get(guildId);
|
||||
const station = jetzt ? getStation(jetzt.stationId) : null;
|
||||
await nachricht.edit({
|
||||
embeds: [tafelEmbed(client, station, jetzt?.titel, jetzt?.channelId)],
|
||||
components: tafelKomponenten(listStations(), jetzt?.stationId),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Anbindung ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ist ausser dem Bot noch jemand im Kanal? */
|
||||
function jemandDa(kanal) {
|
||||
return kanal?.members?.filter((m) => !m.user.bot).size > 0;
|
||||
}
|
||||
|
||||
export function startRadio(client) {
|
||||
// Ohne Sender ist das Modul nutzlos, und niemand tippt 44 Adressen ab.
|
||||
// Passiert genau einmal — wer löscht, behält es gelöscht.
|
||||
senderGrundstock();
|
||||
|
||||
// Titel nachführen. 30 Sekunden sind ein Kompromiss: ein Lied dauert
|
||||
// selten unter zwei Minuten, und jede Abfrage kostet ein paar Kilobyte.
|
||||
setInterval(async () => {
|
||||
if (!moduleEnabled('radio')) return;
|
||||
// Alles einpacken: eine unbehandelte Rejection in einem Timer beendet
|
||||
// seit Node 15 den ganzen Prozess — dafür ist ein Liedtitel zu wenig.
|
||||
try {
|
||||
for (const [guildId, s] of laufend) {
|
||||
const station = getStation(s.stationId);
|
||||
if (!station) continue;
|
||||
const { titel } = await titelHolen(station.url);
|
||||
if (titel && titel !== s.titel) {
|
||||
s.titel = titel;
|
||||
await tafelAktualisieren(client, guildId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[radio] Titel-Abgleich:', error.message);
|
||||
}
|
||||
}, 30_000).unref();
|
||||
|
||||
// Leert sich der Kanal, geht der Bot. Sonst sendet er stundenlang gegen
|
||||
// eine leere Wand und hält eine ffmpeg-Instanz am Leben.
|
||||
client.on(Events.VoiceStateUpdate, async (vorher) => {
|
||||
try {
|
||||
const s = laufend.get(vorher.guild?.id);
|
||||
if (!s || vorher.channelId !== s.channelId) return;
|
||||
const kanal = await client.channels.fetch(s.channelId).catch(() => null);
|
||||
if (jemandDa(kanal)) return;
|
||||
stoppen(vorher.guild.id);
|
||||
clearRadioState(vorher.guild.id);
|
||||
await tafelAktualisieren(client, vorher.guild.id);
|
||||
} catch (error) {
|
||||
console.error('[radio] Sprachkanal-Wechsel:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Nach einem Neustart zurück in den Kanal — ein Deploy soll die Musik
|
||||
// nicht endgültig beenden, sondern nur kurz unterbrechen.
|
||||
client.once(Events.ClientReady, async () => {
|
||||
if (!moduleEnabled('radio')) return;
|
||||
for (const stand of radioStates()) {
|
||||
const station = stand.station_id ? getStation(stand.station_id) : null;
|
||||
const kanal = station ? await client.channels.fetch(stand.channel_id).catch(() => null) : null;
|
||||
if (!kanal?.isVoiceBased() || !jemandDa(kanal)) {
|
||||
// Niemand mehr da, oder der Sender wurde gelöscht — dann gilt
|
||||
// der Merker als verbraucht statt in einen leeren Kanal zu gehen.
|
||||
clearRadioState(stand.guild_id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await spielen(kanal, station);
|
||||
await tafelAktualisieren(client, stand.guild_id);
|
||||
console.log(`[radio] wieder in ${kanal.name}: ${station.name}`);
|
||||
} catch (e) {
|
||||
console.error('[radio] Rückkehr fehlgeschlagen:', e.message);
|
||||
clearRadioState(stand.guild_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Knöpfe und Auswahlmenü der Steuertafel */
|
||||
export async function handleRadioInteraction(interaction) {
|
||||
const { customId } = interaction;
|
||||
const istAuswahl = customId.startsWith('radio_select:');
|
||||
if (!istAuswahl && !['radio_stop', 'radio_refresh'].includes(customId)) return false;
|
||||
|
||||
const guildId = interaction.guild?.id;
|
||||
const eigenerKanal = interaction.member?.voice?.channel;
|
||||
|
||||
if (customId === 'radio_stop') {
|
||||
stoppen(guildId);
|
||||
clearRadioState(guildId);
|
||||
} else if (istAuswahl) {
|
||||
const station = getStation(Number(interaction.values[0]));
|
||||
// Wer nicht im Sprachkanal sitzt, soll den anderen nicht den Sender
|
||||
// umstellen können — dafür reicht ein Hinweis statt einer Sperre.
|
||||
const ziel = eigenerKanal ?? (laufend.get(guildId)
|
||||
? await interaction.client.channels.fetch(laufend.get(guildId).channelId).catch(() => null)
|
||||
: null);
|
||||
if (!station || !ziel) {
|
||||
await interaction.reply({
|
||||
content: '❌ Geh erst in einen Sprachkanal, dann wähle einen Sender.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await spielen(ziel, station);
|
||||
setRadioState({
|
||||
guildId,
|
||||
channelId: ziel.id,
|
||||
stationId: station.id,
|
||||
panelChannelId: interaction.message.channelId,
|
||||
panelMessageId: interaction.message.id,
|
||||
});
|
||||
// Den Titel gleich mitholen, sonst steht 30 Sekunden „wird abgefragt"
|
||||
const { titel } = await titelHolen(station.url);
|
||||
const s = laufend.get(guildId);
|
||||
if (s) s.titel = titel;
|
||||
}
|
||||
|
||||
const jetzt = laufend.get(guildId);
|
||||
const station = jetzt ? getStation(jetzt.stationId) : null;
|
||||
await interaction.update({
|
||||
embeds: [tafelEmbed(interaction.client, station, jetzt?.titel, jetzt?.channelId)],
|
||||
components: tafelKomponenten(listStations(), jetzt?.stationId),
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
@@ -1882,3 +1882,78 @@ export function archiveStats() {
|
||||
lastCommit: lastCommitStmt.get().ts ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Radio ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Sender sind frei eintragbar, nicht fest verdrahtet: es gibt keinen Grund,
|
||||
// warum jemand nur die Sender hören dürfen sollte, die ich kenne. Die URL ist
|
||||
// eindeutig — denselben Sender zweimal in der Liste hilft niemandem.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS radio_stations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
emoji TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
const listStationsStmt = db.prepare('SELECT * FROM radio_stations ORDER BY sort, name');
|
||||
const getStationStmt = db.prepare('SELECT * FROM radio_stations WHERE id = ?');
|
||||
const insertStationStmt = db.prepare(`
|
||||
INSERT INTO radio_stations (name, url, emoji, sort) VALUES (@name, @url, @emoji, @sort)
|
||||
ON CONFLICT(url) DO NOTHING
|
||||
`);
|
||||
const updateStationStmt = db.prepare(`
|
||||
UPDATE radio_stations SET name = @name, url = @url, emoji = @emoji, sort = @sort WHERE id = @id
|
||||
`);
|
||||
const deleteStationStmt = db.prepare('DELETE FROM radio_stations WHERE id = ?');
|
||||
export const listStations = () => listStationsStmt.all();
|
||||
export const getStation = (id) => getStationStmt.get(id) ?? null;
|
||||
export const createStation = (s) => insertStationStmt.run({
|
||||
name: String(s.name ?? '').slice(0, 60),
|
||||
url: String(s.url ?? ''),
|
||||
emoji: s.emoji ? String(s.emoji).slice(0, 8) : null,
|
||||
sort: Number(s.sort) || 0,
|
||||
}).lastInsertRowid;
|
||||
export const updateStation = (s) => updateStationStmt.run({
|
||||
id: Number(s.id),
|
||||
name: String(s.name ?? '').slice(0, 60),
|
||||
url: String(s.url ?? ''),
|
||||
emoji: s.emoji ? String(s.emoji).slice(0, 8) : null,
|
||||
sort: Number(s.sort) || 0,
|
||||
});
|
||||
export const deleteStation = (id) => deleteStationStmt.run(id).changes > 0;
|
||||
|
||||
// Was gerade wo läuft. Steht in der Datenbank und nicht nur im Speicher, damit
|
||||
// der Bot nach einem Neustart von selbst zurückkommt — ein Deploy wirft ihn
|
||||
// sonst aus dem Sprachkanal und die Musik ist einfach weg.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS radio_state (
|
||||
guild_id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
station_id INTEGER,
|
||||
panel_channel_id TEXT,
|
||||
panel_message_id TEXT,
|
||||
seit TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
const radioStateStmt = db.prepare('SELECT * FROM radio_state WHERE guild_id = ?');
|
||||
const radioStatesStmt = db.prepare('SELECT * FROM radio_state');
|
||||
const setRadioStateStmt = db.prepare(`
|
||||
INSERT INTO radio_state (guild_id, channel_id, station_id, panel_channel_id, panel_message_id, seit)
|
||||
VALUES (@guild_id, @channel_id, @station_id, @panel_channel_id, @panel_message_id, datetime('now'))
|
||||
ON CONFLICT(guild_id) DO UPDATE SET
|
||||
channel_id = excluded.channel_id, station_id = excluded.station_id,
|
||||
panel_channel_id = excluded.panel_channel_id, panel_message_id = excluded.panel_message_id
|
||||
`);
|
||||
const clearRadioStateStmt = db.prepare('DELETE FROM radio_state WHERE guild_id = ?');
|
||||
export const radioState = (guildId) => radioStateStmt.get(guildId) ?? null;
|
||||
export const radioStates = () => radioStatesStmt.all();
|
||||
export const setRadioState = (s) => setRadioStateStmt.run({
|
||||
guild_id: String(s.guildId),
|
||||
channel_id: String(s.channelId),
|
||||
station_id: s.stationId != null ? Number(s.stationId) : null,
|
||||
panel_channel_id: s.panelChannelId ? String(s.panelChannelId) : null,
|
||||
panel_message_id: s.panelMessageId ? String(s.panelMessageId) : null,
|
||||
});
|
||||
export const clearRadioState = (guildId) => clearRadioStateStmt.run(guildId).changes > 0;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { startWatchdog } from './bot/watchdog.js';
|
||||
import { scheduleBackups } from './backup.js';
|
||||
import { startServerMonitor } from './bot/server-monitor.js';
|
||||
import { startLsFarm } from './bot/ls-farm.js';
|
||||
import { startRadio } from './bot/radio.js';
|
||||
import { startGiveaways } from './bot/giveaways.js';
|
||||
import { startScheduledPosts } from './web/scheduled-posts.js';
|
||||
import { startSocialNotify } from './bot/social-notify.js';
|
||||
@@ -24,6 +25,7 @@ try {
|
||||
scheduleBackups(client);
|
||||
startServerMonitor(client);
|
||||
startLsFarm(client);
|
||||
startRadio(client);
|
||||
startGiveaways(client);
|
||||
startScheduledPosts(client);
|
||||
startSocialNotify(client);
|
||||
|
||||
@@ -107,6 +107,14 @@ export const MODULES = [
|
||||
desc: 'Schnelle Abstimmungen als native Discord-Umfrage — /umfrage.',
|
||||
tab: 'community', default: true,
|
||||
},
|
||||
{
|
||||
id: 'radio', group: 'community', name: 'Radio',
|
||||
desc: 'Webradio im Sprachkanal — /radio öffnet eine Tafel mit Sender-Auswahl zum '
|
||||
+ 'Durchschalten. 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,
|
||||
tool: { tab: 'community', label: 'Sender verwalten' },
|
||||
},
|
||||
{
|
||||
id: 'giveaways', group: 'community', name: 'Verlosungen',
|
||||
desc: 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung möglich.',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
createService, updateService, listServices, deleteService,
|
||||
savePage, getPage, listPages, listPublishedPages, deletePage,
|
||||
lsState, lsModSizes, setLsModSize,
|
||||
listStations, createStation, updateStation, deleteStation,
|
||||
} from '../db.js';
|
||||
import crypto from 'node:crypto';
|
||||
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
|
||||
@@ -35,6 +36,7 @@ import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
|
||||
import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
|
||||
import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js';
|
||||
import { istLsServer, modBasis } from '../bot/ls-farm.js';
|
||||
import { titelHolen } from '../bot/radio.js';
|
||||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||||
import {
|
||||
@@ -357,6 +359,65 @@ export function registerApiRoutes(app, client) {
|
||||
return { deleted: deleteMonitored(Number(request.params.id)), dienste: listMonitored() };
|
||||
});
|
||||
|
||||
// --- Radio-Sender ---
|
||||
//
|
||||
// Frei eintragbar statt fest verdrahtet: es gibt keinen Grund, warum
|
||||
// jemand nur die Sender hören dürfen sollte, die im Code stehen.
|
||||
|
||||
/** Eingaben prüfen; antwortet bei Fehlern selbst */
|
||||
function senderBody(request, reply) {
|
||||
const b = request.body ?? {};
|
||||
const name = String(b.name ?? '').trim().slice(0, 60);
|
||||
const url = String(b.url ?? '').trim();
|
||||
if (!name) { reply.code(400).send({ error: 'Name fehlt' }); return null; }
|
||||
// Kein http:// — der Strom liefe sonst unverschlüsselt durch den Server
|
||||
if (!/^https?:\/\/.+/.test(url)) { reply.code(400).send({ error: 'URL mit http(s) erwartet' }); return null; }
|
||||
return {
|
||||
name, url,
|
||||
emoji: String(b.emoji ?? '').trim().slice(0, 8) || null,
|
||||
sort: Number(b.sort) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/radio', async (request, reply) => {
|
||||
if (requireAnyScope(request, reply)) return;
|
||||
return { sender: listStations() };
|
||||
});
|
||||
|
||||
app.post('/api/radio', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'settings')) return;
|
||||
const s = senderBody(request, reply);
|
||||
if (!s) return;
|
||||
createStation(s);
|
||||
logAudit(getSessionUser(request), 'radiosender angelegt', `${s.name} (${s.url})`);
|
||||
return { ok: true, sender: listStations() };
|
||||
});
|
||||
|
||||
app.put('/api/radio/:id', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'settings')) return;
|
||||
const s = senderBody(request, reply);
|
||||
if (!s) return;
|
||||
updateStation({ ...s, id: Number(request.params.id) });
|
||||
logAudit(getSessionUser(request), 'radiosender geändert', s.name);
|
||||
return { ok: true, sender: listStations() };
|
||||
});
|
||||
|
||||
app.delete('/api/radio/:id', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'settings')) return;
|
||||
logAudit(getSessionUser(request), 'radiosender entfernt', String(request.params.id));
|
||||
return { deleted: deleteStation(Number(request.params.id)), sender: listStations() };
|
||||
});
|
||||
|
||||
// Was der Sender gerade spielt — zum Prüfen beim Eintragen. Der Titel
|
||||
// kommt aus dem Datenstrom selbst, es braucht also keine Sender-API.
|
||||
app.post('/api/radio/pruefen', async (request, reply) => {
|
||||
if (requireScope(request, reply, 'settings')) return;
|
||||
const url = String(request.body?.url ?? '').trim();
|
||||
if (!/^https?:\/\/.+/.test(url)) return reply.code(400).send({ error: 'URL mit http(s) erwartet' });
|
||||
const { titel, sender } = await titelHolen(url);
|
||||
return { erreichbar: sender != null || titel != null, sender, titel };
|
||||
});
|
||||
|
||||
// Ticket-Anliegen. Ohne Eintrag bleibt es beim einen Knopf — deshalb ist
|
||||
// eine leere Liste kein Fehler, sondern der Normalfall fuer kleine Server.
|
||||
app.get('/api/ticket-categories', async (request, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user