summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAleksa Vuckovic <aleksa@vuckovic.cc>2025-11-11 14:14:48 +0100
committerYour Name <you@example.com>2025-11-13 19:07:45 +0100
commitf10e48a8d8d0cdca589c9d73791b9a46e896425d (patch)
tree97055c143dffa352cec1a3e122a9061584a1e324 /src
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/bot.js113
-rw-r--r--src/commands/definitions.js89
-rw-r--r--src/handlers/controlCommands.js97
-rw-r--r--src/handlers/playCommand.js271
-rw-r--r--src/handlers/queueCommand.js38
-rw-r--r--src/handlers/seekCommand.js51
-rw-r--r--src/handlers/volumeCommand.js28
-rw-r--r--src/utils/commandRegistry.js16
-rw-r--r--src/utils/helpers.js24
-rw-r--r--src/utils/player.js516
10 files changed, 1243 insertions, 0 deletions
diff --git a/src/bot.js b/src/bot.js
new file mode 100644
index 0000000..cd7c2db
--- /dev/null
+++ b/src/bot.js
@@ -0,0 +1,113 @@
+const { Client, GatewayIntentBits } = require('discord.js');
+require('dotenv').config();
+
+const commands = require('./commands/definitions');
+const { registerCommands } = require('./utils/commandRegistry');
+const { handlePlay } = require('./handlers/playCommand');
+const { handlePause, handleResume, handleSkip, handleClear, handleLoop, handleQuit } = require('./handlers/controlCommands');
+const { handleQueue } = require('./handlers/queueCommand');
+const { handleVolume } = require('./handlers/volumeCommand');
+const { handleSeek } = require('./handlers/seekCommand');
+
+const client = new Client({
+ intents: [
+ GatewayIntentBits.Guilds,
+ GatewayIntentBits.GuildVoiceStates,
+ ],
+});
+
+const queues = new Map();
+
+client.once('ready', async () => {
+ console.log(`Bot ready: ${client.user.tag}`);
+ console.log(`Ready at: ${new Date().toISOString()}`);
+ await registerCommands(commands, process.env.DISCORD_TOKEN, process.env.CLIENT_ID);
+
+ setInterval(() => {
+ const activeQueues = queues.size;
+ console.log(`[HEARTBEAT] ${new Date().toISOString()} - Active queues: ${activeQueues}`);
+ }, 5 * 60 * 1000);
+});
+
+function handleError(error, context = 'Unknown') {
+ const timestamp = new Date().toISOString();
+ console.error(`[ERROR] ${timestamp} - Context: ${context}`);
+ console.error(error);
+
+ if (error.stack) {
+ console.error('Stack trace:', error.stack);
+ }
+}
+
+async function handleInteractionError(interaction, error, commandName) {
+ handleError(error, `Command: ${commandName}`);
+
+ try {
+ const reply = interaction.deferred || interaction.replied ? 'editReply' : 'reply';
+ await interaction[reply]({
+ content: 'An error occurred while executing this command!',
+ ephemeral: true
+ }).catch(() => {});
+ } catch (replyError) {
+ console.error('Failed to send error message to user:', replyError);
+ }
+}
+
+client.on('interactionCreate', async (interaction) => {
+ if (!interaction.isChatInputCommand()) return;
+
+ try {
+ switch (interaction.commandName) {
+ case 'play':
+ await handlePlay(interaction, queues);
+ break;
+ case 'pause':
+ handlePause(interaction, queues);
+ break;
+ case 'resume':
+ handleResume(interaction, queues);
+ break;
+ case 'skip':
+ handleSkip(interaction, queues);
+ break;
+ case 'clear':
+ handleClear(interaction, queues);
+ break;
+ case 'loop':
+ handleLoop(interaction, queues);
+ break;
+ case 'quit':
+ handleQuit(interaction, queues);
+ break;
+ case 'queue':
+ handleQueue(interaction, queues);
+ break;
+ case 'volume':
+ handleVolume(interaction, queues);
+ break;
+ case 'seek':
+ handleSeek(interaction, queues);
+ break;
+ }
+ } catch (error) {
+ await handleInteractionError(interaction, error, interaction.commandName);
+ }
+});
+
+process.on('unhandledRejection', (error) => {
+ handleError(error, 'Unhandled Promise Rejection');
+});
+
+process.on('uncaughtException', (error) => {
+ handleError(error, 'Uncaught Exception');
+});
+
+client.on('error', (error) => {
+ handleError(error, 'Discord Client Error');
+});
+
+client.on('warn', (info) => {
+ console.warn(`[WARN] ${new Date().toISOString()} - ${info}`);
+});
+
+client.login(process.env.DISCORD_TOKEN); \ No newline at end of file
diff --git a/src/commands/definitions.js b/src/commands/definitions.js
new file mode 100644
index 0000000..626fcc6
--- /dev/null
+++ b/src/commands/definitions.js
@@ -0,0 +1,89 @@
+const { SlashCommandBuilder } = require('discord.js');
+
+module.exports = [
+ new SlashCommandBuilder()
+ .setName('play')
+ .setDescription('Play music from YouTube')
+ .addStringOption(option =>
+ option.setName('query')
+ .setDescription('YouTube URL or search query')
+ .setRequired(true)
+ ),
+ new SlashCommandBuilder()
+ .setName('pause')
+ .setDescription('Pause the current song'),
+ new SlashCommandBuilder()
+ .setName('resume')
+ .setDescription('Resume the paused song'),
+ new SlashCommandBuilder()
+ .setName('skip')
+ .setDescription('Skip the current song'),
+ new SlashCommandBuilder()
+ .setName('clear')
+ .setDescription('Clear the entire queue'),
+ new SlashCommandBuilder()
+ .setName('loop')
+ .setDescription('Toggle loop mode')
+ .addStringOption(option =>
+ option.setName('mode')
+ .setDescription('Loop mode')
+ .setRequired(false)
+ .addChoices(
+ { name: 'Off', value: 'off' },
+ { name: 'Song', value: 'song' },
+ { name: 'Queue', value: 'queue' }
+ )
+ ),
+ new SlashCommandBuilder()
+ .setName('quit')
+ .setDescription('Leave the voice channel'),
+ new SlashCommandBuilder()
+ .setName('queue')
+ .setDescription('Show the music queue'),
+ new SlashCommandBuilder()
+ .setName('volume')
+ .setDescription('Adjust volume')
+ .addSubcommand(subcommand =>
+ subcommand
+ .setName('set')
+ .setDescription('Set volume to a specific value')
+ .addIntegerOption(option =>
+ option.setName('value')
+ .setDescription('Volume (0-100)')
+ .setRequired(true)
+ .setMinValue(0)
+ .setMaxValue(100)
+ )
+ )
+ .addSubcommand(subcommand =>
+ subcommand
+ .setName('inc')
+ .setDescription('Increase volume')
+ .addIntegerOption(option =>
+ option.setName('amount')
+ .setDescription('Amount to increase (default: 10)')
+ .setMinValue(1)
+ .setMaxValue(100)
+ )
+ )
+ .addSubcommand(subcommand =>
+ subcommand
+ .setName('dec')
+ .setDescription('Decrease volume')
+ .addIntegerOption(option =>
+ option.setName('amount')
+ .setDescription('Amount to decrease (default: 10)')
+ .setMinValue(1)
+ .setMaxValue(100)
+ )
+ ),
+ new SlashCommandBuilder()
+ .setName('seek')
+ .setDescription('Skip forward or backward in the current song')
+ .addIntegerOption(option =>
+ option.setName('seconds')
+ .setDescription('Seconds to skip (use negative to go back)')
+ .setRequired(true)
+ ),
+
+];
diff --git a/src/handlers/controlCommands.js b/src/handlers/controlCommands.js
new file mode 100644
index 0000000..14c73f7
--- /dev/null
+++ b/src/handlers/controlCommands.js
@@ -0,0 +1,97 @@
+const { playSong, safeCleanup, clearSongBuffer } = require('../utils/player');
+const { getQueueOrReply } = require('../utils/helpers');
+
+function handlePause(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Nothing is playing!');
+ if (!queue) return;
+
+ queue.player.pause();
+ interaction.reply('Paused!');
+}
+
+function handleResume(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Nothing is paused!');
+ if (!queue) return;
+
+ queue.player.unpause();
+ interaction.reply('Resumed!');
+}
+
+function handleSkip(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Nothing to skip!');
+ if (!queue || queue.songs.length === 0) {
+ if (queue) interaction.reply('Nothing to skip!');
+ return;
+ }
+
+ const playerState = queue.player.state.status;
+ console.log(`[SKIP] Guild: ${interaction.guild.id}, Skipping: ${queue.songs[0]?.title}, Player state: ${playerState}, Queue length: ${queue.songs.length}`);
+
+ clearSongBuffer(queue.songs[0], 'skipped');
+ safeCleanup(queue, 'Skip');
+
+ queue.player.stop();
+ interaction.reply(`Skipped! (${queue.songs.length - 1} songs remaining)`);
+}
+
+function handleClear(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Queue is already empty!');
+ if (!queue || queue.songs.length === 0) {
+ if (queue) interaction.reply('Queue is already empty!');
+ return;
+ }
+
+ const currentSong = queue.songs[0];
+ const clearedCount = queue.songs.length - 1;
+
+ for (let i = 1; i < queue.songs.length; i++) {
+ clearSongBuffer(queue.songs[i], 'queue cleared');
+ }
+
+ queue.songs = [currentSong];
+ console.log(`[QUEUE] Cleared ${clearedCount} songs from queue`);
+ interaction.reply(`Cleared queue! Only current song remains: ${currentSong.title}`);
+}
+
+function handleLoop(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues);
+ if (!queue) return;
+
+ const mode = interaction.options.getString('mode');
+
+ if (mode) {
+ queue.loopMode = mode;
+ } else {
+ const modes = ['off', 'song', 'queue'];
+ const currentIndex = modes.indexOf(queue.loopMode || 'off');
+ queue.loopMode = modes[(currentIndex + 1) % modes.length];
+ }
+
+ const modeEmojis = {
+ off: 'Loop disabled',
+ song: 'Looping current song',
+ queue: 'Looping queue'
+ };
+
+ interaction.reply(modeEmojis[queue.loopMode]);
+}
+
+function handleQuit(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues);
+ if (!queue) return;
+
+ try { queue.player.stop(); } catch (e) {}
+ safeCleanup(queue, 'Quit');
+ queue.connection.destroy();
+ queues.delete(interaction.guild.id);
+ interaction.reply('Left voice channel!');
+}
+
+module.exports = {
+ handlePause,
+ handleResume,
+ handleSkip,
+ handleClear,
+ handleLoop,
+ handleQuit,
+};
diff --git a/src/handlers/playCommand.js b/src/handlers/playCommand.js
new file mode 100644
index 0000000..5551d28
--- /dev/null
+++ b/src/handlers/playCommand.js
@@ -0,0 +1,271 @@
+const { EmbedBuilder } = require('discord.js');
+const {
+ createAudioPlayer,
+ joinVoiceChannel,
+ AudioPlayerStatus,
+ VoiceConnectionStatus,
+ entersState,
+} = require('@discordjs/voice');
+const { getVideoInfo, playSong, preloadSong, formatDuration, safeCleanup, clearSongBuffer } = require('../utils/player');
+const { requireVoiceChannel } = require('../utils/helpers');
+
+function createSongsFromVideos(videos, requestedBy) {
+ return videos.map(v => ({
+ title: v.title,
+ url: v.url,
+ duration: v.duration,
+ thumbnail: v.thumbnail,
+ requestedBy: requestedBy,
+ }));
+}
+
+function onPlayerIdle(interaction, queue) {
+ console.log(`[PLAYER IDLE] Guild: ${interaction.guild.id}, Loop mode: ${queue.loopMode}, Is seeking: ${queue.isSeeking || false}`);
+
+ safeCleanup(queue, 'Player Idle');
+
+ if (queue.isSeeking) {
+ console.log(`[IDLE SKIP] Skipping queue shift because seek is in progress`);
+ return;
+ }
+
+ if (queue.loopMode === 'song') {
+ if (queue.songs[0]) {
+ queue.songs[0].retryCount = 0;
+ }
+ playSong(interaction.guild.id, queue);
+ } else if (queue.loopMode === 'queue') {
+ const finishedSong = queue.songs.shift();
+ queue.songs.push(finishedSong);
+ if (queue.songs.length > 0) {
+ playSong(interaction.guild.id, queue);
+ }
+ } else {
+ const finishedSong = queue.songs.shift();
+ clearSongBuffer(finishedSong, 'finished playing');
+ if (queue.songs.length > 0) {
+ playSong(interaction.guild.id, queue);
+ } else {
+ console.log(`[QUEUE EMPTY] Guild: ${interaction.guild.id}`);
+ }
+ }
+}
+
+function onPlayerError(error, interaction, queue) {
+ console.error('Audio player error:', error);
+ safeCleanup(queue, 'Player Error');
+
+ if (queue.isSeeking) {
+ console.log(`[ERROR DURING SEEK] Not shifting queue, seek in progress`);
+ queue.isSeeking = false;
+ return;
+ }
+
+ queue.songs.shift();
+ if (queue.songs.length > 0) {
+ playSong(interaction.guild.id, queue);
+ }
+}
+
+function onPlayerAutoPaused(player) {
+ console.warn('Player auto-paused, attempting to resume...');
+ try {
+ player.unpause();
+ } catch (err) {
+ console.error('Failed to unpause:', err);
+ }
+}
+
+function onConnectionError(error, queue) {
+ console.error('Voice connection error:', error);
+ safeCleanup(queue, 'Connection Error');
+}
+
+async function onConnectionDisconnected(connection, queues, guildId) {
+ try {
+ await Promise.race([
+ entersState(connection, VoiceConnectionStatus.Signalling, 5000),
+ entersState(connection, VoiceConnectionStatus.Connecting, 5000),
+ ]);
+ } catch (error) {
+ connection.destroy();
+ queues.delete(guildId);
+ }
+}
+
+function onConnectionDestroyed(queues, guildId) {
+ queues.delete(guildId);
+}
+
+function setupPlayerEventListeners(player, interaction, queue) {
+ player.on(AudioPlayerStatus.Idle, () => onPlayerIdle(interaction, queue));
+ player.on('error', (error) => onPlayerError(error, interaction, queue));
+ player.on(AudioPlayerStatus.AutoPaused, () => onPlayerAutoPaused(player));
+}
+
+function setupConnectionEventListeners(connection, queues, guildId, queue) {
+ connection.on('error', (error) => onConnectionError(error, queue));
+ connection.on(VoiceConnectionStatus.Disconnected, () =>
+ onConnectionDisconnected(connection, queues, guildId)
+ );
+ connection.on(VoiceConnectionStatus.Destroyed, () =>
+ onConnectionDestroyed(queues, guildId)
+ );
+}
+
+async function createVoiceConnection(voiceChannel, interaction) {
+ const connection = joinVoiceChannel({
+ channelId: voiceChannel.id,
+ guildId: interaction.guild.id,
+ adapterCreator: voiceChannel.guild.voiceAdapterCreator,
+ });
+
+ try {
+ await entersState(connection, VoiceConnectionStatus.Ready, 30000);
+ return connection;
+ } catch (error) {
+ console.error('Failed to join voice channel:', error);
+ connection.destroy();
+ throw error;
+ }
+}
+
+async function initializeQueue(voiceChannel, interaction, queues, songs) {
+ const player = createAudioPlayer();
+ let connection;
+
+ try {
+ connection = await createVoiceConnection(voiceChannel, interaction);
+ } catch (error) {
+ return null;
+ }
+
+ connection.subscribe(player);
+
+ const queue = {
+ voiceChannel,
+ connection,
+ player,
+ songs: songs,
+ volume: 50,
+ isPlaying: false,
+ loopMode: 'off',
+ };
+
+ queues.set(interaction.guild.id, queue);
+
+ setupPlayerEventListeners(player, interaction, queue);
+ setupConnectionEventListeners(connection, queues, interaction.guild.id, queue);
+
+ return queue;
+}
+
+function addSongsToQueue(queue, songs, interaction) {
+ const oldLength = queue.songs.length;
+ const wasIdle = queue.player.state.status === AudioPlayerStatus.Idle;
+
+ songs.forEach(song => queue.songs.push(song));
+ const newLength = queue.songs.length;
+
+ console.log(`[QUEUE ADD] Guild: ${interaction.guild.id}, Added ${songs.length} songs, Queue: ${oldLength} -> ${newLength}, Player state: ${queue.player.state.status}, Was idle: ${wasIdle}`);
+
+ if (wasIdle && oldLength === 0 && queue.songs.length > 0) {
+ if (queue.isSeeking) {
+ console.log(`[AUTO-PLAY BLOCKED] Seek in progress, not starting playback`);
+ } else {
+ console.log(`[AUTO-PLAY] Starting playback, songs in queue: ${queue.songs.length}`);
+ playSong(interaction.guild.id, queue);
+ }
+ }
+
+ if (queue.songs.length >= 2) {
+ const nextSong = queue.songs[1];
+ if (!nextSong.audioBuffer && !nextSong.isPreloading) {
+ console.log(`[PRELOAD TRIGGER] Preloading next song: ${nextSong.title}`);
+ preloadSong(nextSong).catch(() => {});
+ }
+ }
+
+ return wasIdle;
+}
+
+function createNewPlaybackEmbed(firstSong, isPlaylist, songs) {
+ const embed = new EmbedBuilder()
+ .setColor(0x00ff00)
+ .setTitle(isPlaylist ? 'Playlist Added' : 'Now Playing')
+ .setDescription(`**${firstSong.title}**`)
+ .addFields(
+ { name: 'Duration', value: formatDuration(firstSong.duration), inline: true },
+ { name: 'Requested by', value: firstSong.requestedBy, inline: true }
+ )
+ .setThumbnail(firstSong.thumbnail);
+
+ if (isPlaylist) {
+ embed.addFields({ name: 'Playlist', value: `${songs.length} songs added to queue` });
+ }
+
+ return embed;
+}
+
+function createAddToQueueEmbed(firstSong, isPlaylist, songs, queuePosition) {
+ const embed = new EmbedBuilder()
+ .setColor(0x00ff00)
+ .setTitle(isPlaylist ? 'Playlist Added to Queue' : 'Added to Queue')
+ .setDescription(`**${firstSong.title}**`)
+ .addFields(
+ { name: 'Position', value: `${queuePosition}`, inline: true },
+ { name: 'Requested by', value: firstSong.requestedBy, inline: true }
+ )
+ .setThumbnail(firstSong.thumbnail);
+
+ if (isPlaylist) {
+ embed.addFields({ name: 'Playlist', value: `${songs.length} songs added to queue` });
+ }
+
+ return embed;
+}
+
+async function handlePlay(interaction, queues) {
+ await interaction.deferReply();
+
+ const query = interaction.options.getString('query');
+ const voiceChannel = requireVoiceChannel(interaction);
+ if (!voiceChannel) return;
+
+ const videoInfo = await getVideoInfo(query);
+ if (!videoInfo) {
+ return interaction.editReply('Could not find that video!');
+ }
+
+ const isPlaylist = videoInfo.isPlaylist;
+ const videos = videoInfo.videos;
+
+ if (!videos || videos.length === 0) {
+ return interaction.editReply('Could not find any videos!');
+ }
+
+ const songs = createSongsFromVideos(videos, interaction.user.tag);
+ const firstSong = songs[0];
+
+ let queue = queues.get(interaction.guild.id);
+
+ if (!queue) {
+ queue = await initializeQueue(voiceChannel, interaction, queues, songs);
+
+ if (!queue) {
+ return interaction.editReply('Failed to join voice channel! Try again.');
+ }
+
+ const embed = createNewPlaybackEmbed(firstSong, isPlaylist, songs);
+ interaction.editReply({ embeds: [embed] });
+ playSong(interaction.guild.id, queue);
+ } else {
+ const queuePosition = queue.songs.length - songs.length + 1;
+ addSongsToQueue(queue, songs, interaction);
+
+ const embed = createAddToQueueEmbed(firstSong, isPlaylist, songs, queuePosition);
+ interaction.editReply({ embeds: [embed] });
+ }
+}
+
+module.exports = { handlePlay };
diff --git a/src/handlers/queueCommand.js b/src/handlers/queueCommand.js
new file mode 100644
index 0000000..e0fbee1
--- /dev/null
+++ b/src/handlers/queueCommand.js
@@ -0,0 +1,38 @@
+const { EmbedBuilder } = require('discord.js');
+const { formatDuration } = require('../utils/player');
+const { getQueueOrReply } = require('../utils/helpers');
+
+function handleQueue(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Queue is empty!');
+ if (!queue || queue.songs.length === 0) {
+ if (queue) interaction.reply('Queue is empty!');
+ return;
+ }
+
+ const nowPlaying = queue.songs[0];
+ const upcoming = queue.songs.slice(1, 10);
+
+ const embed = new EmbedBuilder()
+ .setColor(0x0099ff)
+ .setTitle('Queue')
+ .setDescription(
+ `**Now Playing:**\n${nowPlaying.title}\n` +
+ `Duration: ${formatDuration(nowPlaying.duration)} | Requested by: ${nowPlaying.requestedBy}`
+ )
+ .setThumbnail(nowPlaying.thumbnail);
+
+ if (upcoming.length > 0) {
+ const upcomingText = upcoming
+ .map((song, i) => `${i + 1}. ${song.title} - ${formatDuration(song.duration)}`)
+ .join('\n');
+ embed.addFields({ name: 'Up Next', value: upcomingText });
+ }
+
+ if (queue.songs.length > 10) {
+ embed.setFooter({ text: `...and ${queue.songs.length - 10} more` });
+ }
+
+ interaction.reply({ embeds: [embed] });
+}
+
+module.exports = { handleQueue };
diff --git a/src/handlers/seekCommand.js b/src/handlers/seekCommand.js
new file mode 100644
index 0000000..e48f603
--- /dev/null
+++ b/src/handlers/seekCommand.js
@@ -0,0 +1,51 @@
+const { EmbedBuilder } = require('discord.js');
+const { playSong, getCurrentProgress, formatDuration, safeCleanup } = require('../utils/player');
+const { getQueueOrReply } = require('../utils/helpers');
+
+function handleSeek(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Nothing is playing!');
+ if (!queue || queue.songs.length === 0) {
+ if (queue) interaction.reply('Nothing is playing!');
+ return;
+ }
+
+ if (queue.isSeeking) {
+ return interaction.reply('Already seeking, please wait!');
+ }
+
+ const seconds = interaction.options.getInteger('seconds');
+ const progress = getCurrentProgress(queue);
+
+ if (!progress) {
+ return interaction.reply('Cannot seek right now!');
+ }
+
+ let newPosition = progress.elapsed + seconds;
+ const song = queue.songs[0];
+
+ if (newPosition < 0) {
+ newPosition = 0;
+ }
+
+ if (newPosition >= song.duration) {
+ queue.player.stop();
+ return interaction.reply('Skipped to next song!');
+ }
+
+ queue.isSeeking = true;
+ safeCleanup(queue, 'Seek');
+
+ queue.player.stop();
+
+ console.log(`[SEEK] Guild: ${interaction.guild.id}, From: ${progress.elapsed}s, To: ${newPosition}s`);
+
+ playSong(interaction.guild.id, queue, newPosition);
+
+ const embed = new EmbedBuilder()
+ .setColor(0x00ff00)
+ .setDescription(`⏩ Seeking to ${formatDuration(newPosition)} / ${formatDuration(song.duration)}`);
+
+ interaction.reply({ embeds: [embed] });
+}
+
+module.exports = { handleSeek };
diff --git a/src/handlers/volumeCommand.js b/src/handlers/volumeCommand.js
new file mode 100644
index 0000000..dbd99de
--- /dev/null
+++ b/src/handlers/volumeCommand.js
@@ -0,0 +1,28 @@
+const { setQueueVolume } = require('../utils/player');
+const { getQueueOrReply } = require('../utils/helpers');
+
+function handleVolume(interaction, queues) {
+ const queue = getQueueOrReply(interaction, queues, 'Nothing is playing!');
+ if (!queue) return;
+
+ const subcommand = interaction.options.getSubcommand();
+
+ if (subcommand === 'set') {
+ const value = interaction.options.getInteger('value');
+ if (value < 0 || value > 100) {
+ return interaction.reply('Volume must be between 0 and 100!');
+ }
+ setQueueVolume(queue, value);
+ interaction.reply(`Volume set to ${value}%`);
+ } else if (subcommand === 'inc') {
+ const amount = interaction.options.getInteger('amount') || 10;
+ const newVolume = setQueueVolume(queue, queue.volume + amount);
+ interaction.reply(`Volume increased to ${newVolume}%`);
+ } else if (subcommand === 'dec') {
+ const amount = interaction.options.getInteger('amount') || 10;
+ const newVolume = setQueueVolume(queue, queue.volume - amount);
+ interaction.reply(`Volume decreased to ${newVolume}%`);
+ }
+}
+
+module.exports = { handleVolume };
diff --git a/src/utils/commandRegistry.js b/src/utils/commandRegistry.js
new file mode 100644
index 0000000..918e7b3
--- /dev/null
+++ b/src/utils/commandRegistry.js
@@ -0,0 +1,16 @@
+const { REST, Routes } = require('discord.js');
+
+async function registerCommands(commands, token, clientId) {
+ const rest = new REST({ version: '10' }).setToken(token);
+
+ try {
+ await rest.put(
+ Routes.applicationCommands(clientId),
+ { body: commands.map(cmd => cmd.toJSON()) }
+ );
+ } catch (error) {
+ console.error('Error registering commands:', error);
+ }
+}
+
+module.exports = { registerCommands };
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
new file mode 100644
index 0000000..f18408f
--- /dev/null
+++ b/src/utils/helpers.js
@@ -0,0 +1,24 @@
+function getQueueOrReply(interaction, queues, message = 'Not in a voice channel!') {
+ const queue = queues.get(interaction.guild.id);
+
+ if (!queue) {
+ interaction.reply(message);
+ return null;
+ }
+
+ return queue;
+}
+
+function requireVoiceChannel(interaction) {
+ if (!interaction.member?.voice?.channel) {
+ interaction.editReply('You need to be in a voice channel!');
+ return null;
+ }
+
+ return interaction.member.voice.channel;
+}
+
+module.exports = {
+ getQueueOrReply,
+ requireVoiceChannel,
+};
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,
+};