feat(url-play): YouTube/Instagram entfernt nur MP3-Links; UI: Checkbox entfernt, Button heißt jetzt 'Download'

This commit is contained in:
vibe-bot 2025-08-08 18:31:15 +02:00
parent d4b839f888
commit c2bd7b4503
3 changed files with 11 additions and 116 deletions

View file

@ -21,9 +21,7 @@ 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';
import ytdl from 'ytdl-core'; // Streaming externer Plattformen entfernt nur MP3-URLs werden noch unterstützt
import { createRequire } from 'node:module';
import child_process from 'node:child_process';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); 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 SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds';
const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? ''; const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? '';
const ADMIN_PWD = process.env.ADMIN_PWD ?? ''; 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 ?? '') const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '')
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
@ -46,36 +43,6 @@ if (!DISCORD_TOKEN) {
fs.mkdirSync(SOUNDS_DIR, { recursive: true }); 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 // Persistente Lautstärke pro Guild speichern
type PersistedState = { volumes: Record<string, number> }; type PersistedState = { volumes: Record<string, number> };
const STATE_FILE = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json'); const STATE_FILE = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json');
@ -615,13 +582,13 @@ app.listen(PORT, () => {
}); });
// --- Medien-URL abspielen --- // --- 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) => { app.post('/api/play-url', async (req: Request, res: Response) => {
try { 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' }); if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' });
// MP3 direkt? // Nur MP3 direkt
const lower = url.toLowerCase(); const lower = url.toLowerCase();
if (lower.endsWith('.mp3')) { if (lower.endsWith('.mp3')) {
const fileName = path.basename(new URL(url).pathname); 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; 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, () => {}); return (app._router as any).handle({ ...req, method: 'POST', url: '/api/play' }, res, () => {});
} }
return res.status(400).json({ error: 'Nur MP3-Links werden unterstützt.' });
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 });
} catch (e: any) { } catch (e: any) {
console.error('play-url error:', e); console.error('play-url error:', e);
return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' });

View file

@ -23,7 +23,6 @@ export default function App() {
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]); 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 [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>(''); const [mediaUrl, setMediaUrl] = useState<string>('');
const [mediaDownload, setMediaDownload] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -186,23 +185,19 @@ export default function App() {
if (e.key === 'Enter') { if (e.key === 'Enter') {
if (!selected) { setError('Bitte Voice-Channel wählen'); return; } if (!selected) { setError('Bitte Voice-Channel wählen'); return; }
const [guildId, channelId] = selected.split(':'); 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'); } catch (err: any) { setError(err?.message || 'Play-URL fehlgeschlagen'); }
} }
}} }}
placeholder="YouTube/Instagram/MP3 URL..." placeholder="MP3 URL..."
/> />
<button type="button" className="tab" onClick={async () => { <button type="button" className="tab" onClick={async () => {
if (!selected) { setError('Bitte Voice-Channel wählen'); return; } if (!selected) { setError('Bitte Voice-Channel wählen'); return; }
const [guildId, channelId] = selected.split(':'); const [guildId, channelId] = selected.split(':');
try { await playUrl(mediaUrl, guildId, channelId, volume, mediaDownload); } try { await playUrl(mediaUrl, guildId, channelId, volume); }
catch (e: any) { setError(e?.message || 'Play-URL fehlgeschlagen'); } catch (e: any) { setError(e?.message || 'Play-URL fehlgeschlagen'); }
}}>Abspielen</button> }}>Download</button>
</div> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={mediaDownload} onChange={(e) => setMediaDownload(e.target.checked)} />
Download speichern
</label>
</section> </section>
{!isAdmin && ( {!isAdmin && (

View file

@ -88,10 +88,10 @@ 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, download?: boolean): Promise<void> { export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise<void> {
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, download: !!download }) body: JSON.stringify({ url, guildId, channelId, volume })
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));