diff --git a/server/package.json b/server/package.json index f922169..b8baa60 100644 --- a/server/package.json +++ b/server/package.json @@ -19,7 +19,9 @@ "sodium-native": "^4.0.8", "cors": "^2.8.5", "multer": "^1.4.5-lts.1", - "ws": "^8.18.0" + "ws": "^8.18.0", + "ytdl-core": "^4.11.5", + "youtube-dl-exec": "^2.4.8" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/server/src/index.ts b/server/src/index.ts index ac3f7ca..bf916b0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,6 +20,9 @@ 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'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -579,6 +582,58 @@ app.listen(PORT, () => { console.log(`Server läuft auf http://0.0.0.0:${PORT}`); }); +// --- Medien-URL abspielen --- +// Unterstützt: YouTube (ytdl-core), generische URLs (yt-dlp), direkte mp3 (Download und Ablage) +app.post('/api/play-url', async (req: Request, res: Response) => { + 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' }); + + // MP3 direkt? + const lower = url.toLowerCase(); + if (lower.endsWith('.mp3')) { + const fileName = path.basename(new URL(url).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); + // sofort abspielen + 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) return res.status(400).json({ error: 'Bitte zuerst einen Sound abspielen, um die Verbindung herzustellen' }); + + const useVolume = typeof volume === 'number' ? Math.max(0, Math.min(1, volume)) : state.currentVolume ?? 1; + + // Audio-Stream besorgen + let stream: any; + if (ytdl.validateURL(url)) { + stream = ytdl(url, { filter: 'audioonly', quality: 'highestaudio', highWaterMark: 1 << 25 }); + } else { + // Fallback via yt-dlp, benötigt binary im Image/Host + // wir nutzen stdout mit ffmpeg pipe + const yt = child_process.spawn('yt-dlp', ['-f', 'bestaudio', '-o', '-', url]); + stream = yt.stdout; + } + + const resource = createAudioResource(stream as any, { inlineVolume: true }); + 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 }); + } 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 ca770cd..50831ca 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename } from './api'; +import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl } from './api'; import type { VoiceChannelInfo, Sound } from './types'; import { getCookie, setCookie } from './cookies'; @@ -21,6 +21,7 @@ export default function App() { const [selectedSet, setSelectedSet] = useState>({}); 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(''); useEffect(() => { (async () => { @@ -172,6 +173,15 @@ export default function App() { +
+ setMediaUrl(e.target.value)} placeholder="YouTube/Instagram/MP3 URL..." /> + +
{!isAdmin && (
setAdminPwd(e.target.value)} placeholder="Admin Passwort" /> diff --git a/web/src/api.ts b/web/src/api.ts index c26564c..c1853a1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -88,6 +88,17 @@ 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): Promise { + 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'); + } +} + diff --git a/web/src/styles.css b/web/src/styles.css index 95f70d4..4d19b02 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -88,7 +88,7 @@ header p { opacity: .8; } } .badge { align-self: flex-start; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.18); padding: 6px 10px; border-radius: 999px; font-size: 13px; } -.controls { display: grid; grid-template-columns: 1fr minmax(240px, 300px) 220px 180px; gap: 12px; align-items: center; margin-bottom: 18px; } +.controls { display: grid; grid-template-columns: 1fr minmax(240px, 300px) 220px 180px minmax(260px, 1fr); gap: 12px; align-items: center; margin-bottom: 18px; } .controls.glass { backdrop-filter: saturate(140%) blur(20px); background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.06));