Radio youtube #2

Merged
D4rkst3r merged 6 commits from radio-youtube into main 2026-08-28 14:02:24 +02:00
3 changed files with 536 additions and 3 deletions
Showing only changes of commit dbec4e9775 - Show all commits
+467
View File
@@ -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 }) {
<span className="field-hint">ohne Haken antwortet /radio nur mit einem Hinweis</span>
</label>
</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">
Adressen von Webradio-Strömen. <code>/radio</code> öffnet im Discord eine
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
// 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() {
const url = senderDraft.url.trim();
if (!url) return;
+55
View File
@@ -3250,3 +3250,58 @@ button.with-icon, a.with-icon { justify-content: center; }
@media (max-width: 560px) {
.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; }
}
+14 -3
View File
@@ -109,10 +109,21 @@ export const MODULES = [
},
{
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.',
desc: 'Webradio und YouTube im Sprachkanal — /radio öffnet eine Tafel mit '
+ 'Sender-Auswahl, /musik eine zweite fürs Einreihen von YouTube-Titeln. '
+ '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,
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' },
},
{