mirror of
				https://github.com/aydenjahola/discord-multipurpose-bot.git
				synced 2025-11-04 08:11:34 +00:00 
			
		
		
		
	Compare commits
	
		
			No commits in common. "ac5122c4ed870cb94fb165833ba1a716c79f28d4" and "ff761092d1ea76cd20e152be23cd3d07dc362e18" have entirely different histories.
		
	
	
		
			ac5122c4ed
			...
			ff761092d1
		
	
		
					 20 changed files with 193 additions and 1671 deletions
				
			
		| 
						 | 
					@ -1,178 +0,0 @@
 | 
				
			||||||
const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
 | 
					 | 
				
			||||||
const { ensure, set } = require("../../utils/musicSettings");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function ok(s) {
 | 
					 | 
				
			||||||
  return `✅ ${s}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
function err(s) {
 | 
					 | 
				
			||||||
  return `❌ ${s}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = {
 | 
					 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					 | 
				
			||||||
    .setName("music")
 | 
					 | 
				
			||||||
    .setDescription("Configure music settings for this server.")
 | 
					 | 
				
			||||||
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc.setName("show").setDescription("Show current music settings.")
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("set")
 | 
					 | 
				
			||||||
        .setDescription("Set basic music defaults.")
 | 
					 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o.setName("volume").setDescription("Default volume (0–200)")
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .addBooleanOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("autoplay")
 | 
					 | 
				
			||||||
            .setDescription("Autoplay when queue ends (true/false)")
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o.setName("maxqueue").setDescription("Max queue size (1–5000)")
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("maxplaylist")
 | 
					 | 
				
			||||||
            .setDescription("Max playlist import size (1–2000)")
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("djrole-add")
 | 
					 | 
				
			||||||
        .setDescription("Allow a role to use DJ commands.")
 | 
					 | 
				
			||||||
        .addRoleOption((o) =>
 | 
					 | 
				
			||||||
          o.setName("role").setDescription("Role").setRequired(true)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("djrole-remove")
 | 
					 | 
				
			||||||
        .setDescription("Remove a DJ role.")
 | 
					 | 
				
			||||||
        .addRoleOption((o) =>
 | 
					 | 
				
			||||||
          o.setName("role").setDescription("Role").setRequired(true)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("channel-allow")
 | 
					 | 
				
			||||||
        .setDescription(
 | 
					 | 
				
			||||||
          "Restrict music commands to a text channel (call multiple times)."
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .addChannelOption((o) =>
 | 
					 | 
				
			||||||
          o.setName("channel").setDescription("Text channel").setRequired(true)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("channel-remove")
 | 
					 | 
				
			||||||
        .setDescription("Remove an allowed text channel.")
 | 
					 | 
				
			||||||
        .addChannelOption((o) =>
 | 
					 | 
				
			||||||
          o.setName("channel").setDescription("Text channel").setRequired(true)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("channel-clear")
 | 
					 | 
				
			||||||
        .setDescription("Allow music commands in all text channels.")
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
  category: "Admin",
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async execute(interaction) {
 | 
					 | 
				
			||||||
    await interaction.deferReply({ ephemeral: true });
 | 
					 | 
				
			||||||
    const guildId = interaction.guildId;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const sub = interaction.options.getSubcommand();
 | 
					 | 
				
			||||||
    const settings = await ensure(guildId);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sub === "show") {
 | 
					 | 
				
			||||||
      const lines = [
 | 
					 | 
				
			||||||
        `**Default Volume:** ${settings.defaultVolume}%`,
 | 
					 | 
				
			||||||
        `**Autoplay:** ${settings.autoplay ? "On" : "Off"}`,
 | 
					 | 
				
			||||||
        `**Max Queue:** ${settings.maxQueue}`,
 | 
					 | 
				
			||||||
        `**Max Playlist Import:** ${settings.maxPlaylistImport}`,
 | 
					 | 
				
			||||||
        `**DJ Roles:** ${
 | 
					 | 
				
			||||||
          settings.djRoleIds.length
 | 
					 | 
				
			||||||
            ? settings.djRoleIds.map((id) => `<@&${id}>`).join(", ")
 | 
					 | 
				
			||||||
            : "*none*"
 | 
					 | 
				
			||||||
        }`,
 | 
					 | 
				
			||||||
        `**Allowed Text Channels:** ${
 | 
					 | 
				
			||||||
          settings.allowedTextChannelIds.length
 | 
					 | 
				
			||||||
            ? settings.allowedTextChannelIds.map((id) => `<#${id}>`).join(", ")
 | 
					 | 
				
			||||||
            : "*all*"
 | 
					 | 
				
			||||||
        }`,
 | 
					 | 
				
			||||||
      ];
 | 
					 | 
				
			||||||
      return interaction.followUp(lines.join("\n"));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // mutate helpers
 | 
					 | 
				
			||||||
    const update = {};
 | 
					 | 
				
			||||||
    if (sub === "set") {
 | 
					 | 
				
			||||||
      const vol = interaction.options.getInteger("volume");
 | 
					 | 
				
			||||||
      const autoplay = interaction.options.getBoolean("autoplay");
 | 
					 | 
				
			||||||
      const maxQ = interaction.options.getInteger("maxqueue");
 | 
					 | 
				
			||||||
      const maxP = interaction.options.getInteger("maxplaylist");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (vol !== null) {
 | 
					 | 
				
			||||||
        if (vol < 0 || vol > 200)
 | 
					 | 
				
			||||||
          return interaction.followUp(err("Volume must be 0–200."));
 | 
					 | 
				
			||||||
        update.defaultVolume = vol;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      if (autoplay !== null) update.autoplay = autoplay;
 | 
					 | 
				
			||||||
      if (maxQ !== null) {
 | 
					 | 
				
			||||||
        if (maxQ < 1 || maxQ > 5000)
 | 
					 | 
				
			||||||
          return interaction.followUp(err("Max queue must be 1–5000."));
 | 
					 | 
				
			||||||
        update.maxQueue = maxQ;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      if (maxP !== null) {
 | 
					 | 
				
			||||||
        if (maxP < 1 || maxP > 2000)
 | 
					 | 
				
			||||||
          return interaction.followUp(err("Max playlist must be 1–2000."));
 | 
					 | 
				
			||||||
        update.maxPlaylistImport = maxP;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await set(guildId, update);
 | 
					 | 
				
			||||||
      return interaction.followUp(ok("Settings updated."));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sub === "djrole-add") {
 | 
					 | 
				
			||||||
      const role = interaction.options.getRole("role", true);
 | 
					 | 
				
			||||||
      const setDoc = new Set(settings.djRoleIds || []);
 | 
					 | 
				
			||||||
      setDoc.add(role.id);
 | 
					 | 
				
			||||||
      await set(guildId, { djRoleIds: Array.from(setDoc) });
 | 
					 | 
				
			||||||
      return interaction.followUp(ok(`Added DJ role ${role}.`));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sub === "djrole-remove") {
 | 
					 | 
				
			||||||
      const role = interaction.options.getRole("role", true);
 | 
					 | 
				
			||||||
      const setDoc = new Set(settings.djRoleIds || []);
 | 
					 | 
				
			||||||
      setDoc.delete(role.id);
 | 
					 | 
				
			||||||
      await set(guildId, { djRoleIds: Array.from(setDoc) });
 | 
					 | 
				
			||||||
      return interaction.followUp(ok(`Removed DJ role ${role}.`));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sub === "channel-allow") {
 | 
					 | 
				
			||||||
      const ch = interaction.options.getChannel("channel", true);
 | 
					 | 
				
			||||||
      const setDoc = new Set(settings.allowedTextChannelIds || []);
 | 
					 | 
				
			||||||
      setDoc.add(ch.id);
 | 
					 | 
				
			||||||
      await set(guildId, { allowedTextChannelIds: Array.from(setDoc) });
 | 
					 | 
				
			||||||
      return interaction.followUp(ok(`Allowed ${ch} for music commands.`));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sub === "channel-remove") {
 | 
					 | 
				
			||||||
      const ch = interaction.options.getChannel("channel", true);
 | 
					 | 
				
			||||||
      const setDoc = new Set(settings.allowedTextChannelIds || []);
 | 
					 | 
				
			||||||
      setDoc.delete(ch.id);
 | 
					 | 
				
			||||||
      await set(guildId, { allowedTextChannelIds: Array.from(setDoc) });
 | 
					 | 
				
			||||||
      return interaction.followUp(ok(`Removed ${ch} from allowed channels.`));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sub === "channel-clear") {
 | 
					 | 
				
			||||||
      await set(guildId, { allowedTextChannelIds: [] });
 | 
					 | 
				
			||||||
      return interaction.followUp(
 | 
					 | 
				
			||||||
        ok("Cleared channel restrictions (music commands allowed everywhere).")
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return interaction.followUp(err("Unknown subcommand."));
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,81 +0,0 @@
 | 
				
			||||||
const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
 | 
					 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = {
 | 
					 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					 | 
				
			||||||
    .setName("livelyrics")
 | 
					 | 
				
			||||||
    .setDescription(
 | 
					 | 
				
			||||||
      "Show synced lyrics in a live thread for the current track."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("start")
 | 
					 | 
				
			||||||
        .setDescription("Start live lyrics for the currently playing song.")
 | 
					 | 
				
			||||||
        .addBooleanOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("newthread")
 | 
					 | 
				
			||||||
            .setDescription(
 | 
					 | 
				
			||||||
              "Force a new thread even if one exists (default: reuse)."
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("stop")
 | 
					 | 
				
			||||||
        .setDescription("Stop live lyrics and remove the thread.")
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
  category: "Music",
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async execute(interaction, client) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      await interaction.deferReply();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const sub = interaction.options.getSubcommand();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Make sure user is in/with the VC and there is a queue
 | 
					 | 
				
			||||||
      requireVC(interaction);
 | 
					 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Lazy-load manager to avoid circular requires on startup
 | 
					 | 
				
			||||||
      const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "start") {
 | 
					 | 
				
			||||||
        const forceNew = interaction.options.getBoolean("newthread") ?? false;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // If a thread is already running and user didn't force, just re-sync and say it's on
 | 
					 | 
				
			||||||
        if (!forceNew) {
 | 
					 | 
				
			||||||
          // Re-sync to current time if already active (no-op otherwise)
 | 
					 | 
				
			||||||
          await live.resume(queue);
 | 
					 | 
				
			||||||
          await live.seek(queue);
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          // Ensure any old thread is removed before starting a new one
 | 
					 | 
				
			||||||
          await live.stop(queue.id, { deleteThread: true });
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const song = queue.songs?.[0];
 | 
					 | 
				
			||||||
        if (!song) {
 | 
					 | 
				
			||||||
          return interaction.followUp("❌ Nothing is playing.");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await live.start(queue, song);
 | 
					 | 
				
			||||||
        return interaction.followUp(
 | 
					 | 
				
			||||||
          "🎤 Live lyrics started. I’ll post lines in a thread (or here if I can’t create one)."
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      } else if (sub === "stop") {
 | 
					 | 
				
			||||||
        await live.stop(queue.id, { deleteThread: true });
 | 
					 | 
				
			||||||
        return interaction.followUp(
 | 
					 | 
				
			||||||
          "🧹 Stopped live lyrics and removed the thread."
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.followUp("❌ Unknown subcommand.");
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      const msg = e?.message ?? "❌ Failed to run /livelyrics.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        await interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        await interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,30 +1,5 @@
 | 
				
			||||||
const { SlashCommandBuilder, EmbedBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder, EmbedBuilder } = require("discord.js");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function fmtTime(totalSeconds = 0) {
 | 
					 | 
				
			||||||
  const s = Math.max(0, Math.floor(totalSeconds));
 | 
					 | 
				
			||||||
  const h = Math.floor(s / 3600);
 | 
					 | 
				
			||||||
  const m = Math.floor((s % 3600) / 60);
 | 
					 | 
				
			||||||
  const sec = s % 60;
 | 
					 | 
				
			||||||
  return h > 0
 | 
					 | 
				
			||||||
    ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
 | 
					 | 
				
			||||||
    : `${m}:${String(sec).padStart(2, "0")}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function makeBar(current, duration, size = 20) {
 | 
					 | 
				
			||||||
  if (!Number.isFinite(duration) || duration <= 0) {
 | 
					 | 
				
			||||||
    // For live/unknown duration, just show a moving head at start
 | 
					 | 
				
			||||||
    const head = "🔘";
 | 
					 | 
				
			||||||
    const rest = "─".repeat(size - 1);
 | 
					 | 
				
			||||||
    return `${head}${rest}`;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  const ratio = Math.min(1, Math.max(0, current / duration));
 | 
					 | 
				
			||||||
  const filled = Math.round(ratio * size);
 | 
					 | 
				
			||||||
  const head = "🔘";
 | 
					 | 
				
			||||||
  const left = "─".repeat(Math.max(0, filled - 1));
 | 
					 | 
				
			||||||
  const right = "─".repeat(Math.max(0, size - filled));
 | 
					 | 
				
			||||||
  return `${left}${head}${right}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("nowplaying")
 | 
					    .setName("nowplaying")
 | 
				
			||||||
| 
						 | 
					@ -32,55 +7,24 @@ module.exports = {
 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
    try {
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
      const queue = client.distube.getQueue(interaction.guildId);
 | 
					 | 
				
			||||||
      if (!queue || !queue.songs?.length) {
 | 
					 | 
				
			||||||
        return interaction.reply("❌ There is no music playing!");
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const song = queue.songs[0];
 | 
					    if (!queue || !queue.songs.length) {
 | 
				
			||||||
      const current = Math.floor(queue.currentTime ?? 0); // seconds
 | 
					      return interaction.reply("❌ There is no music playing!");
 | 
				
			||||||
      const duration = Number.isFinite(song.duration)
 | 
					 | 
				
			||||||
        ? Math.floor(song.duration)
 | 
					 | 
				
			||||||
        : null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const positionStr =
 | 
					 | 
				
			||||||
        duration != null
 | 
					 | 
				
			||||||
          ? `${fmtTime(current)} / ${fmtTime(duration)}`
 | 
					 | 
				
			||||||
          : `${fmtTime(current)} / LIVE`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const bar = makeBar(current, duration ?? 0, 20);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const embed = new EmbedBuilder()
 | 
					 | 
				
			||||||
        .setColor(0x0099ff)
 | 
					 | 
				
			||||||
        .setTitle("🎵 Now Playing")
 | 
					 | 
				
			||||||
        .setDescription(
 | 
					 | 
				
			||||||
          [
 | 
					 | 
				
			||||||
            `**${song.name || "Unknown title"}**`,
 | 
					 | 
				
			||||||
            "",
 | 
					 | 
				
			||||||
            `\`\`\`${bar}\`\`\``,
 | 
					 | 
				
			||||||
            `**Position:** \`${positionStr}\``,
 | 
					 | 
				
			||||||
          ].join("\n")
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .addFields(
 | 
					 | 
				
			||||||
          {
 | 
					 | 
				
			||||||
            name: "Requested by",
 | 
					 | 
				
			||||||
            value: song.user?.toString?.() || "Unknown",
 | 
					 | 
				
			||||||
            inline: true,
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          { name: "Volume", value: `${queue.volume ?? 100}%`, inline: true },
 | 
					 | 
				
			||||||
          { name: "URL", value: song.url || "No URL available", inline: false }
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .setThumbnail(song.thumbnail || null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.reply({ embeds: [embed] });
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("nowplaying failed:", e);
 | 
					 | 
				
			||||||
      const msg = "❌ Failed to show now playing info.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const song = queue.songs[0];
 | 
				
			||||||
 | 
					    const embed = new EmbedBuilder()
 | 
				
			||||||
 | 
					      .setColor("#0099ff")
 | 
				
			||||||
 | 
					      .setTitle("🎵 Now Playing")
 | 
				
			||||||
 | 
					      .setDescription(`**${song.name}**`)
 | 
				
			||||||
 | 
					      .addFields(
 | 
				
			||||||
 | 
					        { name: "Duration", value: song.formattedDuration, inline: true },
 | 
				
			||||||
 | 
					        { name: "Requested by", value: song.user.toString(), inline: true },
 | 
				
			||||||
 | 
					        { name: "URL", value: song.url || "No URL available" }
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					      .setThumbnail(song.thumbnail || null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    interaction.reply({ embeds: [embed] });
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,36 +1,28 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("pause")
 | 
					    .setName("pause")
 | 
				
			||||||
    .setDescription("Pauses the current song."),
 | 
					    .setDescription("Pauses the current song"),
 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!queue) {
 | 
				
			||||||
 | 
					      return interaction.reply("❌ There is no music playing!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (queue.paused) {
 | 
				
			||||||
 | 
					      return interaction.reply("⏸️ Music is already paused!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply({ ephemeral: false });
 | 
					      await queue.pause();
 | 
				
			||||||
 | 
					      interaction.reply("⏸️ Paused the current song!");
 | 
				
			||||||
      requireVC(interaction);
 | 
					    } catch (error) {
 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					      interaction.reply("❌ Failed to pause the music.");
 | 
				
			||||||
      if (queue.paused) {
 | 
					 | 
				
			||||||
        return interaction.followUp({
 | 
					 | 
				
			||||||
          content: "⏸️ Music is already paused.",
 | 
					 | 
				
			||||||
          ephemeral: true,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      queue.pause();
 | 
					 | 
				
			||||||
      return interaction.followUp("⏸️ Paused the current song!");
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("pause command failed:", e);
 | 
					 | 
				
			||||||
      const msg = e?.message || "❌ Failed to pause the music.";
 | 
					 | 
				
			||||||
      // If something above threw (e.g., guards), ensure user gets a response
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,58 +1,33 @@
 | 
				
			||||||
const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("play")
 | 
					    .setName("play")
 | 
				
			||||||
    .setDescription(
 | 
					    .setDescription("Plays a song from YouTube, Spotify, or SoundCloud.")
 | 
				
			||||||
      "Play a song or playlist (YouTube by default; Spotify supported)."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addStringOption((option) =>
 | 
					    .addStringOption((option) =>
 | 
				
			||||||
      option
 | 
					      option
 | 
				
			||||||
        .setName("query")
 | 
					        .setName("query")
 | 
				
			||||||
        .setDescription("URL or search query")
 | 
					        .setDescription("Song name or URL")
 | 
				
			||||||
        .setRequired(true)
 | 
					        .setRequired(true)
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    await interaction.deferReply();
 | 
				
			||||||
 | 
					    const query = interaction.options.getString("query");
 | 
				
			||||||
 | 
					    const voiceChannel = interaction.member.voice.channel;
 | 
				
			||||||
 | 
					    if (!voiceChannel) {
 | 
				
			||||||
 | 
					      return interaction.followUp("❌ You need to be in a voice channel!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply();
 | 
					      await client.distube.play(voiceChannel, query, {
 | 
				
			||||||
 | 
					 | 
				
			||||||
      const vc = requireVC(interaction);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Early permission check for better UX
 | 
					 | 
				
			||||||
      const me = interaction.guild.members.me;
 | 
					 | 
				
			||||||
      const perms = vc.permissionsFor(me);
 | 
					 | 
				
			||||||
      if (
 | 
					 | 
				
			||||||
        !perms?.has(PermissionFlagsBits.Connect) ||
 | 
					 | 
				
			||||||
        !perms?.has(PermissionFlagsBits.Speak)
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        throw new Error(
 | 
					 | 
				
			||||||
          "❌ I don't have permission to **Connect**/**Speak** in your voice channel."
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const query = interaction.options.getString("query", true).trim();
 | 
					 | 
				
			||||||
      if (!query) throw new Error("❌ Give me something to play.");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Avoid insanely long strings
 | 
					 | 
				
			||||||
      if (query.length > 2000) throw new Error("❌ Query too long.");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Play! (YouTube is default via plugin order)
 | 
					 | 
				
			||||||
      await client.distube.play(vc, query, {
 | 
					 | 
				
			||||||
        textChannel: interaction.channel,
 | 
					        textChannel: interaction.channel,
 | 
				
			||||||
        member: interaction.member,
 | 
					        member: interaction.member,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					      await interaction.followUp(`🔍 Searching: \`${query}\``);
 | 
				
			||||||
      await interaction.followUp(`🔍 Searching **${query.slice(0, 128)}**…`);
 | 
					    } catch (error) {
 | 
				
			||||||
    } catch (e) {
 | 
					      console.error(error);
 | 
				
			||||||
      const msg = e?.message ?? "❌ Failed to play.";
 | 
					      interaction.followUp("❌ Failed to play the song.");
 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        await interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        await interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,269 +1,20 @@
 | 
				
			||||||
const {
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
  SlashCommandBuilder,
 | 
					 | 
				
			||||||
  ActionRowBuilder,
 | 
					 | 
				
			||||||
  ButtonBuilder,
 | 
					 | 
				
			||||||
  ButtonStyle,
 | 
					 | 
				
			||||||
  EmbedBuilder,
 | 
					 | 
				
			||||||
  StringSelectMenuBuilder,
 | 
					 | 
				
			||||||
  ComponentType,
 | 
					 | 
				
			||||||
} = require("discord.js");
 | 
					 | 
				
			||||||
const { requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const PAGE_SIZE = 10;
 | 
					 | 
				
			||||||
const COLLECTOR_IDLE_MS = 60_000; // stop listening after 60s idle
 | 
					 | 
				
			||||||
const REFRESH_INTERVAL_MS = 2000; // live refresh throttle
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function fmtHMS(totalSeconds = 0) {
 | 
					 | 
				
			||||||
  const s = Math.max(0, Math.floor(totalSeconds));
 | 
					 | 
				
			||||||
  const h = Math.floor(s / 3600);
 | 
					 | 
				
			||||||
  const m = Math.floor((s % 3600) / 60);
 | 
					 | 
				
			||||||
  const sec = s % 60;
 | 
					 | 
				
			||||||
  return h > 0
 | 
					 | 
				
			||||||
    ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
 | 
					 | 
				
			||||||
    : `${m}:${String(sec).padStart(2, "0")}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function safe(text, max = 128) {
 | 
					 | 
				
			||||||
  if (!text) return "";
 | 
					 | 
				
			||||||
  text = String(text);
 | 
					 | 
				
			||||||
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function sumDurations(songs) {
 | 
					 | 
				
			||||||
  let total = 0;
 | 
					 | 
				
			||||||
  for (const s of songs) if (Number.isFinite(s?.duration)) total += s.duration;
 | 
					 | 
				
			||||||
  return total;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function queueFingerprint(q) {
 | 
					 | 
				
			||||||
  // A tiny hash-ish snapshot: current song id/url + length + volume
 | 
					 | 
				
			||||||
  const now = q.songs?.[0];
 | 
					 | 
				
			||||||
  const id = now?.id || now?.url || now?.name || "";
 | 
					 | 
				
			||||||
  return `${id}|len:${q.songs?.length || 0}|vol:${q.volume || 0}|t:${Math.floor(
 | 
					 | 
				
			||||||
    q.currentTime || 0
 | 
					 | 
				
			||||||
  )}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/** Build a single queue page embed */
 | 
					 | 
				
			||||||
function buildEmbed(queue, page, totalPages) {
 | 
					 | 
				
			||||||
  const now = queue.songs[0];
 | 
					 | 
				
			||||||
  const upcoming = queue.songs.slice(1);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const start = page * PAGE_SIZE;
 | 
					 | 
				
			||||||
  const end = Math.min(start + PAGE_SIZE, upcoming.length);
 | 
					 | 
				
			||||||
  const chunk = upcoming.slice(start, end);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const lines = chunk.map((song, i) => {
 | 
					 | 
				
			||||||
    const index = start + i + 1;
 | 
					 | 
				
			||||||
    const dur = Number.isFinite(song.duration) ? fmtHMS(song.duration) : "LIVE";
 | 
					 | 
				
			||||||
    const requester = song.user?.id || song.member?.id;
 | 
					 | 
				
			||||||
    return `**${index}.** ${safe(song.name, 90)} — \`${dur}\`${
 | 
					 | 
				
			||||||
      requester ? ` • <@${requester}>` : ""
 | 
					 | 
				
			||||||
    }`;
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const totalLen = sumDurations(queue.songs);
 | 
					 | 
				
			||||||
  const footerParts = [
 | 
					 | 
				
			||||||
    `Page ${page + 1}/${totalPages}`,
 | 
					 | 
				
			||||||
    `Volume ${queue.volume ?? 100}%`,
 | 
					 | 
				
			||||||
    `Total: ${fmtHMS(totalLen)} • ${queue.songs.length} track${
 | 
					 | 
				
			||||||
      queue.songs.length === 1 ? "" : "s"
 | 
					 | 
				
			||||||
    }`,
 | 
					 | 
				
			||||||
  ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const embed = new EmbedBuilder()
 | 
					 | 
				
			||||||
    .setColor(0x00a2ff)
 | 
					 | 
				
			||||||
    .setTitle("🎶 Music Queue")
 | 
					 | 
				
			||||||
    .setDescription(
 | 
					 | 
				
			||||||
      [
 | 
					 | 
				
			||||||
        `**Now Playing**`,
 | 
					 | 
				
			||||||
        `${safe(now?.name ?? "Nothing", 128)} — \`${
 | 
					 | 
				
			||||||
          Number.isFinite(now?.duration) ? fmtHMS(now.duration) : "LIVE"
 | 
					 | 
				
			||||||
        }\`${now?.user?.id ? ` • <@${now.user.id}>` : ""}`,
 | 
					 | 
				
			||||||
        "",
 | 
					 | 
				
			||||||
        chunk.length ? "**Up Next**" : "*No more songs queued.*",
 | 
					 | 
				
			||||||
        lines.join("\n") || "",
 | 
					 | 
				
			||||||
      ].join("\n")
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .setFooter({ text: footerParts.join("  •  ") });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (now?.thumbnail) embed.setThumbnail(now.thumbnail);
 | 
					 | 
				
			||||||
  return embed;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function buildButtons(page, totalPages, disabled = false) {
 | 
					 | 
				
			||||||
  const first = new ButtonBuilder()
 | 
					 | 
				
			||||||
    .setCustomId("queue_first")
 | 
					 | 
				
			||||||
    .setEmoji("⏮️")
 | 
					 | 
				
			||||||
    .setStyle(ButtonStyle.Secondary)
 | 
					 | 
				
			||||||
    .setDisabled(disabled || page === 0);
 | 
					 | 
				
			||||||
  const prev = new ButtonBuilder()
 | 
					 | 
				
			||||||
    .setCustomId("queue_prev")
 | 
					 | 
				
			||||||
    .setEmoji("◀️")
 | 
					 | 
				
			||||||
    .setStyle(ButtonStyle.Secondary)
 | 
					 | 
				
			||||||
    .setDisabled(disabled || page === 0);
 | 
					 | 
				
			||||||
  const next = new ButtonBuilder()
 | 
					 | 
				
			||||||
    .setCustomId("queue_next")
 | 
					 | 
				
			||||||
    .setEmoji("▶️")
 | 
					 | 
				
			||||||
    .setStyle(ButtonStyle.Secondary)
 | 
					 | 
				
			||||||
    .setDisabled(disabled || page >= totalPages - 1);
 | 
					 | 
				
			||||||
  const last = new ButtonBuilder()
 | 
					 | 
				
			||||||
    .setCustomId("queue_last")
 | 
					 | 
				
			||||||
    .setEmoji("⏭️")
 | 
					 | 
				
			||||||
    .setStyle(ButtonStyle.Secondary)
 | 
					 | 
				
			||||||
    .setDisabled(disabled || page >= totalPages - 1);
 | 
					 | 
				
			||||||
  const stop = new ButtonBuilder()
 | 
					 | 
				
			||||||
    .setCustomId("queue_stop")
 | 
					 | 
				
			||||||
    .setEmoji("🛑")
 | 
					 | 
				
			||||||
    .setStyle(ButtonStyle.Danger)
 | 
					 | 
				
			||||||
    .setDisabled(disabled);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return new ActionRowBuilder().addComponents(first, prev, next, last, stop);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function buildJumpMenu(page, totalPages, disabled = false) {
 | 
					 | 
				
			||||||
  // Up to 25 options allowed by Discord — group pages in chunks
 | 
					 | 
				
			||||||
  const options = [];
 | 
					 | 
				
			||||||
  for (let p = 0; p < totalPages && options.length < 25; p++) {
 | 
					 | 
				
			||||||
    options.push({
 | 
					 | 
				
			||||||
      label: `Page ${p + 1}`,
 | 
					 | 
				
			||||||
      value: String(p),
 | 
					 | 
				
			||||||
      description: `Tracks ${p * PAGE_SIZE + 1}–${Math.min(
 | 
					 | 
				
			||||||
        (p + 1) * PAGE_SIZE,
 | 
					 | 
				
			||||||
        Math.max(0, totalPages * PAGE_SIZE)
 | 
					 | 
				
			||||||
      )}`,
 | 
					 | 
				
			||||||
      default: p === page,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  const menu = new StringSelectMenuBuilder()
 | 
					 | 
				
			||||||
    .setCustomId("queue_jump")
 | 
					 | 
				
			||||||
    .setPlaceholder("Jump to page…")
 | 
					 | 
				
			||||||
    .setDisabled(disabled)
 | 
					 | 
				
			||||||
    .addOptions(options);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return new ActionRowBuilder().addComponents(menu);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("queue")
 | 
					    .setName("queue")
 | 
				
			||||||
    .setDescription("Show the current music queue (live, paginated)."),
 | 
					    .setDescription("Shows the current music queue."),
 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
    try {
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
      await interaction.deferReply();
 | 
					    if (!queue || queue.songs.length === 0) {
 | 
				
			||||||
 | 
					      return interaction.reply("❌ The queue is empty!");
 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					 | 
				
			||||||
      const upcomingCount = Math.max(0, queue.songs.length - 1);
 | 
					 | 
				
			||||||
      let totalPages = Math.max(1, Math.ceil(upcomingCount / PAGE_SIZE));
 | 
					 | 
				
			||||||
      let page = 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      let fingerprint = queueFingerprint(queue);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const embed = buildEmbed(queue, page, totalPages);
 | 
					 | 
				
			||||||
      const rowButtons = buildButtons(page, totalPages);
 | 
					 | 
				
			||||||
      const rowJump = buildJumpMenu(page, totalPages);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const message = await interaction.followUp({
 | 
					 | 
				
			||||||
        embeds: [embed],
 | 
					 | 
				
			||||||
        components: [rowButtons, rowJump],
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Live refresh loop (throttled)
 | 
					 | 
				
			||||||
      let stopped = false;
 | 
					 | 
				
			||||||
      const interval = setInterval(async () => {
 | 
					 | 
				
			||||||
        if (stopped) return;
 | 
					 | 
				
			||||||
        const q = client.distube.getQueue(interaction.guildId);
 | 
					 | 
				
			||||||
        if (!q) return; // might have ended
 | 
					 | 
				
			||||||
        const fp = queueFingerprint(q);
 | 
					 | 
				
			||||||
        if (fp !== fingerprint) {
 | 
					 | 
				
			||||||
          fingerprint = fp;
 | 
					 | 
				
			||||||
          // recompute pagination info if size changed
 | 
					 | 
				
			||||||
          const upCount = Math.max(0, q.songs.length - 1);
 | 
					 | 
				
			||||||
          totalPages = Math.max(1, Math.ceil(upCount / PAGE_SIZE));
 | 
					 | 
				
			||||||
          if (page > totalPages - 1) page = totalPages - 1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          const newEmbed = buildEmbed(q, page, totalPages);
 | 
					 | 
				
			||||||
          const newButtons = buildButtons(page, totalPages);
 | 
					 | 
				
			||||||
          const newJump = buildJumpMenu(page, totalPages);
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            await message.edit({
 | 
					 | 
				
			||||||
              embeds: [newEmbed],
 | 
					 | 
				
			||||||
              components: [newButtons, newJump],
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          } catch {}
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }, REFRESH_INTERVAL_MS);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Component collector (buttons + select menu)
 | 
					 | 
				
			||||||
      const collector = message.createMessageComponentCollector({
 | 
					 | 
				
			||||||
        componentType: ComponentType.MessageComponent,
 | 
					 | 
				
			||||||
        filter: (i) => i.user.id === interaction.user.id,
 | 
					 | 
				
			||||||
        idle: COLLECTOR_IDLE_MS,
 | 
					 | 
				
			||||||
        time: COLLECTOR_IDLE_MS * 3,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      collector.on("collect", async (i) => {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          if (i.customId === "queue_stop") {
 | 
					 | 
				
			||||||
            collector.stop("stopped");
 | 
					 | 
				
			||||||
            return i.update({
 | 
					 | 
				
			||||||
              components: [
 | 
					 | 
				
			||||||
                buildButtons(page, totalPages, true),
 | 
					 | 
				
			||||||
                buildJumpMenu(page, totalPages, true),
 | 
					 | 
				
			||||||
              ],
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          if (i.customId === "queue_jump" && i.isStringSelectMenu()) {
 | 
					 | 
				
			||||||
            const choice = Number(i.values?.[0] ?? 0);
 | 
					 | 
				
			||||||
            page = Math.min(Math.max(0, choice), totalPages - 1);
 | 
					 | 
				
			||||||
          } else if (i.customId === "queue_first") page = 0;
 | 
					 | 
				
			||||||
          else if (i.customId === "queue_prev") page = Math.max(0, page - 1);
 | 
					 | 
				
			||||||
          else if (i.customId === "queue_next")
 | 
					 | 
				
			||||||
            page = Math.min(totalPages - 1, page + 1);
 | 
					 | 
				
			||||||
          else if (i.customId === "queue_last") page = totalPages - 1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          const q = client.distube.getQueue(interaction.guildId) ?? queue;
 | 
					 | 
				
			||||||
          const upCount = Math.max(0, q.songs.length - 1);
 | 
					 | 
				
			||||||
          totalPages = Math.max(1, Math.ceil(upCount / PAGE_SIZE));
 | 
					 | 
				
			||||||
          if (page > totalPages - 1) page = totalPages - 1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          const newEmbed = buildEmbed(q, page, totalPages);
 | 
					 | 
				
			||||||
          const newButtons = buildButtons(page, totalPages);
 | 
					 | 
				
			||||||
          const newJump = buildJumpMenu(page, totalPages);
 | 
					 | 
				
			||||||
          await i.update({
 | 
					 | 
				
			||||||
            embeds: [newEmbed],
 | 
					 | 
				
			||||||
            components: [newButtons, newJump],
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
        } catch (err) {
 | 
					 | 
				
			||||||
          console.error("queue component update failed:", err);
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            await i.deferUpdate();
 | 
					 | 
				
			||||||
          } catch {}
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      collector.on("end", async () => {
 | 
					 | 
				
			||||||
        stopped = true;
 | 
					 | 
				
			||||||
        clearInterval(interval);
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          await message.edit({
 | 
					 | 
				
			||||||
            components: [
 | 
					 | 
				
			||||||
              buildButtons(page, totalPages, true),
 | 
					 | 
				
			||||||
              buildJumpMenu(page, totalPages, true),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
        } catch {}
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      const msg = e?.message ?? "❌ Failed to show queue.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        await interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        await interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    const tracks = queue.songs.map(
 | 
				
			||||||
 | 
					      (song, index) =>
 | 
				
			||||||
 | 
					        `${index + 1}. ${song.name} - \`${song.formattedDuration}\``
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    interaction.reply(`**Current Queue:**\n${tracks.join("\n")}`);
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,42 +1,28 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("resume")
 | 
					    .setName("resume")
 | 
				
			||||||
    .setDescription("Resumes the paused song."),
 | 
					    .setDescription("Resumes the paused song"),
 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!queue) {
 | 
				
			||||||
 | 
					      return interaction.reply("❌ There is no music playing!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!queue.paused) {
 | 
				
			||||||
 | 
					      return interaction.reply("▶️ Music is not paused!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply();
 | 
					      await queue.resume();
 | 
				
			||||||
 | 
					      interaction.reply("▶️ Resumed the music!");
 | 
				
			||||||
      requireVC(interaction);
 | 
					    } catch (error) {
 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					      interaction.reply("❌ Failed to resume the music.");
 | 
				
			||||||
      if (!queue.paused) {
 | 
					 | 
				
			||||||
        return interaction.followUp({
 | 
					 | 
				
			||||||
          content: "▶️ Music is not paused.",
 | 
					 | 
				
			||||||
          ephemeral: true,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      queue.resume();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If you use live lyrics, resume the scheduler to stay in sync
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
        await live.resume(queue);
 | 
					 | 
				
			||||||
      } catch {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.followUp("▶️ Resumed the music!");
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("resume command failed:", e);
 | 
					 | 
				
			||||||
      const msg = e?.message || "❌ Failed to resume the music.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,98 +0,0 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/** Parse "90", "1:30", "01:02:03", "+30", "-10", "+1:00", "-0:30" */
 | 
					 | 
				
			||||||
function parseTime(input) {
 | 
					 | 
				
			||||||
  const t = input.trim();
 | 
					 | 
				
			||||||
  const sign = t.startsWith("+") ? 1 : t.startsWith("-") ? -1 : 0;
 | 
					 | 
				
			||||||
  const core = sign ? t.slice(1) : t;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (/^\d+$/.test(core)) {
 | 
					 | 
				
			||||||
    const secs = Number(core);
 | 
					 | 
				
			||||||
    return { seconds: sign ? sign * secs : secs, relative: Boolean(sign) };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (/^\d{1,2}:\d{1,2}(:\d{1,2})?$/.test(core)) {
 | 
					 | 
				
			||||||
    const parts = core.split(":").map(Number);
 | 
					 | 
				
			||||||
    let secs = 0;
 | 
					 | 
				
			||||||
    if (parts.length === 3) {
 | 
					 | 
				
			||||||
      const [hh, mm, ss] = parts;
 | 
					 | 
				
			||||||
      secs = hh * 3600 + mm * 60 + ss;
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      const [mm, ss] = parts;
 | 
					 | 
				
			||||||
      secs = mm * 60 + ss;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return { seconds: sign ? sign * secs : secs, relative: Boolean(sign) };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return null;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function fmt(seconds) {
 | 
					 | 
				
			||||||
  seconds = Math.max(0, Math.floor(seconds));
 | 
					 | 
				
			||||||
  const h = Math.floor(seconds / 3600);
 | 
					 | 
				
			||||||
  const m = Math.floor((seconds % 3600) / 60);
 | 
					 | 
				
			||||||
  const s = seconds % 60;
 | 
					 | 
				
			||||||
  return h > 0
 | 
					 | 
				
			||||||
    ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
 | 
					 | 
				
			||||||
    : `${m}:${String(s).padStart(2, "0")}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = {
 | 
					 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					 | 
				
			||||||
    .setName("seek")
 | 
					 | 
				
			||||||
    .setDescription("Seek to a timestamp or jump by seconds.")
 | 
					 | 
				
			||||||
    .addStringOption((opt) =>
 | 
					 | 
				
			||||||
      opt
 | 
					 | 
				
			||||||
        .setName("to")
 | 
					 | 
				
			||||||
        .setDescription("e.g. 90, 1:30, 01:02:03, +30, -10")
 | 
					 | 
				
			||||||
        .setRequired(true)
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
  category: "Music",
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async execute(interaction, client) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      await interaction.deferReply();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      requireVC(interaction);
 | 
					 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const song = queue.songs?.[0];
 | 
					 | 
				
			||||||
      if (!song || !Number.isFinite(song.duration) || song.isLive) {
 | 
					 | 
				
			||||||
        throw new Error("❌ This stream/track doesn’t support seeking.");
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const input = interaction.options.getString("to", true);
 | 
					 | 
				
			||||||
      const parsed = parseTime(input);
 | 
					 | 
				
			||||||
      if (!parsed) {
 | 
					 | 
				
			||||||
        throw new Error(
 | 
					 | 
				
			||||||
          "❌ Invalid time. Use `90`, `1:30`, `01:02:03`, `+30`, or `-10`."
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const current = Math.floor(queue.currentTime ?? 0);
 | 
					 | 
				
			||||||
      const duration = Math.floor(song.duration);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      let target = parsed.relative ? current + parsed.seconds : parsed.seconds;
 | 
					 | 
				
			||||||
      target = Math.max(0, Math.min(duration - 1, Math.floor(target)));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await queue.seek(target);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
        live.seek(queue, target);
 | 
					 | 
				
			||||||
      } catch {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await interaction.followUp(
 | 
					 | 
				
			||||||
        `⏭️ Seeked to **${fmt(target)}** (track length \`${fmt(duration)}\`).`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      const msg = e?.message ?? "❌ Failed to seek.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        await interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        await interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,79 +1,24 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function fisherYates(arr) {
 | 
					 | 
				
			||||||
  for (let i = arr.length - 1; i > 0; i--) {
 | 
					 | 
				
			||||||
    const j = Math.floor(Math.random() * (i + 1));
 | 
					 | 
				
			||||||
    [arr[i], arr[j]] = [arr[j], arr[i]];
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return arr;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("shuffle")
 | 
					    .setName("shuffle")
 | 
				
			||||||
    .setDescription("Shuffles the up-next songs (keeps the current track).")
 | 
					    .setDescription("Shuffles the current queue"),
 | 
				
			||||||
    .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
      o
 | 
					 | 
				
			||||||
        .setName("amount")
 | 
					 | 
				
			||||||
        .setDescription(
 | 
					 | 
				
			||||||
          "Only shuffle the first N upcoming songs (default: all)."
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .setMinValue(2)
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!queue || queue.songs.length <= 2) {
 | 
				
			||||||
 | 
					      return interaction.reply("❌ Not enough songs in the queue to shuffle!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply();
 | 
					      await queue.shuffle();
 | 
				
			||||||
 | 
					      interaction.reply("🔀 Shuffled the queue!");
 | 
				
			||||||
      requireVC(interaction);
 | 
					    } catch (error) {
 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					      interaction.reply("❌ Failed to shuffle the queue.");
 | 
				
			||||||
      const total = queue.songs.length;
 | 
					 | 
				
			||||||
      if (total <= 2) {
 | 
					 | 
				
			||||||
        // 0 or 1 upcoming track is not worth shuffling
 | 
					 | 
				
			||||||
        return interaction.followUp({
 | 
					 | 
				
			||||||
          content: "❌ Not enough songs to shuffle.",
 | 
					 | 
				
			||||||
          ephemeral: true,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const upcoming = queue.songs.slice(1); // exclude the currently playing track
 | 
					 | 
				
			||||||
      const amountOpt = interaction.options.getInteger("amount");
 | 
					 | 
				
			||||||
      const amount = Math.min(
 | 
					 | 
				
			||||||
        Math.max(amountOpt ?? upcoming.length, 2),
 | 
					 | 
				
			||||||
        upcoming.length
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If user didn't specify an amount, prefer DisTube's built-in shuffle
 | 
					 | 
				
			||||||
      if (amountOpt == null && typeof queue.shuffle === "function") {
 | 
					 | 
				
			||||||
        // DisTube's shuffle shuffles upcoming by default (keeps current)
 | 
					 | 
				
			||||||
        queue.shuffle();
 | 
					 | 
				
			||||||
        return interaction.followUp(
 | 
					 | 
				
			||||||
          `🔀 Shuffled **${upcoming.length}** upcoming track(s)!`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Manual partial shuffle (first N upcoming)
 | 
					 | 
				
			||||||
      const head = upcoming.slice(0, amount);
 | 
					 | 
				
			||||||
      const tail = upcoming.slice(amount);
 | 
					 | 
				
			||||||
      fisherYates(head);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Splice back into queue: [ current, ...shuffledHead, ...tail ]
 | 
					 | 
				
			||||||
      queue.songs.splice(1, amount, ...head);
 | 
					 | 
				
			||||||
      // tail is already in place so no need to modify if amount === upcoming.length
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.followUp(
 | 
					 | 
				
			||||||
        `🔀 Shuffled the next **${amount}** track(s).`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("shuffle command failed:", e);
 | 
					 | 
				
			||||||
      const msg = e?.message || "❌ Failed to shuffle the queue.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,140 +1,19 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("skip")
 | 
					    .setName("skip")
 | 
				
			||||||
    .setDescription(
 | 
					    .setDescription("Skips the current song."),
 | 
				
			||||||
      "Skips the current song (or jump to a specific upcoming track)."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
      o
 | 
					 | 
				
			||||||
        .setName("to")
 | 
					 | 
				
			||||||
        .setDescription(
 | 
					 | 
				
			||||||
          "Queue index to jump to (as shown in /queue). 1 = next song."
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .setMinValue(1)
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
 | 
					    if (!queue) return interaction.reply("❌ No songs in queue!");
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply();
 | 
					      await queue.skip();
 | 
				
			||||||
 | 
					      interaction.reply("⏭️ Skipped the current song!");
 | 
				
			||||||
      requireVC(interaction);
 | 
					    } catch (error) {
 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					      interaction.reply("❌ Failed to skip.");
 | 
				
			||||||
 | 
					 | 
				
			||||||
      const toIndex = interaction.options.getInteger("to"); // 1-based (1 = next)
 | 
					 | 
				
			||||||
      const total = queue.songs.length;
 | 
					 | 
				
			||||||
      const upcomingCount = Math.max(0, total - 1);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (upcomingCount === 0 && !queue.autoplay) {
 | 
					 | 
				
			||||||
        // Nothing to skip to, and autoplay is off -> stop and leave
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          queue.stop();
 | 
					 | 
				
			||||||
        } catch {}
 | 
					 | 
				
			||||||
        client.distube.voices.leave(interaction.guildId);
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
          await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
        } catch {}
 | 
					 | 
				
			||||||
        return interaction.followUp(
 | 
					 | 
				
			||||||
          "🏁 No more songs — stopped and left the voice channel."
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If user specified a target index (jump)
 | 
					 | 
				
			||||||
      if (toIndex != null) {
 | 
					 | 
				
			||||||
        if (toIndex > upcomingCount) {
 | 
					 | 
				
			||||||
          // Jumping past the end
 | 
					 | 
				
			||||||
          // Clear all upcoming; then behave like “no more songs”
 | 
					 | 
				
			||||||
          queue.songs.splice(1); // remove all upcoming
 | 
					 | 
				
			||||||
          if (!queue.autoplay) {
 | 
					 | 
				
			||||||
            try {
 | 
					 | 
				
			||||||
              queue.stop();
 | 
					 | 
				
			||||||
            } catch {}
 | 
					 | 
				
			||||||
            client.distube.voices.leave(interaction.guildId);
 | 
					 | 
				
			||||||
            try {
 | 
					 | 
				
			||||||
              const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
              await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
            } catch {}
 | 
					 | 
				
			||||||
            return interaction.followUp(
 | 
					 | 
				
			||||||
              `⏭️ Skipped past the end (${toIndex}). Queue empty — stopped and left.`
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          // Autoplay on: try to skip and let autoplay find a related track
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            queue.skip(); // will trigger autoplay resolution
 | 
					 | 
				
			||||||
          } catch {}
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
            await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
          } catch {}
 | 
					 | 
				
			||||||
          return interaction.followUp(
 | 
					 | 
				
			||||||
            `⏭️ Skipped past the end (${toIndex}). Autoplay will pick something.`
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Remove the tracks between current and target (keep current at index 0)
 | 
					 | 
				
			||||||
        // Example: toIndex=1 -> remove none, we’ll just skip once below.
 | 
					 | 
				
			||||||
        if (toIndex > 1) {
 | 
					 | 
				
			||||||
          // delete from position 1..(toIndex-1)
 | 
					 | 
				
			||||||
          queue.songs.splice(1, toIndex - 1);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Now the desired target is at index 1, skip once to play it
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          queue.skip();
 | 
					 | 
				
			||||||
        } catch (e) {
 | 
					 | 
				
			||||||
          // If skip throws but autoplay is on, let autoplay do its thing
 | 
					 | 
				
			||||||
          if (!queue.autoplay) throw e;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Stop any active live-lyrics thread for the current song
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
          await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
        } catch {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return interaction.followUp(
 | 
					 | 
				
			||||||
          `⏭️ Jumped to track **#${toIndex}** in the queue.`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Simple “skip next”
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        queue.skip();
 | 
					 | 
				
			||||||
      } catch (e) {
 | 
					 | 
				
			||||||
        // If there’s no next but autoplay is on, allow autoplay
 | 
					 | 
				
			||||||
        if (!queue.autoplay) {
 | 
					 | 
				
			||||||
          // Fallback: nothing to skip to
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            queue.stop();
 | 
					 | 
				
			||||||
          } catch {}
 | 
					 | 
				
			||||||
          client.distube.voices.leave(interaction.guildId);
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
            await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
          } catch {}
 | 
					 | 
				
			||||||
          return interaction.followUp(
 | 
					 | 
				
			||||||
            "🏁 No more songs — stopped and left the voice channel."
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
        await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
      } catch {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.followUp("⏭️ Skipped the current song!");
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("skip command failed:", e);
 | 
					 | 
				
			||||||
      const msg = e?.message || "❌ Failed to skip.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,51 +1,19 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("stop")
 | 
					    .setName("stop")
 | 
				
			||||||
    .setDescription(
 | 
					    .setDescription("Stops the music and clears the queue."),
 | 
				
			||||||
      "Stops playback, clears the queue, and leaves the voice channel."
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
 | 
					    if (!queue) return interaction.reply("❌ No music is playing!");
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply();
 | 
					      await client.distube.stop(interaction.guildId);
 | 
				
			||||||
 | 
					      interaction.reply("🛑 Stopped the player and cleared the queue!");
 | 
				
			||||||
      const vc = requireVC(interaction);
 | 
					    } catch (error) {
 | 
				
			||||||
      const queue = client.distube.getQueue(interaction.guildId);
 | 
					      interaction.reply("❌ Failed to stop.");
 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!queue) {
 | 
					 | 
				
			||||||
        return interaction.followUp({
 | 
					 | 
				
			||||||
          content: "ℹ️ Nothing is playing.",
 | 
					 | 
				
			||||||
          ephemeral: true,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Clear the queue and stop playback
 | 
					 | 
				
			||||||
      // In DisTube v5, `queue.stop()` stops playback and clears upcoming songs.
 | 
					 | 
				
			||||||
      queue.stop();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Leave the voice channel via manager (recommended)
 | 
					 | 
				
			||||||
      client.distube.voices.leave(interaction.guildId);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If you use live lyrics, clean up the thread
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const live = require("../../utils/liveLyricsManager");
 | 
					 | 
				
			||||||
        await live.stop(interaction.guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
      } catch {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.followUp(
 | 
					 | 
				
			||||||
        "⏹️ Stopped playback, cleared the queue, and left the voice channel."
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("stop command failed:", e);
 | 
					 | 
				
			||||||
      const msg = e?.message || "❌ Failed to stop playback.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,131 +1,33 @@
 | 
				
			||||||
const { SlashCommandBuilder } = require("discord.js");
 | 
					const { SlashCommandBuilder } = require("discord.js");
 | 
				
			||||||
const { requireVC, requireQueue } = require("../../utils/musicGuards");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function clamp(n, min, max) {
 | 
					 | 
				
			||||||
  return Math.max(min, Math.min(max, n));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  data: new SlashCommandBuilder()
 | 
					  data: new SlashCommandBuilder()
 | 
				
			||||||
    .setName("volume")
 | 
					    .setName("volume")
 | 
				
			||||||
    .setDescription("Manage playback volume (0–200).")
 | 
					    .setDescription("Adjust the playback volume (1-100)")
 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					    .addIntegerOption((option) =>
 | 
				
			||||||
      sc.setName("show").setDescription("Show the current volume.")
 | 
					      option
 | 
				
			||||||
    )
 | 
					        .setName("level")
 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					        .setDescription("Volume level (1-100)")
 | 
				
			||||||
      sc
 | 
					        .setRequired(true)
 | 
				
			||||||
        .setName("set")
 | 
					        .setMinValue(1)
 | 
				
			||||||
        .setDescription("Set the volume to a specific level (0–200).")
 | 
					        .setMaxValue(100)
 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("level")
 | 
					 | 
				
			||||||
            .setDescription("Volume percent (0–200)")
 | 
					 | 
				
			||||||
            .setRequired(true)
 | 
					 | 
				
			||||||
            .setMinValue(0)
 | 
					 | 
				
			||||||
            .setMaxValue(200)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("up")
 | 
					 | 
				
			||||||
        .setDescription("Turn the volume up by N (default 10).")
 | 
					 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("by")
 | 
					 | 
				
			||||||
            .setDescription("Percent to increase (1–100)")
 | 
					 | 
				
			||||||
            .setMinValue(1)
 | 
					 | 
				
			||||||
            .setMaxValue(100)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("down")
 | 
					 | 
				
			||||||
        .setDescription("Turn the volume down by N (default 10).")
 | 
					 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("by")
 | 
					 | 
				
			||||||
            .setDescription("Percent to decrease (1–100)")
 | 
					 | 
				
			||||||
            .setMinValue(1)
 | 
					 | 
				
			||||||
            .setMaxValue(100)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc.setName("mute").setDescription("Set volume to 0%.")
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .addSubcommand((sc) =>
 | 
					 | 
				
			||||||
      sc
 | 
					 | 
				
			||||||
        .setName("unmute")
 | 
					 | 
				
			||||||
        .setDescription("Restore volume to 100% (or specify level).")
 | 
					 | 
				
			||||||
        .addIntegerOption((o) =>
 | 
					 | 
				
			||||||
          o
 | 
					 | 
				
			||||||
            .setName("level")
 | 
					 | 
				
			||||||
            .setDescription("Volume percent (1–200)")
 | 
					 | 
				
			||||||
            .setMinValue(1)
 | 
					 | 
				
			||||||
            .setMaxValue(200)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
  category: "Music",
 | 
					  category: "Music",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async execute(interaction, client) {
 | 
					  async execute(interaction, client) {
 | 
				
			||||||
 | 
					    const volume = interaction.options.getInteger("level");
 | 
				
			||||||
 | 
					    const queue = client.distube.getQueue(interaction.guildId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!queue) {
 | 
				
			||||||
 | 
					      return interaction.reply("❌ There is no music playing!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await interaction.deferReply();
 | 
					      await queue.setVolume(volume);
 | 
				
			||||||
 | 
					      interaction.reply(`🔊 Volume set to ${volume}%!`);
 | 
				
			||||||
      requireVC(interaction);
 | 
					    } catch (error) {
 | 
				
			||||||
      const queue = requireQueue(client, interaction);
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					      interaction.reply("❌ Failed to adjust volume.");
 | 
				
			||||||
      const sub = interaction.options.getSubcommand();
 | 
					 | 
				
			||||||
      const current = clamp(Number(queue.volume ?? 100), 0, 200);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Helper to apply and confirm
 | 
					 | 
				
			||||||
      const apply = (val) => {
 | 
					 | 
				
			||||||
        const v = clamp(Math.round(val), 0, 200);
 | 
					 | 
				
			||||||
        queue.setVolume(v);
 | 
					 | 
				
			||||||
        const advisory = v > 100 ? " *(warning: may distort >100%)*" : "";
 | 
					 | 
				
			||||||
        return interaction.followUp(`🔊 Volume set to **${v}%**${advisory}`);
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "show") {
 | 
					 | 
				
			||||||
        const advisory = current > 100 ? " *(>100% may distort)*" : "";
 | 
					 | 
				
			||||||
        return interaction.followUp(
 | 
					 | 
				
			||||||
          `🔊 Current volume: **${current}%**${advisory}`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "set") {
 | 
					 | 
				
			||||||
        const level = interaction.options.getInteger("level", true);
 | 
					 | 
				
			||||||
        return apply(level);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "up") {
 | 
					 | 
				
			||||||
        const step = interaction.options.getInteger("by") ?? 10;
 | 
					 | 
				
			||||||
        return apply(current + step);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "down") {
 | 
					 | 
				
			||||||
        const step = interaction.options.getInteger("by") ?? 10;
 | 
					 | 
				
			||||||
        return apply(current - step);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "mute") {
 | 
					 | 
				
			||||||
        return apply(0);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sub === "unmute") {
 | 
					 | 
				
			||||||
        const level = interaction.options.getInteger("level") ?? 100;
 | 
					 | 
				
			||||||
        return apply(level);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return interaction.followUp({
 | 
					 | 
				
			||||||
        content: "❌ Unknown subcommand.",
 | 
					 | 
				
			||||||
        ephemeral: true,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.error("volume command failed:", e);
 | 
					 | 
				
			||||||
      const msg = e?.message || "❌ Failed to adjust volume.";
 | 
					 | 
				
			||||||
      if (interaction.deferred || interaction.replied) {
 | 
					 | 
				
			||||||
        return interaction.followUp({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return interaction.reply({ content: msg, ephemeral: true });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,4 @@
 | 
				
			||||||
const { EmbedBuilder } = require("discord.js");
 | 
					const { EmbedBuilder } = require("discord.js");
 | 
				
			||||||
const { ensure: ensureMusicSettings } = require("../utils/musicSettings");
 | 
					 | 
				
			||||||
const live = require("../utils/liveLyricsManager"); // we still use this to sync/cleanup
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = (distube, botName) => {
 | 
					module.exports = (distube, botName) => {
 | 
				
			||||||
  const footerConfig = {
 | 
					  const footerConfig = {
 | 
				
			||||||
| 
						 | 
					@ -15,44 +13,23 @@ module.exports = (distube, botName) => {
 | 
				
			||||||
      .setDescription(description)
 | 
					      .setDescription(description)
 | 
				
			||||||
      .setFooter(footerConfig);
 | 
					      .setFooter(footerConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (thumbnail) embed.setThumbnail(thumbnail);
 | 
					    if (thumbnail) {
 | 
				
			||||||
 | 
					      embed.setThumbnail(thumbnail);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return embed;
 | 
					    return embed;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  distube
 | 
					  distube
 | 
				
			||||||
    .on("initQueue", async (queue) => {
 | 
					    .on("playSong", (queue, song) => {
 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const settings = await ensureMusicSettings(queue.id);
 | 
					 | 
				
			||||||
        queue.volume = Math.max(
 | 
					 | 
				
			||||||
          0,
 | 
					 | 
				
			||||||
          Math.min(200, settings.defaultVolume ?? 100)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        queue.autoplay = !!settings.autoplay;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const maxQ = settings.maxQueue ?? 1000;
 | 
					 | 
				
			||||||
        if (Array.isArray(queue.songs) && queue.songs.length > maxQ) {
 | 
					 | 
				
			||||||
          queue.songs.length = maxQ;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } catch (e) {
 | 
					 | 
				
			||||||
        console.error("initQueue settings apply failed:", e);
 | 
					 | 
				
			||||||
        queue.volume = 100;
 | 
					 | 
				
			||||||
        queue.autoplay = false;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("playSong", async (queue, song) => {
 | 
					 | 
				
			||||||
      // ❗️ NO auto-start of live lyrics here anymore
 | 
					 | 
				
			||||||
      const embed = createEmbed(
 | 
					      const embed = createEmbed(
 | 
				
			||||||
        0x0099ff,
 | 
					        0x0099ff,
 | 
				
			||||||
        "🎶 Now Playing",
 | 
					        "🎶 Now Playing",
 | 
				
			||||||
        `**${song.name}** - \`${song.formattedDuration}\``,
 | 
					        `**${song.name}** - \`${song.formattedDuration}\``,
 | 
				
			||||||
        song.thumbnail
 | 
					        song.thumbnail
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					      queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
      // If /livelyrics was already started manually, keep it aligned after a track change:
 | 
					 | 
				
			||||||
      live.seek(queue).catch(() => {});
 | 
					 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("addSong", (queue, song) => {
 | 
					    .on("addSong", (queue, song) => {
 | 
				
			||||||
      const embed = createEmbed(
 | 
					      const embed = createEmbed(
 | 
				
			||||||
        0x00ff00,
 | 
					        0x00ff00,
 | 
				
			||||||
| 
						 | 
					@ -60,138 +37,83 @@ module.exports = (distube, botName) => {
 | 
				
			||||||
        `**${song.name}** - \`${song.formattedDuration}\``,
 | 
					        `**${song.name}** - \`${song.formattedDuration}\``,
 | 
				
			||||||
        song.thumbnail
 | 
					        song.thumbnail
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					      queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					    .on("error", (channel, error) => {
 | 
				
			||||||
    .on("addList", (queue, playlist) => {
 | 
					      // Check if the 'error' is actually an Error object
 | 
				
			||||||
      const embed = createEmbed(
 | 
					      if (error instanceof Error) {
 | 
				
			||||||
        0x00ccff,
 | 
					        console.error("DisTube error:", error);
 | 
				
			||||||
        "📚 Playlist Added",
 | 
					        if (channel && channel.send) {
 | 
				
			||||||
        `**${playlist.name}** with **${playlist.songs.length}** tracks has been queued.`
 | 
					          channel.send("❌ An error occurred: " + error.message.slice(0, 1000));
 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("pause", (queue) => {
 | 
					 | 
				
			||||||
      const embed = createEmbed(
 | 
					 | 
				
			||||||
        0xffff00,
 | 
					 | 
				
			||||||
        "⏸️ Music Paused",
 | 
					 | 
				
			||||||
        "Playback has been paused."
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					 | 
				
			||||||
      // If live lyrics are running, pause scheduling (no-op otherwise)
 | 
					 | 
				
			||||||
      live.pause(queue.id).catch(() => {});
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("resume", (queue) => {
 | 
					 | 
				
			||||||
      const embed = createEmbed(
 | 
					 | 
				
			||||||
        0x00ff00,
 | 
					 | 
				
			||||||
        "▶️ Music Resumed",
 | 
					 | 
				
			||||||
        "Playback has been resumed."
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					 | 
				
			||||||
      // If live lyrics are running, resume scheduling (no-op otherwise)
 | 
					 | 
				
			||||||
      live.resume(queue).catch(() => {});
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("seek", (queue, time) => {
 | 
					 | 
				
			||||||
      // Keep live thread synced if it’s running (no-op otherwise)
 | 
					 | 
				
			||||||
      live.seek(queue, time).catch(() => {});
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("volumeChange", (queue, volume) => {
 | 
					 | 
				
			||||||
      const embed = createEmbed(
 | 
					 | 
				
			||||||
        0x0099ff,
 | 
					 | 
				
			||||||
        "🔊 Volume Changed",
 | 
					 | 
				
			||||||
        `Volume set to ${volume}%`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("finishSong", (queue, song) => {
 | 
					 | 
				
			||||||
      // If a manual /livelyrics is active, stop the current thread for this song.
 | 
					 | 
				
			||||||
      // If the next song plays and the user wants lyrics again, they can run /livelyrics start.
 | 
					 | 
				
			||||||
      live.stop(queue.id, { deleteThread: true }).catch(() => {});
 | 
					 | 
				
			||||||
      // If nothing left and autoplay is off, leave now.
 | 
					 | 
				
			||||||
      setImmediate(() => {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          const remaining = Array.isArray(queue.songs) ? queue.songs.length : 0;
 | 
					 | 
				
			||||||
          if (remaining === 0 && !queue.autoplay) {
 | 
					 | 
				
			||||||
            queue.distube.voices.leave(queue.id);
 | 
					 | 
				
			||||||
            queue.textChannel?.send({
 | 
					 | 
				
			||||||
              embeds: [
 | 
					 | 
				
			||||||
                createEmbed(
 | 
					 | 
				
			||||||
                  0x0099ff,
 | 
					 | 
				
			||||||
                  "🏁 No More Songs",
 | 
					 | 
				
			||||||
                  `Finished **${
 | 
					 | 
				
			||||||
                    song?.name ?? "track"
 | 
					 | 
				
			||||||
                  }** — nothing left, disconnecting.`
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
              ],
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } catch (e) {
 | 
					 | 
				
			||||||
          console.error("finishSong immediate-leave failed:", e);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      });
 | 
					      } else {
 | 
				
			||||||
    })
 | 
					        // Handle cases where 'error' might be something else (like a Queue object)
 | 
				
			||||||
 | 
					        console.error("Unexpected error parameter received:", typeof error);
 | 
				
			||||||
 | 
					        console.error("Error content:", error);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    .on("noRelated", (queue) => {
 | 
					        if (channel && channel.send) {
 | 
				
			||||||
      const embed = createEmbed(
 | 
					          channel.send("❌ An unexpected playback error occurred.");
 | 
				
			||||||
        0xff0000,
 | 
					        }
 | 
				
			||||||
        "❌ No Related Videos",
 | 
					      }
 | 
				
			||||||
        "Could not find related video for autoplay!"
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      queue.textChannel?.send({ embeds: [embed] });
 | 
					 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("finish", (queue) => {
 | 
					    .on("finish", (queue) => {
 | 
				
			||||||
      try {
 | 
					      if (queue.textChannel) {
 | 
				
			||||||
        queue.distube.voices.leave(queue.id);
 | 
					 | 
				
			||||||
        const embed = createEmbed(
 | 
					        const embed = createEmbed(
 | 
				
			||||||
          0x0099ff,
 | 
					          0x0099ff,
 | 
				
			||||||
          "🏁 Queue Finished",
 | 
					          "🎵 Queue Finished",
 | 
				
			||||||
          "Queue ended — disconnecting now."
 | 
					          "The music queue has ended."
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        queue.textChannel?.send({ embeds: [embed] });
 | 
					        queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
      } catch (e) {
 | 
					      }
 | 
				
			||||||
        console.error("Immediate leave on finish failed:", e);
 | 
					    })
 | 
				
			||||||
      } finally {
 | 
					    .on("pause", (queue) => {
 | 
				
			||||||
        // Always cleanup any live thread if one was running
 | 
					      if (queue.textChannel) {
 | 
				
			||||||
        live.stop(queue.id, { deleteThread: true }).catch(() => {});
 | 
					        const embed = createEmbed(
 | 
				
			||||||
 | 
					          0xffff00,
 | 
				
			||||||
 | 
					          "⏸️ Music Paused",
 | 
				
			||||||
 | 
					          "Playback has been paused."
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    .on("resume", (queue) => {
 | 
				
			||||||
 | 
					      if (queue.textChannel) {
 | 
				
			||||||
 | 
					        const embed = createEmbed(
 | 
				
			||||||
 | 
					          0x00ff00,
 | 
				
			||||||
 | 
					          "▶️ Music Resumed",
 | 
				
			||||||
 | 
					          "Playback has been resumed."
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    .on("volumeChange", (queue, volume) => {
 | 
				
			||||||
 | 
					      if (queue.textChannel) {
 | 
				
			||||||
 | 
					        const embed = createEmbed(
 | 
				
			||||||
 | 
					          0x0099ff,
 | 
				
			||||||
 | 
					          "🔊 Volume Changed",
 | 
				
			||||||
 | 
					          `Volume set to ${volume}%`
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    .on("noRelated", (queue) => {
 | 
				
			||||||
 | 
					      if (queue.textChannel) {
 | 
				
			||||||
 | 
					        const embed = createEmbed(
 | 
				
			||||||
 | 
					          0xff0000,
 | 
				
			||||||
 | 
					          "❌ No Related Videos",
 | 
				
			||||||
 | 
					          "Could not find related video for autoplay!"
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("empty", (queue) => {
 | 
					    .on("empty", (queue) => {
 | 
				
			||||||
      try {
 | 
					      if (queue.textChannel) {
 | 
				
			||||||
        queue.distube.voices.leave(queue.id);
 | 
					        const embed = createEmbed(
 | 
				
			||||||
        queue.textChannel?.send({
 | 
					          0xff0000,
 | 
				
			||||||
          embeds: [
 | 
					          "🔇 Voice Channel Empty",
 | 
				
			||||||
            createEmbed(
 | 
					          "Voice channel is empty! Leaving the channel."
 | 
				
			||||||
              0xff0000,
 | 
					        );
 | 
				
			||||||
              "🔇 Left Voice Channel",
 | 
					        queue.textChannel.send({ embeds: [embed] });
 | 
				
			||||||
              "Channel became empty — disconnecting now."
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
          ],
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      } catch (e) {
 | 
					 | 
				
			||||||
        console.error("Immediate leave on empty failed:", e);
 | 
					 | 
				
			||||||
      } finally {
 | 
					 | 
				
			||||||
        live.stop(queue.id, { deleteThread: true }).catch(() => {});
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("disconnect", (queue) => {
 | 
					 | 
				
			||||||
      // Always cleanup on manual disconnect too
 | 
					 | 
				
			||||||
      live.stop(queue.id, { deleteThread: true }).catch(() => {});
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .on("error", (error, queue) => {
 | 
					 | 
				
			||||||
      console.error("DisTube error:", error);
 | 
					 | 
				
			||||||
      queue?.textChannel?.send(
 | 
					 | 
				
			||||||
        "❌ Playback error: " + (error?.message || String(error)).slice(0, 500)
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      if (queue?.id)
 | 
					 | 
				
			||||||
        live.stop(queue.id, { deleteThread: true }).catch(() => {});
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										43
									
								
								index.js
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								index.js
									
									
									
									
									
								
							| 
						 | 
					@ -7,9 +7,8 @@ const {
 | 
				
			||||||
  Routes,
 | 
					  Routes,
 | 
				
			||||||
  PresenceUpdateStatus,
 | 
					  PresenceUpdateStatus,
 | 
				
			||||||
} = require("discord.js");
 | 
					} = require("discord.js");
 | 
				
			||||||
const { DisTube, isVoiceChannelEmpty } = require("distube");
 | 
					const { DisTube } = require("distube");
 | 
				
			||||||
const { SpotifyPlugin } = require("@distube/spotify");
 | 
					const { SpotifyPlugin } = require("@distube/spotify");
 | 
				
			||||||
const { YouTubePlugin } = require("@distube/youtube");
 | 
					 | 
				
			||||||
const { SoundCloudPlugin } = require("@distube/soundcloud");
 | 
					const { SoundCloudPlugin } = require("@distube/soundcloud");
 | 
				
			||||||
const mongoose = require("mongoose");
 | 
					const mongoose = require("mongoose");
 | 
				
			||||||
const fs = require("fs");
 | 
					const fs = require("fs");
 | 
				
			||||||
| 
						 | 
					@ -18,7 +17,6 @@ const ServerSettings = require("./models/ServerSettings");
 | 
				
			||||||
const seedShopItems = require("./utils/seedShopItems");
 | 
					const seedShopItems = require("./utils/seedShopItems");
 | 
				
			||||||
const seedSpyfallLocations = require("./utils/seedSpyfallLocations");
 | 
					const seedSpyfallLocations = require("./utils/seedSpyfallLocations");
 | 
				
			||||||
const setupDisTubeEvents = require("./events/distubeEvents");
 | 
					const setupDisTubeEvents = require("./events/distubeEvents");
 | 
				
			||||||
const ffmpeg = require("ffmpeg-static");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Console colors
 | 
					// Console colors
 | 
				
			||||||
const colors = {
 | 
					const colors = {
 | 
				
			||||||
| 
						 | 
					@ -86,16 +84,8 @@ client.commands = new Collection();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Initialize DisTube
 | 
					// Initialize DisTube
 | 
				
			||||||
client.distube = new DisTube(client, {
 | 
					client.distube = new DisTube(client, {
 | 
				
			||||||
  plugins: [
 | 
					 | 
				
			||||||
    new YouTubePlugin(), // YouTube takes priority
 | 
					 | 
				
			||||||
    new SpotifyPlugin(), // resolves Spotify → YouTube
 | 
					 | 
				
			||||||
    new SoundCloudPlugin(), // resolves SoundCloud → YouTube
 | 
					 | 
				
			||||||
  ],
 | 
					 | 
				
			||||||
  emitNewSongOnly: true,
 | 
					  emitNewSongOnly: true,
 | 
				
			||||||
  emitAddSongWhenCreatingQueue: false, // scale for big playlists
 | 
					  plugins: [new SpotifyPlugin(), new SoundCloudPlugin()],
 | 
				
			||||||
  emitAddListWhenCreatingQueue: true,
 | 
					 | 
				
			||||||
  savePreviousSongs: false, // lower memory over long sessions
 | 
					 | 
				
			||||||
  joinNewVoiceChannel: true, // smoother UX if user moves VC
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Function to recursively read commands from subdirectories
 | 
					// Function to recursively read commands from subdirectories
 | 
				
			||||||
| 
						 | 
					@ -212,35 +202,6 @@ client.on("guildCreate", async (guild) => {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
client.on("voiceStateUpdate", async (oldState, newState) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    // ignore bot state changes (including the music bot itself)
 | 
					 | 
				
			||||||
    if (oldState.member?.user?.bot || newState.member?.user?.bot) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const guildId = oldState.guild?.id || newState.guild?.id;
 | 
					 | 
				
			||||||
    if (!guildId) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const queue = client.distube.getQueue(guildId);
 | 
					 | 
				
			||||||
    if (!queue) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const vc = queue.voice?.channel ?? queue.voiceChannel;
 | 
					 | 
				
			||||||
    if (!vc) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Only react to humans leaving/moving out of our VC
 | 
					 | 
				
			||||||
    const userLeftOurVC =
 | 
					 | 
				
			||||||
      oldState.channelId === vc.id && newState.channelId !== vc.id;
 | 
					 | 
				
			||||||
    if (!userLeftOurVC) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Check emptiness based on the channel they just left
 | 
					 | 
				
			||||||
    if (isVoiceChannelEmpty(oldState)) {
 | 
					 | 
				
			||||||
      client.distube.voices.leave(guildId);
 | 
					 | 
				
			||||||
      queue.textChannel?.send("🔇 Channel is empty — leaving.");
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  } catch (e) {
 | 
					 | 
				
			||||||
    console.error("voiceStateUpdate immediate-leave error:", e);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// MongoDB connection
 | 
					// MongoDB connection
 | 
				
			||||||
console.log(
 | 
					console.log(
 | 
				
			||||||
  `${colors.yellow}${colors.bright}🔗 Connecting to MongoDB...${colors.reset}`
 | 
					  `${colors.yellow}${colors.bright}🔗 Connecting to MongoDB...${colors.reset}`
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,16 +0,0 @@
 | 
				
			||||||
const { Schema, model } = require("mongoose");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const MusicSettingsSchema = new Schema(
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    guildId: { type: String, unique: true, index: true, required: true },
 | 
					 | 
				
			||||||
    defaultVolume: { type: Number, default: 100, min: 0, max: 200 },
 | 
					 | 
				
			||||||
    autoplay: { type: Boolean, default: false },
 | 
					 | 
				
			||||||
    allowedTextChannelIds: { type: [String], default: [] }, // empty => all allowed
 | 
					 | 
				
			||||||
    djRoleIds: { type: [String], default: [] },
 | 
					 | 
				
			||||||
    maxQueue: { type: Number, default: 1000, min: 1, max: 5000 },
 | 
					 | 
				
			||||||
    maxPlaylistImport: { type: Number, default: 500, min: 1, max: 2000 },
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  { timestamps: true }
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = model("MusicSettings", MusicSettingsSchema);
 | 
					 | 
				
			||||||
| 
						 | 
					@ -15,14 +15,12 @@
 | 
				
			||||||
    "@discordjs/voice": "^0.19.0",
 | 
					    "@discordjs/voice": "^0.19.0",
 | 
				
			||||||
    "@distube/soundcloud": "^2.0.4",
 | 
					    "@distube/soundcloud": "^2.0.4",
 | 
				
			||||||
    "@distube/spotify": "^2.0.2",
 | 
					    "@distube/spotify": "^2.0.2",
 | 
				
			||||||
    "@distube/youtube": "^1.0.4",
 | 
					 | 
				
			||||||
    "@snazzah/davey": "^0.1.6",
 | 
					    "@snazzah/davey": "^0.1.6",
 | 
				
			||||||
    "axios": "^1.7.7",
 | 
					    "axios": "^1.7.7",
 | 
				
			||||||
    "discord.js": "^14.15.3",
 | 
					    "discord.js": "^14.15.3",
 | 
				
			||||||
    "distube": "^5.0.7",
 | 
					    "distube": "^5.0.7",
 | 
				
			||||||
    "dotenv": "^16.4.5",
 | 
					    "dotenv": "^16.4.5",
 | 
				
			||||||
    "express": "^4.19.2",
 | 
					    "express": "^4.19.2",
 | 
				
			||||||
    "ffmpeg-static": "^5.2.0",
 | 
					 | 
				
			||||||
    "genius-lyrics": "^4.4.7",
 | 
					    "genius-lyrics": "^4.4.7",
 | 
				
			||||||
    "html-entities": "^2.5.2",
 | 
					    "html-entities": "^2.5.2",
 | 
				
			||||||
    "moment": "^2.30.1",
 | 
					    "moment": "^2.30.1",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,138 +0,0 @@
 | 
				
			||||||
const { getSyncedLyrics } = require("./lyricsProvider");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const states = new Map(); // guildId -> { thread, parent, timers, startedAtMs, pausedAtMs, songId, lastSentAtMs, lyrics }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function clearTimers(state) {
 | 
					 | 
				
			||||||
  if (!state?.timers) return;
 | 
					 | 
				
			||||||
  for (const t of state.timers) clearTimeout(t);
 | 
					 | 
				
			||||||
  state.timers = new Set();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/** coerce number seconds */
 | 
					 | 
				
			||||||
function sec(x) {
 | 
					 | 
				
			||||||
  return Math.max(0, Math.floor(Number(x || 0)));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/** schedule lyric lines starting from startTimeSec */
 | 
					 | 
				
			||||||
function scheduleLines(state, queue, lyrics, startTimeSec) {
 | 
					 | 
				
			||||||
  clearTimers(state);
 | 
					 | 
				
			||||||
  state.timers = new Set();
 | 
					 | 
				
			||||||
  state.startedAtMs = Date.now() - startTimeSec * 1000;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const parentToSend = state.thread || state.parent;
 | 
					 | 
				
			||||||
  const MIN_GAP_MS = 400; // rate-limit safety
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (const line of lyrics) {
 | 
					 | 
				
			||||||
    if (!line || typeof line.t !== "number") continue;
 | 
					 | 
				
			||||||
    const delayMs = Math.max(0, Math.round((line.t - startTimeSec) * 1000));
 | 
					 | 
				
			||||||
    const timer = setTimeout(async () => {
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        const now = Date.now();
 | 
					 | 
				
			||||||
        if (state.lastSentAtMs && now - state.lastSentAtMs < MIN_GAP_MS) {
 | 
					 | 
				
			||||||
          await new Promise((r) =>
 | 
					 | 
				
			||||||
            setTimeout(r, MIN_GAP_MS - (now - state.lastSentAtMs))
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        state.lastSentAtMs = Date.now();
 | 
					 | 
				
			||||||
        await parentToSend.send(line.text || "");
 | 
					 | 
				
			||||||
      } catch (e) {
 | 
					 | 
				
			||||||
        console.error("live lyrics send failed:", e?.message || e);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }, delayMs);
 | 
					 | 
				
			||||||
    state.timers.add(timer);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function createThreadOrFallback(queue, song) {
 | 
					 | 
				
			||||||
  const parent = queue.textChannel;
 | 
					 | 
				
			||||||
  let thread = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (parent?.threads?.create) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      thread = await parent.threads.create({
 | 
					 | 
				
			||||||
        name: `${(song?.name || "Now Playing").slice(0, 80)} • Live`,
 | 
					 | 
				
			||||||
        autoArchiveDuration: 60,
 | 
					 | 
				
			||||||
        reason: "Live lyrics",
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      console.warn("Thread create failed, falling back to parent:", e?.message);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return { thread, parent };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function start(queue, song) {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const guildId = queue.id;
 | 
					 | 
				
			||||||
    await stop(guildId, { deleteThread: true });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const lyrics = await getSyncedLyrics(song);
 | 
					 | 
				
			||||||
    if (!lyrics || lyrics.length === 0) {
 | 
					 | 
				
			||||||
      queue.textChannel?.send("🎤 No synced lyrics available for this track.");
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const { thread, parent } = await createThreadOrFallback(queue, song);
 | 
					 | 
				
			||||||
    const state = {
 | 
					 | 
				
			||||||
      thread,
 | 
					 | 
				
			||||||
      parent,
 | 
					 | 
				
			||||||
      timers: new Set(),
 | 
					 | 
				
			||||||
      startedAtMs: Date.now(),
 | 
					 | 
				
			||||||
      pausedAtMs: null,
 | 
					 | 
				
			||||||
      songId: song?.id || song?.url || song?.name,
 | 
					 | 
				
			||||||
      lastSentAtMs: 0,
 | 
					 | 
				
			||||||
      lyrics,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    states.set(guildId, state);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const header = `**Live lyrics for:** ${song?.name || "Unknown title"}`;
 | 
					 | 
				
			||||||
    if (thread) await thread.send(header);
 | 
					 | 
				
			||||||
    else await parent.send(`${header} *(thread unavailable, posting here)*`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const current = sec(queue.currentTime);
 | 
					 | 
				
			||||||
    scheduleLines(state, queue, lyrics, current);
 | 
					 | 
				
			||||||
  } catch (e) {
 | 
					 | 
				
			||||||
    console.error("liveLyrics.start failed:", e?.message || e);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function pause(guildId) {
 | 
					 | 
				
			||||||
  const state = states.get(guildId);
 | 
					 | 
				
			||||||
  if (!state || state.pausedAtMs) return;
 | 
					 | 
				
			||||||
  state.pausedAtMs = Date.now();
 | 
					 | 
				
			||||||
  clearTimers(state);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function resume(queue) {
 | 
					 | 
				
			||||||
  const state = states.get(queue.id);
 | 
					 | 
				
			||||||
  if (!state || !state.pausedAtMs) return;
 | 
					 | 
				
			||||||
  state.pausedAtMs = null;
 | 
					 | 
				
			||||||
  const current = sec(queue.currentTime);
 | 
					 | 
				
			||||||
  scheduleLines(state, queue, state.lyrics, current);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function seek(queue, timeSecOptional) {
 | 
					 | 
				
			||||||
  const state = states.get(queue.id);
 | 
					 | 
				
			||||||
  if (!state) return;
 | 
					 | 
				
			||||||
  const current = sec(timeSecOptional ?? queue.currentTime ?? 0);
 | 
					 | 
				
			||||||
  scheduleLines(state, queue, state.lyrics, current);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function stop(guildId, { deleteThread = false } = {}) {
 | 
					 | 
				
			||||||
  const state = states.get(guildId);
 | 
					 | 
				
			||||||
  if (!state) return;
 | 
					 | 
				
			||||||
  clearTimers(state);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    if (deleteThread && state.thread?.delete) {
 | 
					 | 
				
			||||||
      await state.thread.delete("Song ended — removing live lyrics thread.");
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  } catch (e) {
 | 
					 | 
				
			||||||
    console.warn("liveLyrics thread delete failed:", e?.message || e);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  states.delete(guildId);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = { start, pause, resume, seek, stop };
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,140 +0,0 @@
 | 
				
			||||||
// Tiny provider that tries LRCLIB first (synced LRC), then falls back to unsynced lines.
 | 
					 | 
				
			||||||
// Docs: https://lrclib.net (no API key required)
 | 
					 | 
				
			||||||
//
 | 
					 | 
				
			||||||
// Returned format: [{ t: Number(seconds), text: String }, ...] sorted by t
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function parseLRC(lrcText) {
 | 
					 | 
				
			||||||
  // Supports tags like [ti:], [ar:], [length:], and timestamp lines [mm:ss.xx]
 | 
					 | 
				
			||||||
  const lines = lrcText.split(/\r?\n/);
 | 
					 | 
				
			||||||
  const out = [];
 | 
					 | 
				
			||||||
  const timeRe = /\[(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?]/g;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (const line of lines) {
 | 
					 | 
				
			||||||
    let m;
 | 
					 | 
				
			||||||
    let lastIndex = 0;
 | 
					 | 
				
			||||||
    // extract all timestamps from this line
 | 
					 | 
				
			||||||
    const stamps = [];
 | 
					 | 
				
			||||||
    while ((m = timeRe.exec(line)) !== null) {
 | 
					 | 
				
			||||||
      const mm = Number(m[1]);
 | 
					 | 
				
			||||||
      const ss = Number(m[2]);
 | 
					 | 
				
			||||||
      const ms = Number(m[3] || 0);
 | 
					 | 
				
			||||||
      const t = mm * 60 + ss + ms / 1000;
 | 
					 | 
				
			||||||
      stamps.push({ t, idx: m.index });
 | 
					 | 
				
			||||||
      lastIndex = timeRe.lastIndex;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (!stamps.length) continue;
 | 
					 | 
				
			||||||
    // text is after last timestamp tag
 | 
					 | 
				
			||||||
    const text = line.slice(lastIndex).trim();
 | 
					 | 
				
			||||||
    if (!text) continue;
 | 
					 | 
				
			||||||
    for (const s of stamps) out.push({ t: s.t, text });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // remove duplicates, sort
 | 
					 | 
				
			||||||
  out.sort((a, b) => a.t - b.t);
 | 
					 | 
				
			||||||
  const dedup = [];
 | 
					 | 
				
			||||||
  let prev = "";
 | 
					 | 
				
			||||||
  for (const l of out) {
 | 
					 | 
				
			||||||
    const key = `${l.t.toFixed(2)}|${l.text}`;
 | 
					 | 
				
			||||||
    if (key !== prev) dedup.push(l);
 | 
					 | 
				
			||||||
    prev = key;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return dedup;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function splitUnsyncedLyrics(text) {
 | 
					 | 
				
			||||||
  // Fallback for plain lyrics (no timestamps): just emit a line every ~2s
 | 
					 | 
				
			||||||
  const lines = text
 | 
					 | 
				
			||||||
    .split(/\r?\n/)
 | 
					 | 
				
			||||||
    .map((l) => l.trim())
 | 
					 | 
				
			||||||
    .filter(Boolean)
 | 
					 | 
				
			||||||
    .slice(0, 500); // keep it sane
 | 
					 | 
				
			||||||
  const out = [];
 | 
					 | 
				
			||||||
  let t = 0;
 | 
					 | 
				
			||||||
  for (const l of lines) {
 | 
					 | 
				
			||||||
    out.push({ t, text: l });
 | 
					 | 
				
			||||||
    t += 2;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return out;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function pickArtistAndTitle(song) {
 | 
					 | 
				
			||||||
  // Try to infer artist/title for better matches
 | 
					 | 
				
			||||||
  const name = song?.name || "";
 | 
					 | 
				
			||||||
  const byUploader = song?.uploader?.name || "";
 | 
					 | 
				
			||||||
  let title = name;
 | 
					 | 
				
			||||||
  let artist = "";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // If the title looks like "Artist - Title"
 | 
					 | 
				
			||||||
  if (name.includes(" - ")) {
 | 
					 | 
				
			||||||
    const [a, b] = name.split(" - ");
 | 
					 | 
				
			||||||
    if (a && b) {
 | 
					 | 
				
			||||||
      artist = a.trim();
 | 
					 | 
				
			||||||
      title = b.trim();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  if (!artist) {
 | 
					 | 
				
			||||||
    artist = byUploader || song?.author || "";
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return { artist, title };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function fetchLRCLIBLyrics(song) {
 | 
					 | 
				
			||||||
  const { artist, title } = pickArtistAndTitle(song);
 | 
					 | 
				
			||||||
  // Build a simple query, we also try with raw name as a fallback
 | 
					 | 
				
			||||||
  const candidates = [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (title) {
 | 
					 | 
				
			||||||
    candidates.push({ track_name: title, artist_name: artist || "" });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  if (song?.name) {
 | 
					 | 
				
			||||||
    candidates.push({ track_name: song.name, artist_name: artist || "" });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (const c of candidates) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const url = new URL("https://lrclib.net/api/get");
 | 
					 | 
				
			||||||
      if (c.track_name) url.searchParams.set("track_name", c.track_name);
 | 
					 | 
				
			||||||
      if (c.artist_name) url.searchParams.set("artist_name", c.artist_name);
 | 
					 | 
				
			||||||
      // lrclib also accepts album_name + duration if you have them
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const res = await fetch(url.toString(), {
 | 
					 | 
				
			||||||
        headers: {
 | 
					 | 
				
			||||||
          "user-agent": "CircuitrixBot/1.0 (+https://github.com/aydenjahola)",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!res.ok) continue;
 | 
					 | 
				
			||||||
      const data = await res.json();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Prefer synced lyrics
 | 
					 | 
				
			||||||
      if (data?.syncedLyrics) {
 | 
					 | 
				
			||||||
        const parsed = parseLRC(data.syncedLyrics);
 | 
					 | 
				
			||||||
        if (parsed.length) return parsed;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Fallback to unsynced lyrics
 | 
					 | 
				
			||||||
      if (data?.plainLyrics) {
 | 
					 | 
				
			||||||
        const parsed = splitUnsyncedLyrics(data.plainLyrics);
 | 
					 | 
				
			||||||
        if (parsed.length) return parsed;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      // Keep trying next candidate
 | 
					 | 
				
			||||||
      // console.warn("LRCLIB fetch failed:", e?.message);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return null;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Return an array of { t, text } (seconds) for synced display, or null if none.
 | 
					 | 
				
			||||||
 * This is the only function the rest of the bot uses.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
async function getSyncedLyrics(song) {
 | 
					 | 
				
			||||||
  // LRCLIB (synced LRC, free)
 | 
					 | 
				
			||||||
  const lrclib = await fetchLRCLIBLyrics(song);
 | 
					 | 
				
			||||||
  if (lrclib && lrclib.length) return lrclib;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return null;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = { getSyncedLyrics, parseLRC, splitUnsyncedLyrics };
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,16 +0,0 @@
 | 
				
			||||||
exports.requireVC = (interaction) => {
 | 
					 | 
				
			||||||
  const userVC = interaction.member?.voice?.channel;
 | 
					 | 
				
			||||||
  if (!userVC) throw new Error("❌ You need to be in a voice channel!");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const meVC = interaction.guild?.members?.me?.voice?.channel;
 | 
					 | 
				
			||||||
  if (meVC && meVC.id !== userVC.id) {
 | 
					 | 
				
			||||||
    throw new Error("❌ You must be in the same voice channel as me.");
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return userVC;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
exports.requireQueue = (client, interaction) => {
 | 
					 | 
				
			||||||
  const q = client.distube.getQueue(interaction.guildId);
 | 
					 | 
				
			||||||
  if (!q || !q.songs?.length) throw new Error("❌ Nothing is playing.");
 | 
					 | 
				
			||||||
  return q;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,34 +0,0 @@
 | 
				
			||||||
const MusicSettings = require("../models/MusicSettings");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/** in-memory cache to cut Mongo roundtrips */
 | 
					 | 
				
			||||||
const cache = new Map(); // guildId -> settings doc (lean POJO)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function ensure(guildId) {
 | 
					 | 
				
			||||||
  if (!guildId) throw new Error("Missing guildId");
 | 
					 | 
				
			||||||
  if (cache.has(guildId)) return cache.get(guildId);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let doc = await MusicSettings.findOne({ guildId }).lean();
 | 
					 | 
				
			||||||
  if (!doc) {
 | 
					 | 
				
			||||||
    doc = await MusicSettings.create({ guildId });
 | 
					 | 
				
			||||||
    doc = doc.toObject();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  cache.set(guildId, doc);
 | 
					 | 
				
			||||||
  return doc;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function set(guildId, patch) {
 | 
					 | 
				
			||||||
  const updated = await MusicSettings.findOneAndUpdate(
 | 
					 | 
				
			||||||
    { guildId },
 | 
					 | 
				
			||||||
    { $set: patch },
 | 
					 | 
				
			||||||
    { upsert: true, new: true }
 | 
					 | 
				
			||||||
  ).lean();
 | 
					 | 
				
			||||||
  cache.set(guildId, updated);
 | 
					 | 
				
			||||||
  return updated;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function clear(guildId) {
 | 
					 | 
				
			||||||
  if (guildId) cache.delete(guildId);
 | 
					 | 
				
			||||||
  else cache.clear();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = { ensure, set, clear };
 | 
					 | 
				
			||||||
		Loading…
	
		Reference in a new issue