diff --git a/.env.example b/.env.example index 74128ca..287a5ed 100644 --- a/.env.example +++ b/.env.example @@ -33,4 +33,10 @@ DJ_ROLE=123456789987654321 # PLEX MEDIA SERVER SETTINGS ENABLE_PLEX=false PLEX_SERVER='http://YOUR_IPADDRESS:32400' -PLEX_AUTHTOKEN='YOUR_AUTH_TOKEN' \ No newline at end of file +PLEX_AUTHTOKEN='YOUR_AUTH_TOKEN' + +# SUBSONIC MEDIA SERVER SETTINGS +ENABLE_SUBSONIC=false +SUBSONIC_URL=http://YOUR_IPADDRESS:4533 +SUBSONIC_USERNAME=YOUR_USERNAME +SUBSONIC_PASSWORD=YOUR_PASSWORD \ No newline at end of file diff --git a/README.md b/README.md index a6020a0..3c32f29 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The first step is to clone the repository or download it manually as a folder to Head over to the download page and download the .zip source code. Next, using a tool such as [7-Zip](https://www.7-zip.org/), extract the files from the .zip folder. You can now move on to the following steps. #### Download using Git -An alternative way to download the repository is through the usage of [Git](https://git-scm.com/). If you do not have Git installed, please use the basic download method. Git users can run the command `git clone https://github.com/ThatGuyJacobee/Elite-Bot-Music/tree/main` to automatically clone the repository to a new folder. +An alternative way to download the repository is through the usage of [Git](https://git-scm.com/). If you do not have Git installed, please use the basic download method. Git users can run the command `gh repo clone ripsawuk/Elite-Music` to automatically clone the repository to a new folder. #### Continuing the Setup Now that you have downloaded the repository, you can continue with the following steps. @@ -74,29 +74,29 @@ Elite Music now has Docker image and Docker Compose support, allowing you to sim You can access the [Docker Image via Docker Hub](https://hub.docker.com/r/thatguyjacobee/elitemusic) which provides the image and the instructions within the description. The instructions to either install via Docker Run or Docker Compose are also provided below. Once you have installed and configured your bot, you will need to add your bot to your server now in order to use it. Follow this [useful guide](https://discordjs.guide/preparations/adding-your-bot-to-servers.html#bot-invite-links) from the discord.js Guide which explains how to do this with great detail if you need help understanding how to do this. #### Docker Run Command -You should use the following command to download the image and run it: +Use the current image and container naming from our compose file: ```docker docker run -d \ ---name=elite-music \ ---env-file /path/to/.env \ +--name=elite-subsonic \ +--env-file .env \ --restart unless-stopped \ -thatguyjacobee/elitemusic:latest +ripsawuk/elitemusic:latest ``` Note: The `--env-file` path is relative to the directory you are running your docker run command from. -See the [.env.sample file](https://github.com/ThatGuyJacobee/Elite-Music/blob/main/.env.example) on the GitHub repository to view and copy over all of the environmental options into your target .env file for the bot. +See the [.env.sample file](https://github.com/ripsawuk/Elite-Music/blob/main/.env.example) on the GitHub repository to view and copy over all of the environmental options into your target .env file for the bot. #### Docker Compose Use the following for your `docker-compose.yml` file: ```yml version: '3' services: - elitemusic: - container_name: 'elite-music' - image: 'thatguyjacobee/elitemusic:latest' + elitesubsonic: + container_name: 'elite-subsonic' + image: 'ripsawuk/elite-subsonic:latest' env_file: - - /path/to/.env + - .env restart: unless-stopped ``` @@ -116,6 +116,24 @@ The Plex optional feature when enabled, allows you to stream music directly from 2. Next, you must provide a direct URL to your Plex Media Center. The default port that Plex Media Server runs on is `32400`. You can test that your `PLEX_SERVER` URL is correct, by pasting it into any web browser, and it should load successfully with a login page. 3. Finally, you must place your plex authentication token into the `PLEX_AUTHTOKEN` field. You can do this by browsing the XML file for a library item. Please follow the [official Plex Support article](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) to access your token. Once you have access to it, place it into your .env file. +Usage (after the bot is up): +- `/plex play query:` — search and play from your library. +- `/plex albums query:` - search and play albums from your library +- `/plex playlists query:` — list your Plex playlists and enqueue them. + +### Subsonic playback +Subsonic lets you stream from your Subsonic-compatible server (Subsonic/Airsonic/Navidrome/etc.) using the built-in Subsonic commands. All auth is via `.env`; no Discord login commands are required. + +1. Set `ENABLE_SUBSONIC` to `true` in `.env`. +2. Set your server URL: `SUBSONIC_URL='http://YOUR_IP:PORT'`. +3. Set credentials: `SUBSONIC_USERNAME` and `SUBSONIC_PASSWORD` (account on your Subsonic server). +4. Restart the bot so the env vars are picked up. + +Usage (after the bot is up): +- `/subsonic play query:` — search and play from your library. +- `/subsonic albums query:` - search and play albums from your library. +- `/subsonic playlists query:` — list your Subsonic playlists and enqueue them. + ### DJ Mode Elite Music comes with a DJ Mode optional feature, which locks down the use of commands and interactions to members who have the specified DJ Role. diff --git a/commands/music/nowplaying.js b/commands/music/nowplaying.js index e528bf8..489bb68 100644 --- a/commands/music/nowplaying.js +++ b/commands/music/nowplaying.js @@ -14,64 +14,82 @@ module.exports = { var queue = player.nodes.get(interaction.guild.id); if (!queue || !queue.isPlaying()) return interaction.reply({ content: `❌ | No music is currently being played!`, ephemeral: true }); - const progress = queue.node.createProgressBar(); - var create = progress.replace(/ 0:00/g, ' ◉ LIVE'); + const progress = queue.node.createProgressBar({ + indicator: '🔘', + leftChar: '▬', + rightChar: '▬', + length: 20 + }); + const createBar = progress.replace(/ 0:00/g, ' ◉ LIVE'); - var coverImage = new AttachmentBuilder(queue.currentTrack.thumbnail, { name: 'coverimage.jpg', description: `Song Cover Image for ${queue.currentTrack.title}` }) + // Get queue info + const queueSize = queue.tracks.size; + const loopMode = queue.repeatMode === 1 ? 'Track' : queue.repeatMode === 2 ? 'Queue' : 'Normal'; + const pauseStatus = queue.node.isPaused() ? 'Paused' : 'Playing'; + + var coverImage = new AttachmentBuilder(queue.currentTrack.thumbnail, { name: 'coverimage.jpg', description: `Song Cover Image for ${queue.currentTrack.title}` }); + const npembed = new EmbedBuilder() - .setAuthor({ name: interaction.client.user.tag, iconURL: interaction.client.user.displayAvatarURL() }) - .setThumbnail('attachment://coverimage.jpg') - .setColor(client.config.embedColour) - .setTitle(`Now playing 🎵`) - .setDescription(`${queue.currentTrack.title} ${queue.currentTrack.queryType != 'arbitrary' ? `([Link](${queue.currentTrack.url}))` : ''}\n${create}`) - //.addField('\u200b', progress.replace(/ 0:00/g, ' ◉ LIVE')) - .setTimestamp() + .setAuthor({ name: interaction.client.user.tag, iconURL: interaction.client.user.displayAvatarURL() }) + .setThumbnail('attachment://coverimage.jpg') + .setColor(client.config.embedColour) + .setTitle(`🎵 Now Playing`) + .setDescription(`**${queue.currentTrack.title}**${queue.currentTrack.queryType != 'arbitrary' ? ` ([Link](${queue.currentTrack.url}))` : ''}`) + .addFields( + { name: '🎤 Artist', value: queue.currentTrack.author || 'Unknown', inline: true }, + { name: '⏱️ Duration', value: queue.currentTrack.duration || 'Unknown', inline: true }, + { name: '📊 Status', value: pauseStatus, inline: true }, + { name: '🔊 Volume', value: `${queue.node.volume}%`, inline: true }, + { name: '🔄 Loop Mode', value: loopMode, inline: true }, + { name: '📑 Queue', value: `${queueSize} song${queueSize !== 1 ? 's' : ''}`, inline: true }, + { name: '⏳ Progress', value: createBar, inline: false } + ) + .setTimestamp(); if (queue.currentTrack.requestedBy != null) { - npembed.setFooter({ text: `Requested by: ${interaction.user.discriminator != 0 ? interaction.user.tag : interaction.user.username}` }) + npembed.setFooter({ text: `Requested by: ${queue.currentTrack.requestedBy.discriminator != 0 ? queue.currentTrack.requestedBy.tag : queue.currentTrack.requestedBy.username}` }); } - var finalComponents = [ - actionbutton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("np-delete") - .setStyle(4) - .setLabel("🗑️"), - //.addOptions(options) + const finalComponents = [ + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("np-back") - .setStyle(1) - .setLabel("⏮️ Previous"), + .setStyle(2) + .setEmoji("⏮️"), new ButtonBuilder() .setCustomId("np-pauseresume") - .setStyle(1) - .setLabel("⏯️ Play/Pause"), + .setStyle(2) + .setEmoji("⏯️"), new ButtonBuilder() .setCustomId("np-skip") - .setStyle(1) - .setLabel("⏭️ Skip"), + .setStyle(2) + .setEmoji("⏭️"), new ButtonBuilder() - .setCustomId("np-clear") - .setStyle(1) - .setLabel("🧹 Clear Queue") + .setCustomId("np-stop") + .setStyle(2) + .setEmoji("⏹️") ), - actionbutton2 = new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("np-volumeadjust") .setStyle(1) - .setLabel("🔊 Adjust Volume"), + .setEmoji("🔊") + .setLabel("Volume"), new ButtonBuilder() .setCustomId("np-loop") .setStyle(1) - .setLabel("🔂 Loop Once"), + .setEmoji("🔄") + .setLabel("Loop"), new ButtonBuilder() .setCustomId("np-shuffle") .setStyle(1) - .setLabel("🔀 Shuffle Queue"), + .setEmoji("🔀") + .setLabel("Shuffle"), new ButtonBuilder() - .setCustomId("np-stop") - .setStyle(1) - .setLabel("🛑 Stop Queue") + .setCustomId("np-clear") + .setStyle(4) + .setEmoji("🧹") + .setLabel("Clear") ) ]; diff --git a/commands/music/play.js b/commands/music/play.js index 6840d5c..5df8ab5 100644 --- a/commands/music/play.js +++ b/commands/music/play.js @@ -61,7 +61,7 @@ module.exports = { actionmenu.components[0].addOptions( new StringSelectMenuOptionBuilder() .setLabel(result.title.length > 100 ? `${result.title.substring(0, 97)}...` : result.title) - .setValue(`${!result.playlist ? 'song' : 'playlist' }_false_url=${result.url}`) // Schema: [type]_[playnext]_[url=track]... + .setValue(`${!result.playlist ? 'song' : 'playlist' }_false_url=${result.url}`) .setDescription(`Duration - ${result.duration}`) .setEmoji(emojis[count-1]) ) diff --git a/commands/music/plex.js b/commands/music/plex.js index 2fd9e8a..ac15a2d 100644 --- a/commands/music/plex.js +++ b/commands/music/plex.js @@ -2,289 +2,572 @@ require("dotenv").config(); const musicFuncs = require('../../utils/sharedFunctions.js') const { SlashCommandBuilder } = require("@discordjs/builders"); const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } = require("discord.js"); +const { useMainPlayer, Track, QueryType } = require('discord-player'); +const { buildImageAttachment } = require('../../utils/utilityFunctions'); module.exports = { data: new SlashCommandBuilder() .setName("plex") - .setDescription("Play a song into the queue!") + .setDescription("Play music from your Plex server") .addSubcommand((subcommand) => subcommand .setName("play") - .setDescription("Play a song from your plex.") + .setDescription("Search and play songs from your Plex server") .addStringOption((option) => option - .setName("music") - .setDescription("Name of the song you want to play.") - .setRequired(true) - ) - ) + .setName("query") + .setDescription("Search for a song by name or artist") + .setRequired(true)) + .addBooleanOption(option => + option.setName("playnext") + .setDescription("Add song to the front of the queue") + .setRequired(false))) .addSubcommand((subcommand) => subcommand - .setName("search") - .setDescription("Search songs and playlists.") + .setName("albums") + .setDescription("Search and play full albums from your Plex server") .addStringOption((option) => option - .setName("music") - .setDescription("Search query for a single song or playlist.") - .setRequired(true) - ) - ) + .setName("query") + .setDescription("Search for an album by name or artist") + .setRequired(true))) .addSubcommand((subcommand) => subcommand - .setName("playnext") - .setDescription("Add a song from your plex to the top of the queue.") - .addStringOption((option) => option - .setName("music") - .setDescription("Search query for a single song or playlist.") - .setRequired(true) - ) - ), + .setName("playlists") + .setDescription("View and play your Plex playlists")), + cooldown: 5, async execute(interaction) { - if (interaction.options.getSubcommand() === "play" || interaction.options.getSubcommand() === "playnext") { - if (client.config.enableDjMode) { - if (!interaction.member.roles.cache.has(client.config.djRole)) return interaction.reply({ content: `❌ | DJ Mode is active! You must have the DJ role <@&${client.config.djRole}> to use any music commands!`, ephemeral: true }); + if (client.config.enableDjMode) { + if (!interaction.member.roles.cache.has(client.config.djRole)) { + return interaction.reply({ content: `❌ | DJ Mode is active! You must have the DJ role <@&${client.config.djRole}> to use any music commands!`, ephemeral: true }); } - - if (!client.config.enablePlex) { - return interaction.reply({ content: `❌ | Plex is currently disabled! Ask the server admin to enable and configure this in the environment file.`, ephemeral: true }); + } + + await interaction.deferReply(); + + if (!client.config.enablePlex) { + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Plex Disabled') + .setDescription('Plex integration is not enabled on this bot.') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const member = interaction.guild.members.cache.get(interaction.user.id); + if (!member.voice.channel) { + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Not in Voice Channel') + .setDescription('You need to be in a voice channel to play music!') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'play') { + await handlePlay(interaction, member); + } else if (subcommand === 'albums') { + await handleAlbums(interaction, member); + } else if (subcommand === 'playlists') { + await handlePlaylists(interaction, member); + } + } +}; + +async function handlePlay(interaction, member) { + const query = interaction.options.getString('query'); + const playNext = interaction.options.getBoolean('playnext') || false; + + try { + const results = await musicFuncs.plexSearchQuery(query); + + if (!results || !results.songs || results.songs.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🔍 No Results Found') + .setDescription(`No songs found for: **${query}**`) + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const songs = results.songs.slice(0, 10); + + if (songs.length === 1) { + await musicFuncs.plexAddTrack(interaction, playNext, songs[0], 'edit'); + return; + } + + let embedFields = [] + let count = 1 + let emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣','5️⃣', '6️⃣', '7️⃣', '8️⃣','9️⃣', '🔟'] + + const actionmenu = new ActionRowBuilder() + .addComponents( + new StringSelectMenuBuilder() + .setCustomId(`plex_song_select_${playNext}`) + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder('Select a song to play') + ) + + for (let item of songs) { + if (count > 10) break; + + let date = new Date(item.duration) + let songTitle = `${item.parentTitle} - ${item.grandparentTitle}` + embedFields.push({ name: `[${count}] ${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`, value: songTitle }) + + actionmenu.components[0].addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(songTitle.length > 100 ? `${songTitle.substring(0, 97)}...` : songTitle) + .setValue(item.key) + .setDescription(`Duration - ${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`) + .setEmoji(emojis[count-1]) + ) + count++ + } + + const searchembed = new EmbedBuilder() + .setAuthor({ name: interaction.client.user.tag, iconURL: interaction.client.user.displayAvatarURL() }) + .setThumbnail(interaction.guild.iconURL({dynamic: true})) + .setTitle(`Plex Search Results 🎵`) + .setDescription('Found multiple songs matching the provided search query, select one from the menu below.') + .addFields(embedFields) + .setColor(client.config.embedColour) + .setTimestamp() + .setFooter({ text: `Requested by: ${interaction.user.discriminator != 0 ? interaction.user.tag : interaction.user.username}` }) + + const actionbutton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("np-delete") + .setStyle(4) + .setLabel("Cancel Search 🗑️"), + ) + + await interaction.editReply({ embeds: [searchembed], components: [actionmenu, actionbutton] }) + + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.customId === `plex_song_select_${playNext}` && i.user.id === interaction.user.id, + time: 60000 + }); + + collector.on('collect', async i => { + await i.deferUpdate(); + const songKey = i.values[0]; + const selectedSong = songs.find(s => s.key === songKey); + await musicFuncs.plexAddTrack(i, playNext, selectedSong, 'edit'); + }); + + collector.on('end', collected => { + if (collected.size === 0) { + interaction.editReply({ components: [] }).catch(() => {}); } - - if (!interaction.member.voice.channelId) return await interaction.reply({ content: "❌ | You are not in a voice channel!", ephemeral: true }); - if (interaction.guild.members.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.members.me.voice.channelId) return await interaction.reply({ content: "❌ | You are not in my voice channel!", ephemeral: true }); + }); + + } catch (error) { + console.error('[PLEX_PLAY] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Search Error') + .setDescription(`Could not search for songs: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } +} + +async function handleAlbums(interaction, member) { + const query = interaction.options.getString('query'); + + try { + const searchResults = await fetch(`${client.config.plexServer}/search?X-Plex-Token=${client.config.plexAuthtoken}&query=${encodeURIComponent(query)}&limit=25&type=9`, { + method: 'GET', + headers: { accept: 'application/json'} + }); + + const result = await searchResults.json(); + + if (!result.MediaContainer.Metadata || result.MediaContainer.Metadata.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🔍 No Albums Found') + .setDescription(`No albums found for: **${query}**`) + .setTimestamp(); - const query = interaction.options.getString("music"); - await musicFuncs.getQueue(interaction); - + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const albums = result.MediaContainer.Metadata.slice(0, 25); + + for (const album of albums) { try { - var results = await musicFuncs.plexSearchQuery(query); - if (!results.songs && !results.playlists) { - return interaction.reply({ content: `❌ | Ooops... something went wrong, couldn't find the song or playlist with the requested query.`, ephemeral: true }) - } - - //Otherwise something is found so defer reply - await interaction.deferReply(); - - //More than one search result, show menu - if (results.size >= 2) { - var embedFields = [] - let count = 1 - let emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣','5️⃣', '6️⃣', '7️⃣', '8️⃣','9️⃣', '🔟'] - - var actionmenu = new ActionRowBuilder() - .addComponents( - new StringSelectMenuBuilder() - .setCustomId("plexsearch") - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder('Add an item to queue 👈') - ) - - if (results.songs) { - for (let item of results.songs) { - if (count > 10) break - - let date = new Date(item.duration) - let songTitle = `${item.parentTitle} - ${item.grandparentTitle}` - embedFields.push({ name: `[${count}] ${item.type.charAt(0).toUpperCase() + item.type.slice(1)} Result (${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()})`, value: songTitle }) - - actionmenu.components[0].addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(songTitle.length > 100 ? `${songTitle.substring(0, 97)}...` : songTitle) - .setValue(`${item.type}_${interaction.options.getSubcommand() == "playnext" ? "true" : "false"}_key=${item.key}`) // Schema: [type]_[playnext]_[key=track]... - .setDescription(`Duration - ${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`) - .setEmoji(emojis[count-1]) - ) - count++ - } - } - - if (results.playlists && interaction.options.getSubcommand() != "playnext") { - for (var item of results.playlists) { - if (count > 10) break - - let date = new Date(item.duration) - embedFields.push({ name: `[${count}] ${item.type.charAt(0).toUpperCase() + item.type.slice(1)} Result (${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()})`, value: `${item.title}` }) - - actionmenu.components[0].addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(item.title.length > 100 ? `${item.title.substring(0, 97)}...` : item.title) - .setValue(`${item.type}_${interaction.options.getSubcommand() == "playnext" ? "true" : "false"}_key=${item.key}`) // Schema: [type]_[playnext]_[url=track]... - .setDescription(`Duration - ${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`) - .setEmoji(emojis[count-1]) - ) - count++ - } - } - - const searchembed = new EmbedBuilder() - .setAuthor({ name: interaction.client.user.tag, iconURL: interaction.client.user.displayAvatarURL() }) - .setThumbnail(interaction.guild.iconURL({dynamic: true})) - .setTitle(`Plex Search Results 🎵`) - .setDescription('Found multiple songs matching the provided search query, select one form the menu below.') - .addFields(embedFields) - .setColor(client.config.embedColour) - .setTimestamp() - .setFooter({ text: `Requested by: ${interaction.user.discriminator != 0 ? interaction.user.tag : interaction.user.username}` }) - - let actionbutton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("np-delete") - .setStyle(4) - .setLabel("Cancel Search 🗑️"), - ) - - interaction.followUp({ embeds: [searchembed], components: [actionmenu, actionbutton] }) - } - - //There is only one search result, play it direct - else { - var itemFound = await (results.songs ? results.songs[0] : null) || (results.playlists ? results.playlists[0] : null) - //console.log(itemFound) - - if (itemFound.type == 'playlist') { - await musicFuncs.plexAddPlaylist(interaction, itemFound, 'send') - } - - else { - await musicFuncs.plexAddTrack(interaction, interaction.options.getSubcommand() == "playnext" ? true : false, itemFound, 'send') - } - } - } - - catch (err) { - console.log(err) - return interaction.followUp({ content: `❌ | Ooops... something went wrong whilst attempting to play the requested song. Please try again.`, ephemeral: true }) + const childrenResult = await fetch(`${client.config.plexServer}/library/metadata/${album.ratingKey}/children?X-Plex-Token=${client.config.plexAuthtoken}`, { + method: 'GET', + headers: { accept: 'application/json'} + }); + const childrenData = await childrenResult.json(); + album.trackCount = childrenData.MediaContainer.Metadata ? childrenData.MediaContainer.Metadata.length : 0; + } catch (error) { + console.error(`[PLEX_ALBUMS] Error fetching track count for ${album.title}:`, error); + album.trackCount = 0; } } - else if (interaction.options.getSubcommand() === "search") { - if (client.config.enableDjMode) { - if (!interaction.member.roles.cache.has(client.config.djRole)) return interaction.reply({ content: `❌ | DJ Mode is active! You must have the DJ role <@&${client.config.djRole}> to use any music commands!`, ephemeral: true }); + if (albums.length === 1) { + return await showAlbumOrderSelection(interaction, albums[0], member.voice.channel); + } + + const options = albums.map((album, index) => ({ + label: album.title.substring(0, 100), + description: `${album.parentTitle || 'Unknown Artist'} • ${album.trackCount || 0} songs`.substring(0, 100), + value: album.ratingKey + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('plex_album_select') + .setPlaceholder('Select an album to play') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const embed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🔍 Album Search Results') + .setDescription(`Found **${albums.length}** album${albums.length !== 1 ? 's' : ''} for: **${query}**`) + .addFields( + albums.slice(0, 10).map((album, index) => ({ + name: `${index + 1}. ${album.title}`, + value: `${album.parentTitle || 'Unknown Artist'} • ${album.trackCount || 0} songs`, + inline: false + })) + ) + .setFooter({ text: 'Select an album from the menu below' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed], components: [row] }); + + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.customId === 'plex_album_select' && i.user.id === interaction.user.id, + time: 60000 + }); + + collector.on('collect', async i => { + await i.deferUpdate(); + const albumKey = i.values[0]; + const album = albums.find(a => a.ratingKey === albumKey); + await showAlbumOrderSelection(i, album, member.voice.channel); + }); + + collector.on('end', collected => { + if (collected.size === 0) { + interaction.editReply({ components: [] }).catch(() => {}); } - - if (!client.config.enablePlex) { - return interaction.reply({ content: `❌ | Plex is currently disabled! Ask the server admin to enable and configure this in the environment file.`, ephemeral: true }); + }); + + } catch (error) { + console.error('[PLEX_ALBUMS] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Search Error') + .setDescription(`Could not search for albums: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } +} + +async function handlePlaylists(interaction, member) { + try { + const playlistsResult = await fetch(`${client.config.plexServer}/playlists?X-Plex-Token=${client.config.plexAuthtoken}&playlistType=audio`, { + method: 'GET', + headers: { accept: 'application/json'} + }); + + const result = await playlistsResult.json(); + + if (!result.MediaContainer.Metadata || result.MediaContainer.Metadata.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('📋 No Playlists Found') + .setDescription('You don\'t have any playlists on your Plex server.') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const playlists = result.MediaContainer.Metadata.slice(0, 25); + + const options = playlists.map(playlist => ({ + label: playlist.title.substring(0, 100), + description: `${playlist.leafCount} songs • ${Math.floor(playlist.duration / 60000)} min`, + value: playlist.ratingKey + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('plex_playlist_select') + .setPlaceholder('Select a playlist to play') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const embed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🎵 Your Plex Playlists') + .setDescription(`Found **${playlists.length}** playlist${playlists.length !== 1 ? 's' : ''}. Select one to play!`) + .addFields( + playlists.slice(0, 10).map(playlist => ({ + name: playlist.title, + value: `${playlist.leafCount} songs • ${Math.floor(playlist.duration / 60000)} minutes`, + inline: true + })) + ) + .setFooter({ text: 'Select a playlist from the menu below' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed], components: [row] }); + + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.customId === 'plex_playlist_select' && i.user.id === interaction.user.id, + time: 60000 + }); + + collector.on('collect', async i => { + await i.deferUpdate(); + const playlistKey = i.values[0]; + const playlist = playlists.find(p => p.ratingKey === playlistKey); + await showPlaylistOrderSelection(i, playlist, member.voice.channel); + }); + + collector.on('end', collected => { + if (collected.size === 0) { + interaction.editReply({ components: [] }).catch(() => {}); } + }); + + } catch (error) { + console.error('[PLEX_PLAYLISTS] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Error Fetching Playlists') + .setDescription(`Could not fetch your playlists: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } +} + +async function showAlbumOrderSelection(interaction, album, voiceChannel) { + const orderSelectMenu = new StringSelectMenuBuilder() + .setCustomId(`plex_album_order_${album.ratingKey}`) + .setPlaceholder('Select playback order') + .addOptions( + { label: 'Regular Order', description: 'Play in track number order', value: 'regular', emoji: '▶️' }, + { label: 'Reverse Order', description: 'Play in reverse order', value: 'reverse', emoji: '◀️' }, + { label: 'Shuffle', description: 'Play in random order', value: 'shuffle', emoji: '🔀' } + ); + + const orderRow = new ActionRowBuilder().addComponents(orderSelectMenu); + + const orderEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🎵 Select Playback Order') + .setDescription(`**${album.title}** by ${album.parentTitle || 'Unknown Artist'}\n${album.trackCount || 0} tracks\n\nChoose how you want to play this album:`) + .setFooter({ text: 'Will default to Regular Order in 30 seconds' }) + .setTimestamp(); - if (!interaction.member.voice.channelId) return await interaction.reply({ content: "❌ | You are not in a voice channel!", ephemeral: true }); - if (interaction.guild.members.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.members.me.voice.channelId) return await interaction.reply({ content: "❌ | You are not in my voice channel!", ephemeral: true }); - - const query = interaction.options.getString("music"); - await musicFuncs.getQueue(interaction); + await interaction.editReply({ embeds: [orderEmbed], components: [orderRow] }); - try { - var results = await musicFuncs.plexSearchQuery(query); - if (!results.songs && !results.playlists) { - return interaction.reply({ content: `❌ | Ooops... something went wrong, couldn't find the song or playlist with the requested query.`, ephemeral: true }) - } - - //Otherwise something is found so defer reply - await interaction.deferReply(); - - var embedFields = [] - let count = 1 - let emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣','5️⃣', '6️⃣', '7️⃣', '8️⃣','9️⃣', '🔟'] - - var actionmenu = new ActionRowBuilder() - .addComponents( - new StringSelectMenuBuilder() - .setCustomId("plexsearch") - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder('Add an item to queue 👈') - ) + const orderCollector = interaction.channel.createMessageComponentCollector({ + filter: col => col.customId === `plex_album_order_${album.ratingKey}` && col.user.id === interaction.user.id, + time: 30000 + }); + + orderCollector.on('collect', async orderInteraction => { + await orderInteraction.deferUpdate(); + const order = orderInteraction.values[0]; + await loadPlexAlbum(orderInteraction, album, voiceChannel, order); + orderCollector.stop(); + }); + + orderCollector.on('end', async (collected) => { + if (collected.size === 0) { + await loadPlexAlbum(interaction, album, voiceChannel, 'regular'); + } + }); +} + +async function showPlaylistOrderSelection(interaction, playlist, voiceChannel) { + const orderSelectMenu = new StringSelectMenuBuilder() + .setCustomId(`plex_playlist_order_${playlist.ratingKey}`) + .setPlaceholder('Select playback order') + .addOptions( + { label: 'Regular Order', description: 'Play in original order', value: 'regular', emoji: '▶️' }, + { label: 'Reverse Order', description: 'Play in reverse order', value: 'reverse', emoji: '◀️' }, + { label: 'Shuffle', description: 'Play in random order', value: 'shuffle', emoji: '🔀' } + ); + + const orderRow = new ActionRowBuilder().addComponents(orderSelectMenu); + + const orderEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🎵 Select Playback Order') + .setDescription(`**${playlist.title}**\n${playlist.leafCount} songs\n\nChoose how you want to play this playlist:`) + .setFooter({ text: 'Will default to Regular Order in 30 seconds' }) + .setTimestamp(); - if (results.songs) { - for (let item of results.songs) { - if (count > 10) break - - let date = new Date(item.duration) - let songTitle = `${item.parentTitle} - ${item.grandparentTitle}` - embedFields.push({ name: `[${count}] ${item.type.charAt(0).toUpperCase() + item.type.slice(1)} Result (${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()})`, value: songTitle }) - - actionmenu.components[0].addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(songTitle.length > 100 ? `${songTitle.substring(0, 97)}...` : songTitle) - .setValue(`${item.type}_${item.key}`) - .setDescription(`Duration - ${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`) - .setEmoji(emojis[count-1]) - ) - count++ - } - } - - if (results.playlists) { - for (var item of results.playlists) { - if (count > 10) break - - let date = new Date(item.duration) - embedFields.push({ name: `[${count}] ${item.type.charAt(0).toUpperCase() + item.type.slice(1)} Result (${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()})`, value: `${item.title}` }) - - actionmenu.components[0].addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(item.title.length > 100 ? `${item.title.substring(0, 97)}...` : item.title) - .setValue(`${item.type}_${item.key}`) - .setDescription(`Duration - ${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`) - .setEmoji(emojis[count-1]) - ) - count++ - } - } - - const searchembed = new EmbedBuilder() - .setAuthor({ name: interaction.client.user.tag, iconURL: interaction.client.user.displayAvatarURL() }) - .setThumbnail(interaction.guild.iconURL({dynamic: true})) - .setTitle(`Plex Search Results 🎵`) - .addFields(embedFields) - .setColor(client.config.embedColour) - .setTimestamp() - .setFooter({ text: `Requested by: ${interaction.user.discriminator != 0 ? interaction.user.tag : interaction.user.username}` }) - - let actionbutton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("np-delete") - .setStyle(4) - .setLabel("Cancel Search 🗑️"), - ) - - interaction.followUp({ embeds: [searchembed], components: [actionmenu, actionbutton] }) - } + await interaction.editReply({ embeds: [orderEmbed], components: [orderRow] }); - catch (err) { - console.log(err) - return interaction.followUp({ content: `❌ | Ooops... something went wrong whilst attempting to play the requested song. Please try again.`, ephemeral: true }) - } + const orderCollector = interaction.channel.createMessageComponentCollector({ + filter: col => col.customId === `plex_playlist_order_${playlist.ratingKey}` && col.user.id === interaction.user.id, + time: 30000 + }); + + orderCollector.on('collect', async orderInteraction => { + await orderInteraction.deferUpdate(); + const order = orderInteraction.values[0]; + + playlist.type = 'playlist'; + await musicFuncs.plexAddPlaylist(orderInteraction, playlist, 'edit', order); + orderCollector.stop(); + }); + + orderCollector.on('end', async (collected) => { + if (collected.size === 0) { + playlist.type = 'playlist'; + await musicFuncs.plexAddPlaylist(interaction, playlist, 'edit', 'regular'); } - } + }); } -client.on('interactionCreate', async (interaction) => { - if (!interaction.isStringSelectMenu()) return; - if (interaction.customId == "plexsearch") { - await musicFuncs.getQueue(interaction); - var allcomponents = interaction.values; // Schema: [type]_[playnext]_[key=track]... - //console.log(allcomponents) +async function loadPlexAlbum(interaction, album, voiceChannel, order = 'regular') { + try { + const albumDetailsResult = await fetch(`${client.config.plexServer}/library/metadata/${album.ratingKey}/children?X-Plex-Token=${client.config.plexAuthtoken}`, { + method: 'GET', + headers: { accept: 'application/json'} + }); - for await (option of allcomponents) { - var getItemType = option.split('_')[0] - var getPlayNext = option.split('_')[1] != null && option.split('_')[1] == "true" ? true : false - var getItemKey = option.split('key=')[1] + const albumDetails = await albumDetailsResult.json(); + const songs = albumDetails.MediaContainer.Metadata; - var request = await fetch(`${client.config.plexServer}${getItemKey}?X-Plex-Token=${client.config.plexAuthtoken}`, { - method: 'GET', - headers: { accept: 'application/json'} - }) + if (!songs || songs.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('❌ Empty Album') + .setDescription('This album has no songs.') + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed], components: [] }); + return; + } - var result = await request.json() + const loadingEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('⏳ Loading Album') + .setDescription(`Loading **${album.title}** by ${album.parentTitle || 'Unknown Artist'} with ${songs.length} songs in **${order === 'shuffle' ? 'shuffled' : order}** order...`) + .setTimestamp(); + + await interaction.editReply({ embeds: [loadingEmbed], components: [] }); - //Defer update from menu interaction - await interaction.deferUpdate(); + const player = useMainPlayer(); + + let queue = player.nodes.get(interaction.guild.id); + if (!queue) { + queue = player.nodes.create(interaction.guild, { + metadata: { + channel: interaction.channel, + client: interaction.guild.members.me, + requestedBy: interaction.user + }, + selfDeaf: true, + volume: client.config.defaultVolume || 50, + leaveOnEmpty: client.config.leaveOnEmpty || false, + leaveOnEmptyCooldown: client.config.leaveOnEmptyCooldown || 300000, + leaveOnEnd: client.config.leaveOnEnd || false, + leaveOnEndCooldown: client.config.leaveOnEndCooldown || 300000 + }); + } - //Playlist - if (getItemType == 'playlist') { - result.MediaContainer.type = getItemType; - await musicFuncs.plexAddPlaylist(interaction, result.MediaContainer, 'edit') + let songsToAdd = [...songs]; + + if (order === 'reverse') { + songsToAdd.reverse(); + } else if (order === 'shuffle') { + for (let i = songsToAdd.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [songsToAdd[i], songsToAdd[j]] = [songsToAdd[j], songsToAdd[i]]; } + } + + let addedCount = 0; + for (const song of songsToAdd) { + try { + let date = new Date(song.duration); + const newTrack = new Track(player, { + title: song.title, + author: song.grandparentTitle || album.parentTitle || 'Unknown Artist', + url: `${client.config.plexServer}${song.Media[0].Part[0].key}?download=1&X-Plex-Token=${client.config.plexAuthtoken}`, + thumbnail: `${client.config.plexServer}${song.thumb}?download=1&X-Plex-Token=${client.config.plexAuthtoken}`, + duration: `${date.getMinutes()}:${date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds()}`, + views: 0, + playlist: null, + description: album.title || null, + requestedBy: interaction.user, + source: 'arbitrary', + engine: `${client.config.plexServer}${song.Media[0].Part[0].key}?download=1&X-Plex-Token=${client.config.plexAuthtoken}`, + queryType: QueryType.ARBITRARY + }); - //Single song - else { - await musicFuncs.plexAddTrack(interaction, getPlayNext, result.MediaContainer.Metadata[0], 'edit') + queue.addTrack(newTrack); + addedCount++; + } catch (error) { + console.error(`[PLEX] Failed to add song ${song.title}:`, error); } } + + if (!queue.connection) await queue.connect(voiceChannel); + if (!queue.isPlaying()) { + await queue.node.play(); + queue.node.setVolume(client.config.defaultVolume); + } + + const firstSong = songsToAdd[0]; + const orderText = order === 'regular' ? '▶️ Regular' : order === 'reverse' ? '◀️ Reverse' : '🔀 Shuffled'; + const successEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('✅ Album Added') + .setDescription(`Added **${addedCount}** songs from **${album.title}** to the queue!`) + .addFields( + { name: '🎵 Now Playing', value: firstSong.title, inline: false }, + { name: '🎤 Artist', value: album.parentTitle || 'Unknown Artist', inline: true }, + { name: '📊 Total Songs', value: `${addedCount}`, inline: true }, + { name: '🔄 Order', value: orderText, inline: true } + ) + .setTimestamp(); + + let files = []; + if (album.thumb) { + const imageAttachment = await buildImageAttachment(`${client.config.plexServer}${album.thumb}?download=1&X-Plex-Token=${client.config.plexAuthtoken}`, { + name: 'coverimage.jpg', + description: `Cover art for ${album.title}` + }); + successEmbed.setThumbnail('attachment://coverimage.jpg'); + files.push(imageAttachment); + } + + await interaction.editReply({ embeds: [successEmbed], files: files, components: [] }); + + } catch (error) { + console.error('[PLEX_ALBUM_LOAD] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Error Loading Album') + .setDescription(`Failed to load album: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed], components: [] }); } -}) \ No newline at end of file +} diff --git a/commands/music/subsonic.js b/commands/music/subsonic.js new file mode 100644 index 0000000..286e763 --- /dev/null +++ b/commands/music/subsonic.js @@ -0,0 +1,772 @@ +const { SlashCommandBuilder } = require("@discordjs/builders"); +const { EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder } = require("discord.js"); +const { getSubsonicClient, isSubsonicEnabled } = require('../../utils/subsonicAPI'); +const { useMainPlayer, Track, QueryType } = require('discord-player'); +const { buildImageAttachment } = require('../../utils/utilityFunctions'); + +module.exports = { + data: new SlashCommandBuilder() + .setName("subsonic") + .setDescription("Play music from your Subsonic server") + .addSubcommand(subcommand => + subcommand + .setName("play") + .setDescription("Search and play songs from your Subsonic server") + .addStringOption(option => + option.setName("query") + .setDescription("Search for a song, album, or artist") + .setRequired(true)) + .addBooleanOption(option => + option.setName("playnext") + .setDescription("Add song to the front of the queue") + .setRequired(false))) + .addSubcommand(subcommand => + subcommand + .setName("playlists") + .setDescription("View and play your Subsonic playlists")) + .addSubcommand(subcommand => + subcommand + .setName("albums") + .setDescription("Search and play full albums from your Subsonic server") + .addStringOption(option => + option.setName("query") + .setDescription("Search for an album by name or artist") + .setRequired(true))), + cooldown: 5, + async execute(interaction) { + if (client.config.enableDjMode) { + if (!interaction.member.roles.cache.has(client.config.djRole)) { + return interaction.reply({ content: `❌ | DJ Mode is active! You must have the DJ role <@&${client.config.djRole}> to use any music commands!`, ephemeral: true }); + } + } + + await interaction.deferReply(); + + if (!isSubsonicEnabled()) { + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Subsonic Disabled') + .setDescription('Subsonic integration is not enabled on this bot.') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const subsonicClient = getSubsonicClient(); + if (!subsonicClient) { + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Configuration Error') + .setDescription('Subsonic is not properly configured. Please check the bot configuration.') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const member = interaction.guild.members.cache.get(interaction.user.id); + if (!member.voice.channel) { + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Not in Voice Channel') + .setDescription('You need to be in a voice channel to play music!') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'play') { + await handlePlay(interaction, subsonicClient, member); + } else if (subcommand === 'playlists') { + await handlePlaylists(interaction, subsonicClient, member); + } else if (subcommand === 'albums') { + await handleAlbums(interaction, subsonicClient, member); + } + } +}; + +async function handlePlay(interaction, subsonicClient, member) { + const query = interaction.options.getString('query'); + const playNext = interaction.options.getBoolean('playnext') || false; + + try { + const searchResults = await subsonicClient.search(query); + + if (!searchResults.song || searchResults.song.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🔍 No Results Found') + .setDescription(`No songs found for: **${query}**`) + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const songs = searchResults.song.slice(0, 25); + + if (songs.length === 1) { + return await playSong(interaction, subsonicClient, songs[0], member.voice.channel, playNext); + } + + const options = songs.map((song, index) => ({ + label: song.title.substring(0, 100), + description: `${song.artist || 'Unknown'} • ${song.album || 'Unknown Album'}`.substring(0, 100), + value: song.id + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('subsonic_song_select') + .setPlaceholder('Select a song to play') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const embed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🔍 Search Results') + .setDescription(`Found **${songs.length}** song${songs.length !== 1 ? 's' : ''} for: **${query}**`) + .addFields( + songs.slice(0, 10).map((song, index) => ({ + name: `${index + 1}. ${song.title}`, + value: `${song.artist || 'Unknown Artist'} • ${song.album || 'Unknown Album'}`, + inline: false + })) + ) + .setFooter({ text: 'Select a song from the menu below' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed], components: [row] }); + + // Create collector for select menu + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.customId === 'subsonic_song_select' && i.user.id === interaction.user.id, + time: 60000 + }); + + collector.on('collect', async i => { + await i.deferUpdate(); + + const songId = i.values[0]; + const selectedSong = songs.find(s => s.id === songId); + + await playSong(i, subsonicClient, selectedSong, member.voice.channel, playNext); + }); + + collector.on('end', collected => { + if (collected.size === 0) { + interaction.editReply({ components: [] }).catch(() => {}); + } + }); + + } catch (error) { + console.error('[SUBSONIC_PLAY] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Search Error') + .setDescription(`Could not search for songs: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } +} + +async function handlePlaylists(interaction, subsonicClient, member) { + try { + const playlists = await subsonicClient.getPlaylists(); + + if (!playlists || playlists.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('📋 No Playlists Found') + .setDescription('You don\'t have any playlists on your Subsonic server.') + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const options = playlists.slice(0, 25).map(playlist => ({ + label: playlist.name.substring(0, 100), + description: `${playlist.songCount} songs • ${Math.floor(playlist.duration / 60)} min`, + value: playlist.id + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('subsonic_playlist_select') + .setPlaceholder('Select a playlist to play') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const embed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🎵 Your Subsonic Playlists') + .setDescription(`Found **${playlists.length}** playlist${playlists.length !== 1 ? 's' : ''}. Select one to play!`) + .addFields( + playlists.slice(0, 10).map(playlist => ({ + name: playlist.name, + value: `${playlist.songCount} songs • ${Math.floor(playlist.duration / 60)} minutes`, + inline: true + })) + ) + .setFooter({ text: 'Select a playlist from the menu below' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed], components: [row] }); + + // Create collector for select menu + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.customId === 'subsonic_playlist_select' && i.user.id === interaction.user.id, + time: 60000 + }); + + collector.on('collect', async i => { + await i.deferUpdate(); + + try { + const playlistId = i.values[0]; + const playlist = await subsonicClient.getPlaylist(playlistId); + + if (!playlist.entry || playlist.entry.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('❌ Empty Playlist') + .setDescription('This playlist has no songs.') + .setTimestamp(); + + await i.editReply({ embeds: [errorEmbed], components: [] }); + return; + } + + const orderSelectMenu = new StringSelectMenuBuilder() + .setCustomId('subsonic_playlist_order') + .setPlaceholder('Select playback order') + .addOptions( + { label: 'Regular Order', description: 'Play in original order', value: 'regular', emoji: '▶️' }, + { label: 'Reverse Order', description: 'Play in reverse order', value: 'reverse', emoji: '◀️' }, + { label: 'Shuffle', description: 'Play in random order', value: 'shuffle', emoji: '🔀' } + ); + + const orderRow = new ActionRowBuilder().addComponents(orderSelectMenu); + + const orderEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🎵 Select Playback Order') + .setDescription(`**${playlist.name}** (${playlist.entry.length} songs)\n\nChoose how you want to play this playlist:`) + .setFooter({ text: 'Will default to Regular Order in 30 seconds' }) + .setTimestamp(); + + await i.editReply({ embeds: [orderEmbed], components: [orderRow] }); + + const orderCollector = interaction.channel.createMessageComponentCollector({ + filter: col => col.customId === 'subsonic_playlist_order' && col.user.id === interaction.user.id, + time: 30000 + }); + + orderCollector.on('collect', async orderInteraction => { + await orderInteraction.deferUpdate(); + const order = orderInteraction.values[0]; + await loadSubsonicPlaylist(orderInteraction, subsonicClient, playlist, member.voice.channel, order); + orderCollector.stop(); + }); + + orderCollector.on('end', async (collected) => { + if (collected.size === 0) { + await loadSubsonicPlaylist(i, subsonicClient, playlist, member.voice.channel, 'regular'); + } + }); + + } catch (error) { + console.error('[SUBSONIC_PLAYLIST] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Error Loading Playlist') + .setDescription(`Failed to load playlist: ${error.message}`) + .setTimestamp(); + + await i.editReply({ embeds: [errorEmbed], components: [] }); + } + }); + + collector.on('end', collected => { + if (collected.size === 0) { + interaction.editReply({ components: [] }).catch(() => {}); + } + }); + + } catch (error) { + console.error('[SUBSONIC_PLAYLISTS] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Error Fetching Playlists') + .setDescription(`Could not fetch your playlists: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } +} + +async function handleAlbums(interaction, subsonicClient, member) { + const query = interaction.options.getString('query'); + + try { + const searchResults = await subsonicClient.search(query, 0, 25, 0); + + if (!searchResults.album || searchResults.album.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('🔍 No Albums Found') + .setDescription(`No albums found for: **${query}**`) + .setTimestamp(); + + return await interaction.editReply({ embeds: [errorEmbed] }); + } + + const albums = searchResults.album.slice(0, 25); + + if (albums.length === 1) { + const album = await subsonicClient.getAlbum(albums[0].id); + return await showAlbumOrderSelection(interaction, subsonicClient, album, member.voice.channel); + } + + const options = albums.map((album, index) => ({ + label: album.name.substring(0, 100), + description: `${album.artist || 'Unknown Artist'} • ${album.songCount || 0} songs`.substring(0, 100), + value: album.id + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('subsonic_album_select') + .setPlaceholder('Select an album to play') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const embed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🔍 Album Search Results') + .setDescription(`Found **${albums.length}** album${albums.length !== 1 ? 's' : ''} for: **${query}**`) + .addFields( + albums.slice(0, 10).map((album, index) => ({ + name: `${index + 1}. ${album.name}`, + value: `${album.artist || 'Unknown Artist'} • ${album.songCount || 0} songs`, + inline: false + })) + ) + .setFooter({ text: 'Select an album from the menu below' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed], components: [row] }); + + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => i.customId === 'subsonic_album_select' && i.user.id === interaction.user.id, + time: 60000 + }); + + collector.on('collect', async i => { + await i.deferUpdate(); + + const albumId = i.values[0]; + const album = await subsonicClient.getAlbum(albumId); + + await showAlbumOrderSelection(i, subsonicClient, album, member.voice.channel); + }); + + collector.on('end', collected => { + if (collected.size === 0) { + interaction.editReply({ components: [] }).catch(() => {}); + } + }); + + } catch (error) { + console.error('[SUBSONIC_ALBUMS] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Search Error') + .setDescription(`Could not search for albums: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed] }); + } +} + +async function showAlbumOrderSelection(interaction, subsonicClient, album, voiceChannel) { + const orderSelectMenu = new StringSelectMenuBuilder() + .setCustomId('subsonic_album_order') + .setPlaceholder('Select playback order') + .addOptions( + { label: 'Regular Order', description: 'Play in track number order', value: 'regular', emoji: '▶️' }, + { label: 'Reverse Order', description: 'Play in reverse order', value: 'reverse', emoji: '◀️' }, + { label: 'Shuffle', description: 'Play in random order', value: 'shuffle', emoji: '🔀' } + ); + + const orderRow = new ActionRowBuilder().addComponents(orderSelectMenu); + + const orderEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('🎵 Select Playback Order') + .setDescription(`**${album.name}** by ${album.artist || 'Unknown Artist'}\\n${album.song?.length || 0} tracks\\n\\nChoose how you want to play this album:`) + .setFooter({ text: 'Will default to Regular Order in 30 seconds' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [orderEmbed], components: [orderRow] }); + + const orderCollector = interaction.channel.createMessageComponentCollector({ + filter: col => col.customId === 'subsonic_album_order' && col.user.id === interaction.user.id, + time: 30000 + }); + + orderCollector.on('collect', async orderInteraction => { + await orderInteraction.deferUpdate(); + const order = orderInteraction.values[0]; + await loadSubsonicAlbum(orderInteraction, subsonicClient, album, voiceChannel, order); + orderCollector.stop(); + }); + + orderCollector.on('end', async (collected) => { + if (collected.size === 0) { + await loadSubsonicAlbum(interaction, subsonicClient, album, voiceChannel, 'regular'); + } + }); +} + +async function loadSubsonicPlaylist(interaction, subsonicClient, playlist, voiceChannel, order = 'regular') { + try { + const loadingEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('⏳ Loading Playlist') + .setDescription(`Loading **${playlist.name}** with ${playlist.entry.length} songs in **${order === 'shuffle' ? 'shuffled' : order}** order...`) + .setTimestamp(); + + await interaction.editReply({ embeds: [loadingEmbed], components: [] }); + + const player = useMainPlayer(); + + let queue = player.nodes.get(interaction.guild.id); + if (!queue) { + queue = player.nodes.create(interaction.guild, { + metadata: { + channel: interaction.channel, + client: interaction.guild.members.me, + requestedBy: interaction.user + }, + selfDeaf: true, + volume: client.config.defaultVolume || 50, + leaveOnEmpty: client.config.leaveOnEmpty || false, + leaveOnEmptyCooldown: client.config.leaveOnEmptyCooldown || 300000, + leaveOnEnd: client.config.leaveOnEnd || false, + leaveOnEndCooldown: client.config.leaveOnEndCooldown || 300000 + }); + } + + let songsToAdd = [...playlist.entry]; + + if (order === 'reverse') { + songsToAdd.reverse(); + } else if (order === 'shuffle') { + for (let i = songsToAdd.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [songsToAdd[i], songsToAdd[j]] = [songsToAdd[j], songsToAdd[i]]; + } + } + + let addedCount = 0; + for (const song of songsToAdd) { + try { + const streamUrl = subsonicClient.getStreamUrl(song.id); + const duration = song.duration ? `${Math.floor(song.duration / 60)}:${String(song.duration % 60).padStart(2, '0')}` : '0:00'; + + const newTrack = new Track(player, { + title: song.title, + author: song.artist || 'Unknown Artist', + url: streamUrl, + thumbnail: song.coverArt ? subsonicClient.getCoverArtUrl(song.coverArt) : null, + duration: duration, + views: 0, + playlist: null, + description: song.album || null, + requestedBy: interaction.user, + source: 'arbitrary', + engine: streamUrl, + queryType: QueryType.ARBITRARY + }); + + queue.addTrack(newTrack); + addedCount++; + } catch (error) { + console.error(`[SUBSONIC] Failed to add song ${song.title}:`, error); + } + } + + if (!queue.connection) await queue.connect(voiceChannel); + if (!queue.isPlaying()) { + await queue.node.play(); + queue.node.setVolume(client.config.defaultVolume); + } + + const firstSong = songsToAdd[0]; + const orderText = order === 'regular' ? '▶️ Regular' : order === 'reverse' ? '◀️ Reverse' : '🔀 Shuffled'; + const successEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('✅ Playlist Added') + .setDescription(`Added **${addedCount}** songs from **${playlist.name}** to the queue!`) + .addFields( + { name: '🎵 Now Playing', value: firstSong.title, inline: false }, + { name: '🎤 Artist', value: firstSong.artist || 'Unknown Artist', inline: true }, + { name: '📊 Total Songs', value: `${addedCount}`, inline: true }, + { name: '🔄 Order', value: orderText, inline: true } + ) + .setTimestamp(); + + let files = []; + if (firstSong.coverArt) { + const coverArtUrl = subsonicClient.getCoverArtUrl(firstSong.coverArt); + const imageAttachment = await buildImageAttachment(coverArtUrl, { + name: 'coverimage.jpg', + description: `Cover art for ${playlist.name}` + }); + successEmbed.setThumbnail('attachment://coverimage.jpg'); + files.push(imageAttachment); + } + + await interaction.editReply({ embeds: [successEmbed], files: files, components: [] }); + + } catch (error) { + console.error('[SUBSONIC_PLAYLIST_LOAD] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Error Loading Playlist') + .setDescription(`Failed to load playlist: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed], components: [] }); + } +} + +async function loadSubsonicAlbum(interaction, subsonicClient, album, voiceChannel, order = 'regular') { + try { + if (!album.song || album.song.length === 0) { + const errorEmbed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('❌ Empty Album') + .setDescription('This album has no songs.') + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed], components: [] }); + return; + } + + const loadingEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('⏳ Loading Album') + .setDescription(`Loading **${album.name}** by ${album.artist || 'Unknown Artist'} with ${album.song.length} songs in **${order === 'shuffle' ? 'shuffled' : order}** order...`) + .setTimestamp(); + + await interaction.editReply({ embeds: [loadingEmbed], components: [] }); + + const player = useMainPlayer(); + + let queue = player.nodes.get(interaction.guild.id); + if (!queue) { + queue = player.nodes.create(interaction.guild, { + metadata: { + channel: interaction.channel, + client: interaction.guild.members.me, + requestedBy: interaction.user + }, + selfDeaf: true, + volume: client.config.defaultVolume || 50, + leaveOnEmpty: client.config.leaveOnEmpty || false, + leaveOnEmptyCooldown: client.config.leaveOnEmptyCooldown || 300000, + leaveOnEnd: client.config.leaveOnEnd || false, + leaveOnEndCooldown: client.config.leaveOnEndCooldown || 300000 + }); + } + + let songsToAdd = [...album.song]; + + if (order === 'reverse') { + songsToAdd.reverse(); + } else if (order === 'shuffle') { + for (let i = songsToAdd.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [songsToAdd[i], songsToAdd[j]] = [songsToAdd[j], songsToAdd[i]]; + } + } + + let addedCount = 0; + for (const song of songsToAdd) { + try { + const streamUrl = subsonicClient.getStreamUrl(song.id); + const duration = song.duration ? `${Math.floor(song.duration / 60)}:${String(song.duration % 60).padStart(2, '0')}` : '0:00'; + + const newTrack = new Track(player, { + title: song.title, + author: song.artist || album.artist || 'Unknown Artist', + url: streamUrl, + thumbnail: album.coverArt ? subsonicClient.getCoverArtUrl(album.coverArt) : null, + duration: duration, + views: 0, + playlist: null, + description: album.name || null, + requestedBy: interaction.user, + source: 'arbitrary', + engine: streamUrl, + queryType: QueryType.ARBITRARY + }); + + queue.addTrack(newTrack); + addedCount++; + } catch (error) { + console.error(`[SUBSONIC] Failed to add song ${song.title}:`, error); + } + } + + if (!queue.connection) await queue.connect(voiceChannel); + if (!queue.isPlaying()) { + await queue.node.play(); + queue.node.setVolume(client.config.defaultVolume); + } + + const firstSong = songsToAdd[0]; + const orderText = order === 'regular' ? '▶️ Regular' : order === 'reverse' ? '◀️ Reverse' : '🔀 Shuffled'; + const successEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('✅ Album Added') + .setDescription(`Added **${addedCount}** songs from **${album.name}** to the queue!`) + .addFields( + { name: '🎵 Now Playing', value: firstSong.title, inline: false }, + { name: '🎤 Artist', value: album.artist || 'Unknown Artist', inline: true }, + { name: '📊 Total Songs', value: `${addedCount}`, inline: true }, + { name: '🔄 Order', value: orderText, inline: true } + ) + .setTimestamp(); + + let files = []; + if (album.coverArt) { + const coverArtUrl = subsonicClient.getCoverArtUrl(album.coverArt); + const imageAttachment = await buildImageAttachment(coverArtUrl, { + name: 'coverimage.jpg', + description: `Cover art for ${album.name}` + }); + successEmbed.setThumbnail('attachment://coverimage.jpg'); + files.push(imageAttachment); + } + + await interaction.editReply({ embeds: [successEmbed], files: files, components: [] }); + + } catch (error) { + console.error('[SUBSONIC_ALBUM_LOAD] Error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Error Loading Album') + .setDescription(`Failed to load album: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed], components: [] }); + } +} + +async function playSong(interaction, subsonicClient, song, voiceChannel, playNext = false) { + try { + const streamUrl = subsonicClient.getStreamUrl(song.id); + + const loadingEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle('⏳ Loading Song') + .setDescription(`Loading **${song.title}** by ${song.artist || 'Unknown Artist'}...`) + .setTimestamp(); + + await interaction.editReply({ embeds: [loadingEmbed], components: [] }); + + const player = useMainPlayer(); + const duration = song.duration ? `${Math.floor(song.duration / 60)}:${String(song.duration % 60).padStart(2, '0')}` : '0:00'; + + const newTrack = new Track(player, { + title: song.title, + author: song.artist || 'Unknown Artist', + url: streamUrl, + thumbnail: song.coverArt ? subsonicClient.getCoverArtUrl(song.coverArt) : null, + duration: duration, + views: 0, + playlist: null, + description: song.album || null, + requestedBy: interaction.user, + source: 'arbitrary', + engine: streamUrl, + queryType: QueryType.ARBITRARY + }); + + let queue = player.nodes.get(interaction.guild.id); + if (!queue) { + queue = player.nodes.create(interaction.guild, { + metadata: { + channel: interaction.channel, + client: interaction.guild.members.me, + requestedBy: interaction.user + }, + selfDeaf: true, + volume: client.config.defaultVolume || 50, + leaveOnEmpty: client.config.leaveOnEmpty || false, + leaveOnEmptyCooldown: client.config.leaveOnEmptyCooldown || 300000, + leaveOnEnd: client.config.leaveOnEnd || false, + leaveOnEndCooldown: client.config.leaveOnEndCooldown || 300000 + }); + } + + if (playNext) { + queue.insertTrack(newTrack, 0); + } else { + queue.addTrack(newTrack); + } + + if (!queue.connection) await queue.connect(voiceChannel); + if (!queue.isPlaying()) { + await queue.node.play(); + queue.node.setVolume(client.config.defaultVolume); + } + + const queuePosition = playNext ? 'Next in queue' : `Position ${queue.tracks.size} in queue`; + const successEmbed = new EmbedBuilder() + .setColor(client.config.embedColour) + .setTitle(playNext ? '⏭️ Added Next from Subsonic' : '🎵 Added to Queue from Subsonic') + .setDescription(`**${song.title}**`) + .addFields( + { name: '🎤 Artist', value: song.artist || 'Unknown Artist', inline: true }, + { name: '💿 Album', value: song.album || 'Unknown Album', inline: true }, + { name: '⏱️ Duration', value: duration, inline: true }, + { name: '📍 Queue', value: queuePosition, inline: true } + ) + .setTimestamp(); + + let files = []; + if (song.coverArt) { + const coverArtUrl = subsonicClient.getCoverArtUrl(song.coverArt); + const imageAttachment = await buildImageAttachment(coverArtUrl, { + name: 'coverimage.jpg', + description: `Cover art for ${song.title}` + }); + successEmbed.setThumbnail('attachment://coverimage.jpg'); + files.push(imageAttachment); + } + + await interaction.editReply({ embeds: [successEmbed], files: files, components: [] }); + + } catch (error) { + console.error('[SUBSONIC_PLAY] Playback error:', error); + + const errorEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('❌ Playback Error') + .setDescription(`Could not play the song: ${error.message}`) + .setTimestamp(); + + await interaction.editReply({ embeds: [errorEmbed], components: [] }); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 4a4e57e..f227edb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ version: '3' services: - elitemusic: - container_name: 'elite-music' - image: 'thatguyjacobee/elitemusic:latest' + elite-subsonic: + container_name: 'elite-subsonic' + image: 'ripsawuk/elite-subsonic:latest' env_file: - - /path/to/.env + - .env restart: unless-stopped \ No newline at end of file diff --git a/events/interactionCreate.js b/events/interactionCreate.js index f29c071..1a7b5b3 100644 --- a/events/interactionCreate.js +++ b/events/interactionCreate.js @@ -26,7 +26,7 @@ module.exports = { if (curtime < expiration) { const timeleft = (expiration - curtime) / 1000; - return interaction.reply({ content: `⏱️ | Cooldown Alert: Please wait **${Match.ceil(timeleft)}** more seconds before using the **/${command.data.name}** command again!`, ephemeral: true }) + return interaction.reply({ content: `⏱️ | Cooldown Alert: Please wait **${Math.ceil(timeleft)}** more seconds before using the **/${command.data.name}** command again!`, ephemeral: true }) } } @@ -46,10 +46,7 @@ module.exports = { else if (interaction.isStringSelectMenu()) { if (interaction.customId == "select") { - //console.log(interaction.values); - //console.log(interaction) const value = interaction.values[0]; - //console.log(value) const guildid = interaction.guild.id; const dirs = []; @@ -59,11 +56,9 @@ module.exports = { let commands = fs.readdirSync(`./commands/${dir}`).filter(file => file.endsWith(".js")); var cmds = []; commands.map((command) => { - let file = require(`../commands/${dir}/${command}`); - //console.log(file.data.options.length) - //console.log(file.data.options) + let file = require(`../commands/${dir}/${command}`); - if (dir == "configuration" || dir == "utilities") { + if (dir == "configuration" || dir == "utilities") { cmds.push({ name: dir, commands: { @@ -99,7 +94,6 @@ module.exports = { } }); - //console.log(cmds); categories.push(cmds.filter(categ => categ.name === dir)); }) @@ -213,7 +207,6 @@ module.exports = { } } - //Check for button interactions else if (interaction.isButton()) { if (interaction.customId == "queue-delete") { if (client.config.enableDjMode) { @@ -482,7 +475,6 @@ module.exports = { var queue = player.nodes.get(interaction.guild.id); if (!queue || !queue.isPlaying()) return interaction.reply({ content: `❌ | No music is currently being played!`, ephemeral: true }); - // const modal = new ModalBuilder() .setCustomId(`adjust_volume_${interaction.guild.id}`) .setTitle(`Adjsut Volume - Currently at ${queue.node.volume}%`) diff --git a/events/musicevents.js b/events/musicevents.js index 44d5fbe..9cddce3 100644 --- a/events/musicevents.js +++ b/events/musicevents.js @@ -9,110 +9,173 @@ player.events.on("error", (queue, error) => { player.events.on("playerError", (queue, error) => { console.log(`[${queue.guild.name}] (ID:${queue.metadata.channel}) Error emitted from the player: ${error.message}`); - queue.metadata.channel.send({ content: '❌ | Failed to extract the following song... skipping to the next!' }) + + try { + if (queue.guild.members.me.permissionsIn(queue.metadata.channel).has(PermissionFlagsBits.SendMessages)) { + queue.metadata.channel.send({ content: '❌ | Failed to extract the following song... skipping to the next!' }); + } + } catch (err) { + console.log(`[MUSIC_EVENTS] Failed to send player error message: ${err.message}`); + } }) player.events.on("playerStart", async (queue, track) => { - const progress = queue.node.createProgressBar(); - var createBar = progress.replace(/ 0:00/g, ' ◉ LIVE'); - - // Handle the song/playlist cover image - let imageAttachment = await buildImageAttachment(queue.currentTrack.thumbnail, { name: 'coverimage.jpg', description: `Song Cover Image for ${queue.currentTrack.title}` }); - - const npembed = new EmbedBuilder() - .setAuthor({ name: player.client.user.tag, iconURL: player.client.user.displayAvatarURL() }) - .setThumbnail('attachment://coverimage.jpg') - .setColor(client.config.embedColour) - .setTitle(`Starting next song... Now Playing 🎵`) - .setDescription(`${queue.currentTrack.title} ${track.queryType != 'arbitrary' ? `([Link](${queue.currentTrack.url}))` : ''}\n${createBar}`) - .setTimestamp() + async function createNowPlayingEmbed() { + const progress = queue.node.createProgressBar({ + indicator: '🔘', + leftChar: '▬', + rightChar: '▬', + length: 20 + }); + const createBar = progress.replace(/ 0:00/g, ' ◉ LIVE'); + + const queueSize = queue.tracks.size; + const loopMode = queue.repeatMode === 1 ? 'Track' : queue.repeatMode === 2 ? 'Queue' : 'Normal'; + const pauseStatus = queue.node.isPaused() ? 'Paused' : 'Playing'; + + let imageAttachment = await buildImageAttachment(queue.currentTrack.thumbnail, { name: 'coverimage.jpg', description: `Song Cover Image for ${queue.currentTrack.title}` }); + + const npembed = new EmbedBuilder() + .setAuthor({ name: player.client.user.tag, iconURL: player.client.user.displayAvatarURL() }) + .setThumbnail('attachment://coverimage.jpg') + .setColor(client.config.embedColour) + .setTitle(`🎵 Now Playing`) + .setDescription(`**${queue.currentTrack.title}**${track.queryType != 'arbitrary' ? ` ([Link](${queue.currentTrack.url}))` : ''}`) + .addFields( + { name: '🎤 Artist', value: queue.currentTrack.author || 'Unknown', inline: true }, + { name: '⏱️ Duration', value: queue.currentTrack.duration || 'Unknown', inline: true }, + { name: '📊 Status', value: pauseStatus, inline: true }, + { name: '🔊 Volume', value: `${queue.node.volume}%`, inline: true }, + { name: '🔄 Loop Mode', value: loopMode, inline: true }, + { name: '📑 Queue', value: `${queueSize} song${queueSize !== 1 ? 's' : ''}`, inline: true }, + { name: '⏳ Progress', value: createBar, inline: false } + ) + .setTimestamp(); + + if (queue.currentTrack.requestedBy != null) { + npembed.setFooter({ text: `Requested by: ${queue.currentTrack.requestedBy.discriminator != 0 ? queue.currentTrack.requestedBy.tag : queue.currentTrack.requestedBy.username}` }); + } - if (queue.currentTrack.requestedBy != null) { - npembed.setFooter({ text: `Requested by: ${queue.currentTrack.requestedBy.discriminator != 0 ? queue.currentTrack.requestedBy.tag : queue.currentTrack.requestedBy.username}` }) + return { embed: npembed, attachment: imageAttachment }; } - var finalComponents = [ + const finalComponents = [ new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("np-delete") - .setStyle(4) - .setLabel("🗑️"), new ButtonBuilder() .setCustomId("np-back") - .setStyle(1) - .setLabel("⏮️ Previous"), + .setStyle(2) + .setEmoji("⏮️"), new ButtonBuilder() .setCustomId("np-pauseresume") - .setStyle(1) - .setLabel("⏯️ Play/Pause"), + .setStyle(2) + .setEmoji("⏯️"), new ButtonBuilder() .setCustomId("np-skip") - .setStyle(1) - .setLabel("⏭️ Skip"), + .setStyle(2) + .setEmoji("⏭️"), new ButtonBuilder() - .setCustomId("np-clear") - .setStyle(1) - .setLabel("🧹 Clear Queue") + .setCustomId("np-stop") + .setStyle(2) + .setEmoji("⏹️") ), new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("np-volumeadjust") .setStyle(1) - .setLabel("🔊 Adjust Volume"), + .setEmoji("🔊") + .setLabel("Volume"), new ButtonBuilder() .setCustomId("np-loop") .setStyle(1) - .setLabel("🔂 Loop Once"), + .setEmoji("🔄") + .setLabel("Loop"), new ButtonBuilder() .setCustomId("np-shuffle") .setStyle(1) - .setLabel("🔀 Shuffle Queue"), + .setEmoji("🔀") + .setLabel("Shuffle"), new ButtonBuilder() - .setCustomId("np-stop") - .setStyle(1) - .setLabel("🛑 Stop Queue") + .setCustomId("np-clear") + .setStyle(4) + .setEmoji("🧹") + .setLabel("Clear") ) ]; - //Check if bot has message perms - if (!queue.guild.members.me.permissionsIn(queue.metadata.channel).has(PermissionFlagsBits.SendMessages)) return console.log(`No Perms! (ID: ${queue.guild.id})`); - var msg = await queue.metadata.channel.send({ embeds: [npembed], components: finalComponents, files: [imageAttachment] }) + const botPermissions = queue.guild.members.me.permissionsIn(queue.metadata.channel); + const requiredPerms = [PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.AttachFiles]; + const missingPerms = requiredPerms.filter(perm => !botPermissions.has(perm)); - // Dyanmically remove components, using collector to get upcoming messages and check if they are a queue-related event. - const filter = (collectorMsg) => { - // If the message is an embed, check if it's a queue-related event and if so return true - if (collectorMsg.embeds[0]) { - if (collectorMsg.embeds[0].title == "Starting next song... Now Playing 🎵" || collectorMsg.embeds[0].title == "Stopped music 🛑" || collectorMsg.embeds[0].title == "Disconnecting 🛑" || collectorMsg.embeds[0].title == "Ending playback 🛑" || collectorMsg.embeds[0].title == "Queue Finished 🛑") { - return true; - } - } - - // Otherwise return false - return false; + if (missingPerms.length > 0) { + console.log(`[MUSIC_EVENTS] Missing permissions in ${queue.metadata.channel.name} (${queue.guild.name}): ${missingPerms.map(p => Object.keys(PermissionFlagsBits).find(k => PermissionFlagsBits[k] === p)).join(', ')}`); + return; + } + + const initialEmbed = await createNowPlayingEmbed(); + var msg; + try { + msg = await queue.metadata.channel.send({ embeds: [initialEmbed.embed], components: finalComponents, files: [initialEmbed.attachment] }); + } catch (err) { + console.log(`[MUSIC_EVENTS] Failed to send now playing message: ${err.message}`); + return; } - const collector = queue.metadata.channel.createMessageCollector({ filter, limit: 1, time: queue.currentTrack.durationMS * 3 }) - - //Remove the buttons if the next song event runs (due to song skip... etc) - collector.on('collect', async () => { - try { - msg.edit({ components: [] }) - } - catch (err) { - console.log(`Now playing msg no longer exists! (ID: ${queue.guild.id})`); + const UPDATE_INTERVAL_MS = 5000; // 5 seconds - safe and smooth + + const updateInterval = setInterval(async () => { + if (!queue.isPlaying() || !queue.currentTrack || queue.currentTrack.id !== track.id) { + clearInterval(updateInterval); + return; } - }) - //Remove the buttons once it expires - collector.on('end', async () => { try { - msg.edit({ components: [] }) + const progress = queue.node.createProgressBar({ + indicator: '🔘', + leftChar: '▬', + rightChar: '▬', + length: 20 + }); + const createBar = progress.replace(/ 0:00/g, ' ◉ LIVE'); + const queueSize = queue.tracks.size; + const loopMode = queue.repeatMode === 1 ? 'Track' : queue.repeatMode === 2 ? 'Queue' : 'Normal'; + const pauseStatus = queue.node.isPaused() ? 'Paused' : 'Playing'; + + const updatedEmbed = new EmbedBuilder() + .setAuthor({ name: player.client.user.tag, iconURL: player.client.user.displayAvatarURL() }) + .setThumbnail('attachment://coverimage.jpg') + .setColor(client.config.embedColour) + .setTitle(`🎵 Now Playing`) + .setDescription(`**${queue.currentTrack.title}**${track.queryType != 'arbitrary' ? ` ([Link](${queue.currentTrack.url}))` : ''}`) + .addFields( + { name: '🎤 Artist', value: queue.currentTrack.author || 'Unknown', inline: true }, + { name: '⏱️ Duration', value: queue.currentTrack.duration || 'Unknown', inline: true }, + { name: '📊 Status', value: pauseStatus, inline: true }, + { name: '🔊 Volume', value: `${queue.node.volume}%`, inline: true }, + { name: '🔄 Loop Mode', value: loopMode, inline: true }, + { name: '📑 Queue', value: `${queueSize} song${queueSize !== 1 ? 's' : ''}`, inline: true }, + { name: '⏳ Progress', value: createBar, inline: false } + ) + .setTimestamp(); + + if (queue.currentTrack.requestedBy != null) { + updatedEmbed.setFooter({ text: `Requested by: ${queue.currentTrack.requestedBy.discriminator != 0 ? queue.currentTrack.requestedBy.tag : queue.currentTrack.requestedBy.username}` }); + } + + await msg.edit({ embeds: [updatedEmbed], components: finalComponents }); + } catch (err) { + clearInterval(updateInterval); } + }, UPDATE_INTERVAL_MS); - catch (err) { - console.log(`Now playing msg no longer exists! (ID: ${queue.guild.id})`); + const trackDuration = track.durationMS || track.duration || 600000; + setTimeout(() => { + clearInterval(updateInterval); + try { + msg.edit({ components: [] }).catch(() => {}); + } catch (err) { + console.log(`[MUSIC_EVENTS] Now playing msg no longer exists! (ID: ${queue.guild.id})`); } - }) + }, trackDuration + 5000); // 5 seconds after song ends }) player.events.on("disconnect", (queue) => { @@ -124,9 +187,13 @@ player.events.on("disconnect", (queue) => { .setDescription(`I've been inactive for a period of time!`) .setTimestamp() - //Check if bot has message perms if (!queue.guild.members.me.permissionsIn(queue.metadata.channel).has(PermissionFlagsBits.SendMessages)) return console.log(`No Perms! (ID: ${queue.guild.id})`); - queue.metadata.channel.send({ embeds: [disconnectedembed] }) + + try { + queue.metadata.channel.send({ embeds: [disconnectedembed] }); + } catch (err) { + console.log(`[MUSIC_EVENTS] Failed to send disconnect message: ${err.message}`); + } }) player.events.on("emptyChannel", (queue) => { @@ -140,7 +207,12 @@ player.events.on("emptyChannel", (queue) => { //Check if bot has message perms if (!queue.guild.members.me.permissionsIn(queue.metadata.channel).has(PermissionFlagsBits.SendMessages)) return console.log(`No Perms! (ID: ${queue.guild.id})`); - queue.metadata.channel.send({ embeds: [emptyembed] }) + + try { + queue.metadata.channel.send({ embeds: [emptyembed] }); + } catch (err) { + console.log(`[MUSIC_EVENTS] Failed to send empty channel message: ${err.message}`); + } }) player.events.on("emptyQueue", (queue) => { @@ -154,5 +226,10 @@ player.events.on("emptyQueue", (queue) => { //Check if bot has message perms if (!queue.guild.members.me.permissionsIn(queue.metadata.channel).has(PermissionFlagsBits.SendMessages)) return console.log(`No Perms! (ID: ${queue.guild.id})`); - queue.metadata.channel.send({ embeds: [endembed] }) + + try { + queue.metadata.channel.send({ embeds: [endembed] }); + } catch (err) { + console.log(`[MUSIC_EVENTS] Failed to send queue finished message: ${err.message}`); + } }) \ No newline at end of file diff --git a/events/ready.js b/events/ready.js index 2b5a403..e390ad0 100644 --- a/events/ready.js +++ b/events/ready.js @@ -74,9 +74,23 @@ module.exports = { ? client.config.plexAuthtoken : (String(process.env.PLEX_AUTHTOKEN) ? process.env.PLEX_AUTHTOKEN : client.config.plexAuthtoken); - //Perform validation checks + client.config.enableSubsonic = typeof (process.env.ENABLE_SUBSONIC) === 'undefined' + ? client.config.enableSubsonic + : (String(process.env.ENABLE_SUBSONIC) === 'true' ? true : false); + + client.config.subsonicUrl = typeof (process.env.SUBSONIC_URL) === 'undefined' + ? client.config.subsonicUrl + : (String(process.env.SUBSONIC_URL) ? process.env.SUBSONIC_URL : client.config.subsonicUrl); + + client.config.subsonicUsername = typeof (process.env.SUBSONIC_USERNAME) === 'undefined' + ? client.config.subsonicUsername + : (String(process.env.SUBSONIC_USERNAME) ? process.env.SUBSONIC_USERNAME : client.config.subsonicUsername); + + client.config.subsonicPassword = typeof (process.env.SUBSONIC_PASSWORD) === 'undefined' + ? client.config.subsonicPassword + : (String(process.env.SUBSONIC_PASSWORD) ? process.env.SUBSONIC_PASSWORD : client.config.subsonicPassword); + if (client.config.enablePlex) { - //Abort fetch after 3 seconds const controller = new AbortController(); setTimeout(() => controller.abort("Fetch aborted: Plex Server URL must be invalid as request received no response."), 3000); @@ -86,7 +100,6 @@ module.exports = { signal: controller.signal }) .then(search => { - //401 = Unauthorized, 404 = Not Found, 200 = OK if (search.status == 401) { console.log(`[ELITE_CONFIG] Plex configuration is invalid. Disabling Plex feature... Your Plex Authentication token is not valid.`) client.config.enablePlex = false; @@ -109,6 +122,24 @@ module.exports = { }) } + if (client.config.enableSubsonic) { + const { getSubsonicClient } = require('../utils/subsonicAPI'); + const subsonicClient = getSubsonicClient(); + + if (!subsonicClient) { + console.log(`[ELITE_CONFIG] Subsonic configuration is invalid. Disabling Subsonic feature... Missing URL, username, or password.`) + client.config.enableSubsonic = false; + } else { + try { + await subsonicClient.ping(); + console.log(`[ELITE_CONFIG] Subsonic connection validated successfully!`) + } catch (err) { + console.log(`[ELITE_CONFIG] Subsonic configuration is invalid. Disabling Subsonic feature... Read more in the trace below:\n${err}`) + client.config.enableSubsonic = false; + } + } + } + // Check for an outdated configuration if (process.env.CFG_VERSION == null || process.env.CFG_VERSION != 1.6) { console.log(`[ELITE_CONFIG] Your .ENV configuration file is outdated. This could mean that you may lose out on new functionality or new customisation options. Please check the latest config via https://github.com/ThatGuyJacobee/Elite-Music/blob/main/.env.example or the .env.example file as your bot version is ahead of your configuration version.`) diff --git a/index.js b/index.js index 814d740..eef2fa3 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,14 @@ require("dotenv").config(); const fs = require("fs"); +const crypto = require("crypto"); +global.crypto = crypto; // Make crypto globally available for discord-player const { REST } = require("@discordjs/rest"); const { Client, GatewayIntentBits, Partials, Collection, Routes } = require("discord.js"); const { Player } = require('discord-player'); const { DefaultExtractors } = require('@discord-player/extractor'); const { YoutubeiExtractor } = require('discord-player-youtubei'); client = new Client({ - intents: [ //Sets the necessary intents which discord requires + intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMessages, @@ -26,7 +28,6 @@ client = new Client({ ], }); -//Added logging for exceptions and rejection process.on('uncaughtException', async function(err) { var date = new Date(); console.log(`Caught Exception: ${err.stack}\n`); @@ -39,7 +40,6 @@ process.on('unhandledRejection', async function(err) { fs.appendFileSync('rejection.txt', `${date.toGMTString()}: ${err.stack}\n`); }); -//Discord-Player initialisation const defaultConsts = require(`./utils/defaultConsts`); const player = new Player(client, { smoothVolume: process.env.SMOOTH_VOLUME, @@ -50,20 +50,18 @@ player.extractors.register(YoutubeiExtractor, { authentication: process.env.YT_CREDS ? process.env.YT_CREDS : null, }) -//Initialise commands through JSON const commands = []; -client.commands = new Collection(); //Creates new command collection +client.commands = new Collection(); fs.readdirSync("./commands/").forEach((dir) => { const commandFiles = fs.readdirSync(`./commands/${dir}`).filter(file => file.endsWith(".js")); - for (const file of commandFiles) { //For each file, retrieve name/desc and push it as JSON + for (const file of commandFiles) { const command = require(`./commands/${dir}/${file}`); client.commands.set(command.data.name, command); commands.push(command.data.toJSON()); } }) -//Register all of the commands client.once('ready', async function() { console.log(`[ELITE_CONFIG] Loading Configuration... (Config Version: ${process.env.CFG_VERSION || 'N/A'})`) const rest = new REST({ version: "10" }).setToken(process.env.TOKEN); @@ -76,8 +74,8 @@ client.once('ready', async function() { } }) -const eventFiles = fs.readdirSync("./events").filter(file => file.endsWith(".js")); //Searches all .js files -for (const file of eventFiles) { //For each file, check if the event is .once or .on and execute it as specified within the event file itself +const eventFiles = fs.readdirSync("./events").filter(file => file.endsWith(".js")); +for (const file of eventFiles) { const event = require(`./events/${file}`); if (event.once) { client.once(event.name, (...args) => event.execute(...args, commands)); @@ -86,7 +84,6 @@ for (const file of eventFiles) { //For each file, check if the event is .once or } } -//Authenticate with Discord via .env passed token if (!process.env.TOKEN) { console.log(`[ELITE_ERROR] The .env file could not be found/doesn't exist. Have you followed the setup instructions correctly (https://github.com/ThatGuyJacobee/Elite-Music) to ensure that you have configured your environment correctly?`) process.exit(0) @@ -97,10 +94,8 @@ client.login(process.env.TOKEN) console.log(`[ELITE_ERROR] Bot could not login and authenticate with Discord. Have you populated your .env file with your bot token and copied it over correctly? (Using token: ${process.env.TOKEN})\nError Trace: ${err}`); }) -//Verbose logging for debugging purposes const verbose = process.env.VERBOSE ? process.env.VERBOSE.toLocaleLowerCase() : "none"; if (verbose == "full" || verbose == "normal") { - //Both normal and full verbose logging will log unhandled rejects, uncaught exceptions and warnings to the console process.on("unhandledRejection", (reason) => console.error(reason)); process.on("uncaughtException", (error) => console.error(error)); process.on("warning", (warning) => console.error(warning)); @@ -108,7 +103,6 @@ if (verbose == "full" || verbose == "normal") { if (verbose == "full") { console.log(`[ELITE_CONFIG] Verbose logging enabled and set to full. This will log everything to the console, including: discord-player debugging, unhandled rejections, uncaught exceptions and warnings to the console.`) - //Full verbose logging will also log everything from discord-player to the console console.log(player.scanDeps());player.on('debug',console.log).events.on('debug',(_,m)=>console.log(m)); } diff --git a/package-lock.json b/package-lock.json index fa07988..9488b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "elite-bot-music", + "name": "elite-subsonic", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "elite-bot-music", + "name": "elite-subsonic", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index ff4cdb1..3ff345a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "elite-bot-music", + "name": "elite-subsonic", "version": "1.0.0", "description": "Elite Bot's original music feature moved to a separate discord bot.", "main": "index.js", @@ -20,7 +20,8 @@ "discord.js": "^14.22.1", "dotenv": "^17.2.1", "ffmpeg-static": "5.2.0", - "ms": "2.1.3" + "ms": "2.1.3", + "axios": "^1.6.0" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/utils/defaultConsts.js b/utils/defaultConsts.js index 1f60474..b64c9ed 100644 --- a/utils/defaultConsts.js +++ b/utils/defaultConsts.js @@ -16,7 +16,11 @@ const defaultConsts = { djRole: 1234567891011, enablePlex: false, plexServer: '', - plexAuthtoken: '' + plexAuthtoken: '', + enableSubsonic: false, + subsonicUrl: '', + subsonicUsername: '', + subsonicPassword: '' }, ytdlOptions: { filter: 'audioonly', diff --git a/utils/sharedFunctions.js b/utils/sharedFunctions.js index 9ba9058..8f39758 100644 --- a/utils/sharedFunctions.js +++ b/utils/sharedFunctions.js @@ -194,14 +194,26 @@ async function plexAddTrack(interaction, nextSong, itemMetadata, responseType) { } } -async function plexAddPlaylist(interaction, itemMetadata, responseType) { +async function plexAddPlaylist(interaction, itemMetadata, responseType, order = 'regular') { var request = await fetch(`${client.config.plexServer}/playlists/${itemMetadata.ratingKey}/items?X-Plex-Token=${client.config.plexAuthtoken}`, { method: 'GET', headers: { accept: 'application/json'} }) var result = await request.json() - for await (var item of result.MediaContainer.Metadata) { + let songsToAdd = result.MediaContainer.Metadata; + + if (order === 'reverse') { + songsToAdd = [...songsToAdd].reverse(); + } else if (order === 'shuffle') { + songsToAdd = [...songsToAdd]; + for (let i = songsToAdd.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [songsToAdd[i], songsToAdd[j]] = [songsToAdd[j], songsToAdd[i]]; + } + } + + for await (var item of songsToAdd) { let date = new Date(item.duration) //console.log(item) var newTrack = new Track(player, { @@ -229,10 +241,10 @@ async function plexAddPlaylist(interaction, itemMetadata, responseType) { } } - await plexQueuePlay(interaction, responseType, itemMetadata, result.MediaContainer.Metadata[0].thumb) + await plexQueuePlay(interaction, responseType, itemMetadata, songsToAdd[0].thumb, null, order) } -async function plexQueuePlay(interaction, responseType, itemMetadata, defaultThumbnail, nextSong) { +async function plexQueuePlay(interaction, responseType, itemMetadata, defaultThumbnail, nextSong, order = 'regular') { var queue = await getQueue(interaction); try { @@ -265,7 +277,9 @@ async function plexQueuePlay(interaction, responseType, itemMetadata, defaultThu } if (itemMetadata.type == 'playlist') { + const orderText = order === 'regular' ? '▶️ Regular' : order === 'reverse' ? '◀️ Reverse' : '🔀 Shuffled'; embed.setDescription(`Imported the **${itemMetadata.title} playlist** with **${itemMetadata.leafCount}** songs and started to play the queue!`) + embed.addFields({ name: '🔄 Order', value: orderText, inline: true }) } else { diff --git a/utils/subsonicAPI.js b/utils/subsonicAPI.js new file mode 100644 index 0000000..106ca8b --- /dev/null +++ b/utils/subsonicAPI.js @@ -0,0 +1,225 @@ +const crypto = require('crypto'); +const axios = require('axios'); + +/** + * Subsonic API Client for interacting with Subsonic-compatible music servers + */ +class SubsonicAPI { + constructor(baseUrl, username, password) { + this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash + this.username = username; + this.password = password; + this.client = 'EliteMusic'; + this.version = '1.16.1'; // Subsonic API version + } + + /** + * Generate authentication parameters using token-based auth (more secure) + */ + getAuthParams() { + const salt = crypto.randomBytes(16).toString('hex'); + const token = crypto.createHash('md5').update(this.password + salt).digest('hex'); + + return { + u: this.username, + t: token, + s: salt, + v: this.version, + c: this.client, + f: 'json' + }; + } + + /** + * Make a request to the Subsonic API + */ + async request(endpoint, params = {}) { + try { + const authParams = this.getAuthParams(); + const allParams = { ...authParams, ...params }; + + const response = await axios.get(`${this.baseUrl}/rest/${endpoint}`, { + params: allParams, + timeout: 10000 + }); + + if (response.data['subsonic-response'].status === 'ok') { + return response.data['subsonic-response']; + } else { + throw new Error(response.data['subsonic-response'].error.message); + } + } catch (error) { + if (error.response) { + throw new Error(`Subsonic API Error: ${error.response.data?.['subsonic-response']?.error?.message || error.message}`); + } + throw new Error(`Connection Error: ${error.message}`); + } + } + + /** + * Test connection and credentials (ping) + */ + async ping() { + try { + await this.request('ping'); + return true; + } catch (error) { + throw error; + } + } + + /** + * Get all playlists for the authenticated user + */ + async getPlaylists() { + try { + const response = await this.request('getPlaylists'); + return response.playlists?.playlist || []; + } catch (error) { + throw error; + } + } + + /** + * Get playlist details with all songs + */ + async getPlaylist(playlistId) { + try { + const response = await this.request('getPlaylist', { id: playlistId }); + return response.playlist; + } catch (error) { + throw error; + } + } + + /** + * Search for songs, albums, or artists + */ + async search(query, artistCount = 10, albumCount = 10, songCount = 20) { + try { + const response = await this.request('search3', { + query: query, + artistCount: artistCount, + albumCount: albumCount, + songCount: songCount + }); + return response.searchResult3 || {}; + } catch (error) { + throw error; + } + } + + /** + * Get a stream URL for a song + */ + getStreamUrl(songId) { + const authParams = this.getAuthParams(); + const params = new URLSearchParams({ + ...authParams, + id: songId + }); + + return `${this.baseUrl}/rest/stream?${params.toString()}`; + } + + /** + * Get song details + */ + async getSong(songId) { + try { + const response = await this.request('getSong', { id: songId }); + return response.song; + } catch (error) { + throw error; + } + } + + /** + * Get album details + */ + async getAlbum(albumId) { + try { + const response = await this.request('getAlbum', { id: albumId }); + return response.album; + } catch (error) { + throw error; + } + } + + /** + * Get cover art URL + */ + getCoverArtUrl(coverArtId, size = 300) { + if (!coverArtId) return null; + + const authParams = this.getAuthParams(); + const params = new URLSearchParams({ + ...authParams, + id: coverArtId, + size: size + }); + + return `${this.baseUrl}/rest/getCoverArt?${params.toString()}`; + } + + /** + * Get random songs + */ + async getRandomSongs(count = 10, genre = null) { + try { + const params = { size: count }; + if (genre) params.genre = genre; + + const response = await this.request('getRandomSongs', params); + return response.randomSongs?.song || []; + } catch (error) { + throw error; + } + } + + /** + * Get starred songs (favorites) + */ + async getStarred() { + try { + const response = await this.request('getStarred'); + return response.starred || {}; + } catch (error) { + throw error; + } + } +} + +/** + * Check if Subsonic is enabled in the configuration + */ +function isSubsonicEnabled() { + const enabled = process.env.ENABLE_SUBSONIC; + return enabled && (enabled.toLowerCase() === 'true' || enabled === '1'); +} + +/** + * Get Subsonic client instance from environment variables + */ +function getSubsonicClient() { + if (!isSubsonicEnabled()) { + return null; + } + + const url = process.env.SUBSONIC_URL; + const username = process.env.SUBSONIC_USERNAME; + const password = process.env.SUBSONIC_PASSWORD; + + if (!url || !username || !password) { + console.error('[SUBSONIC] Missing configuration in .env file'); + return null; + } + + return new SubsonicAPI(url, username, password); +} + +module.exports = { + SubsonicAPI, + isSubsonicEnabled, + getSubsonicClient +};