feat(url-play): YouTube/Instagram entfernt nur MP3-Links; UI: Checkbox entfernt, Button heißt jetzt 'Download'
This commit is contained in:
parent
d4b839f888
commit
c2bd7b4503
3 changed files with 11 additions and 116 deletions
|
|
@ -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' });
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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(() => ({}));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue