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
|
|
@ -22,7 +22,6 @@ import {
|
|||
} from '@discordjs/voice';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
import nacl from 'tweetnacl';
|
||||
// Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt
|
||||
import child_process from 'node:child_process';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
|
||||
|
|
@ -46,6 +45,187 @@ if (!DISCORD_TOKEN) {
|
|||
|
||||
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
|
||||
type Category = { id: string; name: string; color?: string; sort?: number };
|
||||
type PersistedState = {
|
||||
|
|
@ -1557,45 +1737,57 @@ app.get('/api/events', (req: Request, res: Response) => {
|
|||
});
|
||||
});
|
||||
|
||||
// --- Medien-URL abspielen ---
|
||||
// Unterstützt: direkte MP3-URL (Download und Ablage)
|
||||
// --- Medien-URL abspielen (YouTube / Instagram / MP3) ---
|
||||
app.post('/api/play-url', async (req: Request, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number };
|
||||
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
|
||||
const { url, guildId, channelId, volume, filename } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; filename?: string };
|
||||
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;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Ungültige URL' });
|
||||
}
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
if (!pathname.endsWith('.mp3')) {
|
||||
return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' });
|
||||
}
|
||||
const fileName = path.basename(parsed.pathname);
|
||||
const dest = path.join(SOUNDS_DIR, fileName);
|
||||
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) });
|
||||
if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId 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, savedPath } = await handleUrlDownload(url, filename);
|
||||
|
||||
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`[Jukebox] [play-url] playing`); }
|
||||
catch (e: any) { console.error(`[Jukebox] [play-url] play failed (file saved): ${e?.message}`); }
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`[Jukebox] [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
|
||||
return res.json({ ok: true, saved: savedFile });
|
||||
} 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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)
|
||||
const webDistPath = path.resolve(__dirname, '../../web/dist');
|
||||
if (fs.existsSync(webDistPath)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue