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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 23:59:31 +01:00
parent 0b2ba0ef86
commit 9ff8a38547
4 changed files with 332 additions and 76 deletions

View file

@ -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}`);