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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue