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:
Daniel 2026-03-07 00:13:48 +01:00
parent 4875747dc5
commit 3c8ad63f99
5 changed files with 579 additions and 61 deletions

View file

@ -41,7 +41,7 @@ ENV PORT=8080
ENV SOUNDS_DIR=/data/sounds ENV SOUNDS_DIR=/data/sounds
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ && curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o /usr/local/bin/yt-dlp \
&& chmod a+rx /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp \
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* && apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*

View file

@ -22,7 +22,6 @@ import {
} from '@discordjs/voice'; } from '@discordjs/voice';
import sodium from 'libsodium-wrappers'; import sodium from 'libsodium-wrappers';
import nacl from 'tweetnacl'; import nacl from 'tweetnacl';
// Streaming externer Plattformen entfernt nur MP3-URLs werden noch unterstützt
import child_process from 'node:child_process'; import child_process from 'node:child_process';
import { PassThrough, Readable } from 'node:stream'; import { PassThrough, Readable } from 'node:stream';
@ -46,6 +45,187 @@ if (!DISCORD_TOKEN) {
fs.mkdirSync(SOUNDS_DIR, { recursive: true }); fs.mkdirSync(SOUNDS_DIR, { recursive: true });
// ── yt-dlp URL detection ──
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) => {
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
'--verbose', // verbose output for logging
url,
];
const startTime = Date.now();
console.log(`[Jukebox] [yt-dlp] ▶ START url=${url}`);
console.log(`[Jukebox] [yt-dlp] args: yt-dlp ${args.join(' ')}`);
const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (d: Buffer) => {
const line = d.toString();
stdout += line;
for (const l of line.split('\n').filter((s: string) => s.trim())) {
console.log(`[Jukebox] [yt-dlp:out] ${l.trim()}`);
}
});
proc.stderr?.on('data', (d: Buffer) => {
const line = d.toString();
stderr += line;
for (const l of line.split('\n').filter((s: string) => s.trim())) {
console.error(`[Jukebox] [yt-dlp:err] ${l.trim()}`);
}
});
proc.on('error', (err) => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`[Jukebox] [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`);
reject(new Error('yt-dlp nicht verfügbar'));
});
proc.on('close', (code) => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code !== 0) {
console.error(`[Jukebox] [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`);
console.error(`[Jukebox] [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`);
console.error(`[Jukebox] [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`);
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 if (stderr.includes('Unsupported URL'))
reject(new Error('URL nicht unterstützt'));
else if (stderr.includes('HTTP Error 404'))
reject(new Error('Video nicht gefunden (404)'));
else if (stderr.includes('HTTP Error 403'))
reject(new Error('Zugriff verweigert (403)'));
else
reject(new Error(`yt-dlp Fehler (exit ${code})`));
return;
}
console.log(`[Jukebox] [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`);
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(`[Jukebox] [yt-dlp] saved: ${filename} (regex match)`);
resolve({ filename, filepath });
return;
}
// Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s)
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 < 60000)
.sort((a, b) => b.mtime - a.mtime);
if (mp3s.length > 0) {
const filename = mp3s[0].name;
console.log(`[Jukebox] [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`);
resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) });
return;
}
console.error(`[Jukebox] [yt-dlp] ✗ OUTPUT FILE NOT FOUND`);
console.error(`[Jukebox] [yt-dlp] full stdout:\n${stdout}`);
console.error(`[Jukebox] [yt-dlp] full stderr:\n${stderr}`);
reject(new Error('Download abgeschlossen, aber Datei nicht gefunden'));
});
});
}
/** 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(`[Jukebox] [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(`[Jukebox] [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(`[Jukebox] [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(`[Jukebox] [url-dl] renamed: ${savedFile}${newName}`);
savedFile = newName;
savedPath = newPath;
}
}
}
if (NORMALIZE_ENABLE) {
try { await normalizeToCache(savedPath); console.log(`[Jukebox] [url-dl] normalized`); }
catch (e: any) { console.error(`[Jukebox] [url-dl] normalize failed: ${e?.message}`); }
}
return { savedFile, savedPath };
}
// Persistenter Zustand: Lautstärke/Plays + Kategorien // Persistenter Zustand: Lautstärke/Plays + Kategorien
type Category = { id: string; name: string; color?: string; sort?: number }; type Category = { id: string; name: string; color?: string; sort?: number };
type PersistedState = { type PersistedState = {
@ -1557,45 +1737,57 @@ app.get('/api/events', (req: Request, res: Response) => {
}); });
}); });
// --- Medien-URL abspielen --- // --- Medien-URL abspielen (YouTube / Instagram / MP3) ---
// Unterstützt: direkte MP3-URL (Download und Ablage)
app.post('/api/play-url', async (req: Request, res: Response) => { app.post('/api/play-url', async (req: Request, res: Response) => {
const startTime = Date.now();
try { try {
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number }; const { url, guildId, channelId, volume, filename } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; filename?: string };
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
console.log(`[Jukebox] [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`);
let parsed: URL; if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
try { try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
parsed = new URL(url); if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' });
} catch {
return res.status(400).json({ error: 'Ungültige URL' }); const { savedFile, savedPath } = await handleUrlDownload(url, filename);
}
const pathname = parsed.pathname.toLowerCase(); try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`[Jukebox] [play-url] playing`); }
if (!pathname.endsWith('.mp3')) { catch (e: any) { console.error(`[Jukebox] [play-url] play failed (file saved): ${e?.message}`); }
return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' });
} const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const fileName = path.basename(parsed.pathname); console.log(`[Jukebox] [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
const dest = path.join(SOUNDS_DIR, fileName); return res.json({ ok: true, saved: savedFile });
const r = await fetch(url);
if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' });
const buf = Buffer.from(await r.arrayBuffer());
fs.writeFileSync(dest, buf);
// Vor dem Abspielen normalisieren → sofort aus Cache
if (NORMALIZE_ENABLE) {
try { await normalizeToCache(dest); } catch (e) { console.warn('Norm after URL import failed:', e); }
}
try {
await playFilePath(guildId, channelId, dest, volume, path.basename(dest));
} catch {
return res.status(500).json({ error: 'Abspielen fehlgeschlagen' });
}
return res.json({ ok: true, saved: path.basename(dest) });
} catch (e: any) { } catch (e: any) {
console.error('play-url error:', e); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`[Jukebox] [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });
} }
}); });
// --- URL nur herunterladen (ohne Abspielen) ---
app.post('/api/download-url', requireAdmin, async (req: Request, res: Response) => {
const startTime = Date.now();
try {
const { url, filename } = req.body as { url?: string; filename?: string };
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
console.log(`[Jukebox] [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`);
if (!url) return res.status(400).json({ error: 'URL erforderlich' });
try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' });
const { savedFile } = await handleUrlDownload(url, filename);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`[Jukebox] [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
return res.json({ ok: true, saved: savedFile });
} catch (e: any) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`[Jukebox] [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`);
return res.status(500).json({ error: e?.message ?? 'Fehler' });
}
});
// Static Frontend ausliefern (Vite build) // Static Frontend ausliefern (Vite build)
const webDistPath = path.resolve(__dirname, '../../web/dist'); const webDistPath = path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDistPath)) { if (fs.existsSync(webDistPath)) {

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { import {
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume,
adminStatus, adminLogin, adminLogout, adminDelete, adminRename, adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
fetchCategories, partyStart, partyStop, subscribeEvents, fetchCategories, partyStart, partyStop, subscribeEvents,
getSelectedChannels, setSelectedChannel, uploadFile, getSelectedChannels, setSelectedChannel, uploadFile,
@ -52,6 +52,13 @@ export default function App() {
const [importUrl, setImportUrl] = useState(''); const [importUrl, setImportUrl] = useState('');
const [importBusy, setImportBusy] = useState(false); 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 ── */ /* ── Channels ── */
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]); const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
const [selected, setSelected] = useState(''); const [selected, setSelected] = useState('');
@ -153,14 +160,35 @@ export default function App() {
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'];
/** 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 { try {
const parsed = new URL(value.trim()); const parsed = new URL(normalizeUrl(value));
return parsed.pathname.toLowerCase().endsWith('.mp3'); 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 { } catch {
return false; 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 guildId = selected ? selected.split(':')[0] : '';
const channelId = selected ? selected.split(':')[1] : ''; const channelId = selected ? selected.split(':')[1] : '';
@ -346,22 +374,42 @@ export default function App() {
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
} }
async function handleUrlImport() { // Open download modal instead of downloading directly
const trimmed = importUrl.trim(); function handleUrlImport() {
if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); const trimmed = normalizeUrl(importUrl);
if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
setImportBusy(true); 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 { 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(''); setImportUrl('');
notify('MP3 importiert und abgespielt');
setRefreshKey(k => k + 1); setRefreshKey(k => k + 1);
await loadAnalytics(); void loadAnalytics();
// Auto-close after 2.5s
setTimeout(() => setDlModal(null), 2500);
} catch (e: any) { } catch (e: any) {
notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null);
} finally {
setImportBusy(false);
} }
} }
@ -715,20 +763,32 @@ export default function App() {
</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="text"
placeholder="MP3-URL einfügen..." 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 ? 'Lädt...' : 'Download'} {importBusy ? 'Lädt...' : 'Download'}
</button> </button>
@ -1252,6 +1312,110 @@ export default function App() {
</div> </div>
</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> </div>
); );
} }

View file

@ -195,15 +195,24 @@ export async function adminRename(from: string, to: string): Promise<string> {
return data?.to as 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`, { 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, filename })
}); });
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;
}
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. */ /** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */

View file

@ -641,6 +641,24 @@ input, select {
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;
@ -2063,6 +2081,141 @@ input, select {
margin-top: 2px; 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 Utility
*/ */