diff --git a/.env.sample b/.env.sample index 28b8223..75e1b80 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,7 @@ # [ENV] Application NODE_PORT NODE_ENV +DEBUG_MODE # [ENV] Discord App DISCORD_APP_LOGIN_TOKEN # [ENV] Discord Bot @@ -9,4 +10,10 @@ DISCORD_BOT_LOGIN_TOKEN # [ENV] AWS AWS_ALEXA_SKILL_ID # [ENV] MongoDB -MONGODB_URI \ No newline at end of file +MONGODB_URI +# [ENV] Deezer +DEEZER_DECRYPTION_KEY +DEEZER_ARL +# [ENV] Spotify +SPOTIFY_CLIENT_ID +SPOTIFY_CLIENT_SECRET \ No newline at end of file diff --git a/.github/workflows/deploy-to-oci.yml b/.github/workflows/deploy-to-oci.yml index 2786b53..6785147 100644 --- a/.github/workflows/deploy-to-oci.yml +++ b/.github/workflows/deploy-to-oci.yml @@ -63,12 +63,17 @@ jobs: echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> $workdir/.env echo "NODE_ENV=${{ secrets.NODE_ENV }}" >> $workdir/.env echo "NODE_PORT=${{ secrets.NODE_PORT }}" >> $workdir/.env + echo "DEBUG_MODE=${{ secrets.DEBUG_MODE }}" >> $workdir/.env echo "DISCORD_APP_LOGIN_TOKEN=${{ secrets.DISCORD_APP_LOGIN_TOKEN }}" >> $workdir/.env echo "DISCORD_BOT_LOGIN_TOKEN=${{ secrets.DISCORD_BOT_LOGIN_TOKEN }}" >> $workdir/.env echo "DISCORD_BOT_OWNER_USER_ID=${{ secrets.DISCORD_BOT_OWNER_USER_ID }}" >> $workdir/.env echo "DISCORD_BOT_PREFIX=°" >> $workdir/.env echo "AWS_ALEXA_SKILL_ID=${{ secrets.AWS_ALEXA_SKILL_ID }}" >> $workdir/.env echo "MONGODB_URI=${{ secrets.MONGODB_URI }}" >> $workdir/.env + echo "DEEZER_DECRYPTION_KEY=${{ secrets.DEEZER_DECRYPTION_KEY }}" >> $workdir/.env + echo "DEEZER_ARL=${{ secrets.DEEZER_ARL }}" >> $workdir/.env + echo "SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}" >> $workdir/.env + echo "SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}" >> $workdir/.env existing_container=$(docker ps -q -f name=$container_name) if [ ! -z "$existing_container" ]; then echo "Stopping and removing the existing $container_name..." diff --git a/.gitignore b/.gitignore index 76d143d..fa37c05 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Enviroments .env +.env.* # npm package-lock.json @@ -35,6 +36,8 @@ lerna-debug.log* *.launch .settings/ *.sublime-workspace +.cursor +.vscode # IDE - VSCode .vscode/* diff --git a/package.json b/package.json index 8f2afa4..cee8c3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.1.0", + "version": "2.2.5", "description": "", "author": "", "private": true, @@ -9,7 +9,7 @@ "build": "nest build", "start": "nest start", "start:dev": "set NODE_OPTIONS=--openssl-legacy-provider && nest start --watch", - "start:debug": "nest start --debug --watch", + "start:debug": "set NODE_OPTIONS=--openssl-legacy-provider && nest start --debug --watch", "start:prod": "node --openssl-legacy-provider dist/main", "test": "jest --runInBand", "test:watch": "jest --watch --coverage --runInBand", @@ -40,6 +40,7 @@ "cache-manager": "5.7.6", "discord-player": "7.1.0", "discord-player-deezer": "2.5.0", + "discord-player-spotify": "1.1.2", "discord-player-youtubei": "1.4.3", "discord.js": "14.18.0", "mongoose": "^8.7.3", diff --git a/src/bot/bot.gateway.ts b/src/bot/bot.gateway.ts index b6ef74a..50b4d53 100644 --- a/src/bot/bot.gateway.ts +++ b/src/bot/bot.gateway.ts @@ -7,7 +7,7 @@ import { InjectDiscordClient, InteractionEvent, On, Once } from '@discord-nestjs import { Injectable, Logger } from '@nestjs/common'; import { IMemberVoiceState } from '@shared/interfaces/memberVoiceState.interface'; import { ActivityType, ButtonInteraction, Client, MessageFlags, VoiceState } from 'discord.js'; -import { VALIDATOR_MESSAGES } from './constants/messages.constant'; +import { FRIENDLY_ERROR_MESSAGES } from './constants/messages.constant'; import { getNowPlayingButtons } from './utils/player.utils'; @Injectable() @@ -61,7 +61,6 @@ export class BotGateway { } case PlayerButtonActionId.SKIP: { await channel.sendTyping(); - await this.disableButtonInteraction(interaction); this._playerService.skip({ guildId }); await interaction.deferUpdate(); return; @@ -77,7 +76,7 @@ export class BotGateway { private async handleInvalidButtonInteraction(interaction: ButtonInteraction): Promise { await this.disableButtonInteraction(interaction); - await interaction.reply({ content: VALIDATOR_MESSAGES.NO_ACTIVE_QUEUE, flags: MessageFlags.Ephemeral }); + await interaction.reply({ content: FRIENDLY_ERROR_MESSAGES.NO_ACTIVE_QUEUE, flags: MessageFlags.Ephemeral }); } private async initPresence(): Promise { diff --git a/src/bot/commands/misc/help.command.ts b/src/bot/commands/misc/help.command.ts index ab9b026..1923d07 100644 --- a/src/bot/commands/misc/help.command.ts +++ b/src/bot/commands/misc/help.command.ts @@ -5,7 +5,7 @@ import { Prefixcommand } from '@bot/decorators/prefix-command.decorator'; import { CommandValidationType } from '@bot/enums/command-validation.enum'; import { MessageFromUserGuard } from '@bot/guards/message-from-user.guard'; import { CommandService } from '@bot/services/command/command.service'; -import { getDescriptionEmbed } from '@bot/utils/embed.utils'; +import { createInfoEmbed } from '@bot/utils/embed.utils'; import { PrefixCommandInterceptor } from '@discord-nestjs/common'; import { On } from '@discord-nestjs/core'; import { Injectable, Logger, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -39,7 +39,7 @@ export class HelpCommand extends BaseCommand { const embed = this.createHelpEmbed(message.guild, commands); await message.reply({ embeds: [embed] }); } catch (error) { - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR })] }); + await message.reply({ embeds: [createInfoEmbed(FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR)] }); this.logger.error(`[onMessageCreate] Error: ${error.message}`); } } diff --git a/src/bot/commands/music/clear.command.ts b/src/bot/commands/music/clear.command.ts index 2bf22df..2f7876b 100644 --- a/src/bot/commands/music/clear.command.ts +++ b/src/bot/commands/music/clear.command.ts @@ -5,7 +5,7 @@ import { CommandValidationType } from '@bot/enums/command-validation.enum'; import { GuildQueueGuard } from '@bot/guards/guild-queue.guard'; import { MessageFromUserGuard } from '@bot/guards/message-from-user.guard'; import { PlayerService } from '@bot/services/player/player.service'; -import { getDescriptionEmbed } from '@bot/utils/embed.utils'; +import { createInfoEmbed } from '@bot/utils/embed.utils'; import { PrefixCommandInterceptor } from '@discord-nestjs/common'; import { MessageEvent, On } from '@discord-nestjs/core'; import { Injectable, Logger, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -37,9 +37,9 @@ export class ClearCommand extends BaseCommand { try { await message.channel.sendTyping(); this._playerService.clear({ guildId }); - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_OK_MESSAGES.QUEUE_CLEARED })] }); + await message.reply({ embeds: [createInfoEmbed(FRIENDLY_OK_MESSAGES.QUEUE_CLEARED)] }); } catch (error) { - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR })] }); + await message.reply({ embeds: [createInfoEmbed(FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR)] }); this.logger.error(`[onMessageCreate] Error: ${error.message}`); } } diff --git a/src/bot/commands/music/play.command.ts b/src/bot/commands/music/play.command.ts index 7213487..145fda5 100644 --- a/src/bot/commands/music/play.command.ts +++ b/src/bot/commands/music/play.command.ts @@ -1,4 +1,4 @@ -import { FRIENDLY_ERROR_MESSAGES, VALIDATOR_MESSAGES } from '@bot/constants/messages.constant'; +import { FRIENDLY_ERROR_MESSAGES } from '@bot/constants/messages.constant'; import { RequireValidation } from '@bot/decorators/command-validation.decorator'; import { Prefixcommand } from '@bot/decorators/prefix-command.decorator'; import { CommandValidationType } from '@bot/enums/command-validation.enum'; @@ -10,10 +10,12 @@ import { getMemberVoiceChannel, getTextchannelFromCache, } from '@bot/utils/discord.utils'; -import { getDescriptionEmbed } from '@bot/utils/embed.utils'; +import { createErrorEmbed, createWarningEmbed } from '@bot/utils/embed.utils'; +import { isAllowedLinkSearch } from '@bot/utils/player.utils'; import { PrefixCommandInterceptor } from '@discord-nestjs/common'; import { InjectDiscordClient, MessageEvent, On } from '@discord-nestjs/core'; import { Injectable, Logger, UseGuards, UseInterceptors } from '@nestjs/common'; +import { HTTP_PREFIX } from '@shared/constants/misc.constants'; import { BaseCommand } from '@shared/interfaces/command.interface'; import { Client, Message } from 'discord.js'; @@ -41,6 +43,8 @@ export class PlayCommand extends BaseCommand { @UseGuards(MessageFromUserGuard) @UseInterceptors(new PrefixCommandInterceptor('play')) async onMessageCreate(@MessageEvent() message: Message): Promise { + this.logger.log(`[onMessageCreate] Searching for '${message.content}'`); + const query = message.content.trim(); try { await message.channel.sendTyping(); const { guildId, author: requester, channelId } = message; @@ -49,19 +53,28 @@ export class PlayCommand extends BaseCommand { const textChannel = getTextchannelFromCache(guild, channelId); const voiceChannel = getMemberVoiceChannel(member); if (!voiceChannel) { - await message.reply({ embeds: [getDescriptionEmbed({ description: VALIDATOR_MESSAGES.NOT_IN_VOICE_CHANNEL })] }); // biome-ignore format: prettier + await message.reply({ embeds: [createErrorEmbed(FRIENDLY_ERROR_MESSAGES.NOT_IN_VOICE_CHANNEL)] }); return; } - const { isEmpty, tracks, isPlaylist } = await this._playerService.search({ query: message.content, requester }); // biome-ignore format: prettier + if (query.toLocaleLowerCase().startsWith(HTTP_PREFIX)) { + const { isValid, host } = isAllowedLinkSearch(query); + if (!isValid) { + this.logger.error(`[onMessageCreate] Invalid URL: ${query}`); + await message.reply({ embeds: [createErrorEmbed(FRIENDLY_ERROR_MESSAGES[`${host}_LINK_NOT_SUPPORTED_YET`])] }); // biome-ignore format: prettier + return; + } + } + + const { isEmpty, tracks, isPlaylist } = await this._playerService.search({ query, requester }); // biome-ignore format: prettier if (isEmpty) { - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_TRACKS_FOUND })] }); // biome-ignore format: prettier + await message.reply({ embeds: [createWarningEmbed(FRIENDLY_ERROR_MESSAGES.NO_TRACKS_FOUND)] }); // biome-ignore format: prettier return; } await this._playerService.play({ tracks, isPlaylist, requester, textChannel, voiceChannel }); // biome-ignore format: prettier } catch (error) { - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR })] }); + await message.reply({ embeds: [createErrorEmbed(FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR)] }); this.logger.error(`[onMessageCreate] Error: ${error.message}`); } } diff --git a/src/bot/commands/music/shuffle.command.ts b/src/bot/commands/music/shuffle.command.ts index 87fef62..724782f 100644 --- a/src/bot/commands/music/shuffle.command.ts +++ b/src/bot/commands/music/shuffle.command.ts @@ -5,7 +5,7 @@ import { CommandValidationType } from '@bot/enums/command-validation.enum'; import { GuildQueueGuard } from '@bot/guards/guild-queue.guard'; import { MessageFromUserGuard } from '@bot/guards/message-from-user.guard'; import { PlayerService } from '@bot/services/player/player.service'; -import { getDescriptionEmbed } from '@bot/utils/embed.utils'; +import { createInfoEmbed } from '@bot/utils/embed.utils'; import { PrefixCommandInterceptor } from '@discord-nestjs/common'; import { On } from '@discord-nestjs/core'; import { Injectable, Logger, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -37,10 +37,10 @@ export class ShuffleCommand extends BaseCommand { try { await message.channel.sendTyping(); const { isEnabled } = this._playerService.shuffle({ guildId }); - const description = isEnabled ? FRIENDLY_OK_MESSAGES.QUEUE_SHUFFLED : FRIENDLY_OK_MESSAGES.QUEUE_UNSHUFFLED; - await message.reply({ embeds: [getDescriptionEmbed({ description })] }); + const embedDescription = isEnabled ? FRIENDLY_OK_MESSAGES.QUEUE_SHUFFLED : FRIENDLY_OK_MESSAGES.QUEUE_UNSHUFFLED; + await message.reply({ embeds: [createInfoEmbed(embedDescription)] }); } catch (error) { - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR })] }); + await message.reply({ embeds: [createInfoEmbed(FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR)] }); this.logger.error(`[onMessageCreate] Error: ${error.message}`); } } diff --git a/src/bot/commands/music/skip.command.ts b/src/bot/commands/music/skip.command.ts index ac91cd2..b9bc4a2 100644 --- a/src/bot/commands/music/skip.command.ts +++ b/src/bot/commands/music/skip.command.ts @@ -5,7 +5,7 @@ import { CommandValidationType } from '@bot/enums/command-validation.enum'; import { GuildQueueGuard } from '@bot/guards/guild-queue.guard'; import { MessageFromUserGuard } from '@bot/guards/message-from-user.guard'; import { PlayerService } from '@bot/services/player/player.service'; -import { getDescriptionEmbed } from '@bot/utils/embed.utils'; +import { createInfoEmbed } from '@bot/utils/embed.utils'; import { PrefixCommandInterceptor } from '@discord-nestjs/common'; import { On } from '@discord-nestjs/core'; import { Injectable, Logger, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -38,7 +38,7 @@ export class SkipCommand extends BaseCommand { await message.channel.sendTyping(); this._playerService.skip({ guildId }); } catch (error) { - await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR })] }); + await message.reply({ embeds: [createInfoEmbed(FRIENDLY_ERROR_MESSAGES.UNEXPECTED_ERROR)] }); this.logger.error(`[onMessageCreate] Error: ${error.message}`); } } diff --git a/src/bot/constants/embeds-props.constant.ts b/src/bot/constants/embeds-props.constant.ts index 34ece8b..5fcddee 100644 --- a/src/bot/constants/embeds-props.constant.ts +++ b/src/bot/constants/embeds-props.constant.ts @@ -6,3 +6,10 @@ export const USAGE_FIELD_NAME = 'Usage:'; /* Misc */ export const EMPTY_SPACE = '\u200B'; + +export const EMBED_COLORS = { + PRIMARY: 0xf9e30b, + ERROR: 0xff4c4c, + INFO: 0x00b0f4, + WARNING: 0xffa500, +} as const; diff --git a/src/bot/constants/messages.constant.ts b/src/bot/constants/messages.constant.ts index 0089b82..245b1a8 100644 --- a/src/bot/constants/messages.constant.ts +++ b/src/bot/constants/messages.constant.ts @@ -1,15 +1,19 @@ export const FRIENDLY_ERROR_MESSAGES = { NO_TRACKS_FOUND: 'Oops! I could not find any tracks!', UNEXPECTED_ERROR: 'Oops! a unexpected error occurred!', -} as const; - -export const VALIDATOR_MESSAGES = { + NO_RELATED_TRACKS_FOUND: 'Oops! I could not find any related tracks, try adding a new track to the queue!', NOT_IN_VOICE_CHANNEL: 'Oops! connect to a voice channel first!', NO_ACTIVE_QUEUE: 'Oops! there is no active queue!', + UNKNOWN_LINK_NOT_SUPPORTED_YET: 'Oops! this provider is not supported for now!', + SPOTIFY_LINK_NOT_SUPPORTED_YET: 'Oops! Spotify links are not supported for now!', + DEEZER_LINK_NOT_SUPPORTED_YET: 'Oops! Deezer links are not supported for now!', + YOUTUBE_LINK_NOT_SUPPORTED_YET: 'Oops! Youtube links are not supported for now!', } as const; export const FRIENDLY_OK_MESSAGES = { QUEUE_CLEARED: 'The queue has been cleared!', QUEUE_SHUFFLED: 'The queue has been shuffled!', QUEUE_UNSHUFFLED: 'shuffled has been removed!', + FETCHING_RELATED_TRACKS: 'Oops! Queue is empty, fetching related tracks...', + RELATED_TRACKS_FETCHED: (count: number) => `${count} related tracks fetched! Adding to queue...`, } as const; diff --git a/src/bot/constants/player.constants.ts b/src/bot/constants/player.constants.ts index fd9361e..178b861 100644 --- a/src/bot/constants/player.constants.ts +++ b/src/bot/constants/player.constants.ts @@ -7,7 +7,7 @@ export const PLAYER_INIT_OPTIONS: PlayerInitOptions = { export const DEFAULT_PLAYER_OPTIONS: GuildNodeCreateOptions = { volume: 80, - repeatMode: QueueRepeatMode.AUTOPLAY, + repeatMode: QueueRepeatMode.OFF, noEmitInsert: true, leaveOnStop: false, leaveOnEnd: false, diff --git a/src/bot/guards/guild-queue.guard.ts b/src/bot/guards/guild-queue.guard.ts index 6842406..6b20af9 100644 --- a/src/bot/guards/guild-queue.guard.ts +++ b/src/bot/guards/guild-queue.guard.ts @@ -1,9 +1,9 @@ -import { VALIDATOR_MESSAGES } from '@bot/constants/messages.constant'; +import { FRIENDLY_ERROR_MESSAGES } from '@bot/constants/messages.constant'; import { COMMAND_VALIDATION_KEY } from '@bot/decorators/command-validation.decorator'; import { CommandValidationType } from '@bot/enums/command-validation.enum'; import { BaseCommandGuard } from '@bot/guards/base-command.guard'; import { PlayerService } from '@bot/services/player/player.service'; -import { getDescriptionEmbed } from '@bot/utils/embed.utils'; +import { createInfoEmbed } from '@bot/utils/embed.utils'; import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; @@ -24,7 +24,7 @@ export class GuildQueueGuard extends BaseCommandGuard { const queue = this._playerService.getGuildQueue(message.guildId); if (queue) return true; - await message.reply({ embeds: [getDescriptionEmbed({ description: VALIDATOR_MESSAGES.NO_ACTIVE_QUEUE })] }); + await message.reply({ embeds: [createInfoEmbed(FRIENDLY_ERROR_MESSAGES.NO_ACTIVE_QUEUE)] }); return false; } } diff --git a/src/bot/providers/player/player.provider.spec.ts b/src/bot/providers/player/player.provider.spec.ts index 3ef5a95..1277646 100644 --- a/src/bot/providers/player/player.provider.spec.ts +++ b/src/bot/providers/player/player.provider.spec.ts @@ -1,4 +1,6 @@ import { PlayerProvider } from '@bot/providers/player/player.provider'; +import { PlayerService } from '@bot/services/player/player.service'; +import { createPlayerServiceMock } from '@bot/services/player/player.service.mocks'; import { ConfigService } from '@config/config.service'; import { createConfigServiceMock } from '@config/config.service.mocks'; import { INJECT_DISCORD_CLIENT } from '@discord-nestjs/core'; @@ -7,8 +9,29 @@ import { CacheManagerService } from '@services/cache-manager/cache-manager.servi import { createCacheManagerServiceMock } from '@services/cache-manager/cache-manager.service.mocks'; import { createDiscordClientMock } from '@shared/mocks/discord-client.mocks'; +jest.mock('discord.js', () => ({ + ...jest.createMockFromModule('discord.js'), + User: jest.fn().mockImplementation(() => ({ + id: '123456789', + username: 'mockUser', + discriminator: '0000', + bot: false, + system: false, + })), +})); + jest.mock('discord-player', () => ({ ...jest.createMockFromModule('discord-player'), + GuildQueue: jest.fn().mockImplementation(() => ({ + clear: jest.fn(), + toggleShuffle: jest.fn(), + addTrack: jest.fn(), + node: { + setPaused: jest.fn(), + isPaused: jest.fn(), + skip: jest.fn(), + }, + })), })); describe('PlayerProvider', () => { @@ -21,6 +44,7 @@ describe('PlayerProvider', () => { { provide: INJECT_DISCORD_CLIENT, useValue: createDiscordClientMock() }, { provide: CacheManagerService, useValue: createCacheManagerServiceMock() }, { provide: ConfigService, useValue: createConfigServiceMock() }, + { provide: PlayerService, useValue: createPlayerServiceMock() }, ], }).compile(); diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index c2fc720..8026a6e 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -1,81 +1,100 @@ -import { TrackMetadata } from '@bot/types/player.types'; -import { getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; -import { extractTrackInfo, filterAlreadyPlayedTracks, getNowPlayingButtons } from '@bot/utils/player.utils'; +import { FRIENDLY_ERROR_MESSAGES, FRIENDLY_OK_MESSAGES } from '@bot/constants/messages.constant'; +import { PlayerService } from '@bot/services/player/player.service'; +import { PlayCommandRequest, TrackMetadata } from '@bot/services/player/player.service.types'; +import { + createAutoplayEmbed, + createNowPlayingEmbed, + createPlayerErrorEmbed, + createQueuedEmbed, + createTrackErrorEmbed, +} from '@bot/utils/embed.utils'; +import { + getNowPlayingButtons, + getUnplayedTracks, + isTrackRequestedByClient, + overrideTrackRequester, + removeDuplicatedTracks, +} from '@bot/utils/player.utils'; import { ConfigService } from '@config/config.service'; import { InjectDiscordClient } from '@discord-nestjs/core'; -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { CacheManagerService } from '@services/cache-manager/cache-manager.service'; -import { CACHE_KEYS } from '@shared/constants/cache.constants'; -import { SECOND_IN_MS } from '@shared/constants/misc.constants'; +import { CACHE_KEYS, DISABLED_CACHE_TTL } from '@shared/constants/cache.constants'; import { generateNormalizedRandom } from '@shared/utils/misc.utils'; import { GuildQueue, GuildQueueEvent, Player, Track, useMainPlayer } from 'discord-player'; -import { Client, GuildTextBasedChannel, Message, MessageFlags } from 'discord.js'; +import { SpotifyExtractor } from 'discord-player-spotify'; +import { Client, GuildTextBasedChannel, Message, MessageFlags, italic } from 'discord.js'; @Injectable() -export class PlayerProvider implements OnModuleInit { +export class PlayerProvider implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PlayerProvider.name); private readonly player: Player; constructor( @InjectDiscordClient() private readonly discordClient: Client, private readonly cacheManager: CacheManagerService, - private readonly _configService: ConfigService, + private readonly configService: ConfigService, + private readonly playerService: PlayerService, ) { this.player = useMainPlayer(); } async onModuleInit(): Promise { - this.registerTrackEvents(); this.registerPlayerEvents(); this.registerErrorEvents(); + this.logger.log('all events registered'); } - private registerTrackEvents(): void { + private registerPlayerEvents(): void { this.player.events.on(GuildQueueEvent.AudioTrackAdd, this.handleTrackAdd.bind(this)); this.player.events.on(GuildQueueEvent.PlayerStart, this.handlePlayerStart.bind(this)); this.player.events.on(GuildQueueEvent.PlayerFinish, this.handlePlayerFinish.bind(this)); - } - - private registerPlayerEvents(): void { - this.player.events.on(GuildQueueEvent.WillAutoPlay, this.handleAutoPlay.bind(this)); + this.player.events.on(GuildQueueEvent.PlayerSkip, this.handlePlayerSkip.bind(this)); + this.player.events.on(GuildQueueEvent.WillAutoPlay, this.handleWillAutoPlay.bind(this)); + this.player.events.on(GuildQueueEvent.ConnectionDestroyed, this.handleConnectionDestroyed.bind(this)); } private registerErrorEvents(): void { this.player.events.on(GuildQueueEvent.Error, this.handleError.bind(this)); this.player.events.on(GuildQueueEvent.PlayerError, this.handlePlayerError.bind(this)); - if (this._configService.getNodeEnv() === 'LOCAL') { - this.player.events.on(GuildQueueEvent.Debug, this.handleDebug.bind(this)); - } + if (!Number(this.configService.getValue('DEBUG_MODE'))) return; + this.player.events.on(GuildQueueEvent.Debug, (queue, message) => + this.logger.debug(`[${queue.guild.name}] ${message}`), + ); } - private handleDebug(queue: GuildQueue, message: string): void { - this.logger.debug(`[${queue.guild.name}] ${message}`); - } + /** EVENTS HANDLERS */ private async handleTrackAdd(queue: GuildQueue, track: Track): Promise { - this.logger.log(`[audioTrackAdd] Audio track added to ${queue.guild.name}`); + this.logger.log(`[audioTrackAdd] ${track.title} by ${track.author} Added to ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; - const trackInfo = extractTrackInfo(track); - const embed = getQueuedEmbed(trackInfo); + if (isTrackRequestedByClient(track, this.discordClient.user)) return; + const embed = createQueuedEmbed(track); await channel.send({ embeds: [embed] }); } private async handlePlayerStart(queue: GuildQueue, track: Track): Promise { - this.logger.log(`[playerStart] Player started playing [${track.source.toUpperCase()}] ${track.title}`); + this.logger.log(`[playerStart] ${track.title} by ${track.author} started playing in ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; - const message = await this.sendNowPlayingMessage(channel, queue, track); - return this.cacheNowPlayingMessage(track, message); + const { id: messageId } = await this.sendNowPlayingMessage(channel, queue, track); + this.cacheManager.add(CACHE_KEYS.LAST_NOW_PLAYING_MESSAGE, messageId, DISABLED_CACHE_TTL); } private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { - this.logger.log(`[playerFinish] Player finished playing ${track.title}`); + this.logger.log(`[playerFinish] ${track.title} by ${track.author} finished playing in ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; - await this.updateFinishedTrackMessage(channel, track); + const isQueueEmpty = queue.isEmpty(); + this.updateLastNowPlayingMessage(channel); + if (isQueueEmpty) this.handleCustomAutoPlay(queue, track); + } + + private async handlePlayerSkip(queue: GuildQueue, track: Track): Promise { + this.logger.log(`[playerSkip] ${track.title} by ${track.author} skipped in ${queue.guild.name}`); } - private handleAutoPlay(queue: GuildQueue, tracks: Track[], nextTrack: (track: Track) => void): void { - this.logger.log(`[willAutoPlay] Will autoplay ${tracks.length} tracks in ${queue.guild.name}`); - if (tracks.length === 0) { + private handleWillAutoPlay(queue: GuildQueue, tracks: Track[], nextTrack: (track: Track) => void): void { + this.logger.log(`[willAutoPlay] Handling autoplay for ${queue.guild.name}`); + if (!tracks.length) { queue.emit(GuildQueueEvent.EmptyQueue, queue); return; } @@ -83,49 +102,94 @@ export class PlayerProvider implements OnModuleInit { nextTrack(nextRandomTrack); } - private async sendNowPlayingMessage( - channel: GuildTextBasedChannel, - queue: GuildQueue, - track: Track, - ): Promise { + private handleConnectionDestroyed(queue: GuildQueue): void { + this.logger.log(`[connectionDestroyed] ${queue.guild.name} connection destroyed`); + const { channel } = queue.metadata as TrackMetadata; + this.updateLastNowPlayingMessage(channel); + } + + private handleError(queue: GuildQueue, error: Error): void { + this.logger.error(`[handleError] Unexpected error in [${queue.guild.name}]: ${error}`); + const { channel } = queue.metadata as TrackMetadata; + channel.send({ embeds: [createPlayerErrorEmbed(queue, error)] }); + } + + private handlePlayerError(queue: GuildQueue, error: Error, track: Track): void { + this.logger.error(`[playerError] Error playing [${track.title}] in [${queue.guild.name}]: ${error}`); + const { channel } = queue.metadata as TrackMetadata; + channel.send({ embeds: [createTrackErrorEmbed(queue, track, error)] }); + } + + /** HELPER FUNCTIONS */ + // biome-ignore format: prettier + private async sendNowPlayingMessage(channel: GuildTextBasedChannel, queue: GuildQueue, track: Track): Promise { const playerActionsButtons = getNowPlayingButtons(); return channel.send({ - embeds: [getNowPlayingEmbed(queue, track)], + embeds: [createNowPlayingEmbed(queue, track)], components: [playerActionsButtons], flags: MessageFlags.SuppressNotifications, }); } - private async cacheNowPlayingMessage(track: Track, message: Message): Promise { - const { id: trackId, durationMS: trackDurationMS } = track; - const { id: messageId } = message; - const cacheKey = `${CACHE_KEYS.NOW_PLAYING_MESSAGE}-${trackId}`; - await this.cacheManager.add(cacheKey, messageId, trackDurationMS + SECOND_IN_MS); + private async handleCustomAutoPlay(queue: GuildQueue, lastTrack: Track): Promise { + const { channel } = queue.metadata as TrackMetadata; + const message = await channel.send({ + embeds: [createAutoplayEmbed(queue, italic(FRIENDLY_OK_MESSAGES.FETCHING_RELATED_TRACKS))], + }); + const { relatedTracks } = await this.playerService.getRelatedTracks({ extractor: SpotifyExtractor, lastTrack, queue }); // biome-ignore format: prettier + + if (!relatedTracks.length) { + this.logger.warn(`[customHandleAutoPlay] No related tracks found for ${lastTrack.title} by ${lastTrack.author} in ${queue.guild.name}`) // biome-ignore format: prettier + await message.edit({ + embeds: [createAutoplayEmbed(queue, italic(FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND))], + }); + return; + } + + this.logger.log(`[customHandleAutoPlay] Found ${relatedTracks.length} related tracks for ${lastTrack.title} by ${lastTrack.author} in ${queue.guild.name}`) // biome-ignore format: prettier + await message.edit({ + embeds: [createAutoplayEmbed(queue, italic(FRIENDLY_OK_MESSAGES.RELATED_TRACKS_FETCHED(relatedTracks.length)))], + }); + + let filteredTracks = relatedTracks; + filteredTracks = removeDuplicatedTracks(filteredTracks); + filteredTracks = getUnplayedTracks(queue, filteredTracks); + filteredTracks = overrideTrackRequester(filteredTracks, this.discordClient.user); + + const playCommandRequest: PlayCommandRequest = { + tracks: filteredTracks, + textChannel: channel, + voiceChannel: queue.channel, + requester: this.discordClient.user, + isPlaylist: true, + }; + const { guildQueue } = await this.playerService.play(playCommandRequest); + guildQueue.player.events.once(GuildQueueEvent.PlayerStart, () => message.delete()); } - private async updateFinishedTrackMessage(channel: GuildTextBasedChannel, track: Track): Promise { - const cacheKey = `${CACHE_KEYS.NOW_PLAYING_MESSAGE}-${track.id}`; - const messageId = await this.cacheManager.get(cacheKey); + private async updateLastNowPlayingMessage(channel: GuildTextBasedChannel): Promise { + const messageId = await this.cacheManager.get(CACHE_KEYS.LAST_NOW_PLAYING_MESSAGE); if (!messageId) return; - const message = channel.messages.cache.find((msg: Message) => msg.id === messageId); - if (!message) return; + const cachedMessage = channel.messages.cache.find((msg: Message) => msg.id === messageId); + if (!cachedMessage) return; const disabledButtons = getNowPlayingButtons({ disabled: true }); - await message.edit({ components: [disabledButtons] }); + await cachedMessage.edit({ components: [disabledButtons] }); + this.cacheManager.del(CACHE_KEYS.LAST_NOW_PLAYING_MESSAGE); } private selectNextAutoPlayTrack(queue: GuildQueue, tracks: Track[]): Track { - const filteredTracks = filterAlreadyPlayedTracks(queue, tracks); - const relatedTracks = filteredTracks.map((track) => { track.requestedBy = this.discordClient.user; return track; }); // biome-ignore format: prettier + const filteredTracks = getUnplayedTracks(queue, tracks); + const relatedTracks = filteredTracks.map((track) => { + track.requestedBy = this.discordClient.user; + return track; + }); return relatedTracks[Math.floor(generateNormalizedRandom() * relatedTracks.length)]; } - private handleError(queue: GuildQueue, error: Error): void { - this.logger.error(`Error in ${queue.guild.name}: ${error.message}`); - } - - private handlePlayerError(queue: GuildQueue, error: Error, track: Track): void { - this.logger.error(`Error playing ${track.title} in ${queue.guild.name}: ${error.message}`); + async onModuleDestroy(): Promise { + this.player.events.removeAllListeners(); + this.logger.log('All event listeners cleaned up'); } } diff --git a/src/bot/services/player/player.service.constants.ts b/src/bot/services/player/player.service.constants.ts new file mode 100644 index 0000000..26b2a25 --- /dev/null +++ b/src/bot/services/player/player.service.constants.ts @@ -0,0 +1,6 @@ +export const DISALLOWED_SEARCH_HOSTS = new Map([ + ['DEEZER', ['www.deezer.com', 'dzr.page.link']], + ['YOUTUBE', ['www.youtube.com', 'music.youtube.com']], +]); + +export const ALLOWED_SEARCH_HOSTS = new Map([['SPOTIFY', ['open.spotify.com']]]); diff --git a/src/bot/services/player/player.service.interface.ts b/src/bot/services/player/player.service.interface.ts index 9f88d50..0812c30 100644 --- a/src/bot/services/player/player.service.interface.ts +++ b/src/bot/services/player/player.service.interface.ts @@ -1,11 +1,13 @@ import { + GetRelatedTracksRequest, + GetRelatedTracksResponse, PlayCommandRequest, PlayCommandResponse, SearchCommandRequest, SearchCommandResponse, ShuffleCommandResponse, TogglePauseCommandResponse, -} from '@bot/types/player.types'; +} from '@bot/services/player/player.service.types'; import { GuildQueue } from 'discord-player'; export interface IPlayerService { @@ -16,4 +18,5 @@ export interface IPlayerService { shuffle: ({ guildId }: { guildId: string }) => ShuffleCommandResponse; togglePause: ({ guildId }: { guildId: string }) => TogglePauseCommandResponse; skip: ({ guildId }: { guildId: string }) => void; + getRelatedTracks: ({ extractor, lastTrack, queue }: GetRelatedTracksRequest) => Promise; } diff --git a/src/bot/services/player/player.service.mocks.ts b/src/bot/services/player/player.service.mocks.ts index a492409..27262b2 100644 --- a/src/bot/services/player/player.service.mocks.ts +++ b/src/bot/services/player/player.service.mocks.ts @@ -1,6 +1,6 @@ import { IPlayerService } from '@bot/services/player/player.service.interface'; -import { GuildQueueMock, TextChannelMock, UserMock, VoiceChannelMock } from '@shared/mocks/discord-client.mocks'; -import { TrackMock } from '@shared/mocks/discord-player.mocks'; +import { TextChannelMock, UserMock, VoiceChannelMock } from '@shared/mocks/discord-client.mocks'; +import { GuildQueueMock, TrackMock } from '@shared/mocks/discord-player.mocks'; import { PlayerNodeInitializationResult, SearchResult } from 'discord-player'; export const createPlayerServiceMock = (): jest.Mocked => ({ @@ -11,6 +11,7 @@ export const createPlayerServiceMock = (): jest.Mocked => ({ shuffle: jest.fn(), togglePause: jest.fn(), skip: jest.fn(), + getRelatedTracks: jest.fn(), }); export const PLAYER_SEARCH_RESULT_MOCK = { @@ -19,6 +20,7 @@ export const PLAYER_SEARCH_RESULT_MOCK = { isEmpty: jest.fn(), hasPlaylist: jest.fn(), } as unknown as SearchResult; + export const SEARCH_COMMAND_REQUEST_MOCK = { query: 'test', requester: new UserMock() }; export const PLAYER_PLAY_RESULT_MOCK = { @@ -26,6 +28,7 @@ export const PLAYER_PLAY_RESULT_MOCK = { track: TrackMock, searchResult: PLAYER_SEARCH_RESULT_MOCK, } as unknown as PlayerNodeInitializationResult; + export const PLAY_COMMAND_REQUEST_MOCK = { requester: new UserMock(), voiceChannel: new VoiceChannelMock(), diff --git a/src/bot/services/player/player.service.spec.ts b/src/bot/services/player/player.service.spec.ts index a778d77..550b9ff 100644 --- a/src/bot/services/player/player.service.spec.ts +++ b/src/bot/services/player/player.service.spec.ts @@ -12,9 +12,12 @@ import { ENVIRONMENT } from '@config/config.service.constants'; import { createConfigServiceMock } from '@config/config.service.mocks'; import { INJECT_DISCORD_CLIENT } from '@discord-nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; -import { GuildQueueMock, createDiscordClientMock } from '@shared/mocks/discord-client.mocks'; -import { QueryType } from 'discord-player'; +import { createDiscordClientMock } from '@shared/mocks/discord-client.mocks'; +import { GuildQueueMock, TrackMock } from '@shared/mocks/discord-player.mocks'; +import { BaseExtractor, QueryType } from 'discord-player'; import { DeezerExtractor } from 'discord-player-deezer'; +import { SpotifyExtractor } from 'discord-player-spotify'; +import { GetRelatedTracksRequest } from './player.service.types'; jest.mock('discord.js', () => ({ ...jest.createMockFromModule('discord.js'), @@ -33,7 +36,7 @@ jest.mock('discord-player', () => ({ events: { on: jest.fn() }, })), Player: jest.fn().mockImplementation(() => ({ - extractors: { register: jest.fn() }, + extractors: { register: jest.fn(), get: () => ({ getRelatedTracks: jest.fn() }) }, scanDeps: jest.fn(), play: jest.fn(), search: jest.fn(), @@ -82,16 +85,16 @@ describe('PlayerService', () => { registerSpy = jest.spyOn(service['player'].extractors, 'register'); }); - it('should initialize player and register Deezer player extractor', () => { - service.onModuleInit(); + it('should initialize player and register player extractors', async () => { + await service.onModuleInit(); - expect(registerSpy).toHaveBeenCalledWith(DeezerExtractor, { - ...DEEZER_EXTRACTOR_OPTIONS, - decryptionKey: expect.any(String), - arl: expect.any(String), - }); + expect(registerSpy).toHaveBeenCalledWith(DeezerExtractor, { ...DEEZER_EXTRACTOR_OPTIONS, decryptionKey: expect.any(String), arl: expect.any(String) }); // biome-ignore format: prettier + //expect(registerSpy).toHaveBeenCalledWith(SpotifyExtractor, { clientId: expect.any(String), clientSecret: expect.any(String) }); // biome-ignore format: prettier + expect(registerSpy).toHaveBeenCalledWith(SpotifyExtractor, {}); // biome-ignore format: prettier expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.DEEZER_DECRYPTION_KEY); expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.DEEZER_ARL); + // expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.SPOTIFY_CLIENT_ID); + // expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.SPOTIFY_CLIENT_SECRET); expect(configServiceSpy).toHaveBeenCalledTimes(2); }); }); @@ -203,4 +206,42 @@ describe('PlayerService', () => { expect(getGuildQueueSpy).toHaveBeenCalledWith(GUILD_REQ_MOCK.id); }); }); + + describe('getRelatedTracks', () => { + let getRelatedTracksReq: GetRelatedTracksRequest; + let extractorInstanceMock: jest.Mocked>; + let getExtractorSpy: jest.SpyInstance>; + beforeEach(() => { + getRelatedTracksReq = { extractor: DeezerExtractor, lastTrack: new TrackMock(), queue: new GuildQueueMock() }; + extractorInstanceMock = { getRelatedTracks: jest.fn() } as unknown as jest.Mocked>; + getExtractorSpy = jest.spyOn(service['player'].extractors, 'get'); + }); + + it('should get related tracks from extractor successfully', async () => { + extractorInstanceMock.getRelatedTracks.mockReturnValueOnce(Promise.resolve({ tracks: [], playlist: null })); + getExtractorSpy.mockReturnValueOnce(extractorInstanceMock); + + const result = await service.getRelatedTracks(getRelatedTracksReq); + + expect(result).toEqual({ relatedTracks: [] }); + expect(getExtractorSpy).toHaveBeenCalledWith(DeezerExtractor.identifier); + expect(getExtractorSpy).toHaveBeenCalledTimes(1); + expect(extractorInstanceMock.getRelatedTracks).toHaveBeenCalledWith( + getRelatedTracksReq.lastTrack, + getRelatedTracksReq.queue.history, + ); + expect(extractorInstanceMock.getRelatedTracks).toHaveBeenCalledTimes(1); + }); + + it('should throw error if extractor is not found', async () => { + getExtractorSpy.mockReturnValueOnce(undefined); + + await expect(service.getRelatedTracks(getRelatedTracksReq)).rejects.toThrow( + `Extractor ${DeezerExtractor.identifier} not found`, + ); + expect(getExtractorSpy).toHaveBeenCalledWith(DeezerExtractor.identifier); + expect(getExtractorSpy).toHaveBeenCalledTimes(1); + expect(extractorInstanceMock.getRelatedTracks).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index c2fbfec..234fdef 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -1,20 +1,22 @@ import { DEEZER_EXTRACTOR_OPTIONS, DEFAULT_PLAYER_OPTIONS, PLAYER_INIT_OPTIONS } from '@bot/constants/player.constants'; import { IPlayerService } from '@bot/services/player/player.service.interface'; import { + GetRelatedTracksRequest, + GetRelatedTracksResponse, PlayCommandRequest, PlayCommandResponse, SearchCommandRequest, SearchCommandResponse, ShuffleCommandResponse, TogglePauseCommandResponse, -} from '@bot/types/player.types'; +} from '@bot/services/player/player.service.types'; import { ConfigService } from '@config/config.service'; import { ENVIRONMENT } from '@config/config.service.constants'; import { InjectDiscordClient } from '@discord-nestjs/core'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { GuildQueue, Player, PlayerNodeInitializerOptions, QueryType, useQueue } from 'discord-player'; import { DeezerExtractor } from 'discord-player-deezer'; - +import { SpotifyExtractor } from 'discord-player-spotify'; import { Client } from 'discord.js'; @Injectable() @@ -40,6 +42,7 @@ export class PlayerService implements IPlayerService, OnModuleInit { decryptionKey: this.configService.getValue(ENVIRONMENT.DEEZER_DECRYPTION_KEY), arl: this.configService.getValue(ENVIRONMENT.DEEZER_ARL), }); + await this.player.extractors.register(SpotifyExtractor, {}); this.logger.debug(this.player.scanDeps()); } catch (error) { this.logger.error(`[PlayerService] Error initializing player: ${error.message}`); @@ -106,4 +109,12 @@ export class PlayerService implements IPlayerService, OnModuleInit { const guildQueue = this.getGuildQueue(guildId); guildQueue.node.skip(); } + + // biome-ignore format: prettier + public async getRelatedTracks({ extractor, lastTrack, queue }: GetRelatedTracksRequest): Promise { + const extractorInstance = this.player.extractors.get(extractor.identifier); + if (!extractorInstance) throw new Error(`Extractor ${extractor.identifier} not found`); + const { tracks } = await extractorInstance.getRelatedTracks(lastTrack, queue.history); + return { relatedTracks: tracks ?? [] }; + } } diff --git a/src/bot/types/player.types.ts b/src/bot/services/player/player.service.types.ts similarity index 63% rename from src/bot/types/player.types.ts rename to src/bot/services/player/player.service.types.ts index 0a76d94..2d28409 100644 --- a/src/bot/types/player.types.ts +++ b/src/bot/services/player/player.service.types.ts @@ -1,6 +1,10 @@ -import { GuildQueue, Playlist, SearchResult, Track } from 'discord-player'; +import { BaseExtractor, GuildQueue, Playlist, SearchResult, Track } from 'discord-player'; import { GuildBasedChannel, GuildTextBasedChannel, User, VoiceBasedChannel } from 'discord.js'; +export type Extractor = { identifier: string } & (new (...args: unknown[]) => BaseExtractor); +export type GetRelatedTracksRequest = { extractor: Extractor; lastTrack: Track; queue: GuildQueue }; +export type GetRelatedTracksResponse = { relatedTracks: Track[] }; + export type SearchCommandRequest = { query: string; requester: User; @@ -43,8 +47,5 @@ export type TogglePauseCommandResponse = { isPaused: boolean; }; -export type TrackInfo = { - trackTitle: string; - trackUrl: string; - requester: User; -}; +export type TrackInfo = Pick & { requester: User }; +export type IsAllowedLinkSearchResult = { isValid: boolean; host: string }; diff --git a/src/bot/types/embed.types.ts b/src/bot/types/embed.types.ts deleted file mode 100644 index 58bb41f..0000000 --- a/src/bot/types/embed.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { User } from 'discord.js'; - -export type QueueEmbedProps = { - trackTitle: string; - trackUrl: string; - requester: User; -}; diff --git a/src/bot/types/utils.types.ts b/src/bot/types/utils.types.ts index 1e40232..53b49c3 100644 --- a/src/bot/types/utils.types.ts +++ b/src/bot/types/utils.types.ts @@ -1,3 +1,7 @@ +import { Embed } from 'discord.js'; + export type NowPlayingButtonOptions = { disabled?: boolean; }; + +export type BaseEmbedOptions = Partial>; diff --git a/src/bot/utils/embed.utils.ts b/src/bot/utils/embed.utils.ts index b27c3cc..4b65435 100644 --- a/src/bot/utils/embed.utils.ts +++ b/src/bot/utils/embed.utils.ts @@ -1,19 +1,71 @@ -import { QueueEmbedProps } from '@bot/types/embed.types'; +import { EMBED_COLORS } from '@bot/constants/embeds-props.constant'; +import { BaseEmbedOptions } from '@bot/types/utils.types'; import { GuildQueue, Track } from 'discord-player'; import { EmbedBuilder, inlineCode } from 'discord.js'; -export const getQueuedEmbed = ({ requester, trackTitle, trackUrl }: QueueEmbedProps): EmbedBuilder => { - return new EmbedBuilder().setDescription( - `Queued - [${inlineCode(trackTitle)}](${trackUrl})\n-# Requested by <@${requester.id}>`, - ); +// biome-ignore format: prettier +const createBaseEmbed = ({ description, author, footer, color = EMBED_COLORS.PRIMARY }: BaseEmbedOptions): EmbedBuilder => { + const embed = new EmbedBuilder() + .setDescription(description) + .setColor(color); + if (author) embed.setAuthor(author); + if (footer) embed.setFooter(footer); + return embed; }; -export const getNowPlayingEmbed = (queue: GuildQueue, track: Track, requester = track.requestedBy): EmbedBuilder => { - return new EmbedBuilder() - .setAuthor({ name: `Now Playing | ${queue.guild.name}`, iconURL: queue.guild.iconURL() }) - .setDescription(`[${inlineCode(track.title)}](${track.url})\n-# Requested by <@${requester.id}>`); +/* Embeds */ +export const createInfoEmbed = (description: string): EmbedBuilder => { + return createBaseEmbed({ description, color: EMBED_COLORS.INFO }); }; -export const getDescriptionEmbed = ({ description }: { description: string }): EmbedBuilder => { - return new EmbedBuilder().setDescription(description); +export const createErrorEmbed = (description: string): EmbedBuilder => { + return createBaseEmbed({ description, color: EMBED_COLORS.ERROR }); +}; + +export const createWarningEmbed = (description: string): EmbedBuilder => { + return createBaseEmbed({ description, color: EMBED_COLORS.WARNING }); +}; + +export const createQueuedEmbed = (track: Track): EmbedBuilder => { + return createBaseEmbed({ + description: `Queued - ${createTrackDescription(track)}`, + color: EMBED_COLORS.INFO, + }); +}; + +export const createNowPlayingEmbed = (queue: GuildQueue, track: Track): EmbedBuilder => { + return createBaseEmbed({ + description: createTrackDescription(track), + author: { name: `Now Playing | ${queue.guild.name}`, iconURL: queue.guild.iconURL() }, + }); +}; + +export const createAutoplayEmbed = (queue: GuildQueue, description: string): EmbedBuilder => { + return createBaseEmbed({ + description, + author: { name: `Autoplaying | ${queue.guild.name}`, iconURL: queue.guild.iconURL() }, + color: EMBED_COLORS.INFO, + }); +}; + +export const createPlayerErrorEmbed = (queue: GuildQueue, error: Error): EmbedBuilder => { + return createBaseEmbed({ + description: `-# :x:Error: ${error.name}`, + author: { name: `Oops! Guild Queue Error | ${queue.guild.name}`, iconURL: queue.guild.iconURL() }, + color: EMBED_COLORS.ERROR, + }); +}; + +export const createTrackErrorEmbed = (queue: GuildQueue, track: Track, error: Error): EmbedBuilder => { + return createBaseEmbed({ + description: `${createTrackDescription(track)}\n-# :x:Error: ${error.name}`, + author: { name: `Oops! Player Track Error | ${queue.guild.name}`, iconURL: queue.guild.iconURL() }, + color: EMBED_COLORS.ERROR, + }); +}; + +/* Embeds utils */ +const createTrackDescription = (track: Track): string => { + const trackTitle = `${track.title} - ${track.author}`; + return `[${inlineCode(trackTitle)}](${track.url})\n-# Requested by <@${track.requestedBy.id}>`; }; diff --git a/src/bot/utils/player.utils.ts b/src/bot/utils/player.utils.ts index 5f5ec2d..95a55ad 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -1,12 +1,13 @@ import { PlayerButtonActionEmoji, PlayerButtonActionId } from '@bot/constants/player.constants'; -import { TrackInfo } from '@bot/types/player.types'; +import { ALLOWED_SEARCH_HOSTS, DISALLOWED_SEARCH_HOSTS } from '@bot/services/player/player.service.constants'; +import { IsAllowedLinkSearchResult } from '@bot/services/player/player.service.types'; import { NowPlayingButtonOptions } from '@bot/types/utils.types'; +import { generateNormalizedRandom } from '@shared/utils/misc.utils'; import { GuildQueue, Track } from 'discord-player'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ClientUser, User } from 'discord.js'; -export const getNowPlayingButtons = ({ - disabled = false, -}: NowPlayingButtonOptions = {}): ActionRowBuilder => { +// biome-ignore format: prettier +export const getNowPlayingButtons = ({ disabled = false }: NowPlayingButtonOptions = {}): ActionRowBuilder => { const playPauseButton = new ButtonBuilder() .setCustomId(PlayerButtonActionId.PLAY_PAUSE) .setEmoji(PlayerButtonActionEmoji.PLAY_PAUSE) @@ -22,15 +23,44 @@ export const getNowPlayingButtons = ({ return new ActionRowBuilder().addComponents(playPauseButton, skipButton); }; -export const filterAlreadyPlayedTracks = (queue: GuildQueue, tracks: Track[]): Track[] => { - return tracks.filter((track) => !queue.history.tracks.some((historyTrack) => historyTrack.url === track.url)); +export const removeDuplicatedTracks = (tracks: Track[]): Track[] => { + return tracks.filter( + (track, index, self) => + index === self.findIndex((t) => t.id === track.id || t.title === track.title || t.url === track.url), + ); }; -export const extractTrackInfo = (track: Track): TrackInfo => { - const { requestedBy, title, url, playlist } = track; - return { - trackTitle: playlist?.title ?? title, - trackUrl: playlist?.url ?? url, - requester: requestedBy, - }; +export const getUnplayedTracks = (queue: GuildQueue, searchedTracks: Track[]): Track[] => { + return searchedTracks.filter( + (track) => + !queue.history.tracks.some( + (historyTrack) => + historyTrack.id === track.id || historyTrack.title === track.title || historyTrack.url === track.url, + ), + ); }; + +export const isAllowedLinkSearch = (url: string): IsAllowedLinkSearchResult => { + const { hostname } = new URL(url); + for (const [key, value] of ALLOWED_SEARCH_HOSTS.entries()) { + if (value.includes(hostname)) return { isValid: true, host: key }; + } + + for (const [key, value] of DISALLOWED_SEARCH_HOSTS.entries()) { + if (value.includes(hostname)) return { isValid: false, host: key }; + } + + return { isValid: false, host: 'UNKNOWN' }; +}; + +export const overrideTrackRequester = (tracks: Track[], requester: User): Track[] => + tracks.map((track) => { + track.requestedBy = requester; + return track; + }); + +export const isTrackRequestedByClient = (track: Track, client: ClientUser): boolean => + track.requestedBy.id === client.id; + +export const getRandomTrack = (tracks: Track[]): Track => + tracks[Math.floor(generateNormalizedRandom() * tracks.length)]; diff --git a/src/config/config.service.constants.ts b/src/config/config.service.constants.ts index 797dfed..8a0544c 100644 --- a/src/config/config.service.constants.ts +++ b/src/config/config.service.constants.ts @@ -10,4 +10,6 @@ export const ENVIRONMENT: Record = { MONGODB_URI: 'MONGODB_URI', DEEZER_DECRYPTION_KEY: 'DEEZER_DECRYPTION_KEY', DEEZER_ARL: 'DEEZER_ARL', + SPOTIFY_CLIENT_ID: 'SPOTIFY_CLIENT_ID', + SPOTIFY_CLIENT_SECRET: 'SPOTIFY_CLIENT_SECRET', } as const; diff --git a/src/config/config.service.interface.ts b/src/config/config.service.interface.ts index c7631bf..436f46e 100644 --- a/src/config/config.service.interface.ts +++ b/src/config/config.service.interface.ts @@ -8,6 +8,7 @@ export interface IConfigService { export type IConfigKey = { NODE_ENV: string; NODE_PORT: string; + DEBUG_MODE: string; DISCORD_APP_LOGIN_TOKEN: string; DISCORD_BOT_LOGIN_TOKEN: string; DISCORD_BOT_OWNER_USER_ID: string; @@ -15,6 +16,8 @@ export type IConfigKey = { MONGODB_URI: string; DEEZER_DECRYPTION_KEY: string; DEEZER_ARL: string; + SPOTIFY_CLIENT_ID: string; + SPOTIFY_CLIENT_SECRET: string; }; export type NodeEnv = 'LOCAL' | 'PROD'; diff --git a/src/shared/constants/cache.constants.ts b/src/shared/constants/cache.constants.ts index 2d2226b..574db80 100644 --- a/src/shared/constants/cache.constants.ts +++ b/src/shared/constants/cache.constants.ts @@ -1,7 +1,8 @@ import { SECOND_IN_MS } from './misc.constants'; export const CACHE_KEYS = { - NOW_PLAYING_MESSAGE: 'amzbot:now-playing-message', + LAST_NOW_PLAYING_MESSAGE: 'amzbot:last-now-playing-message', } as const; export const DEFAULT_CACHE_TTL = SECOND_IN_MS * 60; +export const DISABLED_CACHE_TTL = 0; diff --git a/src/shared/constants/misc.constants.ts b/src/shared/constants/misc.constants.ts index bb22f5b..e452782 100644 --- a/src/shared/constants/misc.constants.ts +++ b/src/shared/constants/misc.constants.ts @@ -1 +1,3 @@ export const SECOND_IN_MS = 1000; + +export const HTTP_PREFIX = 'https://'; diff --git a/src/shared/mocks/discord-client.mocks.ts b/src/shared/mocks/discord-client.mocks.ts index ebeece0..988f616 100644 --- a/src/shared/mocks/discord-client.mocks.ts +++ b/src/shared/mocks/discord-client.mocks.ts @@ -1,4 +1,3 @@ -import { GuildQueue } from 'discord-player'; import { TextChannel, User, VoiceChannel } from 'discord.js'; export const createDiscordClientMock = (): jest.Mocked => ({ @@ -9,4 +8,3 @@ export const createDiscordClientMock = (): jest.Mocked => ({ export const UserMock = User as jest.Mock; export const VoiceChannelMock = VoiceChannel as jest.Mock; export const TextChannelMock = TextChannel as jest.Mock; -export const GuildQueueMock = GuildQueue as jest.Mock; diff --git a/src/shared/mocks/discord-player.mocks.ts b/src/shared/mocks/discord-player.mocks.ts index 6f98c55..669098c 100644 --- a/src/shared/mocks/discord-player.mocks.ts +++ b/src/shared/mocks/discord-player.mocks.ts @@ -1,3 +1,4 @@ -import { Track } from 'discord-player'; +import { GuildQueue, Track } from 'discord-player'; -export const TrackMock = jest.mocked(Track); +export const GuildQueueMock = GuildQueue as jest.Mock; +export const TrackMock = Track as unknown as jest.Mock;