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:
parent
06326de465
commit
200f03c1f8
3 changed files with 243 additions and 28 deletions
|
|
@ -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 ──
|
// ── PCM Memory Cache ──
|
||||||
const pcmMemoryCache = new Map<string, Buffer>();
|
const pcmMemoryCache = new Map<string, Buffer>();
|
||||||
let pcmMemoryCacheBytes = 0;
|
let pcmMemoryCacheBytes = 0;
|
||||||
|
|
@ -824,17 +931,60 @@ const soundboardPlugin: Plugin = {
|
||||||
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; }
|
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
|
||||||
let parsed: URL;
|
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
|
||||||
try { parsed = 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; }
|
||||||
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));
|
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);
|
const r = await fetch(url);
|
||||||
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; }
|
if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; }
|
||||||
fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer()));
|
fs.writeFileSync(savedPath, 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; }
|
if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); } catch {} }
|
||||||
res.json({ ok: true, saved: path.basename(dest) });
|
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' }); }
|
} catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<void> {
|
async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise<{ saved?: string }> {
|
||||||
const res = await fetch(`${API_BASE}/play-url`, {
|
const res = await fetch(`${API_BASE}/play-url`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url, guildId, channelId, volume })
|
body: JSON.stringify({ url, guildId, channelId, volume })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data?.error || 'Play-URL fehlgeschlagen');
|
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) {
|
async function apiPartyStart(guildId: string, channelId: string) {
|
||||||
|
|
@ -404,14 +413,30 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
setTimeout(() => setNotification(null), 3000);
|
setTimeout(() => setNotification(null), 3000);
|
||||||
}, []);
|
}, []);
|
||||||
const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []);
|
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 {
|
try {
|
||||||
const parsed = new URL(value.trim());
|
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 {
|
} catch {
|
||||||
return false;
|
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 guildId = selected ? selected.split(':')[0] : '';
|
||||||
const channelId = selected ? selected.split(':')[1] : '';
|
const channelId = selected ? selected.split(':')[1] : '';
|
||||||
|
|
@ -608,18 +633,28 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
|
|
||||||
async function handleUrlImport() {
|
async function handleUrlImport() {
|
||||||
const trimmed = importUrl.trim();
|
const trimmed = importUrl.trim();
|
||||||
if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error');
|
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
|
||||||
if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error');
|
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
|
||||||
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error');
|
|
||||||
setImportBusy(true);
|
setImportBusy(true);
|
||||||
|
const urlType = getUrlType(trimmed);
|
||||||
try {
|
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('');
|
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);
|
setRefreshKey(k => k + 1);
|
||||||
await loadAnalytics();
|
await loadAnalytics();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notify(e?.message || 'URL-Import fehlgeschlagen', 'error');
|
notify(e?.message || 'Download fehlgeschlagen', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setImportBusy(false);
|
setImportBusy(false);
|
||||||
}
|
}
|
||||||
|
|
@ -975,20 +1010,32 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="url-import-wrap">
|
<div className="url-import-wrap">
|
||||||
<span className="material-icons url-import-icon">link</span>
|
<span className="material-icons url-import-icon">
|
||||||
|
{getUrlType(importUrl) === 'youtube' ? 'smart_display'
|
||||||
|
: getUrlType(importUrl) === 'instagram' ? 'photo_camera'
|
||||||
|
: 'link'}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
className="url-import-input"
|
className="url-import-input"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="MP3-URL einfuegen..."
|
placeholder="YouTube / Instagram / MP3-Link..."
|
||||||
value={importUrl}
|
value={importUrl}
|
||||||
onChange={e => setImportUrl(e.target.value)}
|
onChange={e => setImportUrl(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }}
|
onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }}
|
||||||
/>
|
/>
|
||||||
|
{importUrl && (
|
||||||
|
<span className={`url-import-tag ${isSupportedUrl(importUrl) ? 'valid' : 'invalid'}`}>
|
||||||
|
{getUrlType(importUrl) === 'youtube' ? 'YT'
|
||||||
|
: getUrlType(importUrl) === 'instagram' ? 'IG'
|
||||||
|
: getUrlType(importUrl) === 'mp3' ? 'MP3'
|
||||||
|
: '?'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="url-import-btn"
|
className="url-import-btn"
|
||||||
onClick={() => { void handleUrlImport(); }}
|
onClick={() => { void handleUrlImport(); }}
|
||||||
disabled={importBusy}
|
disabled={importBusy || (!!importUrl && !isSupportedUrl(importUrl))}
|
||||||
title="MP3 importieren"
|
title="Sound herunterladen"
|
||||||
>
|
>
|
||||||
{importBusy ? 'Laedt...' : 'Download'}
|
{importBusy ? 'Laedt...' : 'Download'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,24 @@
|
||||||
pointer-events: none;
|
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 ── */
|
/* ── Toolbar Buttons ── */
|
||||||
.tb-btn {
|
.tb-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue