diff --git a/server/src/index.ts b/server/src/index.ts index c1d62b3..a000fd5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,9 +21,7 @@ import { } from '@discordjs/voice'; import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; -import ytdl from 'ytdl-core'; -import { createRequire } from 'node:module'; -import child_process from 'node:child_process'; +// Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -33,7 +31,6 @@ const PORT = Number(process.env.PORT ?? 8080); const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? ''; const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; -const YTDLP_COOKIES_FILE = process.env.YTDLP_COOKIES_FILE ?? ''; const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '') .split(',') .map((s) => s.trim()) @@ -46,36 +43,6 @@ if (!DISCORD_TOKEN) { fs.mkdirSync(SOUNDS_DIR, { recursive: true }); -function buildYtDlpArgs(url: string, mode: 'stream' | 'download', outPath?: string): string[] { - const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1'; - const host = (() => { try { return new URL(url).hostname; } catch { return 'www.youtube.com'; } })(); - const referer = `https://${host}/`; - const base = [ - '--no-playlist', - '--no-warnings', - '--geo-bypass', - '--user-agent', ua, - '--referer', referer, - ]; - // Feintuning je nach Host - if (host.includes('youtube')) { - base.push('--extractor-args', 'youtube:player_client=android'); - } - if (host.includes('instagram')) { - // Instagram braucht oft Login → Cookies nutzen, sonst public Reels funktionieren ggf. ohne - // Keine speziellen extractor-args nötig, aber Referer & UA helfen - } - if (YTDLP_COOKIES_FILE) { - base.push('--cookies', YTDLP_COOKIES_FILE); - } - if (mode === 'stream') { - return ['-f', 'bestaudio/best', ...base, '-o', '-', url]; - } - // download - const out = outPath ?? path.join(SOUNDS_DIR, `media-${Date.now()}.mp3`); - return ['-x', '--audio-format', 'mp3', '--audio-quality', '0', ...base, '-o', out, url]; -} - // Persistente Lautstärke pro Guild speichern type PersistedState = { volumes: Record }; const STATE_FILE = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json'); @@ -615,13 +582,13 @@ app.listen(PORT, () => { }); // --- Medien-URL abspielen --- -// Unterstützt: YouTube (ytdl-core), generische URLs (yt-dlp), direkte mp3 (Download und Ablage) +// Unterstützt: direkte MP3-URL (Download und Ablage) app.post('/api/play-url', async (req: Request, res: Response) => { try { - const { url, guildId, channelId, volume, download } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; download?: boolean }; + 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' }); - // MP3 direkt? + // Nur MP3 direkt const lower = url.toLowerCase(); if (lower.endsWith('.mp3')) { const fileName = path.basename(new URL(url).pathname); @@ -634,74 +601,7 @@ app.post('/api/play-url', async (req: Request, res: Response) => { req.body = { soundName: path.parse(fileName).name, guildId, channelId, volume, relativePath: fileName } as any; return (app._router as any).handle({ ...req, method: 'POST', url: '/api/play' }, res, () => {}); } - - const guild = client.guilds.cache.get(guildId); - if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); - let state = guildAudioState.get(guildId); - if (!state) { - const channel = guild.channels.cache.get(channelId); - if (!channel || (channel.type !== ChannelType.GuildVoice && channel.type !== ChannelType.GuildStageVoice)) { - return res.status(400).json({ error: 'Ungültiger Voice-Channel' }); - } - const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfDeaf: false, selfMute: false }); - const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); - connection.subscribe(player); - state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) }; - guildAudioState.set(guildId, state); - state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); - attachVoiceLifecycle(state, guild); - } - - const useVolume = typeof volume === 'number' ? Math.max(0, Math.min(1, volume)) : state.currentVolume ?? 1; - - // Audio-Stream besorgen - // Download in Datei (mp3) falls gewünscht - if (download === true) { - const safeBase = `media-${Date.now()}`; - const outPath = path.join(SOUNDS_DIR, `${safeBase}.mp3`); - const yt = child_process.spawn('yt-dlp', buildYtDlpArgs(url, 'download', outPath)); - yt.stderr.on('data', (d) => console.log(`[yt-dlp] ${String(d)}`)); - yt.on('error', (err) => console.error('yt-dlp spawn error:', err)); - yt.on('close', async (code) => { - if (code !== 0) { - console.error('yt-dlp exited with code', code); - try { res.status(500).json({ error: 'Download fehlgeschlagen' }); } catch {} - return; - } - // Datei abspielen - try { - const resource = createAudioResource(outPath, { inlineVolume: true }); - if (resource.volume) resource.volume.setVolume(useVolume); - state!.player.stop(); - state!.player.play(resource); - state!.currentResource = resource; - state!.currentVolume = useVolume; - try { res.json({ ok: true, saved: path.basename(outPath) }); } catch {} - } catch (e) { - console.error('play downloaded file error:', e); - try { res.status(500).json({ error: 'Abspielen der Datei fehlgeschlagen' }); } catch {} - } - }); - return; - } - - // Streaming: yt-dlp + ffmpeg Transcoding (stabiler als ytdl-core) - const ytArgs = buildYtDlpArgs(url, 'stream'); - const yt = child_process.spawn('yt-dlp', ytArgs); - yt.stderr.on('data', (d) => console.log(`[yt-dlp] ${String(d)}`)); - yt.on('error', (err) => console.error('yt-dlp spawn error:', err)); - const ffArgs = ['-loglevel', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']; - const ff = child_process.spawn('ffmpeg', ffArgs); - ff.stderr.on('data', (d) => console.log(`[ffmpeg] ${String(d)}`)); - yt.stdout.pipe(ff.stdin); - - const resource = createAudioResource(ff.stdout as any, { inlineVolume: true, inputType: StreamType.Raw }); - if (resource.volume) resource.volume.setVolume(useVolume); - state.player.stop(); - state.player.play(resource); - state.currentResource = resource; - state.currentVolume = useVolume; - return res.json({ ok: true }); + return res.status(400).json({ error: 'Nur MP3-Links werden unterstützt.' }); } catch (e: any) { console.error('play-url error:', e); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 4c6944c..31b9889 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,7 +23,6 @@ export default function App() { const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]); const [clock, setClock] = useState(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date())); const [mediaUrl, setMediaUrl] = useState(''); - const [mediaDownload, setMediaDownload] = useState(false); useEffect(() => { (async () => { @@ -186,23 +185,19 @@ export default function App() { if (e.key === 'Enter') { if (!selected) { setError('Bitte Voice-Channel wählen'); return; } const [guildId, channelId] = selected.split(':'); - try { await playUrl(mediaUrl, guildId, channelId, volume, mediaDownload); } + try { await playUrl(mediaUrl, guildId, channelId, volume); } catch (err: any) { setError(err?.message || 'Play-URL fehlgeschlagen'); } } }} - placeholder="YouTube/Instagram/MP3 URL..." + placeholder="MP3 URL..." /> + }}>⬇ Download - {!isAdmin && ( diff --git a/web/src/api.ts b/web/src/api.ts index 668892f..c1853a1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -88,10 +88,10 @@ export async function adminRename(from: string, to: string): Promise { return data?.to as string; } -export async function playUrl(url: string, guildId: string, channelId: string, volume: number, download?: boolean): Promise { +export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise { const res = await fetch(`${API_BASE}/play-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, guildId, channelId, volume, download: !!download }) + body: JSON.stringify({ url, guildId, channelId, volume }) }); if (!res.ok) { const data = await res.json().catch(() => ({}));