diff --git a/commands/admin/music.js b/commands/admin/music.js new file mode 100644 index 0000000..619577c --- /dev/null +++ b/commands/admin/music.js @@ -0,0 +1,178 @@ +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.")); + }, +}; diff --git a/commands/music/livelyrics.js b/commands/music/livelyrics.js new file mode 100644 index 0000000..d0149c2 --- /dev/null +++ b/commands/music/livelyrics.js @@ -0,0 +1,81 @@ +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 }); + } + } + }, +}; diff --git a/commands/music/nowplaying.js b/commands/music/nowplaying.js index 1dbd658..2b3f7be 100644 --- a/commands/music/nowplaying.js +++ b/commands/music/nowplaying.js @@ -1,5 +1,30 @@ 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 = { data: new SlashCommandBuilder() .setName("nowplaying") @@ -7,24 +32,55 @@ module.exports = { category: "Music", async execute(interaction, client) { - const queue = client.distube.getQueue(interaction.guildId); + try { + const queue = client.distube.getQueue(interaction.guildId); + if (!queue || !queue.songs?.length) { + return interaction.reply("❌ There is no music playing!"); + } - if (!queue || !queue.songs.length) { - return interaction.reply("❌ There is no music playing!"); + const song = queue.songs[0]; + const current = Math.floor(queue.currentTime ?? 0); // seconds + 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] }); }, }; diff --git a/commands/music/pause.js b/commands/music/pause.js index 799e1b9..fee5382 100644 --- a/commands/music/pause.js +++ b/commands/music/pause.js @@ -1,28 +1,36 @@ const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); module.exports = { data: new SlashCommandBuilder() .setName("pause") - .setDescription("Pauses the current song"), + .setDescription("Pauses the current song."), category: "Music", 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 { - await queue.pause(); - interaction.reply("⏸️ Paused the current song!"); - } catch (error) { - console.error(error); - interaction.reply("❌ Failed to pause the music."); + await interaction.deferReply({ ephemeral: false }); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + 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 }); } }, }; diff --git a/commands/music/play.js b/commands/music/play.js index c9c2dda..a704984 100644 --- a/commands/music/play.js +++ b/commands/music/play.js @@ -1,33 +1,58 @@ -const { SlashCommandBuilder } = require("discord.js"); +const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js"); +const { requireVC } = require("../../utils/musicGuards"); module.exports = { data: new SlashCommandBuilder() .setName("play") - .setDescription("Plays a song from YouTube, Spotify, or SoundCloud.") + .setDescription( + "Play a song or playlist (YouTube by default; Spotify supported)." + ) .addStringOption((option) => option .setName("query") - .setDescription("Song name or URL") + .setDescription("URL or search query") .setRequired(true) ), category: "Music", 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 { - await client.distube.play(voiceChannel, query, { + await interaction.deferReply(); + + 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, member: interaction.member, }); - await interaction.followUp(`🔍 Searching: \`${query}\``); - } catch (error) { - console.error(error); - interaction.followUp("❌ Failed to play the song."); + + await interaction.followUp(`🔍 Searching **${query.slice(0, 128)}**…`); + } catch (e) { + const msg = e?.message ?? "❌ Failed to play."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } } }, }; diff --git a/commands/music/queue.js b/commands/music/queue.js index f588fd0..8af54d4 100644 --- a/commands/music/queue.js +++ b/commands/music/queue.js @@ -1,20 +1,269 @@ -const { SlashCommandBuilder } = require("discord.js"); +const { + 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 = { data: new SlashCommandBuilder() .setName("queue") - .setDescription("Shows the current music queue."), + .setDescription("Show the current music queue (live, paginated)."), category: "Music", async execute(interaction, client) { - const queue = client.distube.getQueue(interaction.guildId); - if (!queue || queue.songs.length === 0) { - return interaction.reply("❌ The queue is empty!"); + try { + await interaction.deferReply(); + + 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")}`); }, }; diff --git a/commands/music/resume.js b/commands/music/resume.js index 3c3f542..555f178 100644 --- a/commands/music/resume.js +++ b/commands/music/resume.js @@ -1,28 +1,42 @@ const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); module.exports = { data: new SlashCommandBuilder() .setName("resume") - .setDescription("Resumes the paused song"), + .setDescription("Resumes the paused song."), category: "Music", 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 { - await queue.resume(); - interaction.reply("▶️ Resumed the music!"); - } catch (error) { - console.error(error); - interaction.reply("❌ Failed to resume the music."); + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + 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 }); } }, }; diff --git a/commands/music/seek.js b/commands/music/seek.js new file mode 100644 index 0000000..8ed6b78 --- /dev/null +++ b/commands/music/seek.js @@ -0,0 +1,98 @@ +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 }); + } + } + }, +}; diff --git a/commands/music/shuffle.js b/commands/music/shuffle.js index 9d6cfd8..e254812 100644 --- a/commands/music/shuffle.js +++ b/commands/music/shuffle.js @@ -1,24 +1,79 @@ 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 = { data: new SlashCommandBuilder() .setName("shuffle") - .setDescription("Shuffles the current queue"), + .setDescription("Shuffles the up-next songs (keeps the current track).") + .addIntegerOption((o) => + o + .setName("amount") + .setDescription( + "Only shuffle the first N upcoming songs (default: all)." + ) + .setMinValue(2) + ), category: "Music", 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 { - await queue.shuffle(); - interaction.reply("🔀 Shuffled the queue!"); - } catch (error) { - console.error(error); - interaction.reply("❌ Failed to shuffle the queue."); + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + 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 }); } }, }; diff --git a/commands/music/skip.js b/commands/music/skip.js index e00c7f6..7e86f26 100644 --- a/commands/music/skip.js +++ b/commands/music/skip.js @@ -1,19 +1,140 @@ const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); module.exports = { data: new SlashCommandBuilder() .setName("skip") - .setDescription("Skips the current song."), + .setDescription( + "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", async execute(interaction, client) { - const queue = client.distube.getQueue(interaction.guildId); - if (!queue) return interaction.reply("❌ No songs in queue!"); try { - await queue.skip(); - interaction.reply("⏭️ Skipped the current song!"); - } catch (error) { - interaction.reply("❌ Failed to skip."); + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + 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 }); } }, }; diff --git a/commands/music/stop.js b/commands/music/stop.js index eb4c634..f54aa64 100644 --- a/commands/music/stop.js +++ b/commands/music/stop.js @@ -1,19 +1,51 @@ const { SlashCommandBuilder } = require("discord.js"); +const { requireVC } = require("../../utils/musicGuards"); module.exports = { data: new SlashCommandBuilder() .setName("stop") - .setDescription("Stops the music and clears the queue."), + .setDescription( + "Stops playback, clears the queue, and leaves the voice channel." + ), category: "Music", async execute(interaction, client) { - const queue = client.distube.getQueue(interaction.guildId); - if (!queue) return interaction.reply("❌ No music is playing!"); try { - await client.distube.stop(interaction.guildId); - interaction.reply("🛑 Stopped the player and cleared the queue!"); - } catch (error) { - interaction.reply("❌ Failed to stop."); + await interaction.deferReply(); + + const vc = requireVC(interaction); + const queue = client.distube.getQueue(interaction.guildId); + + 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 }); } }, }; diff --git a/commands/music/volume.js b/commands/music/volume.js index 9f9d43e..caa71c5 100644 --- a/commands/music/volume.js +++ b/commands/music/volume.js @@ -1,33 +1,131 @@ 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 = { data: new SlashCommandBuilder() .setName("volume") - .setDescription("Adjust the playback volume (1-100)") - .addIntegerOption((option) => - option - .setName("level") - .setDescription("Volume level (1-100)") - .setRequired(true) - .setMinValue(1) - .setMaxValue(100) + .setDescription("Manage playback volume (0–200).") + .addSubcommand((sc) => + sc.setName("show").setDescription("Show the current volume.") + ) + .addSubcommand((sc) => + sc + .setName("set") + .setDescription("Set the volume to a specific level (0–200).") + .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", 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 { - await queue.setVolume(volume); - interaction.reply(`🔊 Volume set to ${volume}%!`); - } catch (error) { - console.error(error); - interaction.reply("❌ Failed to adjust volume."); + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + 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 }); } }, }; diff --git a/events/distubeEvents.js b/events/distubeEvents.js index 3eeda05..85d522c 100644 --- a/events/distubeEvents.js +++ b/events/distubeEvents.js @@ -1,4 +1,6 @@ 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) => { const footerConfig = { @@ -13,23 +15,44 @@ module.exports = (distube, botName) => { .setDescription(description) .setFooter(footerConfig); - if (thumbnail) { - embed.setThumbnail(thumbnail); - } - + if (thumbnail) embed.setThumbnail(thumbnail); return embed; }; distube - .on("playSong", (queue, song) => { + .on("initQueue", async (queue) => { + 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( 0x0099ff, "🎶 Now Playing", `**${song.name}** - \`${song.formattedDuration}\``, 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) => { const embed = createEmbed( 0x00ff00, @@ -37,83 +60,138 @@ module.exports = (distube, botName) => { `**${song.name}** - \`${song.formattedDuration}\``, song.thumbnail ); - queue.textChannel.send({ embeds: [embed] }); + queue.textChannel?.send({ embeds: [embed] }); }) - .on("error", (channel, error) => { - // Check if the 'error' is actually an Error object - if (error instanceof Error) { - console.error("DisTube error:", error); - if (channel && channel.send) { - channel.send("❌ An error occurred: " + error.message.slice(0, 1000)); - } - } 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); - if (channel && channel.send) { - channel.send("❌ An unexpected playback error occurred."); - } - } - }) - .on("finish", (queue) => { - if (queue.textChannel) { - const embed = createEmbed( - 0x0099ff, - "🎵 Queue Finished", - "The music queue has ended." - ); - queue.textChannel.send({ embeds: [embed] }); - } + .on("addList", (queue, playlist) => { + const embed = createEmbed( + 0x00ccff, + "📚 Playlist Added", + `**${playlist.name}** with **${playlist.songs.length}** tracks has been queued.` + ); + queue.textChannel?.send({ embeds: [embed] }); }) + .on("pause", (queue) => { - if (queue.textChannel) { - const embed = createEmbed( - 0xffff00, - "⏸️ Music Paused", - "Playback has been paused." - ); - queue.textChannel.send({ embeds: [embed] }); - } + 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) => { - if (queue.textChannel) { - const embed = createEmbed( - 0x00ff00, - "▶️ Music Resumed", - "Playback has been resumed." - ); - queue.textChannel.send({ embeds: [embed] }); - } + 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) => { - if (queue.textChannel) { + 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); + } + }); + }) + + .on("noRelated", (queue) => { + const embed = createEmbed( + 0xff0000, + "❌ No Related Videos", + "Could not find related video for autoplay!" + ); + queue.textChannel?.send({ embeds: [embed] }); + }) + + .on("finish", (queue) => { + try { + queue.distube.voices.leave(queue.id); const embed = createEmbed( 0x0099ff, - "🔊 Volume Changed", - `Volume set to ${volume}%` + "🏁 Queue Finished", + "Queue ended — disconnecting now." ); - 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] }); + queue.textChannel?.send({ embeds: [embed] }); + } catch (e) { + console.error("Immediate leave on finish failed:", e); + } finally { + // Always cleanup any live thread if one was running + live.stop(queue.id, { deleteThread: true }).catch(() => {}); } }) + .on("empty", (queue) => { - if (queue.textChannel) { - const embed = createEmbed( - 0xff0000, - "🔇 Voice Channel Empty", - "Voice channel is empty! Leaving the channel." - ); - queue.textChannel.send({ embeds: [embed] }); + try { + queue.distube.voices.leave(queue.id); + queue.textChannel?.send({ + embeds: [ + createEmbed( + 0xff0000, + "🔇 Left Voice Channel", + "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(() => {}); }); }; diff --git a/index.js b/index.js index 1c1e7c5..c10d8ca 100644 --- a/index.js +++ b/index.js @@ -7,8 +7,9 @@ const { Routes, PresenceUpdateStatus, } = require("discord.js"); -const { DisTube } = require("distube"); +const { DisTube, isVoiceChannelEmpty } = require("distube"); const { SpotifyPlugin } = require("@distube/spotify"); +const { YouTubePlugin } = require("@distube/youtube"); const { SoundCloudPlugin } = require("@distube/soundcloud"); const mongoose = require("mongoose"); const fs = require("fs"); @@ -17,6 +18,7 @@ const ServerSettings = require("./models/ServerSettings"); const seedShopItems = require("./utils/seedShopItems"); const seedSpyfallLocations = require("./utils/seedSpyfallLocations"); const setupDisTubeEvents = require("./events/distubeEvents"); +const ffmpeg = require("ffmpeg-static"); // Console colors const colors = { @@ -84,8 +86,17 @@ client.commands = new Collection(); // Initialize DisTube client.distube = new DisTube(client, { + plugins: [ + new YouTubePlugin(), // YouTube takes priority + new SpotifyPlugin(), // resolves Spotify → YouTube + new SoundCloudPlugin(), // resolves SoundCloud → YouTube + ], emitNewSongOnly: true, - plugins: [new SpotifyPlugin(), new SoundCloudPlugin()], + emitAddSongWhenCreatingQueue: false, // scale for big playlists + emitAddListWhenCreatingQueue: true, + savePreviousSongs: false, // lower memory over long sessions + joinNewVoiceChannel: true, // smoother UX if user moves VC + ffmpeg: { path: ffmpeg }, }); // Function to recursively read commands from subdirectories @@ -202,6 +213,35 @@ 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 console.log( `${colors.yellow}${colors.bright}🔗 Connecting to MongoDB...${colors.reset}` diff --git a/models/MusicSettings.js b/models/MusicSettings.js new file mode 100644 index 0000000..2777902 --- /dev/null +++ b/models/MusicSettings.js @@ -0,0 +1,16 @@ +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); diff --git a/package.json b/package.json index 3901bd2..8cb808a 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,14 @@ "@discordjs/voice": "^0.19.0", "@distube/soundcloud": "^2.0.4", "@distube/spotify": "^2.0.2", + "@distube/youtube": "^1.0.4", "@snazzah/davey": "^0.1.6", "axios": "^1.7.7", "discord.js": "^14.15.3", "distube": "^5.0.7", "dotenv": "^16.4.5", "express": "^4.19.2", + "ffmpeg-static": "^5.2.0", "genius-lyrics": "^4.4.7", "html-entities": "^2.5.2", "moment": "^2.30.1", diff --git a/utils/liveLyricsManager.js b/utils/liveLyricsManager.js new file mode 100644 index 0000000..4967c56 --- /dev/null +++ b/utils/liveLyricsManager.js @@ -0,0 +1,138 @@ +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 }; diff --git a/utils/lyricsProvider.js b/utils/lyricsProvider.js new file mode 100644 index 0000000..b35d0d8 --- /dev/null +++ b/utils/lyricsProvider.js @@ -0,0 +1,140 @@ +// 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 }; diff --git a/utils/musicGuards.js b/utils/musicGuards.js new file mode 100644 index 0000000..37bb587 --- /dev/null +++ b/utils/musicGuards.js @@ -0,0 +1,16 @@ +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; +}; diff --git a/utils/musicSettings.js b/utils/musicSettings.js new file mode 100644 index 0000000..f1fc2e6 --- /dev/null +++ b/utils/musicSettings.js @@ -0,0 +1,34 @@ +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 };