mirror of
https://github.com/aydenjahola/discord-multipurpose-bot.git
synced 2025-10-03 19:51:33 +01:00
music commands imporvements, implement live lyrics, some guards and bot leaving on empty
This commit is contained in:
parent
ff761092d1
commit
08b2914cbc
20 changed files with 1669 additions and 190 deletions
178
commands/admin/music.js
Normal file
178
commands/admin/music.js
Normal file
|
@ -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."));
|
||||
},
|
||||
};
|
81
commands/music/livelyrics.js
Normal file
81
commands/music/livelyrics.js
Normal file
|
@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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) {
|
||||
try {
|
||||
const queue = client.distube.getQueue(interaction.guildId);
|
||||
|
||||
if (!queue || !queue.songs.length) {
|
||||
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("#0099ff")
|
||||
.setColor(0x0099ff)
|
||||
.setTitle("🎵 Now Playing")
|
||||
.setDescription(`**${song.name}**`)
|
||||
.setDescription(
|
||||
[
|
||||
`**${song.name || "Unknown title"}**`,
|
||||
"",
|
||||
`\`\`\`${bar}\`\`\``,
|
||||
`**Position:** \`${positionStr}\``,
|
||||
].join("\n")
|
||||
)
|
||||
.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" }
|
||||
{
|
||||
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);
|
||||
|
||||
interaction.reply({ embeds: [embed] });
|
||||
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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: false });
|
||||
|
||||
if (!queue) {
|
||||
return interaction.reply("❌ There is no music playing!");
|
||||
}
|
||||
requireVC(interaction);
|
||||
const queue = requireQueue(client, interaction);
|
||||
|
||||
if (queue.paused) {
|
||||
return interaction.reply("⏸️ Music is already paused!");
|
||||
return interaction.followUp({
|
||||
content: "⏸️ Music is already paused.",
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await queue.pause();
|
||||
interaction.reply("⏸️ Paused the current song!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
interaction.reply("❌ Failed to pause the music.");
|
||||
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,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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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")}`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
|
||||
if (!queue) {
|
||||
return interaction.reply("❌ There is no music playing!");
|
||||
}
|
||||
requireVC(interaction);
|
||||
const queue = requireQueue(client, interaction);
|
||||
|
||||
if (!queue.paused) {
|
||||
return interaction.reply("▶️ Music is not 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 {
|
||||
await queue.resume();
|
||||
interaction.reply("▶️ Resumed the music!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
interaction.reply("❌ Failed to resume the music.");
|
||||
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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
98
commands/music/seek.js
Normal file
98
commands/music/seek.js
Normal file
|
@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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);
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
|
||||
if (!queue || queue.songs.length <= 2) {
|
||||
return interaction.reply("❌ Not enough songs in the queue to shuffle!");
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await queue.shuffle();
|
||||
interaction.reply("🔀 Shuffled the queue!");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
interaction.reply("❌ Failed to shuffle the queue.");
|
||||
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,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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
.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 level (1-100)")
|
||||
.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);
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
|
||||
if (!queue) {
|
||||
return interaction.reply("❌ There is no music playing!");
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await queue.setVolume(volume);
|
||||
interaction.reply(`🔊 Volume set to ${volume}%!`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
interaction.reply("❌ Failed to adjust volume.");
|
||||
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,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) {
|
||||
.on("addList", (queue, playlist) => {
|
||||
const embed = createEmbed(
|
||||
0x0099ff,
|
||||
"🎵 Queue Finished",
|
||||
"The music queue has ended."
|
||||
0x00ccff,
|
||||
"📚 Playlist Added",
|
||||
`**${playlist.name}** with **${playlist.songs.length}** tracks has been queued.`
|
||||
);
|
||||
queue.textChannel.send({ embeds: [embed] });
|
||||
}
|
||||
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] });
|
||||
}
|
||||
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] });
|
||||
}
|
||||
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] });
|
||||
}
|
||||
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) => {
|
||||
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] });
|
||||
})
|
||||
|
||||
.on("finish", (queue) => {
|
||||
try {
|
||||
queue.distube.voices.leave(queue.id);
|
||||
const embed = createEmbed(
|
||||
0x0099ff,
|
||||
"🏁 Queue Finished",
|
||||
"Queue ended — disconnecting now."
|
||||
);
|
||||
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(
|
||||
try {
|
||||
queue.distube.voices.leave(queue.id);
|
||||
queue.textChannel?.send({
|
||||
embeds: [
|
||||
createEmbed(
|
||||
0xff0000,
|
||||
"🔇 Voice Channel Empty",
|
||||
"Voice channel is empty! Leaving the channel."
|
||||
);
|
||||
queue.textChannel.send({ embeds: [embed] });
|
||||
"🔇 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(() => {});
|
||||
});
|
||||
};
|
||||
|
|
44
index.js
44
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}`
|
||||
|
|
16
models/MusicSettings.js
Normal file
16
models/MusicSettings.js
Normal file
|
@ -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);
|
|
@ -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",
|
||||
|
|
138
utils/liveLyricsManager.js
Normal file
138
utils/liveLyricsManager.js
Normal file
|
@ -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 };
|
140
utils/lyricsProvider.js
Normal file
140
utils/lyricsProvider.js
Normal file
|
@ -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 };
|
16
utils/musicGuards.js
Normal file
16
utils/musicGuards.js
Normal file
|
@ -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;
|
||||
};
|
34
utils/musicSettings.js
Normal file
34
utils/musicSettings.js
Normal file
|
@ -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 };
|
Loading…
Reference in a new issue