Radio: Webradio im Sprachkanal, 44 Sender zum Durchschalten
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

/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:
2026-08-14 11:40:15 +02:00
co-authored by Claude Opus 5
parent b7f54185f7
commit 89f6dfcd29
17 changed files with 994 additions and 4 deletions
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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'],
+137
View File
@@ -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);
+37
View File
@@ -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; }
}