From 0b2ba0ef8612f24b7bb31783f34706e864c6254c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 23:47:24 +0100 Subject: [PATCH] feat(soundboard): add comprehensive yt-dlp and URL download logging Every step now logged with timestamps, exit codes, stdout/stderr streaming in real-time. yt-dlp runs with --verbose flag. Routes log URL type detection, download progress, normalization, play status. Error messages include HTTP status codes and specific yt-dlp errors. Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/soundboard/index.ts | 129 +++++++++++++++++++------ 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/server/src/plugins/soundboard/index.ts b/server/src/plugins/soundboard/index.ts index 77e3e08..869770d 100644 --- a/server/src/plugins/soundboard/index.ts +++ b/server/src/plugins/soundboard/index.ts @@ -164,8 +164,6 @@ function isSupportedUrl(url: string): boolean { /** 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 @@ -177,26 +175,49 @@ function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: s '--restrict-filenames', // safe filenames (ASCII, no spaces) '--max-filesize', '50m', // same limit as file upload '--socket-timeout', '30', // timeout for slow connections + '--verbose', // verbose output for logging url, ]; - console.log(`${SB} yt-dlp downloading: ${url}`); + const startTime = Date.now(); + console.log(`${SB} [yt-dlp] ▶ START url=${url}`); + console.log(`${SB} [yt-dlp] args: yt-dlp ${args.join(' ')}`); + 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.stdout?.on('data', (d: Buffer) => { + const line = d.toString(); + stdout += line; + // Stream yt-dlp progress to console in real-time + for (const l of line.split('\n').filter((s: string) => s.trim())) { + console.log(`${SB} [yt-dlp:out] ${l.trim()}`); + } + }); + proc.stderr?.on('data', (d: Buffer) => { + const line = d.toString(); + stderr += line; + for (const l of line.split('\n').filter((s: string) => s.trim())) { + console.error(`${SB} [yt-dlp:err] ${l.trim()}`); + } + }); proc.on('error', (err) => { - console.error(`${SB} yt-dlp spawn error:`, err.message); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`${SB} [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`); reject(new Error('yt-dlp nicht verfügbar')); }); proc.on('close', (code) => { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + if (code !== 0) { - console.error(`${SB} yt-dlp failed (code ${code}): ${stderr.slice(0, 500)}`); - // Extract useful error message + console.error(`${SB} [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`); + console.error(`${SB} [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`); + console.error(`${SB} [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`); + + // Extract useful error for frontend if (stderr.includes('Video unavailable') || stderr.includes('is not available')) reject(new Error('Video nicht verfügbar')); else if (stderr.includes('Private video')) @@ -205,11 +226,19 @@ function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: s reject(new Error('Login erforderlich')); else if (stderr.includes('exceeds maximum')) reject(new Error('Datei zu groß (max 50 MB)')); + else if (stderr.includes('Unsupported URL')) + reject(new Error('URL nicht unterstützt')); + else if (stderr.includes('HTTP Error 404')) + reject(new Error('Video nicht gefunden (404)')); + else if (stderr.includes('HTTP Error 403')) + reject(new Error('Zugriff verweigert (403)')); else - reject(new Error('Download fehlgeschlagen')); + reject(new Error(`yt-dlp Fehler (exit ${code})`)); return; } + console.log(`${SB} [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`); + // 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) @@ -218,27 +247,29 @@ function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: s if (destMatch) { const filepath = destMatch[1].trim(); const filename = path.basename(filepath); - console.log(`${SB} yt-dlp saved: ${filename}`); + console.log(`${SB} [yt-dlp] saved: ${filename} (regex match)`); resolve({ filename, filepath }); return; } - // Fallback: scan SOUNDS_DIR for newest MP3 (within last 30s) + // Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s) 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) + .filter(f => now - f.mtime < 60000) .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}`); + console.log(`${SB} [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`); resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) }); return; } - console.error(`${SB} yt-dlp: could not find output file. stdout: ${stdout.slice(0, 500)}`); + console.error(`${SB} [yt-dlp] ✗ OUTPUT FILE NOT FOUND`); + console.error(`${SB} [yt-dlp] full stdout:\n${stdout}`); + console.error(`${SB} [yt-dlp] full stderr:\n${stderr}`); reject(new Error('Download abgeschlossen, aber Datei nicht gefunden')); }); }); @@ -928,49 +959,70 @@ const soundboardPlugin: Plugin = { }); app.post('/api/soundboard/play-url', async (req, res) => { + const startTime = Date.now(); try { const { url, guildId, channelId, volume } = req.body ?? {}; - 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; } + const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; + console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} guild=${guildId} channel=${channelId}`); + + 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; } let savedFile: string; let savedPath: string; if (isYtDlpUrl(url)) { - // YouTube / Instagram → yt-dlp extract audio as MP3 + console.log(`${SB} [play-url] → delegating to yt-dlp...`); 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); + console.log(`${SB} [play-url] → direct MP3 download: ${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 (!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); } catch {} } - try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); } - catch { /* play failed, but file is saved — that's ok */ } + 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`); } + catch (e: any) { console.error(`${SB} [play-url] play failed (file saved): ${e?.message}`); } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`${SB} [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`); res.json({ ok: true, saved: savedFile }); - } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } + } catch (e: any) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`${SB} [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); + 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) => { + const startTime = Date.now(); 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; } + const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; + console.log(`${SB} [download-url] ▶ type=${urlType} url=${url}`); + + 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; } 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; @@ -978,14 +1030,27 @@ const soundboardPlugin: Plugin = { 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) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } - fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer())); + 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); } catch {} } + 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 elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`); res.json({ ok: true, saved: savedFile }); - } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } + } catch (e: any) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`${SB} [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); + res.status(500).json({ error: e?.message ?? 'Fehler' }); + } }); // ── Volume ──