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

@ -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`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, guildId, channelId, volume })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Play-URL fehlgeschlagen');
}
const data = await res.json().catch(() => ({}));
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) {
@ -404,14 +413,30 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
setTimeout(() => setNotification(null), 3000);
}, []);
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 {
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 {
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 channelId = selected ? selected.split(':')[1] : '';
@ -608,18 +633,28 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
async function handleUrlImport() {
const trimmed = importUrl.trim();
if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error');
if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error');
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error');
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
setImportBusy(true);
const urlType = getUrlType(trimmed);
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('');
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);
await loadAnalytics();
} catch (e: any) {
notify(e?.message || 'URL-Import fehlgeschlagen', 'error');
notify(e?.message || 'Download fehlgeschlagen', 'error');
} finally {
setImportBusy(false);
}
@ -975,20 +1010,32 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</div>
<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
className="url-import-input"
type="url"
placeholder="MP3-URL einfuegen..."
placeholder="YouTube / Instagram / MP3-Link..."
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
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
className="url-import-btn"
onClick={() => { void handleUrlImport(); }}
disabled={importBusy}
title="MP3 importieren"
disabled={importBusy || (!!importUrl && !isSupportedUrl(importUrl))}
title="Sound herunterladen"
>
{importBusy ? 'Laedt...' : 'Download'}
</button>