From f9e9dc8d0bd545296e9a9ac94fad73821d357024 Mon Sep 17 00:00:00 2001 From: vibe-bot Date: Fri, 8 Aug 2025 17:04:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(media):=20Download-Option=20f=C3=BCr=20URL?= =?UTF-8?q?-Player;=20yt-dlp/ffmpeg=20Args=20f=C3=BCr=20YouTube=20stabilis?= =?UTF-8?q?iert;=20UI:=20Checkbox=20'Download=20speichern'=20+=20Dropdown?= =?UTF-8?q?=20layering=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/index.ts | 42 ++++++++++++++++++++++++++++++++++++++---- web/src/App.tsx | 9 +++++++-- web/src/api.ts | 4 ++-- web/src/styles.css | 8 +++++--- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index fee3cc2..85c81cf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -587,7 +587,7 @@ app.listen(PORT, () => { // Unterstützt: YouTube (ytdl-core), generische URLs (yt-dlp), direkte mp3 (Download und Ablage) app.post('/api/play-url', async (req: Request, res: Response) => { 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' }); // 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; // Audio-Stream besorgen - // Einheitlicher Weg: yt-dlp + ffmpeg Transcoding (stabiler als ytdl-core) - const yt = child_process.spawn('yt-dlp', ['-f', 'bestaudio', '--no-playlist', '--quiet', '--no-warnings', '-o', '-', url]); + // Download in Datei (mp3) falls gewünscht + 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)); - 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); const resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 51a654c..53305e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,6 +22,7 @@ export default function App() { const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]); const [clock, setClock] = useState(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date())); const [mediaUrl, setMediaUrl] = useState(''); + const [mediaDownload, setMediaDownload] = useState(false); useEffect(() => { (async () => { @@ -184,7 +185,7 @@ export default function App() { if (e.key === 'Enter') { if (!selected) { setError('Bitte Voice-Channel wählen'); return; } 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'); } } }} @@ -193,10 +194,14 @@ export default function App() { + {!isAdmin && ( diff --git a/web/src/api.ts b/web/src/api.ts index c1853a1..668892f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -88,10 +88,10 @@ export async function adminRename(from: string, to: string): Promise { return data?.to as string; } -export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise { +export async function playUrl(url: string, guildId: string, channelId: string, volume: number, download?: boolean): Promise { const res = await fetch(`${API_BASE}/play-url`, { 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) { const data = await res.json().catch(() => ({})); diff --git a/web/src/styles.css b/web/src/styles.css index 335d2e8..cabcc04 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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.row2 { grid-template-columns: minmax(400px, 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 { backdrop-filter: saturate(140%) blur(20px); 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; } /* Custom Select */ -.custom-select { position: relative; } +.custom-select { position: relative; z-index: 10; } .select-trigger { width: 100%; text-align: left; @@ -137,7 +139,7 @@ header p { opacity: .8; } background: #0f1530; box-shadow: 0 24px 48px rgba(0,0,0,.5); max-height: 280px; overflow-y: auto; - z-index: 20; + z-index: 100; } .select-item { width: 100%; text-align: left; padding: 10px 12px; color: #e7e7ee;