diff options
Diffstat (limited to 'src/utils/player.js')
| -rw-r--r-- | src/utils/player.js | 516 |
1 files changed, 516 insertions, 0 deletions
diff --git a/src/utils/player.js b/src/utils/player.js new file mode 100644 index 0000000..44510d1 --- /dev/null +++ b/src/utils/player.js @@ -0,0 +1,516 @@ +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/best', + '--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, +}; |
