feat(media): Download-Option für URL-Player; yt-dlp/ffmpeg Args für YouTube stabilisiert; UI: Checkbox 'Download speichern' + Dropdown layering fix

This commit is contained in:
vibe-bot 2025-08-08 17:04:38 +02:00
parent e401124001
commit f9e9dc8d0b
4 changed files with 52 additions and 11 deletions

View file

@ -587,7 +587,7 @@ app.listen(PORT, () => {
// Unterstützt: YouTube (ytdl-core), generische URLs (yt-dlp), direkte mp3 (Download und Ablage) // Unterstützt: YouTube (ytdl-core), generische URLs (yt-dlp), direkte mp3 (Download und Ablage)
app.post('/api/play-url', async (req: Request, res: Response) => { app.post('/api/play-url', async (req: Request, res: Response) => {
try { try {
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number }; const { url, guildId, channelId, volume, download } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; download?: boolean };
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
// MP3 direkt? // MP3 direkt?
@ -624,10 +624,44 @@ app.post('/api/play-url', async (req: Request, res: Response) => {
const useVolume = typeof volume === 'number' ? Math.max(0, Math.min(1, volume)) : state.currentVolume ?? 1; const useVolume = typeof volume === 'number' ? Math.max(0, Math.min(1, volume)) : state.currentVolume ?? 1;
// Audio-Stream besorgen // Audio-Stream besorgen
// Einheitlicher Weg: yt-dlp + ffmpeg Transcoding (stabiler als ytdl-core) // Download in Datei (mp3) falls gewünscht
const yt = child_process.spawn('yt-dlp', ['-f', 'bestaudio', '--no-playlist', '--quiet', '--no-warnings', '-o', '-', url]); if (download === true) {
const safeBase = `media-${Date.now()}`;
const outPath = path.join(SOUNDS_DIR, `${safeBase}.mp3`);
const yt = child_process.spawn('yt-dlp', ['--no-playlist', '-x', '--audio-format', 'mp3', '--audio-quality', '0', '--no-warnings', '--geo-bypass', '-o', outPath, url]);
yt.stderr.on('data', (d) => console.log(`[yt-dlp] ${String(d)}`));
yt.on('error', (err) => console.error('yt-dlp spawn error:', err));
yt.on('close', async (code) => {
if (code !== 0) {
console.error('yt-dlp exited with code', code);
try { res.status(500).json({ error: 'Download fehlgeschlagen' }); } catch {}
return;
}
// Datei abspielen
try {
const resource = createAudioResource(outPath, { inlineVolume: true });
if (resource.volume) resource.volume.setVolume(useVolume);
state!.player.stop();
state!.player.play(resource);
state!.currentResource = resource;
state!.currentVolume = useVolume;
try { res.json({ ok: true, saved: path.basename(outPath) }); } catch {}
} catch (e) {
console.error('play downloaded file error:', e);
try { res.status(500).json({ error: 'Abspielen der Datei fehlgeschlagen' }); } catch {}
}
});
return;
}
// Streaming: yt-dlp + ffmpeg Transcoding (stabiler als ytdl-core)
const ytArgs = ['-f', 'bestaudio/best', '--no-playlist', '--no-warnings', '--geo-bypass', '-o', '-', url];
const yt = child_process.spawn('yt-dlp', ytArgs);
yt.stderr.on('data', (d) => console.log(`[yt-dlp] ${String(d)}`));
yt.on('error', (err) => console.error('yt-dlp spawn error:', err)); yt.on('error', (err) => console.error('yt-dlp spawn error:', err));
const ff = child_process.spawn('ffmpeg', ['-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']); const ffArgs = ['-loglevel', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'];
const ff = child_process.spawn('ffmpeg', ffArgs);
ff.stderr.on('data', (d) => console.log(`[ffmpeg] ${String(d)}`));
yt.stdout.pipe(ff.stdin); yt.stdout.pipe(ff.stdin);
const resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw }); const resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw });

View file

@ -22,6 +22,7 @@ export default function App() {
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]); const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]);
const [clock, setClock] = useState<string>(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date())); const [clock, setClock] = useState<string>(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date()));
const [mediaUrl, setMediaUrl] = useState<string>(''); const [mediaUrl, setMediaUrl] = useState<string>('');
const [mediaDownload, setMediaDownload] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -184,7 +185,7 @@ export default function App() {
if (e.key === 'Enter') { if (e.key === 'Enter') {
if (!selected) { setError('Bitte Voice-Channel wählen'); return; } if (!selected) { setError('Bitte Voice-Channel wählen'); return; }
const [guildId, channelId] = selected.split(':'); const [guildId, channelId] = selected.split(':');
try { await playUrl(mediaUrl, guildId, channelId, volume); } try { await playUrl(mediaUrl, guildId, channelId, volume, mediaDownload); }
catch (err: any) { setError(err?.message || 'Play-URL fehlgeschlagen'); } catch (err: any) { setError(err?.message || 'Play-URL fehlgeschlagen'); }
} }
}} }}
@ -193,10 +194,14 @@ export default function App() {
<button type="button" className="tab" onClick={async () => { <button type="button" className="tab" onClick={async () => {
if (!selected) { setError('Bitte Voice-Channel wählen'); return; } if (!selected) { setError('Bitte Voice-Channel wählen'); return; }
const [guildId, channelId] = selected.split(':'); const [guildId, channelId] = selected.split(':');
try { await playUrl(mediaUrl, guildId, channelId, volume); } try { await playUrl(mediaUrl, guildId, channelId, volume, mediaDownload); }
catch (e: any) { setError(e?.message || 'Play-URL fehlgeschlagen'); } catch (e: any) { setError(e?.message || 'Play-URL fehlgeschlagen'); }
}}> Abspielen</button> }}> Abspielen</button>
</div> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={mediaDownload} onChange={(e) => setMediaDownload(e.target.checked)} />
Download speichern
</label>
</section> </section>
{!isAdmin && ( {!isAdmin && (

View file

@ -88,10 +88,10 @@ export async function adminRename(from: string, to: string): Promise<string> {
return data?.to as string; return data?.to as string;
} }
export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise<void> { export async function playUrl(url: string, guildId: string, channelId: string, volume: number, download?: boolean): Promise<void> {
const res = await fetch(`${API_BASE}/play-url`, { const res = await fetch(`${API_BASE}/play-url`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, guildId, channelId, volume }) body: JSON.stringify({ url, guildId, channelId, volume, download: !!download })
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));

View file

@ -91,7 +91,9 @@ header p { opacity: .8; }
.controls { display: grid; grid-template-columns: 1fr minmax(240px, 320px) 260px 200px; gap: 12px; align-items: center; margin-bottom: 12px; } .controls { display: grid; grid-template-columns: 1fr minmax(240px, 320px) 260px 200px; gap: 12px; align-items: center; margin-bottom: 12px; }
.controls.row2 { grid-template-columns: minmax(400px, 1fr); } .controls.row2 { grid-template-columns: minmax(400px, 1fr); }
.controls.row3 { grid-template-columns: 1fr; } .controls.row3 { grid-template-columns: 1fr; }
.controls.glass { padding: 18px; } .controls.glass { padding: 18px; position: relative; z-index: 1; }
.controls.row2 { grid-template-columns: minmax(400px, 1fr); z-index: 5; }
.controls.row3 { grid-template-columns: 1fr; z-index: 1; }
.controls.glass { .controls.glass {
backdrop-filter: saturate(140%) blur(20px); backdrop-filter: saturate(140%) blur(20px);
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06)); background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06));
@ -116,7 +118,7 @@ header p { opacity: .8; }
.control select optgroup { background-color: #0f1530; color: #c8c8d8; } .control select optgroup { background-color: #0f1530; color: #c8c8d8; }
/* Custom Select */ /* Custom Select */
.custom-select { position: relative; } .custom-select { position: relative; z-index: 10; }
.select-trigger { .select-trigger {
width: 100%; width: 100%;
text-align: left; text-align: left;
@ -137,7 +139,7 @@ header p { opacity: .8; }
background: #0f1530; background: #0f1530;
box-shadow: 0 24px 48px rgba(0,0,0,.5); box-shadow: 0 24px 48px rgba(0,0,0,.5);
max-height: 280px; overflow-y: auto; max-height: 280px; overflow-y: auto;
z-index: 20; z-index: 100;
} }
.select-item { .select-item {
width: 100%; text-align: left; padding: 10px 12px; color: #e7e7ee; width: 100%; text-align: left; padding: 10px 12px; color: #e7e7ee;