From e54c23cc63a6bf6e52a5209e5499d8e7b5255e89 Mon Sep 17 00:00:00 2001 From: aki Date: Thu, 24 Apr 2025 00:25:02 +0800 Subject: [PATCH] feat(lavalink): Migrate from Erela.js to Shoukaku for music playback management --- package.json | 2 +- src/commands/play.js | 203 ++++++++++------------ src/events/ready.js | 37 +--- src/index.js | 47 +++--- src/structures/ShoukakuEvents.js | 280 +++++++++++++++++++++++++++++++ 5 files changed, 401 insertions(+), 168 deletions(-) create mode 100644 src/structures/ShoukakuEvents.js diff --git a/package.json b/package.json index fe46870..1548cfd 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/commands/play.js b/src/commands/play.js index cc11c50..48f1bca 100644 --- a/src/commands/play.js +++ b/src/commands/play.js @@ -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}`); }, }; diff --git a/src/events/ready.js b/src/events/ready.js index e88cdde..f454c4f 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -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 }); }, }; diff --git a/src/index.js b/src/index.js index 66fa7c8..e001a82 100644 --- a/src/index.js +++ b/src/index.js @@ -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) diff --git a/src/structures/ShoukakuEvents.js b/src/structures/ShoukakuEvents.js new file mode 100644 index 0000000..7f2fc6d --- /dev/null +++ b/src/structures/ShoukakuEvents.js @@ -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 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 +}; \ No newline at end of file