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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 23:38:09 +01:00
parent 06326de465
commit 200f03c1f8
3 changed files with 243 additions and 28 deletions

View file

@ -137,6 +137,113 @@ function normalizeToCache(filePath: string): Promise<string> {
});
}
// ── 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<string, Buffer>();
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' }); }
});