From 200f03c1f8525f8548f1b6a14fe4a0cacafd8e90 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 23:38:09 +0100 Subject: [PATCH] feat(soundboard): extend URL download to support YouTube & Instagram yt-dlp extracts audio as MP3 from YouTube and Instagram links. Direct MP3 links continue to work as before. URL input field now shows a type indicator (YT/IG/MP3) and validates all three formats. Backend: downloadWithYtDlp() spawns yt-dlp with --extract-audio, saves to SOUNDS_DIR, normalizes if enabled. New /download-url route for save-only without auto-play. play-url route extended for all types. Frontend: isSupportedUrl() validates YouTube/Instagram/MP3, dynamic icon changes per URL type, disabled state when URL is unsupported. Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/soundboard/index.ts | 172 +++++++++++++++++-- web/src/plugins/soundboard/SoundboardTab.tsx | 81 +++++++-- web/src/plugins/soundboard/soundboard.css | 18 ++ 3 files changed, 243 insertions(+), 28 deletions(-) diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index 8935942..77e3e08 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -137,6 +137,113 @@ function normalizeToCache(filePath: string): Promise { }); } +// ── yt-dlp URL detection & download ── +const YTDLP_HOSTS = [ + 'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', + 'music.youtube.com', + 'instagram.com', 'www.instagram.com', +]; + +function isYtDlpUrl(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h)); + } catch { return false; } +} + +function isDirectMp3Url(url: string): boolean { + try { + return new URL(url).pathname.toLowerCase().endsWith('.mp3'); + } catch { return false; } +} + +function isSupportedUrl(url: string): boolean { + return isYtDlpUrl(url) || isDirectMp3Url(url); +} + +/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */ +function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> { + return new Promise((resolve, reject) => { + // Use yt-dlp to extract audio as best quality MP3 + // Output template: title sanitized, placed in SOUNDS_DIR + const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s'); + const args = [ + '-x', // extract audio only + '--audio-format', 'mp3', // convert to MP3 + '--audio-quality', '0', // best quality + '-o', outputTemplate, // output path template + '--no-playlist', // single video only + '--no-overwrites', // don't overwrite existing + '--restrict-filenames', // safe filenames (ASCII, no spaces) + '--max-filesize', '50m', // same limit as file upload + '--socket-timeout', '30', // timeout for slow connections + url, + ]; + + console.log(`${SB} yt-dlp downloading: ${url}`); + const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + + proc.on('error', (err) => { + console.error(`${SB} yt-dlp spawn error:`, err.message); + reject(new Error('yt-dlp nicht verfügbar')); + }); + + proc.on('close', (code) => { + if (code !== 0) { + console.error(`${SB} yt-dlp failed (code ${code}): ${stderr.slice(0, 500)}`); + // Extract useful error message + if (stderr.includes('Video unavailable') || stderr.includes('is not available')) + reject(new Error('Video nicht verfügbar')); + else if (stderr.includes('Private video')) + reject(new Error('Privates Video')); + else if (stderr.includes('Sign in') || stderr.includes('login')) + reject(new Error('Login erforderlich')); + else if (stderr.includes('exceeds maximum')) + reject(new Error('Datei zu groß (max 50 MB)')); + else + reject(new Error('Download fehlgeschlagen')); + return; + } + + // Find the downloaded MP3 file — yt-dlp prints the final filename + const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i) + ?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i) + ?? stdout.match(/Destination: (.+\.mp3)/i); + + if (destMatch) { + const filepath = destMatch[1].trim(); + const filename = path.basename(filepath); + console.log(`${SB} yt-dlp saved: ${filename}`); + resolve({ filename, filepath }); + return; + } + + // Fallback: scan SOUNDS_DIR for newest MP3 (within last 30s) + const now = Date.now(); + const mp3s = fs.readdirSync(SOUNDS_DIR) + .filter(f => f.endsWith('.mp3')) + .map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs })) + .filter(f => now - f.mtime < 30000) + .sort((a, b) => b.mtime - a.mtime); + + if (mp3s.length > 0) { + const filename = mp3s[0].name; + console.log(`${SB} yt-dlp saved (fallback detect): ${filename}`); + resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) }); + return; + } + + console.error(`${SB} yt-dlp: could not find output file. stdout: ${stdout.slice(0, 500)}`); + reject(new Error('Download abgeschlossen, aber Datei nicht gefunden')); + }); + }); +} + // ── PCM Memory Cache ── const pcmMemoryCache = new Map(); let pcmMemoryCacheBytes = 0; @@ -824,17 +931,60 @@ const soundboardPlugin: Plugin = { try { const { url, guildId, channelId, volume } = req.body ?? {}; if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; } - let parsed: URL; - try { parsed = new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } - if (!parsed.pathname.toLowerCase().endsWith('.mp3')) { res.status(400).json({ error: 'Nur MP3-Links' }); return; } - const dest = path.join(SOUNDS_DIR, path.basename(parsed.pathname)); - const r = await fetch(url); - if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } - fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer())); - if (NORMALIZE_ENABLE) { try { await normalizeToCache(dest); } catch {} } - try { await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); } - catch { res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); return; } - res.json({ ok: true, saved: path.basename(dest) }); + 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)) { + // YouTube / Instagram → yt-dlp extract audio as MP3 + const result = await downloadWithYtDlp(url); + savedFile = result.filename; + savedPath = result.filepath; + } else { + // Direct MP3 link → fetch and save + const parsed = new URL(url); + savedFile = path.basename(parsed.pathname); + savedPath = path.join(SOUNDS_DIR, savedFile); + const r = await fetch(url); + if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } + fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer())); + } + + if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); } catch {} } + try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); } + catch { /* play failed, but file is saved — that's ok */ } + res.json({ ok: true, saved: savedFile }); + } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } + }); + + // Download-only route (save without auto-play) + app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => { + try { + const { url } = req.body ?? {}; + 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)) { + 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); + const r = await fetch(url); + if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } + fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer())); + } + + if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); } catch {} } + res.json({ ok: true, saved: savedFile }); } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } }); diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx index 887060c..3a961d2 100644 --- a/web/src/plugins/soundboard/SoundboardTab.tsx +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -129,15 +129,24 @@ async function apiPlaySound(soundName: string, guildId: string, channelId: strin } } -async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise { +async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): 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 }) }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error || 'Play-URL fehlgeschlagen'); - } + 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 }> { + const res = await fetch(`${API_BASE}/download-url`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen'); + return data; } async function apiPartyStart(guildId: string, channelId: string) { @@ -404,14 +413,30 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { setTimeout(() => setNotification(null), 3000); }, []); const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); - const isMp3Url = useCallback((value: string) => { + const YTDLP_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com', 'instagram.com', 'www.instagram.com']; + const isSupportedUrl = useCallback((value: string) => { try { const parsed = new URL(value.trim()); - return parsed.pathname.toLowerCase().endsWith('.mp3'); + const host = parsed.hostname.toLowerCase(); + // Direct MP3 link + if (parsed.pathname.toLowerCase().endsWith('.mp3')) return true; + // YouTube / Instagram + if (YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h))) return true; + return false; } catch { return false; } }, []); + const getUrlType = useCallback((value: string): 'youtube' | 'instagram' | 'mp3' | null => { + try { + const parsed = new URL(value.trim()); + const host = parsed.hostname.toLowerCase(); + if (host.includes('youtube') || host === 'youtu.be') return 'youtube'; + if (host.includes('instagram')) return 'instagram'; + if (parsed.pathname.toLowerCase().endsWith('.mp3')) return 'mp3'; + return null; + } catch { return null; } + }, []); const guildId = selected ? selected.split(':')[0] : ''; const channelId = selected ? selected.split(':')[1] : ''; @@ -608,18 +633,28 @@ export default function SoundboardTab({ data }: SoundboardTabProps) { async function handleUrlImport() { const trimmed = importUrl.trim(); - if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); - if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error'); - if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); + 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); try { - await apiPlayUrl(trimmed, guildId, channelId, volume); + let savedName: string | undefined; + if (selected && guildId && channelId) { + // Voice channel selected → download + play + const result = await apiPlayUrl(trimmed, guildId, channelId, volume); + savedName = result.saved; + } else { + // No voice channel → download only + const result = await apiDownloadUrl(trimmed); + savedName = result.saved; + } setImportUrl(''); - notify('MP3 importiert und abgespielt'); + const typeLabel = urlType === 'youtube' ? 'YouTube' : urlType === 'instagram' ? 'Instagram' : 'MP3'; + notify(`${typeLabel} gespeichert${selected ? ' und abgespielt' : ''}: ${savedName ?? ''}`); setRefreshKey(k => k + 1); await loadAnalytics(); } catch (e: any) { - notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); + notify(e?.message || 'Download fehlgeschlagen', 'error'); } finally { setImportBusy(false); } @@ -975,20 +1010,32 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
- link + + {getUrlType(importUrl) === 'youtube' ? 'smart_display' + : getUrlType(importUrl) === 'instagram' ? 'photo_camera' + : 'link'} + setImportUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} /> + {importUrl && ( + + {getUrlType(importUrl) === 'youtube' ? 'YT' + : getUrlType(importUrl) === 'instagram' ? 'IG' + : getUrlType(importUrl) === 'mp3' ? 'MP3' + : '?'} + + )} diff --git a/web/src/plugins/soundboard/soundboard.css b/web/src/plugins/soundboard/soundboard.css index f309daa..00090ff 100644 --- a/web/src/plugins/soundboard/soundboard.css +++ b/web/src/plugins/soundboard/soundboard.css @@ -592,6 +592,24 @@ pointer-events: none; } +.url-import-tag { + flex-shrink: 0; + padding: 1px 6px; + border-radius: 8px; + font-size: 10px; + font-weight: 800; + letter-spacing: .5px; + text-transform: uppercase; +} +.url-import-tag.valid { + background: rgba(46, 204, 113, .18); + color: #2ecc71; +} +.url-import-tag.invalid { + background: rgba(231, 76, 60, .18); + color: #e74c3c; +} + /* ── Toolbar Buttons ── */ .tb-btn { display: flex;