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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 23:47:24 +01:00
parent df937f3e40
commit 0b2ba0ef86

View file

@ -164,8 +164,6 @@ function isSupportedUrl(url: string): boolean {
/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */ /** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */
function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> { function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> {
return new Promise((resolve, reject) => { 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 outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s');
const args = [ const args = [
'-x', // extract audio only '-x', // extract audio only
@ -177,26 +175,49 @@ function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: s
'--restrict-filenames', // safe filenames (ASCII, no spaces) '--restrict-filenames', // safe filenames (ASCII, no spaces)
'--max-filesize', '50m', // same limit as file upload '--max-filesize', '50m', // same limit as file upload
'--socket-timeout', '30', // timeout for slow connections '--socket-timeout', '30', // timeout for slow connections
'--verbose', // verbose output for logging
url, 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'] }); const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); proc.stdout?.on('data', (d: Buffer) => {
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); 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) => { 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')); reject(new Error('yt-dlp nicht verfügbar'));
}); });
proc.on('close', (code) => { proc.on('close', (code) => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code !== 0) { if (code !== 0) {
console.error(`${SB} yt-dlp failed (code ${code}): ${stderr.slice(0, 500)}`); console.error(`${SB} [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`);
// Extract useful error message 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')) if (stderr.includes('Video unavailable') || stderr.includes('is not available'))
reject(new Error('Video nicht verfügbar')); reject(new Error('Video nicht verfügbar'));
else if (stderr.includes('Private video')) else if (stderr.includes('Private video'))
@ -205,11 +226,19 @@ function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: s
reject(new Error('Login erforderlich')); reject(new Error('Login erforderlich'));
else if (stderr.includes('exceeds maximum')) else if (stderr.includes('exceeds maximum'))
reject(new Error('Datei zu groß (max 50 MB)')); 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 else
reject(new Error('Download fehlgeschlagen')); reject(new Error(`yt-dlp Fehler (exit ${code})`));
return; return;
} }
console.log(`${SB} [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`);
// Find the downloaded MP3 file — yt-dlp prints the final filename // Find the downloaded MP3 file — yt-dlp prints the final filename
const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i) const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i)
?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/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) { if (destMatch) {
const filepath = destMatch[1].trim(); const filepath = destMatch[1].trim();
const filename = path.basename(filepath); const filename = path.basename(filepath);
console.log(`${SB} yt-dlp saved: ${filename}`); console.log(`${SB} [yt-dlp] saved: ${filename} (regex match)`);
resolve({ filename, filepath }); resolve({ filename, filepath });
return; 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 now = Date.now();
const mp3s = fs.readdirSync(SOUNDS_DIR) const mp3s = fs.readdirSync(SOUNDS_DIR)
.filter(f => f.endsWith('.mp3')) .filter(f => f.endsWith('.mp3'))
.map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs })) .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); .sort((a, b) => b.mtime - a.mtime);
if (mp3s.length > 0) { if (mp3s.length > 0) {
const filename = mp3s[0].name; 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) }); resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) });
return; 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')); 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) => { app.post('/api/soundboard/play-url', async (req, res) => {
const startTime = Date.now();
try { try {
const { url, guildId, channelId, volume } = req.body ?? {}; const { url, guildId, channelId, volume } = req.body ?? {};
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; } const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} guild=${guildId} channel=${channelId}`);
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
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 savedFile: string;
let savedPath: string; let savedPath: string;
if (isYtDlpUrl(url)) { 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); const result = await downloadWithYtDlp(url);
savedFile = result.filename; savedFile = result.filename;
savedPath = result.filepath; savedPath = result.filepath;
} else { } else {
// Direct MP3 link → fetch and save
const parsed = new URL(url); const parsed = new URL(url);
savedFile = path.basename(parsed.pathname); savedFile = path.basename(parsed.pathname);
savedPath = path.join(SOUNDS_DIR, savedFile); savedPath = path.join(SOUNDS_DIR, savedFile);
console.log(`${SB} [play-url] → direct MP3 download: ${savedFile}`);
const r = await fetch(url); const r = await fetch(url);
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } if (!r.ok) {
fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer())); 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 {} } 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); } try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing in channel`); }
catch { /* play failed, but file is saved — that's ok */ } 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 }); 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) // Download-only route (save without auto-play)
app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => { app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
const startTime = Date.now();
try { try {
const { url } = req.body ?? {}; const { url } = req.body ?? {};
if (!url) { res.status(400).json({ error: 'URL erforderlich' }); return; } const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } console.log(`${SB} [download-url] ▶ type=${urlType} url=${url}`);
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
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 savedFile: string;
let savedPath: string; let savedPath: string;
if (isYtDlpUrl(url)) { if (isYtDlpUrl(url)) {
console.log(`${SB} [download-url] → delegating to yt-dlp...`);
const result = await downloadWithYtDlp(url); const result = await downloadWithYtDlp(url);
savedFile = result.filename; savedFile = result.filename;
savedPath = result.filepath; savedPath = result.filepath;
@ -978,14 +1030,27 @@ const soundboardPlugin: Plugin = {
const parsed = new URL(url); const parsed = new URL(url);
savedFile = path.basename(parsed.pathname); savedFile = path.basename(parsed.pathname);
savedPath = path.join(SOUNDS_DIR, savedFile); savedPath = path.join(SOUNDS_DIR, savedFile);
console.log(`${SB} [download-url] → direct MP3 download: ${savedFile}`);
const r = await fetch(url); const r = await fetch(url);
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } if (!r.ok) {
fs.writeFileSync(savedPath, Buffer.from(await r.arrayBuffer())); 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 }); 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 ── // ── Volume ──