summaryrefslogtreecommitdiff
path: root/src/utils/player.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils/player.js')
-rw-r--r--src/utils/player.js516
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,
+};