feat(media): URL-Player (YouTube/Instagram via ytdl/yt-dlp, MP3-Download und sofortiges Abspielen) + Frontend-URL-Feld
This commit is contained in:
parent
018c36487d
commit
6d4dba3ad3
5 changed files with 81 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, boolean>>({});
|
||||
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]);
|
||||
const [clock, setClock] = useState<string>(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date()));
|
||||
const [mediaUrl, setMediaUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
|
@ -172,6 +173,15 @@ export default function App() {
|
|||
<option value="rainbow">Rainbow Chaos</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="control" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8 }}>
|
||||
<input value={mediaUrl} onChange={(e) => setMediaUrl(e.target.value)} placeholder="YouTube/Instagram/MP3 URL..." />
|
||||
<button type="button" className="tab" onClick={async () => {
|
||||
if (!selected) { setError('Bitte Voice-Channel wählen'); return; }
|
||||
const [guildId, channelId] = selected.split(':');
|
||||
try { await playUrl(mediaUrl, guildId, channelId, volume); }
|
||||
catch (e: any) { setError(e?.message || 'Play-URL fehlgeschlagen'); }
|
||||
}}>▶ Abspielen</button>
|
||||
</div>
|
||||
{!isAdmin && (
|
||||
<div className="control" style={{ display: 'flex', gap: 8 }}>
|
||||
<input type="password" value={adminPwd} onChange={(e) => setAdminPwd(e.target.value)} placeholder="Admin Passwort" />
|
||||
|
|
|
|||
|
|
@ -88,6 +88,17 @@ 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> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue