const { spawn } = require('child_process'); const { createAudioResource, StreamType } = require('@discordjs/voice'); const COOKIES_FILE = process.env.COOKIES_FILE || 'cookies.txt'; class Mutex { constructor() { this.locked = false; this.queue = []; } async lock() { if (!this.locked) { this.locked = true; return Promise.resolve(); } return new Promise(resolve => this.queue.push(resolve)); } unlock() { if (this.queue.length > 0) { this.queue.shift()(); } else { this.locked = false; } } } const guildLocks = new Map(); async function downloadWithYtDlp(song, options = {}) { const { logPrefix = 'DOWNLOAD' } = options; const ytdlp = spawn('yt-dlp', [ '--cookies', COOKIES_FILE, '--format', 'bestaudio[ext=m4a]/bestaudio', '--external-downloader', 'aria2c', '--external-downloader-args', '-x16 -s16 -k1M', '--output', '-', song.url, ]); const chunks = []; let downloadComplete = false; ytdlp.stdout.on('data', (chunk) => chunks.push(chunk)); ytdlp.stderr.on('data', (data) => { console.error('[yt-dlp stderr]:', data.toString().trim()); }); ytdlp.on('error', (err) => { console.error('yt-dlp process error:', err); }); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { if (!downloadComplete) { ytdlp.kill('SIGKILL'); reject(new Error('Download timeout after 60s')); } }, 60000); ytdlp.on('exit', (code) => { clearTimeout(timeout); downloadComplete = true; if (code !== 0) { console.error(`yt-dlp exited with code ${code}`); return reject(new Error(`yt-dlp failed with code ${code}`)); } song.audioBuffer = Buffer.concat(chunks); console.log(`[${logPrefix} COMPLETE] ${song.title}: ${(song.audioBuffer.length / 1024 / 1024).toFixed(2)} MB`); resolve(); }); }); } async function preloadSong(song) { if (song.audioBuffer || song.isPreloading) return; song.isPreloading = true; console.log(`[PRELOAD] Starting background download: ${song.title}`); try { await downloadWithYtDlp(song, { logPrefix: 'PRELOAD' }); } catch (error) { console.log(`[PRELOAD FAILED] ${song.title}: ${error.message}`); } finally { song.isPreloading = false; } } async function getVideoInfo(query) { return new Promise((resolve, reject) => { const isUrl = query.startsWith('http://') || query.startsWith('https://'); const isPlaylist = isUrl && query.includes('list='); const args = [ '--cookies', COOKIES_FILE, '--dump-json', ]; if (isPlaylist) { args.push('--flat-playlist', '--yes-playlist'); } else { args.push('--no-playlist'); } if (!isUrl) { args.push(`ytsearch1:${query}`); } else { args.push(query); } const ytdlp = spawn('yt-dlp', args); let output = ''; let error = ''; ytdlp.stdout.on('data', (data) => { output += data.toString(); }); ytdlp.stderr.on('data', (data) => { error += data.toString(); }); ytdlp.on('close', (code) => { if (code !== 0) { console.error('yt-dlp error:', error); return resolve(null); } try { const lines = output.trim().split('\n').filter(l => l.trim()); if (isPlaylist && lines.length > 1) { const videos = lines.map(line => { try { const info = JSON.parse(line); return { title: info.title, url: info.url || `https://www.youtube.com/watch?v=${info.id}`, duration: info.duration || 0, thumbnail: info.thumbnail || info.thumbnails?.[0]?.url, }; } catch (e) { return null; } }).filter(v => v !== null); return resolve({ isPlaylist: true, videos }); } else { const info = JSON.parse(lines[0]); resolve({ isPlaylist: false, videos: [{ title: info.title, url: info.webpage_url || info.url, duration: info.duration || 0, thumbnail: info.thumbnail, }] }); } } catch (err) { console.error('Parse error:', err); resolve(null); } }); }); } async function downloadAudio(song) { if (song.audioBuffer) { console.log(`[USING CACHE] Audio already downloaded for: ${song.title}`); return; } console.log(`[DOWNLOAD] Downloading full audio for: ${song.title}`); await downloadWithYtDlp(song, { logPrefix: 'DOWNLOAD' }); } function createFFmpegProcess(seekSeconds) { const ffmpegArgs = [ '-i', 'pipe:0', '-analyzeduration', '0', '-loglevel', 'error', ]; if (seekSeconds > 0) { ffmpegArgs.push('-ss', seekSeconds.toString()); } ffmpegArgs.push( '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1' ); return spawn('ffmpeg', ffmpegArgs); } function setupFFmpegMonitoring(ffmpeg, song, guildId, queue, seekSeconds, onRetry) { let processesKilled = false; let streamStarted = false; let ffmpegErrors = ''; let lastDataTime = Date.now(); const cleanup = () => { if (!processesKilled) { processesKilled = true; try { ffmpeg.kill('SIGKILL'); } catch (e) {} } }; const handleRetry = () => { if (queue.isSeeking) { console.log(`[RETRY DURING SEEK] Resetting seek flag`); queue.isSeeking = false; } if (song.retryCount < 2) { song.retryCount++; console.log(`[RETRY] Attempting retry ${song.retryCount}/2 for: ${song.title}`); onRetry(guildId, queue, seekSeconds); } else { console.error(`[SKIP] Max retries reached for: ${song.title}`); delete song.audioBuffer; queue.songs.shift(); if (queue.songs.length > 0) { onRetry(guildId, queue); } } }; const startTimeout = setTimeout(() => { if (!streamStarted && !processesKilled) { console.error(`[TIMEOUT] FFmpeg failed to start in 10s: ${song.title}`); cleanup(); handleRetry(); } }, 10000); const watchdogInterval = setInterval(() => { const timeSinceLastData = Date.now() - lastDataTime; if (timeSinceLastData > 10000 && !processesKilled && streamStarted) { console.error(`[WATCHDOG] No data for 10s, killing ffmpeg for: ${song.title}`); cleanup(); clearTimeout(startTimeout); clearInterval(watchdogInterval); handleRetry(); } }, 5000); ffmpeg.stderr.on('data', (data) => { ffmpegErrors += data.toString(); console.error('[ffmpeg stderr]:', data.toString().trim()); }); ffmpeg.stdout.on('data', () => { lastDataTime = Date.now(); if (!streamStarted) { streamStarted = true; clearTimeout(startTimeout); console.log(`[STREAM STARTED] Guild: ${guildId}, Song: ${song.title}`); } }); ffmpeg.on('exit', (code, signal) => { clearTimeout(startTimeout); clearInterval(watchdogInterval); if (code !== 0 && code !== null && !processesKilled) { console.error(`ffmpeg exited with code ${code}, signal ${signal}`); console.error('ffmpeg errors:', ffmpegErrors); } }); ffmpeg.on('error', (err) => { console.error('ffmpeg process error:', err); clearTimeout(startTimeout); clearInterval(watchdogInterval); cleanup(); }); ffmpeg.stdin.on('error', (err) => { if (err.code !== 'EPIPE') { console.error('ffmpeg stdin error:', err); clearTimeout(startTimeout); clearInterval(watchdogInterval); cleanup(); } }); ffmpeg.stdout.on('error', (err) => { if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') { console.error('ffmpeg stdout error:', err); clearTimeout(startTimeout); clearInterval(watchdogInterval); cleanup(); } }); return { cleanup: () => { clearTimeout(startTimeout); clearInterval(watchdogInterval); cleanup(); } }; } function setupResourceHandlers(resource, guildId, queue, cleanupHandlers, onRetry) { if (resource.playStream && typeof resource.playStream.on === 'function') { resource.playStream.on('error', (err) => { console.error('Resource playStream error:', err); cleanupHandlers.cleanup(); if (queue.isSeeking) { console.log(`[PLAYSTREAM ERROR DURING SEEK] Resetting seek flag`); queue.isSeeking = false; return; } queue.songs.shift(); if (queue.songs.length > 0) { onRetry(guildId, queue); } }); resource.playStream.on('close', () => { if (queue.isPlaying) { console.warn('Stream closed unexpectedly'); } cleanupHandlers.cleanup(); }); } else { console.warn('playStream not available on resource'); } } function setResourceVolume(resource, queue) { try { if (resource.volume) { resource.volume.setVolume(queue.volume / 100); } else { console.warn('Volume control not available on resource'); } } catch (err) { console.error('Failed to set volume:', err); } } function handlePlaybackComplete(guildId, queue, cleanupHandlers) { queue.seekOffset = 0; queue.isPlaying = true; if (queue.isSeeking) { queue.isSeeking = false; console.log(`[SEEK COMPLETE] Guild: ${guildId}, Seek flag cleared`); } queue.cleanup = cleanupHandlers.cleanup; console.log(`[PLAY SUCCESS] Guild: ${guildId}, Now playing: ${queue.songs[0].title}`); if (queue.songs.length > 1) { const nextSong = queue.songs[1]; preloadSong(nextSong).catch(() => {}); } } async function playSong(guildId, queue, seekSeconds = 0) { if (!guildLocks.has(guildId)) { guildLocks.set(guildId, new Mutex()); } const lock = guildLocks.get(guildId); await lock.lock(); try { return await _playSongInternal(guildId, queue, seekSeconds); } finally { lock.unlock(); } } async function _playSongInternal(guildId, queue, seekSeconds = 0) { const song = queue.songs[0]; if (!song) return; if (!song.retryCount) { song.retryCount = 0; } console.log(`[PLAY START] Guild: ${guildId}, Song: ${song.title}, URL: ${song.url}, Seek: ${seekSeconds}s, Retry: ${song.retryCount}`); try { await downloadAudio(song); const ffmpeg = createFFmpegProcess(seekSeconds); const cleanupHandlers = setupFFmpegMonitoring(ffmpeg, song, guildId, queue, seekSeconds, playSong); ffmpeg.stdin.write(song.audioBuffer); ffmpeg.stdin.end(); const resource = createAudioResource(ffmpeg.stdout, { inputType: StreamType.Raw, inlineVolume: true, }); setupResourceHandlers(resource, guildId, queue, cleanupHandlers, playSong); setResourceVolume(resource, queue); queue.resource = resource; queue.seekOffset = seekSeconds; queue.player.play(resource); handlePlaybackComplete(guildId, queue, cleanupHandlers); } catch (error) { console.error('Play error:', error); if (queue.cleanup) { try { queue.cleanup(); } catch (e) { console.error('Cleanup error:', e); } } if (queue.isSeeking) { console.log(`[CATCH ERROR DURING SEEK] Resetting seek flag`); queue.isSeeking = false; return; } queue.songs.shift(); if (queue.songs.length > 0) { playSong(guildId, queue); } } } function formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; } function safeCleanup(queue, context = 'Unknown') { if (queue.cleanup) { try { queue.cleanup(); queue.cleanup = null; } catch (e) { console.error(`Cleanup error (${context}):`, e); } } } function clearSongBuffer(song, reason = 'cleanup') { if (song && song.audioBuffer) { delete song.audioBuffer; console.log(`[MEMORY] Cleared buffer for: ${song.title} (${reason})`); } } function setQueueVolume(queue, volume) { if (!queue) return false; const clampedVolume = Math.max(0, Math.min(100, volume)); queue.volume = clampedVolume; if (queue.resource?.volume) { queue.resource.volume.setVolume(clampedVolume / 100); } return clampedVolume; } function getCurrentProgress(queue) { if (!queue || !queue.songs[0]) { return null; } let elapsed = 0; if (queue.resource && queue.resource.playbackDuration !== undefined) { elapsed = Math.floor(queue.resource.playbackDuration / 1000) + (queue.seekOffset || 0); } else { return null; } const duration = queue.songs[0].duration; return { elapsed, duration, percentage: duration > 0 ? Math.min(100, (elapsed / duration) * 100) : 0, }; } module.exports = { getVideoInfo, playSong, preloadSong, formatDuration, getCurrentProgress, safeCleanup, clearSongBuffer, setQueueVolume, };