feat(lavalink): Migrate from Erela.js to Shoukaku for music playback management

This commit is contained in:
Jose Daniel G. Percy 2025-04-24 00:25:02 +08:00
parent 5a29fe3d9d
commit e54c23cc63
5 changed files with 401 additions and 168 deletions

View File

@ -13,7 +13,7 @@
"dependencies": {
"discord.js": "^14.18.0",
"dotenv": "^16.5.0",
"erela.js": "^2.4.0",
"shoukaku": "^4.1.1",
"winston": "^3.17.0"
},
"devDependencies": {

View File

@ -1,5 +1,6 @@
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } = require('discord.js');
const logger = require('../utils/logger');
const { musicPlayer } = require('../structures/ShoukakuEvents');
module.exports = {
data: new SlashCommandBuilder()
@ -9,7 +10,7 @@ module.exports = {
option.setName('query')
.setDescription('The URL or search term for the song/playlist')
.setRequired(true)),
async execute(interaction, client) { // Added client parameter
async execute(interaction, client) {
await interaction.deferReply(); // Defer reply immediately
const member = interaction.member;
@ -30,128 +31,100 @@ module.exports = {
return interaction.editReply('I need permission to **speak** in your voice channel!');
}
if (voiceChannel.type !== ChannelType.GuildVoice) {
return interaction.editReply('I can only join standard voice channels.');
return interaction.editReply('I can only join standard voice channels.');
}
// 3. Get or create player, connect if necessary
let player = client.manager.get(interaction.guildId);
if (!player) {
try {
player = client.manager.create({
guild: interaction.guildId,
voiceChannel: voiceChannel.id,
textChannel: interaction.channelId,
selfDeafen: true,
});
player.connect();
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
} catch (error) {
logger.error(`Failed to create/connect player for guild ${interaction.guildId} during play command: ${error.message}`, error);
return interaction.editReply('An error occurred while trying to join the voice channel.');
}
} else {
// Ensure bot is in the user's channel if already connected elsewhere
if (player.voiceChannel !== voiceChannel.id) {
// Optional: Add check if user has permission to move bot or if bot is already playing
// if (player.playing) {
// return interaction.editReply("I'm currently playing in another channel!");
// }
player.setVoiceChannel(voiceChannel.id);
if (!player.playing && !player.paused && !player.queue.size) {
player.connect();
}
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
}
// Update text channel if needed
if (player.textChannel !== interaction.channelId) {
player.setTextChannel(interaction.channelId);
logger.debug(`Updated player text channel to ${interaction.channel.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
}
}
// 4. Search for tracks
try {
const searchResult = await player.search(query, interaction.user); // Pass interaction.user as requester
// --- Handle Search Results ---
let responseEmbed = new EmbedBuilder().setColor('#0099ff');
switch (searchResult.loadType) {
case 'LOAD_FAILED':
logger.error(`Search failed for query "${query}" in guild ${interaction.guildId}. Error: ${searchResult.exception?.message}`);
await interaction.editReply(`Oops! I couldn't load tracks for your query. Error: ${searchResult.exception?.message || 'Unknown error'}`);
// Optionally destroy player if nothing is playing/queued
if (!player.playing && !player.paused && player.queue.isEmpty) {
player.destroy();
}
return;
case 'NO_MATCHES':
await interaction.editReply(`No results found for "${query}".`);
if (!player.playing && !player.paused && player.queue.isEmpty) {
player.destroy();
}
return;
case 'TRACK_LOADED':
const track = searchResult.tracks[0];
player.queue.add(track);
logger.info(`Added track to queue: ${track.title} (Guild: ${interaction.guildId})`);
responseEmbed
.setTitle('Track Added to Queue')
.setDescription(`[${track.title}](${track.uri})`)
.addFields({ name: 'Position in queue', value: `${player.queue.size}`, inline: true });
if (track.thumbnail) {
responseEmbed.setThumbnail(track.thumbnail);
}
await interaction.editReply({ embeds: [responseEmbed] });
break; // Proceed to play check
case 'PLAYLIST_LOADED':
player.queue.add(searchResult.tracks);
logger.info(`Added playlist: ${searchResult.playlist?.name} (${searchResult.tracks.length} tracks) (Guild: ${interaction.guildId})`);
responseEmbed
.setTitle('Playlist Added to Queue')
.setDescription(`**${searchResult.playlist?.name || 'Unnamed Playlist'}** (${searchResult.tracks.length} tracks)`)
.addFields({ name: 'Starting track', value: `[${searchResult.tracks[0].title}](${searchResult.tracks[0].uri})` });
await interaction.editReply({ embeds: [responseEmbed] });
break; // Proceed to play check
case 'SEARCH_RESULT':
const firstTrack = searchResult.tracks[0];
player.queue.add(firstTrack);
logger.info(`Added search result to queue: ${firstTrack.title} (Guild: ${interaction.guildId})`);
responseEmbed
.setTitle('Track Added to Queue')
.setDescription(`[${firstTrack.title}](${firstTrack.uri})`)
.addFields({ name: 'Position in queue', value: `${player.queue.size}`, inline: true });
if (firstTrack.thumbnail) {
responseEmbed.setThumbnail(firstTrack.thumbnail);
}
await interaction.editReply({ embeds: [responseEmbed] });
break; // Proceed to play check
// 3. Get or create player
let player = musicPlayer.getPlayer(interaction.guildId);
if (!player) {
try {
player = await musicPlayer.createPlayer({
guildId: interaction.guildId,
textChannel: interaction.channelId,
voiceChannel: voiceChannel.id
});
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
} catch (error) {
logger.error(`Failed to create/connect player for guild ${interaction.guildId} during play command: ${error.message}`);
return interaction.editReply('An error occurred while trying to join the voice channel.');
}
} else if (player.voiceChannel !== voiceChannel.id) {
// If player exists but in a different voice channel, destroy it and create a new one
player.destroy();
player = await musicPlayer.createPlayer({
guildId: interaction.guildId,
textChannel: interaction.channelId,
voiceChannel: voiceChannel.id
});
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
}
// 5. Start playing if not already playing
if (!player.playing && !player.paused && player.queue.totalSize > 0) {
player.play();
logger.info(`Started playing in guild ${interaction.guildId}`);
// 4. Search for tracks
const searchResults = await musicPlayer.search({
query: query,
requester: interaction.user
});
if (!searchResults || searchResults.length === 0) {
await interaction.editReply(`No results found for "${query}".`);
if (!player.playing && player.queue.length === 0) {
player.destroy();
}
return;
}
// 5. Add track(s) to queue and create response embed
const responseEmbed = new EmbedBuilder().setColor('#0099ff');
// Add first track (or all tracks if it's a playlist)
const firstTrack = searchResults[0];
// Detect if it's a playlist based on number of tracks
const isPlaylist = searchResults.length > 1 &&
searchResults[0].info.uri.includes('playlist');
if (isPlaylist) {
// Add all tracks to the queue
for (const track of searchResults) {
await player.enqueue(track);
}
// Set up playlist embed
responseEmbed
.setTitle('Playlist Added to Queue')
.setDescription(`**Playlist** (${searchResults.length} tracks)`)
.addFields({ name: 'Starting track', value: `[${firstTrack.info.title}](${firstTrack.info.uri})` });
logger.info(`Added playlist with ${searchResults.length} tracks to queue (Guild: ${interaction.guildId})`);
} else {
// Add single track to queue
await player.enqueue(firstTrack);
// Set up track embed
responseEmbed
.setTitle('Track Added to Queue')
.setDescription(`[${firstTrack.info.title}](${firstTrack.info.uri})`)
.addFields({ name: 'Position in queue', value: `${player.queue.length}`, inline: true });
// Add thumbnail if available
if (firstTrack.info.thumbnail) {
responseEmbed.setThumbnail(firstTrack.info.thumbnail);
}
logger.info(`Added track to queue: ${firstTrack.info.title} (Guild: ${interaction.guildId})`);
}
// Send response
await interaction.editReply({ embeds: [responseEmbed] });
} catch (error) {
logger.error(`Error during search/play for query "${query}" in guild ${interaction.guildId}: ${error.message}`, error);
await interaction.editReply('An unexpected error occurred while trying to play the music.').catch(e => logger.error(`Failed to send error reply for play command: ${e.message}`));
// Optionally destroy player on critical error
// if (!player.playing && !player.paused && player.queue.isEmpty) {
// player.destroy();
// }
logger.error(`Error during search/play for query "${query}" in guild ${interaction.guildId}: ${error.message}`);
await interaction.editReply('An unexpected error occurred while trying to play the music.').catch(e =>
logger.error(`Failed to send error reply for play command: ${e.message}`)
);
}
logger.info(`Executed command 'play' for user ${interaction.user.tag}`);
},
};

View File

@ -1,6 +1,6 @@
const { Events } = require('discord.js');
const { Events, ActivityType } = require('discord.js');
const logger = require('../utils/logger');
const loadErelaEvents = require('../structures/ErelaEvents'); // Import the Erela event loader
const { setupPlayer } = require('../structures/ShoukakuEvents'); // Import the Shoukaku player
module.exports = {
name: Events.ClientReady,
@ -8,37 +8,16 @@ module.exports = {
async execute(client) {
logger.info(`Ready! Logged in as ${client.user.tag}`);
// Wait a moment before initializing Erela.js to give Lavalink time to start up
// This is especially important in Docker environments
await new Promise(resolve => setTimeout(resolve, 5000)); // 5 second delay
// Initialize the Erela Manager once the client is ready
// Initialize the Shoukaku music player
try {
// Log the actual connection details being used (without exposing the password)
const node = client.manager.nodes[0];
logger.info(`Attempting to connect to Lavalink at ${node.options.host}:${node.options.port} with identifier "${node.options.identifier}"`);
client.manager.init(client.user.id);
logger.info(`Erela.js Manager initialized for user ID: ${client.user.id}`);
// Load Erela.js event handlers
loadErelaEvents(client);
// Set up the music player with the client
client.player = setupPlayer(client);
logger.info('Shoukaku music player initialized successfully');
} catch (error) {
logger.error(`Failed to initialize Erela.js Manager: ${error.message}`);
// Try to reconnect after a delay if initialization fails
setTimeout(() => {
try {
client.manager.init(client.user.id);
logger.info(`Erela.js Manager initialization retry successful`);
loadErelaEvents(client);
} catch (retryError) {
logger.error(`Retry initialization also failed: ${retryError.message}`);
}
}, 10000); // 10 second delay before retry
logger.error(`Failed to initialize Shoukaku music player: ${error.message}`);
}
// Set activity status
client.user.setActivity('Music | /play', { type: 'LISTENING' });
client.user.setActivity('Music | /play', { type: ActivityType.Listening });
},
};

View File

@ -1,7 +1,7 @@
// Load environment variables from .env file
require('dotenv').config();
const { Client, GatewayIntentBits, Collection } = require('discord.js');
const { Manager } = require('erela.js');
const { Shoukaku, Connectors } = require('shoukaku');
const logger = require('./utils/logger');
const fs = require('fs');
const path = require('path');
@ -27,28 +27,26 @@ const client = new Client({
],
});
// Initialize Erela.js Manager
client.manager = new Manager({
nodes: [
{
host: process.env.LAVALINK_HOST || 'localhost',
port: parseInt(process.env.LAVALINK_PORT || '2333'),
password: process.env.LAVALINK_PASSWORD || 'youshallnotpass',
secure: process.env.LAVALINK_SECURE === 'true',
retryAmount: 10, // Number of connection attempts
retryDelay: 5000, // 5 seconds between each retry
identifier: "lavalink", // Identifier for logs
},
],
// Function to send raw voice data to Discord
send(id, payload) {
const guild = client.guilds.cache.get(id);
if (guild) guild.shard.send(payload);
},
// Define Shoukaku nodes
const Nodes = [
{
name: 'lavalink',
url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || '2333'}/v4/websocket`,
auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass',
secure: process.env.LAVALINK_SECURE === 'true'
}
];
// Initialize Shoukaku
client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, {
moveOnDisconnect: false,
resume: true,
reconnectTries: 10,
reconnectInterval: 5000,
});
// Show the actual Lavalink connection details (without exposing the actual password)
logger.info(`Lavalink connection configured to: ${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT} (Password: ${process.env.LAVALINK_PASSWORD ? '[SET]' : '[NOT SET]'})`);
logger.info(`Lavalink connection configured to: ${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT}/v4/websocket (Password: ${process.env.LAVALINK_PASSWORD ? '[SET]' : '[NOT SET]'})`);
// Collections for commands
client.commands = new Collection();
@ -89,9 +87,12 @@ for (const file of eventFiles) {
}
}
// --- Erela.js Event Handling ---
// (This is handled within the 'ready' event after manager initialization)
logger.info('Erela.js event handling will be attached in the ready event.');
// --- Shoukaku Event Handling ---
// Set up Shoukaku event handlers
client.shoukaku.on('ready', (name) => logger.info(`Lavalink Node: ${name} is now connected`));
client.shoukaku.on('error', (name, error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`));
client.shoukaku.on('close', (name, code, reason) => logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || 'No reason'}`));
client.shoukaku.on('disconnect', (name, reason) => logger.warn(`Lavalink Node: ${name} disconnected. Reason: ${reason || 'No reason'}`));
// Log in to Discord with your client's token
client.login(process.env.DISCORD_TOKEN)

View File

@ -0,0 +1,280 @@
const logger = require('../utils/logger');
const { EmbedBuilder } = require('discord.js');
/**
* Manages player instances and track playback using Shoukaku
* @param {Client} client Discord.js client
*/
class MusicPlayer {
constructor(client) {
this.client = client;
this.players = new Map(); // Store active players
}
/**
* Creates a player for a guild or returns existing one
* @param {Object} options Options for creating the player
* @param {string} options.guildId The guild ID
* @param {string} options.textChannel The text channel ID
* @param {string} options.voiceChannel The voice channel ID
* @returns {Object} The player object
*/
async createPlayer({ guildId, textChannel, voiceChannel }) {
// Check if player already exists
if (this.players.has(guildId)) {
return this.players.get(guildId);
}
// Get Shoukaku node
const node = this.client.shoukaku.getNode();
if (!node) {
throw new Error('No available Lavalink nodes!');
}
try {
// Create a new connection to the voice channel
const connection = await node.joinChannel({
guildId: guildId,
channelId: voiceChannel,
shardId: 0,
deaf: true
});
// Create a player object to track state and add methods
const player = {
guild: guildId,
textChannel: textChannel,
voiceChannel: voiceChannel,
connection: connection,
queue: [],
current: null,
playing: false,
volume: 100,
// Play a track
async play(track) {
this.current = track;
// Start playback
await this.connection.playTrack({ track: track.track });
this.playing = true;
return this;
},
// Stop the current track
stop() {
this.connection.stopTrack();
return this;
},
// Skip to the next track
skip() {
this.stop();
if (this.queue.length > 0) {
const nextTrack = this.queue.shift();
this.play(nextTrack);
} else {
this.current = null;
this.playing = false;
}
return this;
},
// Set player volume
setVolume(volume) {
this.volume = volume;
this.connection.setGlobalVolume(volume);
return this;
},
// Pause playback
pause() {
this.connection.setPaused(true);
return this;
},
// Resume playback
resume() {
this.connection.setPaused(false);
return this;
},
// Destroy the player and disconnect
destroy() {
this.connection.disconnect();
musicPlayer.players.delete(this.guild);
return this;
},
// Add a track to the queue or play it if nothing is playing
async enqueue(track, immediate = false) {
if (immediate || (!this.playing && !this.current)) {
await this.play(track);
} else {
this.queue.push(track);
}
return this;
}
};
// Set up event listeners for this player
connection.on('start', () => {
logger.info(`Track started in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`);
// Send now playing message
if (player.current) {
const channel = this.client.channels.cache.get(player.textChannel);
if (channel) {
const embed = new EmbedBuilder()
.setColor('#0099ff')
.setTitle('Now Playing')
.setDescription(`[${player.current.info.title}](${player.current.info.uri})`)
.addFields({ name: 'Requested by', value: `${player.current.requester?.tag || 'Unknown'}`, inline: true })
.setTimestamp();
if (player.current.info.thumbnail) {
embed.setThumbnail(player.current.info.thumbnail);
}
channel.send({ embeds: [embed] }).catch(e =>
logger.error(`Failed to send trackStart message: ${e.message}`)
);
}
}
});
connection.on('end', () => {
logger.info(`Track ended in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`);
player.playing = false;
player.current = null;
// Play next track in queue if available
if (player.queue.length > 0) {
const nextTrack = player.queue.shift();
player.play(nextTrack);
} else {
// Send queue end message
const channel = this.client.channels.cache.get(player.textChannel);
if (channel) {
channel.send('Queue finished. Add more songs!').catch(e =>
logger.error(`Failed to send queueEnd message: ${e.message}`)
);
}
// Optional: Add timeout before disconnecting
// setTimeout(() => {
// if (!player.playing) player.destroy();
// }, 300000); // 5 minutes
player.destroy();
}
});
connection.on('exception', (error) => {
logger.error(`Track error in guild ${player.guild}: ${error.message}`);
const channel = this.client.channels.cache.get(player.textChannel);
if (channel) {
channel.send(`An error occurred while trying to play: ${player.current?.info?.title || 'the track'}.
Details: ${error.message || 'Unknown error'}`).catch(e =>
logger.error(`Failed to send trackError message: ${e.message}`)
);
}
player.skip();
});
// Store the player and return it
this.players.set(guildId, player);
return player;
} catch (error) {
logger.error(`Failed to create player for guild ${guildId}: ${error.message}`);
throw error;
}
}
/**
* Get an existing player
* @param {string} guildId The guild ID
* @returns {Object|null} The player object or null
*/
getPlayer(guildId) {
return this.players.get(guildId) || null;
}
/**
* Search for tracks using Shoukaku
* @param {Object} options Options for the search
* @param {string} options.query The search query
* @param {string} options.requester The user who requested the track
* @returns {Promise<Array>} Array of track objects
*/
async search({ query, requester }) {
const node = this.client.shoukaku.getNode();
if (!node) throw new Error('No available Lavalink nodes!');
try {
// Determine search type
let searchOptions = {};
if (query.startsWith('http')) {
// Direct URL
searchOptions = { query };
} else {
// Search with prefix
searchOptions = { query: `ytsearch:${query}` };
}
// Perform the search
const result = await node.rest.resolve(searchOptions);
if (!result || result.loadType === 'error' || result.loadType === 'empty') {
throw new Error(result?.exception?.message || 'No results found');
}
// Process results
let tracks = [];
if (result.loadType === 'playlist') {
// Playlist processing
tracks = result.data.tracks.map(track => ({
track: track.encoded,
info: track.info,
requester: requester
}));
} else if (result.loadType === 'track') {
// Single track
const track = result.data;
tracks = [{
track: track.encoded,
info: track.info,
requester: requester
}];
} else if (result.loadType === 'search') {
// Search results
tracks = result.data.slice(0, 10).map(track => ({
track: track.encoded,
info: track.info,
requester: requester
}));
}
return tracks;
} catch (error) {
logger.error(`Search error: ${error.message}`);
throw error;
}
}
}
// Create and export the player manager
const musicPlayer = new MusicPlayer(null);
module.exports = {
setupPlayer: (client) => {
if (!client || !client.shoukaku) {
logger.error("ShoukakuEvents requires a client with an initialized shoukaku instance.");
return;
}
// Initialize the player with the client
musicPlayer.client = client;
logger.info("Shoukaku music player initialized and ready.");
return musicPlayer;
},
musicPlayer
};