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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user