feat: YouTube/Instagram/MP3 download with modal + yt-dlp support
Sync from gaming-hub soundboard plugin: - Add yt-dlp URL detection (YouTube, Instagram) + direct MP3 support - downloadWithYtDlp() with verbose logging, error detection, fallback scan - handleUrlDownload() shared logic with custom filename + rename - Download modal: filename input, progress spinner, success/error phases - URL type badges (YT/IG/MP3) in toolbar input - Auto-prepend https:// for URLs without protocol - Fix Dockerfile: yt-dlp_linux standalone binary (no Python needed) - download-url route (admin-only, save without playing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4875747dc5
commit
3c8ad63f99
5 changed files with 579 additions and 61 deletions
208
web/src/App.tsx
208
web/src/App.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume,
|
||||
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume,
|
||||
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
|
||||
fetchCategories, partyStart, partyStop, subscribeEvents,
|
||||
getSelectedChannels, setSelectedChannel, uploadFile,
|
||||
|
|
@ -52,6 +52,13 @@ export default function App() {
|
|||
const [importUrl, setImportUrl] = useState('');
|
||||
const [importBusy, setImportBusy] = useState(false);
|
||||
|
||||
// Download modal state
|
||||
const [dlModal, setDlModal] = useState<{
|
||||
url: string; type: 'youtube' | 'instagram' | 'mp3' | null;
|
||||
filename: string; phase: 'input' | 'downloading' | 'done' | 'error';
|
||||
savedName?: string; error?: string;
|
||||
} | null>(null);
|
||||
|
||||
/* ── Channels ── */
|
||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
|
|
@ -153,14 +160,35 @@ export default function App() {
|
|||
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'];
|
||||
/** Auto-prepend https:// if missing */
|
||||
const normalizeUrl = useCallback((value: string): string => {
|
||||
const v = value.trim();
|
||||
if (!v) return v;
|
||||
if (/^https?:\/\//i.test(v)) return v;
|
||||
return 'https://' + v;
|
||||
}, []);
|
||||
const isSupportedUrl = useCallback((value: string) => {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
return parsed.pathname.toLowerCase().endsWith('.mp3');
|
||||
const parsed = new URL(normalizeUrl(value));
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (parsed.pathname.toLowerCase().endsWith('.mp3')) return true;
|
||||
if (YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h))) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
}, [normalizeUrl]);
|
||||
const getUrlType = useCallback((value: string): 'youtube' | 'instagram' | 'mp3' | null => {
|
||||
try {
|
||||
const parsed = new URL(normalizeUrl(value));
|
||||
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; }
|
||||
}, [normalizeUrl]);
|
||||
|
||||
const guildId = selected ? selected.split(':')[0] : '';
|
||||
const channelId = selected ? selected.split(':')[1] : '';
|
||||
|
|
@ -346,22 +374,42 @@ export default function App() {
|
|||
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
||||
}
|
||||
|
||||
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 auswählen', 'error');
|
||||
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error');
|
||||
setImportBusy(true);
|
||||
// Open download modal instead of downloading directly
|
||||
function handleUrlImport() {
|
||||
const trimmed = normalizeUrl(importUrl);
|
||||
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
|
||||
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
|
||||
const urlType = getUrlType(trimmed);
|
||||
// Pre-fill filename for MP3 links (basename without .mp3), empty for YT/IG
|
||||
let defaultName = '';
|
||||
if (urlType === 'mp3') {
|
||||
try { defaultName = new URL(trimmed).pathname.split('/').pop()?.replace(/\.mp3$/i, '') ?? ''; } catch {}
|
||||
}
|
||||
setDlModal({ url: trimmed, type: urlType, filename: defaultName, phase: 'input' });
|
||||
}
|
||||
|
||||
// Actual download triggered from modal
|
||||
async function handleModalDownload() {
|
||||
if (!dlModal) return;
|
||||
setDlModal(prev => prev ? { ...prev, phase: 'downloading' } : null);
|
||||
try {
|
||||
await playUrl(trimmed, guildId, channelId, volume);
|
||||
let savedName: string | undefined;
|
||||
const fn = dlModal.filename.trim() || undefined;
|
||||
if (selected && guildId && channelId) {
|
||||
const result = await playUrl(dlModal.url, guildId, channelId, volume, fn);
|
||||
savedName = result.saved;
|
||||
} else {
|
||||
const result = await downloadUrl(dlModal.url, fn);
|
||||
savedName = result.saved;
|
||||
}
|
||||
setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null);
|
||||
setImportUrl('');
|
||||
notify('MP3 importiert und abgespielt');
|
||||
setRefreshKey(k => k + 1);
|
||||
await loadAnalytics();
|
||||
void loadAnalytics();
|
||||
// Auto-close after 2.5s
|
||||
setTimeout(() => setDlModal(null), 2500);
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'URL-Import fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
setImportBusy(false);
|
||||
setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -715,20 +763,32 @@ export default function App() {
|
|||
</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 einfügen..."
|
||||
type="text"
|
||||
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 ? 'Lädt...' : 'Download'}
|
||||
</button>
|
||||
|
|
@ -1252,6 +1312,110 @@ export default function App() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Download Modal ── */}
|
||||
{dlModal && (
|
||||
<div className="dl-modal-overlay" onClick={() => dlModal.phase !== 'downloading' && setDlModal(null)}>
|
||||
<div className="dl-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="dl-modal-header">
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>
|
||||
{dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'}
|
||||
</span>
|
||||
<span>
|
||||
{dlModal.phase === 'input' ? 'Sound herunterladen'
|
||||
: dlModal.phase === 'downloading' ? 'Wird heruntergeladen...'
|
||||
: dlModal.phase === 'done' ? 'Fertig!'
|
||||
: 'Fehler'}
|
||||
</span>
|
||||
{dlModal.phase !== 'downloading' && (
|
||||
<button className="dl-modal-close" onClick={() => setDlModal(null)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dl-modal-body">
|
||||
{/* URL badge */}
|
||||
<div className="dl-modal-url">
|
||||
<span className={`dl-modal-tag ${dlModal.type ?? ''}`}>
|
||||
{dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'}
|
||||
</span>
|
||||
<span className="dl-modal-url-text" title={dlModal.url}>
|
||||
{dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filename input (input phase only) */}
|
||||
{dlModal.phase === 'input' && (
|
||||
<div className="dl-modal-field">
|
||||
<label className="dl-modal-label">Dateiname</label>
|
||||
<div className="dl-modal-input-wrap">
|
||||
<input
|
||||
className="dl-modal-input"
|
||||
type="text"
|
||||
placeholder={dlModal.type === 'mp3' ? 'Dateiname...' : 'Wird automatisch erkannt...'}
|
||||
value={dlModal.filename}
|
||||
onChange={e => setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<span className="dl-modal-ext">.mp3</span>
|
||||
</div>
|
||||
<span className="dl-modal-hint">Leer lassen = automatischer Name</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress (downloading phase) */}
|
||||
{dlModal.phase === 'downloading' && (
|
||||
<div className="dl-modal-progress">
|
||||
<div className="dl-modal-spinner" />
|
||||
<span>
|
||||
{dlModal.type === 'youtube' || dlModal.type === 'instagram'
|
||||
? 'Audio wird extrahiert...'
|
||||
: 'MP3 wird heruntergeladen...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{dlModal.phase === 'done' && (
|
||||
<div className="dl-modal-success">
|
||||
<span className="material-icons dl-modal-check">check_circle</span>
|
||||
<span>Gespeichert als <b>{dlModal.savedName}</b></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{dlModal.phase === 'error' && (
|
||||
<div className="dl-modal-error">
|
||||
<span className="material-icons" style={{ color: '#e74c3c' }}>error</span>
|
||||
<span>{dlModal.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{dlModal.phase === 'input' && (
|
||||
<div className="dl-modal-actions">
|
||||
<button className="dl-modal-cancel" onClick={() => setDlModal(null)}>Abbrechen</button>
|
||||
<button className="dl-modal-submit" onClick={() => void handleModalDownload()}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>download</span>
|
||||
Herunterladen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dlModal.phase === 'error' && (
|
||||
<div className="dl-modal-actions">
|
||||
<button className="dl-modal-cancel" onClick={() => setDlModal(null)}>Schliessen</button>
|
||||
<button className="dl-modal-submit" onClick={() => setDlModal(prev => prev ? { ...prev, phase: 'input', error: undefined } : null)}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>refresh</span>
|
||||
Nochmal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,15 +195,24 @@ export async function adminRename(from: string, to: string): Promise<string> {
|
|||
return data?.to as string;
|
||||
}
|
||||
|
||||
export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise<void> {
|
||||
export async function playUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): 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 })
|
||||
body: JSON.stringify({ url, guildId, channelId, volume, filename })
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
export async function downloadUrl(url: string, filename?: string): Promise<{ saved?: string }> {
|
||||
const res = await fetch(`${API_BASE}/download-url`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, filename })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */
|
||||
|
|
|
|||
|
|
@ -641,6 +641,24 @@ input, select {
|
|||
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 ── */
|
||||
.tb-btn {
|
||||
display: flex;
|
||||
|
|
@ -2063,6 +2081,141 @@ input, select {
|
|||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────
|
||||
Download Modal
|
||||
──────────────────────────────────────────── */
|
||||
.dl-modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 300;
|
||||
animation: fade-in 150ms ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.dl-modal {
|
||||
width: 420px; max-width: 92vw;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 60px rgba(0, 0, 0, .5);
|
||||
animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
@keyframes scale-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } }
|
||||
|
||||
.dl-modal-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .06);
|
||||
font-size: 14px; font-weight: 700;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.dl-modal-header .material-icons { color: var(--accent); }
|
||||
|
||||
.dl-modal-close {
|
||||
margin-left: auto;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
border: none; background: rgba(255,255,255,.06);
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.dl-modal-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); }
|
||||
|
||||
.dl-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* URL display */
|
||||
.dl-modal-url {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
background: rgba(0, 0, 0, .2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dl-modal-tag {
|
||||
flex-shrink: 0; padding: 2px 8px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase;
|
||||
}
|
||||
.dl-modal-tag.youtube { background: rgba(255, 0, 0, .18); color: #ff4444; }
|
||||
.dl-modal-tag.instagram { background: rgba(225, 48, 108, .18); color: #e1306c; }
|
||||
.dl-modal-tag.mp3 { background: rgba(46, 204, 113, .18); color: #2ecc71; }
|
||||
.dl-modal-url-text {
|
||||
font-size: 11px; color: var(--text-faint);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Filename field */
|
||||
.dl-modal-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.dl-modal-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.dl-modal-input-wrap {
|
||||
display: flex; align-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, .1); border-radius: 8px;
|
||||
background: rgba(0, 0, 0, .15);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.dl-modal-input-wrap:focus-within { border-color: var(--accent); }
|
||||
.dl-modal-input {
|
||||
flex: 1; border: none; background: transparent;
|
||||
padding: 8px 10px; color: var(--text-normal);
|
||||
font-size: 13px; font-family: var(--font); outline: none;
|
||||
}
|
||||
.dl-modal-input::placeholder { color: var(--text-faint); }
|
||||
.dl-modal-ext {
|
||||
padding: 0 10px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-faint); background: rgba(255, 255, 255, .04);
|
||||
align-self: stretch; display: flex; align-items: center;
|
||||
}
|
||||
.dl-modal-hint { font-size: 10px; color: var(--text-faint); }
|
||||
|
||||
/* Progress spinner */
|
||||
.dl-modal-progress {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 20px 0; justify-content: center;
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
}
|
||||
.dl-modal-spinner {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
border: 3px solid rgba(var(--accent-rgb), .2);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 800ms linear infinite;
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.dl-modal-success {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 16px 0; justify-content: center;
|
||||
font-size: 13px; color: var(--text-normal);
|
||||
}
|
||||
.dl-modal-check { color: #2ecc71; font-size: 28px; }
|
||||
|
||||
/* Error */
|
||||
.dl-modal-error {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 0; justify-content: center;
|
||||
font-size: 13px; color: #e74c3c;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.dl-modal-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 0 16px 14px;
|
||||
}
|
||||
.dl-modal-cancel {
|
||||
padding: 7px 14px; border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, .1); background: transparent;
|
||||
color: var(--text-muted); font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; transition: all var(--transition);
|
||||
}
|
||||
.dl-modal-cancel:hover { background: rgba(255,255,255,.06); color: var(--text-normal); }
|
||||
.dl-modal-submit {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 7px 16px; border-radius: 8px;
|
||||
border: none; background: var(--accent);
|
||||
color: #fff; font-size: 12px; font-weight: 700;
|
||||
cursor: pointer; transition: filter var(--transition);
|
||||
}
|
||||
.dl-modal-submit:hover { filter: brightness(1.15); }
|
||||
|
||||
/* ────────────────────────────────────────────
|
||||
Utility
|
||||
──────────────────────────────────────────── */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue