From 9ff8a38547920256d8da1dd4a02216f71479b4e2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 23:59:31 +0100 Subject: [PATCH] feat(soundboard): download modal with filename input + fix yt-dlp binary - Add download modal: filename input, progress phases (input/downloading/done/error) - Refactor backend: shared handleUrlDownload() with optional custom filename + rename - Fix Dockerfile: use yt-dlp_linux standalone binary (no Python dependency) - Modal shows URL type badge (YouTube/Instagram/MP3), spinner, retry on error Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- server/src/plugins/soundboard/index.ts | 118 +++++++------- web/src/plugins/soundboard/SoundboardTab.tsx | 153 +++++++++++++++++-- web/src/plugins/soundboard/soundboard.css | 135 ++++++++++++++++ 4 files changed, 332 insertions(+), 76 deletions(-) diff --git a/Dockerfile b/Dockerfile index 92fbe3a..f0b8a5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ FROM node:24-slim AS runtime WORKDIR /app ENV NODE_ENV=production PORT=8080 DATA_DIR=/data RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ - && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ + && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp \ && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index 869770d..21e1772 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -958,42 +958,66 @@ const soundboardPlugin: Plugin = { } catch (e: any) { console.error(`${SB} /play error: ${e?.message ?? e}`); res.status(500).json({ error: e?.message ?? 'Fehler' }); } }); + /** Shared download logic for play-url and download-url */ + async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> { + let savedFile: string; + let savedPath: string; + + if (isYtDlpUrl(url)) { + console.log(`${SB} [url-dl] → yt-dlp...`); + const result = await downloadWithYtDlp(url); + savedFile = result.filename; + savedPath = result.filepath; + } else { + const parsed = new URL(url); + savedFile = path.basename(parsed.pathname); + savedPath = path.join(SOUNDS_DIR, savedFile); + console.log(`${SB} [url-dl] → direct MP3: ${savedFile}`); + const r = await fetch(url); + if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`); + const buf = Buffer.from(await r.arrayBuffer()); + fs.writeFileSync(savedPath, buf); + console.log(`${SB} [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`); + } + + // Rename if custom filename provided + if (customFilename) { + const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim(); + if (safeName) { + const ext = path.extname(savedFile).toLowerCase() || '.mp3'; + const newName = safeName.endsWith(ext) ? safeName : safeName + ext; + const newPath = path.join(SOUNDS_DIR, newName); + if (newPath !== savedPath && !fs.existsSync(newPath)) { + fs.renameSync(savedPath, newPath); + console.log(`${SB} [url-dl] renamed: ${savedFile} → ${newName}`); + savedFile = newName; + savedPath = newPath; + } + } + } + + if (NORMALIZE_ENABLE) { + try { await normalizeToCache(savedPath); console.log(`${SB} [url-dl] normalized`); } + catch (e: any) { console.error(`${SB} [url-dl] normalize failed: ${e?.message}`); } + } + + return { savedFile, savedPath }; + } + app.post('/api/soundboard/play-url', async (req, res) => { const startTime = Date.now(); try { - const { url, guildId, channelId, volume } = req.body ?? {}; + const { url, guildId, channelId, volume, filename } = req.body ?? {}; const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; - console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} guild=${guildId} channel=${channelId}`); + console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`); - if (!url || !guildId || !channelId) { console.log(`${SB} [play-url] ✗ missing params`); res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; } - try { new URL(url); } catch { console.log(`${SB} [play-url] ✗ invalid URL`); res.status(400).json({ error: 'Ungültige URL' }); return; } - if (!isSupportedUrl(url)) { console.log(`${SB} [play-url] ✗ unsupported URL type`); res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; } + if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; } + try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } + if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; } - let savedFile: string; - let savedPath: string; + const { savedFile, savedPath } = await handleUrlDownload(url, filename); - if (isYtDlpUrl(url)) { - console.log(`${SB} [play-url] → delegating to yt-dlp...`); - const result = await downloadWithYtDlp(url); - savedFile = result.filename; - savedPath = result.filepath; - } else { - const parsed = new URL(url); - savedFile = path.basename(parsed.pathname); - savedPath = path.join(SOUNDS_DIR, savedFile); - console.log(`${SB} [play-url] → direct MP3 download: ${savedFile}`); - const r = await fetch(url); - if (!r.ok) { - console.error(`${SB} [play-url] ✗ HTTP ${r.status} ${r.statusText}`); - res.status(400).json({ error: `Download fehlgeschlagen (HTTP ${r.status})` }); return; - } - const buf = Buffer.from(await r.arrayBuffer()); - fs.writeFileSync(savedPath, buf); - console.log(`${SB} [play-url] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`); - } - - if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); console.log(`${SB} [play-url] normalized`); } catch (e: any) { console.error(`${SB} [play-url] normalize failed: ${e?.message}`); } } - try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing in channel`); } + try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing`); } catch (e: any) { console.error(`${SB} [play-url] play failed (file saved): ${e?.message}`); } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -1006,42 +1030,18 @@ const soundboardPlugin: Plugin = { } }); - // Download-only route (save without auto-play) app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => { const startTime = Date.now(); try { - const { url } = req.body ?? {}; + const { url, filename } = req.body ?? {}; const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; - console.log(`${SB} [download-url] ▶ type=${urlType} url=${url}`); + console.log(`${SB} [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`); - if (!url) { console.log(`${SB} [download-url] ✗ no URL`); res.status(400).json({ error: 'URL erforderlich' }); return; } - try { new URL(url); } catch { console.log(`${SB} [download-url] ✗ invalid URL`); res.status(400).json({ error: 'Ungültige URL' }); return; } - if (!isSupportedUrl(url)) { console.log(`${SB} [download-url] ✗ unsupported URL type`); res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; } + if (!url) { res.status(400).json({ error: 'URL erforderlich' }); return; } + try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } + if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; } - let savedFile: string; - let savedPath: string; - - if (isYtDlpUrl(url)) { - console.log(`${SB} [download-url] → delegating to yt-dlp...`); - const result = await downloadWithYtDlp(url); - savedFile = result.filename; - savedPath = result.filepath; - } else { - const parsed = new URL(url); - savedFile = path.basename(parsed.pathname); - savedPath = path.join(SOUNDS_DIR, savedFile); - console.log(`${SB} [download-url] → direct MP3 download: ${savedFile}`); - const r = await fetch(url); - if (!r.ok) { - console.error(`${SB} [download-url] ✗ HTTP ${r.status} ${r.statusText}`); - res.status(400).json({ error: `Download fehlgeschlagen (HTTP ${r.status})` }); return; - } - const buf = Buffer.from(await r.arrayBuffer()); - fs.writeFileSync(savedPath, buf); - console.log(`${SB} [download-url] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`); - } - - if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); console.log(`${SB} [download-url] normalized`); } catch (e: any) { console.error(`${SB} [download-url] normalize failed: ${e?.message}`); } } + const { savedFile } = await handleUrlDownload(url, filename); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`); diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index ec94668..380b65a 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -129,20 +129,20 @@ async function apiPlaySound(soundName: string, guildId: string, channelId: strin } } -async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise<{ saved?: string }> { +async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): Promise<{ saved?: string }> { 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, filename }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen'); return data; } -async function apiDownloadUrl(url: string): Promise<{ saved?: string }> { +async function apiDownloadUrl(url: string, filename?: string): Promise<{ saved?: string }> { const res = await fetch(`${API_BASE}/download-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }) + body: JSON.stringify({ url, filename }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen'); @@ -319,6 +319,13 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { const [importUrl, setImportUrl] = useState(''); const [importBusy, setImportBusy] = useState(false); + // Download modal state + const [dlModal, setDlModal] = useState<{ + url: string; type: 'youtube' | 'instagram' | 'mp3' | null; + filename: string; phase: 'input' | 'downloading' | 'done' | 'error'; + savedName?: string; error?: string; + } | null>(null); + /* ── Channels ── */ const [channels, setChannels] = useState([]); const [selected, setSelected] = useState(''); @@ -636,32 +643,42 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } - async function handleUrlImport() { + // Open download modal instead of downloading directly + function handleUrlImport() { const trimmed = normalizeUrl(importUrl); if (!trimmed) return notify('Bitte einen Link eingeben', 'error'); if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error'); - setImportBusy(true); const urlType = getUrlType(trimmed); + // Pre-fill filename for MP3 links (basename without .mp3), empty for YT/IG + let defaultName = ''; + if (urlType === 'mp3') { + try { defaultName = new URL(trimmed).pathname.split('/').pop()?.replace(/\.mp3$/i, '') ?? ''; } catch {} + } + setDlModal({ url: trimmed, type: urlType, filename: defaultName, phase: 'input' }); + } + + // Actual download triggered from modal + async function handleModalDownload() { + if (!dlModal) return; + setDlModal(prev => prev ? { ...prev, phase: 'downloading' } : null); try { let savedName: string | undefined; + const fn = dlModal.filename.trim() || undefined; if (selected && guildId && channelId) { - // Voice channel selected → download + play - const result = await apiPlayUrl(trimmed, guildId, channelId, volume); + const result = await apiPlayUrl(dlModal.url, guildId, channelId, volume, fn); savedName = result.saved; } else { - // No voice channel → download only - const result = await apiDownloadUrl(trimmed); + const result = await apiDownloadUrl(dlModal.url, fn); savedName = result.saved; } + setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null); setImportUrl(''); - const typeLabel = urlType === 'youtube' ? 'YouTube' : urlType === 'instagram' ? 'Instagram' : 'MP3'; - notify(`${typeLabel} gespeichert${selected ? ' und abgespielt' : ''}: ${savedName ?? ''}`); setRefreshKey(k => k + 1); - await loadAnalytics(); + void loadAnalytics(); + // Auto-close after 2.5s + setTimeout(() => setDlModal(null), 2500); } catch (e: any) { - notify(e?.message || 'Download fehlgeschlagen', 'error'); - } finally { - setImportBusy(false); + setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null); } } @@ -1564,6 +1581,110 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { )} + + {/* ── Download Modal ── */} + {dlModal && ( +
dlModal.phase !== 'downloading' && setDlModal(null)}> +
e.stopPropagation()}> +
+ + {dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'} + + + {dlModal.phase === 'input' ? 'Sound herunterladen' + : dlModal.phase === 'downloading' ? 'Wird heruntergeladen...' + : dlModal.phase === 'done' ? 'Fertig!' + : 'Fehler'} + + {dlModal.phase !== 'downloading' && ( + + )} +
+ +
+ {/* URL badge */} +
+ + {dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'} + + + {dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url} + +
+ + {/* Filename input (input phase only) */} + {dlModal.phase === 'input' && ( +
+ +
+ setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)} + onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }} + autoFocus + /> + .mp3 +
+ Leer lassen = automatischer Name +
+ )} + + {/* Progress (downloading phase) */} + {dlModal.phase === 'downloading' && ( +
+
+ + {dlModal.type === 'youtube' || dlModal.type === 'instagram' + ? 'Audio wird extrahiert...' + : 'MP3 wird heruntergeladen...'} + +
+ )} + + {/* Success */} + {dlModal.phase === 'done' && ( +
+ check_circle + Gespeichert als {dlModal.savedName} +
+ )} + + {/* Error */} + {dlModal.phase === 'error' && ( +
+ error + {dlModal.error} +
+ )} +
+ + {/* Actions */} + {dlModal.phase === 'input' && ( +
+ + +
+ )} + {dlModal.phase === 'error' && ( +
+ + +
+ )} +
+
+ )}
); } diff --git a/web/src/plugins/soundboard/soundboard.css b/web/src/plugins/soundboard/soundboard.css index 00090ff..be47e1a 100644 --- a/web/src/plugins/soundboard/soundboard.css +++ b/web/src/plugins/soundboard/soundboard.css @@ -2032,6 +2032,141 @@ margin-top: 2px; } +/* ──────────────────────────────────────────── + Download Modal + ──────────────────────────────────────────── */ +.dl-modal-overlay { + position: fixed; inset: 0; + background: rgba(0, 0, 0, .55); + display: flex; align-items: center; justify-content: center; + z-index: 300; + animation: fade-in 150ms ease; +} +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + +.dl-modal { + width: 420px; max-width: 92vw; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 16px; + box-shadow: 0 12px 60px rgba(0, 0, 0, .5); + animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1); +} +@keyframes scale-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } } + +.dl-modal-header { + display: flex; align-items: center; gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, .06); + font-size: 14px; font-weight: 700; + color: var(--text-normal); +} +.dl-modal-header .material-icons { color: var(--accent); } + +.dl-modal-close { + margin-left: auto; + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: 50%; + border: none; background: rgba(255,255,255,.06); + color: var(--text-muted); cursor: pointer; + transition: background var(--transition); +} +.dl-modal-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); } + +.dl-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; } + +/* URL display */ +.dl-modal-url { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: 8px; + background: rgba(0, 0, 0, .2); + overflow: hidden; +} +.dl-modal-tag { + flex-shrink: 0; padding: 2px 8px; border-radius: 6px; + font-size: 10px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase; +} +.dl-modal-tag.youtube { background: rgba(255, 0, 0, .18); color: #ff4444; } +.dl-modal-tag.instagram { background: rgba(225, 48, 108, .18); color: #e1306c; } +.dl-modal-tag.mp3 { background: rgba(46, 204, 113, .18); color: #2ecc71; } +.dl-modal-url-text { + font-size: 11px; color: var(--text-faint); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Filename field */ +.dl-modal-field { display: flex; flex-direction: column; gap: 5px; } +.dl-modal-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } +.dl-modal-input-wrap { + display: flex; align-items: center; + border: 1px solid rgba(255, 255, 255, .1); border-radius: 8px; + background: rgba(0, 0, 0, .15); + overflow: hidden; + transition: border-color var(--transition); +} +.dl-modal-input-wrap:focus-within { border-color: var(--accent); } +.dl-modal-input { + flex: 1; border: none; background: transparent; + padding: 8px 10px; color: var(--text-normal); + font-size: 13px; font-family: var(--font); outline: none; +} +.dl-modal-input::placeholder { color: var(--text-faint); } +.dl-modal-ext { + padding: 0 10px; font-size: 12px; font-weight: 600; + color: var(--text-faint); background: rgba(255, 255, 255, .04); + align-self: stretch; display: flex; align-items: center; +} +.dl-modal-hint { font-size: 10px; color: var(--text-faint); } + +/* Progress spinner */ +.dl-modal-progress { + display: flex; align-items: center; gap: 12px; + padding: 20px 0; justify-content: center; + font-size: 13px; color: var(--text-muted); +} +.dl-modal-spinner { + width: 24px; height: 24px; border-radius: 50%; + border: 3px solid rgba(var(--accent-rgb), .2); + border-top-color: var(--accent); + animation: spin 800ms linear infinite; +} + +/* Success */ +.dl-modal-success { + display: flex; align-items: center; gap: 10px; + padding: 16px 0; justify-content: center; + font-size: 13px; color: var(--text-normal); +} +.dl-modal-check { color: #2ecc71; font-size: 28px; } + +/* Error */ +.dl-modal-error { + display: flex; align-items: center; gap: 10px; + padding: 12px 0; justify-content: center; + font-size: 13px; color: #e74c3c; +} + +/* Actions */ +.dl-modal-actions { + display: flex; justify-content: flex-end; gap: 8px; + padding: 0 16px 14px; +} +.dl-modal-cancel { + padding: 7px 14px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, .1); background: transparent; + color: var(--text-muted); font-size: 12px; font-weight: 600; + cursor: pointer; transition: all var(--transition); +} +.dl-modal-cancel:hover { background: rgba(255,255,255,.06); color: var(--text-normal); } +.dl-modal-submit { + display: flex; align-items: center; gap: 5px; + padding: 7px 16px; border-radius: 8px; + border: none; background: var(--accent); + color: #fff; font-size: 12px; font-weight: 700; + cursor: pointer; transition: filter var(--transition); +} +.dl-modal-submit:hover { filter: brightness(1.15); } + /* ──────────────────────────────────────────── Utility ──────────────────────────────────────────── */