Skip to content
This repository was archived by the owner on Aug 27, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions modules/music/handlers/loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,13 @@ export function createLoopCommand(ctx) {
const mode = interaction.options.getString("mode");

try {
if (mode === "off") {
player.repeatMode = "none";
await interaction.editReply({ embeds: [embed.success("Loop mode set to off.")] });
} else if (mode === "song") {
player.repeatMode = "track";
await interaction.editReply({ embeds: [embed.success("Loop mode set to song.")] });
} else if (mode === "queue") {
player.repeatMode = "queue";
await interaction.editReply({ embeds: [embed.success("Loop mode set to queue.")] });
}
// The setRepeatMode method ensures the state is set correctly.
// The valid modes are "off", "track", and "queue".
player.setRepeatMode(mode);

// Capitalize first letter for the reply message
const friendlyMode = mode.charAt(0).toUpperCase() + mode.slice(1);
await interaction.editReply({ embeds: [embed.success(`Loop mode set to ${friendlyMode}.`)] });
} catch (error) {
logger.error(`[Music] Error setting loop mode: ${error.message}`);
await interaction.editReply({ embeds: [embed.error(`An error occurred while trying to set the loop mode: ${error.message}`)] });
Expand Down
17 changes: 3 additions & 14 deletions modules/music/handlers/nowplaying.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatDuration, generateProgressBar } from "../utils/formatters.js";

export function createNowPlayingCommand(ctx) {
const { v2, logger, music, embed } = ctx;
const { manager } = music;
Expand All @@ -19,7 +21,7 @@ export function createNowPlayingCommand(ctx) {
const isStream = song.info.isStream;
const totalDuration = isStream ? "LIVE" : formatDuration(player.queue.current.info.duration);
const currentPosition = isStream ? "0:00" : formatDuration(player.position);
const progressBar = isStream ? "[▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬]" : generateProgressBar(player.position, player.queue.current.info.duration);
const progressBar = isStream ? "" : generateProgressBar(player.position, player.queue.current.info.duration);

const nowPlayingEmbed = embed.info("Now Playing");
nowPlayingEmbed.setTitle(song.info.title);
Expand All @@ -35,16 +37,3 @@ export function createNowPlayingCommand(ctx) {

return cmdNowPlaying;
}

function formatDuration(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
}

function generateProgressBar(current, total, size = 20) {
const percentage = current / total;
const progress = Math.round(size * percentage);
const empty = size - progress;
return "[" + "=".repeat(progress) + "-".repeat(empty) + "]";
}
7 changes: 7 additions & 0 deletions modules/music/handlers/play.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export function createPlayCommand(ctx) {

const res = await player.search({ query, source: "ytsearch" }, interaction.user);

// Re-validate player state after await, in case it was destroyed during search
const currentPlayer = manager.players.get(interaction.guild.id);
if (!currentPlayer || currentPlayer.state === "DESTROYED") {
logger.info(`[Music] Player for guild ${interaction.guild.id} was destroyed during song search. Aborting play command.`);
return;
}

if (!res || !res.tracks.length) {
return interaction.editReply({ embeds: [embed.error(`No results found for
${query}
Expand Down
23 changes: 13 additions & 10 deletions modules/music/handlers/queue/list.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApplicationCommandOptionType } from "discord.js";
import { createPaginatedEmbed } from "../../../../core/ui.js";
import { formatDuration } from "../../utils/formatters.js";

export function createListCommand(ctx, cmdQueue) {
const { logger, music, embed, lifecycle } = ctx;
Expand All @@ -15,7 +16,9 @@ export function createListCommand(ctx, cmdQueue) {
}

const itemsPerPage = 10;
const totalPages = Math.ceil(player.queue.tracks.length / itemsPerPage);
const maxPages = 25; // Cap to prevent performance issues with massive queues
const totalTracks = player.queue.tracks.length;
const totalPages = Math.min(Math.ceil(totalTracks / itemsPerPage), maxPages);
const page = interaction.options.getInteger("page") || 1;

if (page < 1 || page > totalPages) {
Expand All @@ -36,14 +39,20 @@ export function createListCommand(ctx, cmdQueue) {
let pageDescription = "";
if (player.queue.current && i === 0) { // Only show "Now Playing" on the first page
const currentDuration = player.queue.current.info.isStream ? "LIVE" : formatDuration(player.queue.current.info.duration);
pageDescription += `**Now Playing:** [${player.queue.current.info.title}](${player.queue.current.info.uri}) - ${player.queue.current.info.author} (${currentDuration})`;
pageDescription += `**Now Playing:** [${player.queue.current.info.title}](${player.queue.current.info.uri}) - ${player.queue.current.info.author} (${currentDuration})\n`;
}
pageDescription += formattedQueue.join("\n");

pages.push({
const pageEmbed = {
title: `Music Queue (Page ${i + 1}/${totalPages})`,
description: pageDescription,
});
};

if (i === maxPages - 1 && totalTracks > maxPages * itemsPerPage) {
pageEmbed.footer = { text: `Displaying first ${maxPages * itemsPerPage} of ${totalTracks} songs.` };
}

pages.push(pageEmbed);
}

const { message, dispose } = createPaginatedEmbed(ctx, cmdQueue, "music", pages, {
Expand All @@ -54,10 +63,4 @@ export function createListCommand(ctx, cmdQueue) {
await interaction.editReply(message);
lifecycle.addDisposable(dispose);
};
}

function formatDuration(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
}
10 changes: 7 additions & 3 deletions modules/music/handlers/queue/skipto.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ export function createSkipToCommand(ctx) {
}

try {
// Lavalink.js queue is 0-indexed, so position - 1
// The play method can take an index to skip to
await player.play(player.queue.tracks[position - 1]);
// Remove all tracks before the target position.
// The `remove` method modifies the queue in place.
player.queue.remove(0, position - 1);

// Skip the current song to start playing the new first song in the queue.
await player.skip();

await interaction.editReply({ embeds: [embed.success(`Skipped to song at position ${position}.`)] });
} catch (error) {
logger.error(`[Music] Error skipping to song: ${error.message}`);
Expand Down
11 changes: 5 additions & 6 deletions modules/music/handlers/seek.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApplicationCommandOptionType } from "discord.js";
import { formatDuration } from "../utils/formatters.js";

export function createSeekCommand(ctx) {
const { v2, logger, music, embed } = ctx;
Expand All @@ -22,6 +23,10 @@ export function createSeekCommand(ctx) {
return interaction.editReply({ embeds: [embed.error("No song is currently playing.")] });
}

if (player.queue.current.info.isStream) {
return interaction.editReply({ embeds: [embed.error("You cannot seek in a live stream.")] });
}

const timeString = interaction.options.getString("time");
let seekToMs = 0;

Expand Down Expand Up @@ -54,9 +59,3 @@ export function createSeekCommand(ctx) {

return cmdSeek;
}

function formatDuration(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
}
5 changes: 3 additions & 2 deletions modules/music/handlers/stop.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export function createStopCommand(ctx) {
}

try {
await player.destroy("User requested stop");
await interaction.editReply({ embeds: [embed.success("Music stopped and queue cleared.")] });
player.queue.clear();
await player.stop();
await interaction.editReply({ embeds: [embed.success("Music stopped and queue cleared. The bot remains in the voice channel.")] });
} catch (error) {
logger.error(`[Music] Error stopping music: ${error.message}`);
await interaction.editReply({ embeds: [embed.error(`An error occurred while trying to stop the music: ${error.message}`)] });
Expand Down
2 changes: 1 addition & 1 deletion modules/music/handlers/volume.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function createVolumeCommand(ctx) {
.setDescription("Sets the player volume.")
.addIntegerOption(opt =>
opt.setName("level")
.setDescription("The volume level (0-100).")
.setDescription("The volume level (0-1000). Values above 100 may cause distortion.")
.setRequired(false)
.setMinValue(0)
.setMaxValue(1000)
Expand Down
32 changes: 23 additions & 9 deletions modules/music/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,36 @@ export default async function init(ctx) {
logger.info(`[Music] Started playing ${track.info.title} by ${track.info.author} on guild ${player.guildId} in channel ${player.voiceChannelId}`);
logger.debug(`[Music] Track details: ${JSON.stringify(track.info)}`);
});
manager.on("trackEnd", (player, track) => {
logger.info(`[Music] Finished playing ${track.info.title} on guild ${player.guildId}.`);
manager.on("trackEnd", (player, track, payload) => {
logger.info(`[Music] Finished playing ${track.info.title} on guild ${player.guildId}. Reason: ${payload.reason}.`);
// autoSkip is enabled, so we don't need to manually call player.play() here.
// lavalink-client will handle playing the next track.
if (player.queue.size > 0) {
player.play();
logger.debug(`[Music] Queue has ${player.queue.size} more tracks. autoSkip will handle playback.`);
} else {
logger.info(`[Music] Queue ended on guild ${player.guildId}.`);
// Optionally destroy player after a delay if no more tracks and nobody is in voice channel
// For now, let's rely on onEmptyQueue destroyAfterMs
logger.info(`[Music] Queue is empty on guild ${player.guildId}.`);
}
});

manager.on("queueEnd", player => {
logger.info(`[Music] Queue ended on guild ${player.guildId} in channel ${player.voiceChannelId}. Destroying player.`);
player.destroy();
manager.on("trackStuck", (player, track, payload) => {
logger.warn(`[Music] Track stuck: ${track.info.title} on guild ${player.guildId}. Reason: ${payload.type}. Threshold: ${payload.thresholdMs}ms.`);
if (player.queue.size > 0) {
logger.info(`[Music] Skipping to next track on guild ${player.guildId}.`);
player.skip();
}
});

manager.on("trackError", (player, track, payload) => {
logger.error(`[Music] Track error: ${track.info.title} on guild ${player.guildId}. Error: ${payload.error}.`);
if (player.queue.size > 0) {
logger.info(`[Music] Skipping to next track on guild ${player.guildId}.`);
player.skip();
}
});

// The onEmptyQueue option is configured to handle this automatically after a 30s delay.
// No explicit queueEnd handler is needed.

// CRITICAL: Handle Discord raw events for voice connections
ctx.client.on("raw", d => manager.sendRawData(d));
lifecycle.addDisposable(() => ctx.client.off("raw", d => manager.sendRawData(d)));
Expand Down
2 changes: 1 addition & 1 deletion modules/music/services/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const DEFAULT_VOLUME = 50; // Default volume if not set
const GuildMusicSettingsSchema = z.object({
_id: z.any().optional(), // MongoDB ObjectId
guildId: z.string(),
volume: z.number().min(0).max(100).default(DEFAULT_VOLUME),
volume: z.number().min(0).max(1000).default(DEFAULT_VOLUME),
});

const CACHE_TTL_MS = 60_000; // Cache for 1 minute
Expand Down
14 changes: 14 additions & 0 deletions modules/music/utils/formatters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function formatDuration(ms) {
if (isNaN(ms) || ms < 0) return "0:00";
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
}

export function generateProgressBar(current, total, size = 20) {
if (isNaN(current) || isNaN(total) || total === 0) return `[${'-'.repeat(size)}]`;
const percentage = Math.min(current / total, 1); // Ensure percentage doesn't exceed 100%
const progress = Math.round(size * percentage);
const empty = size - progress;
return "[" + "=".repeat(progress) + "-".repeat(empty) + "]";
}