From 6d4866ec4d3c870633543f503e47d0be978d8351 Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 10 Apr 2025 20:22:13 -0400 Subject: [PATCH 01/26] feat: Integrate Spotify extractor and enhance player functionality with AutoPlay related track handling --- package.json | 1 + src/bot/constants/messages.constant.ts | 2 + src/bot/constants/player.constants.ts | 2 +- src/bot/providers/player/player.provider.ts | 61 ++++++++++++++++++++- src/bot/services/player/player.service.ts | 2 + 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8f2afa4..fc24cdb 100644 --- a/package.json +++ b/package.json @@ -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/constants/messages.constant.ts b/src/bot/constants/messages.constant.ts index 0089b82..ba93bc0 100644 --- a/src/bot/constants/messages.constant.ts +++ b/src/bot/constants/messages.constant.ts @@ -1,6 +1,7 @@ export const FRIENDLY_ERROR_MESSAGES = { NO_TRACKS_FOUND: 'Oops! I could not find any tracks!', UNEXPECTED_ERROR: 'Oops! a unexpected error occurred!', + NO_RELATED_TRACKS_FOUND: 'Oops! I could not find any related tracks, try adding a new track to the queue!', } as const; export const VALIDATOR_MESSAGES = { @@ -12,4 +13,5 @@ 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!', + AUTO_PLAY_HANDLED: 'Oops! no more tracks in queue, fetching related tracks for you!', } 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/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index c2fc720..8bc9f20 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -1,5 +1,7 @@ +import { FRIENDLY_ERROR_MESSAGES, FRIENDLY_OK_MESSAGES } from '@bot/constants/messages.constant'; +import { PlayerService } from '@bot/services/player/player.service'; import { TrackMetadata } from '@bot/types/player.types'; -import { getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; +import { getDescriptionEmbed, getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; import { extractTrackInfo, filterAlreadyPlayedTracks, getNowPlayingButtons } from '@bot/utils/player.utils'; import { ConfigService } from '@config/config.service'; import { InjectDiscordClient } from '@discord-nestjs/core'; @@ -9,6 +11,7 @@ import { CACHE_KEYS } from '@shared/constants/cache.constants'; import { SECOND_IN_MS } from '@shared/constants/misc.constants'; import { generateNormalizedRandom } from '@shared/utils/misc.utils'; import { GuildQueue, GuildQueueEvent, Player, Track, useMainPlayer } from 'discord-player'; +import { SpotifyExtractor } from 'discord-player-spotify'; import { Client, GuildTextBasedChannel, Message, MessageFlags } from 'discord.js'; @Injectable() @@ -20,6 +23,7 @@ export class PlayerProvider implements OnModuleInit { @InjectDiscordClient() private readonly discordClient: Client, private readonly cacheManager: CacheManagerService, private readonly _configService: ConfigService, + private readonly playerService: PlayerService, ) { this.player = useMainPlayer(); } @@ -69,6 +73,12 @@ export class PlayerProvider implements OnModuleInit { private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { this.logger.log(`[playerFinish] Player finished playing ${track.title}`); + const isQueueEmpty = queue.isEmpty(); + if (isQueueEmpty) this.customHandleAutoPlay(queue, track); + } + + private async handlePlayerSkip(queue: GuildQueue, track: Track): Promise { + this.logger.log(`[playerSkip] Player skipped ${track.title}`); const { channel } = queue.metadata as TrackMetadata; await this.updateFinishedTrackMessage(channel, track); } @@ -83,6 +93,7 @@ export class PlayerProvider implements OnModuleInit { nextTrack(nextRandomTrack); } + /** HELPER FUNCTIONS */ private async sendNowPlayingMessage( channel: GuildTextBasedChannel, queue: GuildQueue, @@ -96,11 +107,57 @@ export class PlayerProvider implements OnModuleInit { }); } + private async customHandleAutoPlay(queue: GuildQueue, track: Track): Promise { + this.logger.log( + `[customHandleAutoPlay] Fetching related tracks for ${track.title}, queue is Empty: ${queue.isEmpty()}`, + ); + const { channel } = queue.metadata as TrackMetadata; + const message = await channel.send({ + embeds: [getDescriptionEmbed({ description: FRIENDLY_OK_MESSAGES.AUTO_PLAY_HANDLED })], + }); + const relatedTracks = await this.getRelatedTracks(track, queue); + if (!relatedTracks.length) { + await message.edit({ + embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND })], + }); + return; + } + if (relatedTracks.length) { + this.logger.log(`[customHandleAutoPlay] Found ${relatedTracks.length} related tracks for ${track.title}`); + } + this.playerService.play({ + requester: this.discordClient.user, + tracks: relatedTracks, + textChannel: channel, + voiceChannel: queue.channel, + }); + } + + private async getRelatedTracks(track: Track, queue: GuildQueue): Promise { + const { tracks = [] } = await this.player.extractors + .get(SpotifyExtractor.identifier) + .getRelatedTracks(track, queue.history); + return this.filterAlreadyPlayedTracks(queue, this.overrideTrackRequestedBy([])); + } + + private filterAlreadyPlayedTracks(queue: GuildQueue, tracks: Track[]): Track[] { + return tracks.filter( + (track) => !queue.history.tracks.some((t) => t.id === track.id || t.title === track.title || t.url === track.url), + ); + } + + private overrideTrackRequestedBy(tracks: Track[] = []): Track[] { + return tracks.map((track) => { + track.requestedBy = this.discordClient.user; + return track; + }); + } + 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); + await this.cacheManager.add(cacheKey, messageId, trackDurationMS + 60 * SECOND_IN_MS); } private async updateFinishedTrackMessage(channel: GuildTextBasedChannel, track: Track): Promise { diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index c2fbfec..5f84b85 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -14,6 +14,7 @@ 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'; @@ -40,6 +41,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}`); From ad53cc0fe91bd9fec4068d8f6f9817edefab595a Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 10 Apr 2025 20:30:33 -0400 Subject: [PATCH 02/26] refactor: enhance logging for track player events --- src/bot/providers/player/player.provider.ts | 25 ++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index 8bc9f20..322eb65 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -29,19 +29,16 @@ export class PlayerProvider implements OnModuleInit { } async onModuleInit(): Promise { - this.registerTrackEvents(); this.registerPlayerEvents(); this.registerErrorEvents(); } - 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)); } private registerErrorEvents(): void { @@ -56,8 +53,10 @@ export class PlayerProvider implements OnModuleInit { 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); @@ -65,27 +64,27 @@ export class PlayerProvider implements OnModuleInit { } 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); } 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 isQueueEmpty = queue.isEmpty(); if (isQueueEmpty) this.customHandleAutoPlay(queue, track); } private async handlePlayerSkip(queue: GuildQueue, track: Track): Promise { - this.logger.log(`[playerSkip] Player skipped ${track.title}`); + this.logger.log(`[playerSkip] ${track.title} by ${track.author} skipped in ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; await this.updateFinishedTrackMessage(channel, track); } - 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; } From 713e8ea028426e52e6d1e2e6c157e9cc30d0148d Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 10 Apr 2025 21:27:23 -0400 Subject: [PATCH 03/26] refactor: replace player.types.ts with player.service.types.ts for improved type organization --- src/bot/providers/player/player.provider.ts | 2 +- src/bot/services/player/player.service.interface.ts | 2 +- src/bot/services/player/player.service.ts | 3 +-- .../player/player.service.types.ts} | 6 +++++- src/bot/utils/player.utils.ts | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) rename src/bot/{types/player.types.ts => services/player/player.service.types.ts} (73%) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index 322eb65..9e8ee75 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -1,6 +1,6 @@ import { FRIENDLY_ERROR_MESSAGES, FRIENDLY_OK_MESSAGES } from '@bot/constants/messages.constant'; import { PlayerService } from '@bot/services/player/player.service'; -import { TrackMetadata } from '@bot/types/player.types'; +import { TrackMetadata } from '@bot/services/player/player.service.types'; import { getDescriptionEmbed, getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; import { extractTrackInfo, filterAlreadyPlayedTracks, getNowPlayingButtons } from '@bot/utils/player.utils'; import { ConfigService } from '@config/config.service'; diff --git a/src/bot/services/player/player.service.interface.ts b/src/bot/services/player/player.service.interface.ts index 9f88d50..5344e3d 100644 --- a/src/bot/services/player/player.service.interface.ts +++ b/src/bot/services/player/player.service.interface.ts @@ -5,7 +5,7 @@ import { SearchCommandResponse, ShuffleCommandResponse, TogglePauseCommandResponse, -} from '@bot/types/player.types'; +} from '@bot/services/player/player.service.types'; import { GuildQueue } from 'discord-player'; export interface IPlayerService { diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index 5f84b85..b7ea3f9 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -7,7 +7,7 @@ import { 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'; @@ -15,7 +15,6 @@ 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() diff --git a/src/bot/types/player.types.ts b/src/bot/services/player/player.service.types.ts similarity index 73% rename from src/bot/types/player.types.ts rename to src/bot/services/player/player.service.types.ts index 0a76d94..ae03952 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; track: Track; queue: GuildQueue }; +export type GetRelatedTracksResponse = { relatedTracks: Track[] }; + export type SearchCommandRequest = { query: string; requester: User; diff --git a/src/bot/utils/player.utils.ts b/src/bot/utils/player.utils.ts index 5f5ec2d..ce68f55 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -1,5 +1,5 @@ import { PlayerButtonActionEmoji, PlayerButtonActionId } from '@bot/constants/player.constants'; -import { TrackInfo } from '@bot/types/player.types'; +import { TrackInfo } from '@bot/services/player/player.service.types'; import { NowPlayingButtonOptions } from '@bot/types/utils.types'; import { GuildQueue, Track } from 'discord-player'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; From 389bd5f8c7c222a63a055977dd3118d01cb80eff Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 14 Apr 2025 22:13:44 -0400 Subject: [PATCH 04/26] refactor: streamline player provider methods and enhance related track handling with Spotify integration --- src/bot/providers/player/player.provider.ts | 79 +++++++++---------- .../player/player.service.interface.ts | 3 + src/bot/services/player/player.service.ts | 13 +++ .../services/player/player.service.types.ts | 8 +- src/bot/utils/player.utils.ts | 31 +++++--- 5 files changed, 74 insertions(+), 60 deletions(-) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index 9e8ee75..6b0c0fd 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -1,8 +1,14 @@ -import { FRIENDLY_ERROR_MESSAGES, FRIENDLY_OK_MESSAGES } from '@bot/constants/messages.constant'; +import { FRIENDLY_ERROR_MESSAGES } from '@bot/constants/messages.constant'; import { PlayerService } from '@bot/services/player/player.service'; import { TrackMetadata } from '@bot/services/player/player.service.types'; import { getDescriptionEmbed, getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; -import { extractTrackInfo, filterAlreadyPlayedTracks, getNowPlayingButtons } from '@bot/utils/player.utils'; +import { + getNowPlayingButtons, + getRandomTrack, + getUnplayedTracks, + isTrackRequestedByClient, + overrideTrackRequester, +} from '@bot/utils/player.utils'; import { ConfigService } from '@config/config.service'; import { InjectDiscordClient } from '@discord-nestjs/core'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; @@ -58,8 +64,8 @@ export class PlayerProvider implements OnModuleInit { private async handleTrackAdd(queue: GuildQueue, track: Track): Promise { 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 = getQueuedEmbed(track); await channel.send({ embeds: [embed] }); } @@ -73,7 +79,7 @@ export class PlayerProvider implements OnModuleInit { private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { this.logger.log(`[playerFinish] ${track.title} by ${track.author} finished playing in ${queue.guild.name}`); const isQueueEmpty = queue.isEmpty(); - if (isQueueEmpty) this.customHandleAutoPlay(queue, track); + if (isQueueEmpty) this.handleCustomAutoPlay(queue, track); } private async handlePlayerSkip(queue: GuildQueue, track: Track): Promise { @@ -106,49 +112,35 @@ export class PlayerProvider implements OnModuleInit { }); } - private async customHandleAutoPlay(queue: GuildQueue, track: Track): Promise { - this.logger.log( - `[customHandleAutoPlay] Fetching related tracks for ${track.title}, queue is Empty: ${queue.isEmpty()}`, - ); + private async handleCustomAutoPlay(queue: GuildQueue, lastTrack: Track): Promise { const { channel } = queue.metadata as TrackMetadata; - const message = await channel.send({ - embeds: [getDescriptionEmbed({ description: FRIENDLY_OK_MESSAGES.AUTO_PLAY_HANDLED })], + const { relatedTracks } = await this.playerService.getRelatedTracks({ + extractor: SpotifyExtractor, + lastTrack, + queue, }); - const relatedTracks = await this.getRelatedTracks(track, queue); if (!relatedTracks.length) { - await message.edit({ - embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND })], - }); + this.logger.warn(`[customHandleAutoPlay] No related tracks found for ${lastTrack.title} by ${lastTrack.author} in ${queue.guild.name}`) // biome-ignore format: prettier + channel.send({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND })] }); return; } - if (relatedTracks.length) { - this.logger.log(`[customHandleAutoPlay] Found ${relatedTracks.length} related tracks for ${track.title}`); - } + + this.logger.log(`[customHandleAutoPlay] Found ${relatedTracks.length} related tracks for ${lastTrack.title} by ${lastTrack.author} in ${queue.guild.name}`) // biome-ignore format: prettier + for (const track of relatedTracks) + console.log('[PLAYER PROVIDER] Track: ', { + title: track.title, + author: track.author, + views: track.views, + popularity: track.metadata, + }); + let filteredTracks = relatedTracks; + filteredTracks = getUnplayedTracks(queue, filteredTracks); + filteredTracks = overrideTrackRequester(filteredTracks, this.discordClient.user); this.playerService.play({ - requester: this.discordClient.user, - tracks: relatedTracks, + tracks: [getRandomTrack(filteredTracks)], textChannel: channel, voiceChannel: queue.channel, - }); - } - - private async getRelatedTracks(track: Track, queue: GuildQueue): Promise { - const { tracks = [] } = await this.player.extractors - .get(SpotifyExtractor.identifier) - .getRelatedTracks(track, queue.history); - return this.filterAlreadyPlayedTracks(queue, this.overrideTrackRequestedBy([])); - } - - private filterAlreadyPlayedTracks(queue: GuildQueue, tracks: Track[]): Track[] { - return tracks.filter( - (track) => !queue.history.tracks.some((t) => t.id === track.id || t.title === track.title || t.url === track.url), - ); - } - - private overrideTrackRequestedBy(tracks: Track[] = []): Track[] { - return tracks.map((track) => { - track.requestedBy = this.discordClient.user; - return track; + requester: this.discordClient.user, }); } @@ -172,8 +164,11 @@ export class PlayerProvider implements OnModuleInit { } 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)]; } diff --git a/src/bot/services/player/player.service.interface.ts b/src/bot/services/player/player.service.interface.ts index 5344e3d..0812c30 100644 --- a/src/bot/services/player/player.service.interface.ts +++ b/src/bot/services/player/player.service.interface.ts @@ -1,4 +1,6 @@ import { + GetRelatedTracksRequest, + GetRelatedTracksResponse, PlayCommandRequest, PlayCommandResponse, SearchCommandRequest, @@ -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.ts b/src/bot/services/player/player.service.ts index b7ea3f9..e11cdcc 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -1,6 +1,8 @@ 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, @@ -107,4 +109,15 @@ export class PlayerService implements IPlayerService, OnModuleInit { const guildQueue = this.getGuildQueue(guildId); guildQueue.node.skip(); } + + 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/services/player/player.service.types.ts b/src/bot/services/player/player.service.types.ts index ae03952..c6d133e 100644 --- a/src/bot/services/player/player.service.types.ts +++ b/src/bot/services/player/player.service.types.ts @@ -2,7 +2,7 @@ import { BaseExtractor, GuildQueue, Playlist, SearchResult, Track } from 'discor import { GuildBasedChannel, GuildTextBasedChannel, User, VoiceBasedChannel } from 'discord.js'; export type Extractor = { identifier: string } & (new (...args: unknown[]) => BaseExtractor); -export type GetRelatedTracksRequest = { extractor: Extractor; track: Track; queue: GuildQueue }; +export type GetRelatedTracksRequest = { extractor: Extractor; lastTrack: Track; queue: GuildQueue }; export type GetRelatedTracksResponse = { relatedTracks: Track[] }; export type SearchCommandRequest = { @@ -47,8 +47,4 @@ export type TogglePauseCommandResponse = { isPaused: boolean; }; -export type TrackInfo = { - trackTitle: string; - trackUrl: string; - requester: User; -}; +export type TrackInfo = Pick & { requester: User }; diff --git a/src/bot/utils/player.utils.ts b/src/bot/utils/player.utils.ts index ce68f55..fd8b412 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -1,8 +1,7 @@ import { PlayerButtonActionEmoji, PlayerButtonActionId } from '@bot/constants/player.constants'; -import { TrackInfo } from '@bot/services/player/player.service.types'; import { NowPlayingButtonOptions } from '@bot/types/utils.types'; 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, @@ -22,15 +21,23 @@ 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 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 extractTrackInfo = (track: Track): TrackInfo => { - const { requestedBy, title, url, playlist } = track; - return { - trackTitle: playlist?.title ?? title, - trackUrl: playlist?.url ?? url, - requester: requestedBy, - }; -}; +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(Math.random() * tracks.length)]; From 69fea4c76d33f81869deefe17e76748078a2af7f Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 14 Apr 2025 22:20:05 -0400 Subject: [PATCH 05/26] feat: Add Spotify configuration and integrate client credentials into player service --- .env.sample | 8 +++++++- src/bot/services/player/player.service.ts | 5 ++++- src/config/config.service.constants.ts | 2 ++ src/config/config.service.interface.ts | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.env.sample b/.env.sample index 28b8223..5b74aee 100644 --- a/.env.sample +++ b/.env.sample @@ -9,4 +9,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/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index e11cdcc..6e441ab 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -42,7 +42,10 @@ 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, {}); + await this.player.extractors.register(SpotifyExtractor, { + clientId: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_ID), + clientSecret: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_SECRET), + }); this.logger.debug(this.player.scanDeps()); } catch (error) { this.logger.error(`[PlayerService] Error initializing player: ${error.message}`); 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..f7e460a 100644 --- a/src/config/config.service.interface.ts +++ b/src/config/config.service.interface.ts @@ -15,6 +15,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'; From c65a7000f2a3a36660a5aa7dba569123716f8448 Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 14 Apr 2025 23:06:38 -0400 Subject: [PATCH 06/26] feat: Implement link validation for music search and enhance error handling in PlayCommand --- src/bot/bot.gateway.ts | 5 ++--- src/bot/commands/music/play.command.ts | 21 +++++++++++++++++-- src/bot/constants/messages.constant.ts | 7 ++++--- src/bot/guards/guild-queue.guard.ts | 4 ++-- .../player/player.service.constants.ts | 6 ++++++ .../services/player/player.service.types.ts | 1 + src/bot/utils/player.utils.ts | 21 ++++++++++++++++++- src/shared/constants/misc.constants.ts | 2 ++ 8 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 src/bot/services/player/player.service.constants.ts 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/music/play.command.ts b/src/bot/commands/music/play.command.ts index 7213487..4deb607 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'; @@ -11,9 +11,11 @@ import { getTextchannelFromCache, } from '@bot/utils/discord.utils'; import { getDescriptionEmbed } 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,7 @@ 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}'`); try { await message.channel.sendTyping(); const { guildId, author: requester, channelId } = message; @@ -49,10 +52,24 @@ 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: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NOT_IN_VOICE_CHANNEL })], + }); return; } + const query = message.content.trim(); + if (query.toLocaleLowerCase().startsWith(HTTP_PREFIX)) { + const { isValid, host } = isAllowedLinkSearch(message.content); + if (!isValid) { + this.logger.error(`[validateLinkSearch] Invalid URL: ${message.content}`); + await message.reply({ + embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES[`${host}_LINK_NOT_SUPPORTED_YET`] })], + }); + return; + } + } + const { isEmpty, tracks, isPlaylist } = await this._playerService.search({ query: message.content, requester }); // biome-ignore format: prettier if (isEmpty) { await message.reply({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_TRACKS_FOUND })] }); // biome-ignore format: prettier diff --git a/src/bot/constants/messages.constant.ts b/src/bot/constants/messages.constant.ts index ba93bc0..8b1752d 100644 --- a/src/bot/constants/messages.constant.ts +++ b/src/bot/constants/messages.constant.ts @@ -2,11 +2,12 @@ export const FRIENDLY_ERROR_MESSAGES = { NO_TRACKS_FOUND: 'Oops! I could not find any tracks!', UNEXPECTED_ERROR: 'Oops! a unexpected error occurred!', NO_RELATED_TRACKS_FOUND: 'Oops! I could not find any related tracks, try adding a new track to the queue!', -} as const; - -export const VALIDATOR_MESSAGES = { 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 = { diff --git a/src/bot/guards/guild-queue.guard.ts b/src/bot/guards/guild-queue.guard.ts index 6842406..e5bc83f 100644 --- a/src/bot/guards/guild-queue.guard.ts +++ b/src/bot/guards/guild-queue.guard.ts @@ -1,4 +1,4 @@ -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'; @@ -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: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_ACTIVE_QUEUE })] }); return false; } } 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.types.ts b/src/bot/services/player/player.service.types.ts index c6d133e..2d28409 100644 --- a/src/bot/services/player/player.service.types.ts +++ b/src/bot/services/player/player.service.types.ts @@ -48,3 +48,4 @@ export type TogglePauseCommandResponse = { }; export type TrackInfo = Pick & { requester: User }; +export type IsAllowedLinkSearchResult = { isValid: boolean; host: string }; diff --git a/src/bot/utils/player.utils.ts b/src/bot/utils/player.utils.ts index fd8b412..3c8a208 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -1,5 +1,8 @@ import { PlayerButtonActionEmoji, PlayerButtonActionId } from '@bot/constants/player.constants'; +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, ClientUser, User } from 'discord.js'; @@ -31,6 +34,21 @@ export const getUnplayedTracks = (queue: GuildQueue, searchedTracks: Track[]): T ); }; +export const isAllowedLinkSearch = (url: string): IsAllowedLinkSearchResult => { + const { hostname } = new URL(url); + for (const [key, value] of ALLOWED_SEARCH_HOSTS.entries()) { + console.log(value, hostname); + if (value.includes(hostname)) return { isValid: true, host: key }; + } + + for (const [key, value] of DISALLOWED_SEARCH_HOSTS.entries()) { + console.log(value, hostname); + 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; @@ -40,4 +58,5 @@ export const overrideTrackRequester = (tracks: Track[], requester: User): Track[ export const isTrackRequestedByClient = (track: Track, client: ClientUser): boolean => track.requestedBy.id === client.id; -export const getRandomTrack = (tracks: Track[]): Track => tracks[Math.floor(Math.random() * tracks.length)]; +export const getRandomTrack = (tracks: Track[]): Track => + tracks[Math.floor(generateNormalizedRandom() * tracks.length)]; 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://'; From d78c00d4a8b3dba79c0fa2e406a3ed1c7f58cf6e Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 14 Apr 2025 23:06:57 -0400 Subject: [PATCH 07/26] refactor: Remove unused QueueEmbedProps type and update getQueuedEmbed function parameters --- src/bot/types/embed.types.ts | 7 ------- src/bot/utils/embed.utils.ts | 5 ++--- 2 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 src/bot/types/embed.types.ts 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/utils/embed.utils.ts b/src/bot/utils/embed.utils.ts index b27c3cc..167f951 100644 --- a/src/bot/utils/embed.utils.ts +++ b/src/bot/utils/embed.utils.ts @@ -1,10 +1,9 @@ -import { QueueEmbedProps } from '@bot/types/embed.types'; import { GuildQueue, Track } from 'discord-player'; import { EmbedBuilder, inlineCode } from 'discord.js'; -export const getQueuedEmbed = ({ requester, trackTitle, trackUrl }: QueueEmbedProps): EmbedBuilder => { +export const getQueuedEmbed = ({ requestedBy, title, url }: Track): EmbedBuilder => { return new EmbedBuilder().setDescription( - `Queued - [${inlineCode(trackTitle)}](${trackUrl})\n-# Requested by <@${requester.id}>`, + `Queued - [${inlineCode(title)}](${url})\n-# Requested by <@${requestedBy.id}>`, ); }; From 4cad32fbdd2009631fa780f6173d24983a6fd4c5 Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 14 Apr 2025 23:15:18 -0400 Subject: [PATCH 08/26] refactor: Simplify SpotifyExtractor registration by removing unused client credentials --- src/bot/services/player/player.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index 6e441ab..e11cdcc 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -42,10 +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, { - clientId: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_ID), - clientSecret: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_SECRET), - }); + await this.player.extractors.register(SpotifyExtractor, {}); this.logger.debug(this.player.scanDeps()); } catch (error) { this.logger.error(`[PlayerService] Error initializing player: ${error.message}`); From d5af6b0d5b3a6fb28523ff7503e0402b7f73775c Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 15 Apr 2025 20:33:03 -0400 Subject: [PATCH 09/26] test: Enhance PlayerProvider and PlayerService tests with additional mocks and related track functionality --- .../providers/player/player.provider.spec.ts | 24 ++++++++ .../services/player/player.service.mocks.ts | 7 ++- .../services/player/player.service.spec.ts | 58 +++++++++++++++---- src/shared/mocks/discord-client.mocks.ts | 2 - src/shared/mocks/discord-player.mocks.ts | 5 +- 5 files changed, 80 insertions(+), 16 deletions(-) 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/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..63450c2 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,14 +85,11 @@ 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, {}); expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.DEEZER_DECRYPTION_KEY); expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.DEEZER_ARL); expect(configServiceSpy).toHaveBeenCalledTimes(2); @@ -203,4 +203,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/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; From 999f2c11a2df864089f9e071c71d0492b5ed36ee Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 15 Apr 2025 20:38:18 -0400 Subject: [PATCH 10/26] style: Add comment to ignore formatting for prettier in getRelatedTracks --- src/bot/services/player/player.service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index e11cdcc..234fdef 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -110,11 +110,8 @@ export class PlayerService implements IPlayerService, OnModuleInit { guildQueue.node.skip(); } - public async getRelatedTracks({ - extractor, - lastTrack, - queue, - }: GetRelatedTracksRequest): Promise { + // 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); From 5b74d5deb81634adf0d372257e9c479a7b5409ca Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 15 Apr 2025 20:49:37 -0400 Subject: [PATCH 11/26] feat: Add Deezer and Spotify credentials to deployment gh action workflow --- .github/workflows/deploy-to-oci.yml | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-to-oci.yml b/.github/workflows/deploy-to-oci.yml index 2786b53..c0611a6 100644 --- a/.github/workflows/deploy-to-oci.yml +++ b/.github/workflows/deploy-to-oci.yml @@ -69,6 +69,10 @@ jobs: 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/package.json b/package.json index fc24cdb..b5aa0b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.1.0", + "version": "2.2.0", "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", From 201772f6eedccc1ad0a9432b5d7d341f2d78f207 Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 15 Apr 2025 21:50:32 -0400 Subject: [PATCH 12/26] refactor: Update SpotifyExtractor registration to include client credentials --- package.json | 2 +- src/bot/services/player/player.service.spec.ts | 6 ++++-- src/bot/services/player/player.service.ts | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b5aa0b2..126043d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.2.0", + "version": "2.2.1", "description": "", "author": "", "private": true, diff --git a/src/bot/services/player/player.service.spec.ts b/src/bot/services/player/player.service.spec.ts index 63450c2..7528886 100644 --- a/src/bot/services/player/player.service.spec.ts +++ b/src/bot/services/player/player.service.spec.ts @@ -89,10 +89,12 @@ describe('PlayerService', () => { await service.onModuleInit(); expect(registerSpy).toHaveBeenCalledWith(DeezerExtractor, { ...DEEZER_EXTRACTOR_OPTIONS, decryptionKey: expect.any(String), arl: expect.any(String) }); // biome-ignore format: prettier - expect(registerSpy).toHaveBeenCalledWith(SpotifyExtractor, {}); + expect(registerSpy).toHaveBeenCalledWith(SpotifyExtractor, { clientId: expect.any(String), clientSecret: expect.any(String) }); // biome-ignore format: prettier expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.DEEZER_DECRYPTION_KEY); expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.DEEZER_ARL); - expect(configServiceSpy).toHaveBeenCalledTimes(2); + expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.SPOTIFY_CLIENT_ID); + expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.SPOTIFY_CLIENT_SECRET); + expect(configServiceSpy).toHaveBeenCalledTimes(4); }); }); diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index 234fdef..d449622 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -42,7 +42,10 @@ 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, {}); + await this.player.extractors.register(SpotifyExtractor, { + clientId: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_ID), + clientSecret: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_SECRET), + }); this.logger.debug(this.player.scanDeps()); } catch (error) { this.logger.error(`[PlayerService] Error initializing player: ${error.message}`); From 418ab04eb9ba71f99bcc09e3db83cf173eb70743 Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 15 Apr 2025 22:25:07 -0400 Subject: [PATCH 13/26] refactor: Enhance customAutoPlay track handling by removing duplicates tracks --- package.json | 2 +- src/bot/providers/player/player.provider.ts | 11 ++++------- src/bot/utils/player.utils.ts | 7 +++++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 126043d..93614a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.2.1", + "version": "2.2.2", "description": "", "author": "", "private": true, diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index 6b0c0fd..cc69cf1 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -4,10 +4,10 @@ import { TrackMetadata } from '@bot/services/player/player.service.types'; import { getDescriptionEmbed, getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; import { getNowPlayingButtons, - getRandomTrack, getUnplayedTracks, isTrackRequestedByClient, overrideTrackRequester, + removeDuplicatedTracks, } from '@bot/utils/player.utils'; import { ConfigService } from '@config/config.service'; import { InjectDiscordClient } from '@discord-nestjs/core'; @@ -114,11 +114,7 @@ export class PlayerProvider implements OnModuleInit { private async handleCustomAutoPlay(queue: GuildQueue, lastTrack: Track): Promise { const { channel } = queue.metadata as TrackMetadata; - const { relatedTracks } = await this.playerService.getRelatedTracks({ - extractor: SpotifyExtractor, - lastTrack, - queue, - }); + 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 channel.send({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND })] }); @@ -134,10 +130,11 @@ export class PlayerProvider implements OnModuleInit { popularity: track.metadata, }); let filteredTracks = relatedTracks; + filteredTracks = removeDuplicatedTracks(filteredTracks); filteredTracks = getUnplayedTracks(queue, filteredTracks); filteredTracks = overrideTrackRequester(filteredTracks, this.discordClient.user); this.playerService.play({ - tracks: [getRandomTrack(filteredTracks)], + tracks: [filteredTracks[0]], textChannel: channel, voiceChannel: queue.channel, requester: this.discordClient.user, diff --git a/src/bot/utils/player.utils.ts b/src/bot/utils/player.utils.ts index 3c8a208..9f3154f 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -24,6 +24,13 @@ export const getNowPlayingButtons = ({ return new ActionRowBuilder().addComponents(playPauseButton, skipButton); }; +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 getUnplayedTracks = (queue: GuildQueue, searchedTracks: Track[]): Track[] => { return searchedTracks.filter( (track) => From 511df5c087c7614d96180a98ce5d92690584071b Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 15 Apr 2025 23:05:32 -0400 Subject: [PATCH 14/26] refactor: Update handlePlayerFinish to always update finished track cached message --- package.json | 2 +- src/bot/providers/player/player.provider.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 93614a0..3acadb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.2.2", + "version": "2.2.3", "description": "", "author": "", "private": true, diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index cc69cf1..1335421 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -78,14 +78,14 @@ export class PlayerProvider implements OnModuleInit { private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { this.logger.log(`[playerFinish] ${track.title} by ${track.author} finished playing in ${queue.guild.name}`); + const { channel } = queue.metadata as TrackMetadata; + this.updateFinishedTrackMessage(channel, track); const isQueueEmpty = queue.isEmpty(); 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}`); - const { channel } = queue.metadata as TrackMetadata; - await this.updateFinishedTrackMessage(channel, track); } private handleWillAutoPlay(queue: GuildQueue, tracks: Track[], nextTrack: (track: Track) => void): void { From 8f6400e1d4619b927ac89117beff659ec03066e7 Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 17 Apr 2025 23:09:34 -0400 Subject: [PATCH 15/26] chore: Update .gitignore to include .cursor and .vscode directories --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 76d143d..0185ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ lerna-debug.log* *.launch .settings/ *.sublime-workspace +.cursor +.vscode # IDE - VSCode .vscode/* From 1604676cb41362e96b6f36b143da9099d1a05aba Mon Sep 17 00:00:00 2001 From: Maaato Date: Fri, 18 Apr 2025 19:57:00 -0400 Subject: [PATCH 16/26] refactor: Revamp embed utility functions to improve structure and readability --- .gitignore | 1 + src/bot/commands/misc/help.command.ts | 4 +- src/bot/commands/music/clear.command.ts | 6 +- src/bot/commands/music/play.command.ts | 22 +++---- src/bot/commands/music/shuffle.command.ts | 8 +-- src/bot/commands/music/skip.command.ts | 4 +- src/bot/constants/embeds-props.constant.ts | 7 ++ src/bot/constants/messages.constant.ts | 1 - src/bot/guards/guild-queue.guard.ts | 4 +- src/bot/providers/player/player.provider.ts | 29 +++++---- src/bot/utils/embed.utils.ts | 71 +++++++++++++++++---- src/bot/utils/player.utils.ts | 2 - 12 files changed, 106 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index 0185ceb..fa37c05 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Enviroments .env +.env.* # npm package-lock.json 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 4deb607..145fda5 100644 --- a/src/bot/commands/music/play.command.ts +++ b/src/bot/commands/music/play.command.ts @@ -10,7 +10,7 @@ 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'; @@ -44,6 +44,7 @@ export class PlayCommand extends BaseCommand { @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; @@ -52,33 +53,28 @@ export class PlayCommand extends BaseCommand { const textChannel = getTextchannelFromCache(guild, channelId); const voiceChannel = getMemberVoiceChannel(member); if (!voiceChannel) { - await message.reply({ - embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NOT_IN_VOICE_CHANNEL })], - }); + await message.reply({ embeds: [createErrorEmbed(FRIENDLY_ERROR_MESSAGES.NOT_IN_VOICE_CHANNEL)] }); return; } - const query = message.content.trim(); if (query.toLocaleLowerCase().startsWith(HTTP_PREFIX)) { - const { isValid, host } = isAllowedLinkSearch(message.content); + const { isValid, host } = isAllowedLinkSearch(query); if (!isValid) { - this.logger.error(`[validateLinkSearch] Invalid URL: ${message.content}`); - await message.reply({ - embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES[`${host}_LINK_NOT_SUPPORTED_YET`] })], - }); + 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: message.content, requester }); // biome-ignore format: prettier + 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 8b1752d..8e4e2c3 100644 --- a/src/bot/constants/messages.constant.ts +++ b/src/bot/constants/messages.constant.ts @@ -14,5 +14,4 @@ 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!', - AUTO_PLAY_HANDLED: 'Oops! no more tracks in queue, fetching related tracks for you!', } as const; diff --git a/src/bot/guards/guild-queue.guard.ts b/src/bot/guards/guild-queue.guard.ts index e5bc83f..6b20af9 100644 --- a/src/bot/guards/guild-queue.guard.ts +++ b/src/bot/guards/guild-queue.guard.ts @@ -3,7 +3,7 @@ import { COMMAND_VALIDATION_KEY } from '@bot/decorators/command-validation.decor 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: FRIENDLY_ERROR_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.ts b/src/bot/providers/player/player.provider.ts index 1335421..ea06e48 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -1,7 +1,13 @@ import { FRIENDLY_ERROR_MESSAGES } from '@bot/constants/messages.constant'; import { PlayerService } from '@bot/services/player/player.service'; import { TrackMetadata } from '@bot/services/player/player.service.types'; -import { getDescriptionEmbed, getNowPlayingEmbed, getQueuedEmbed } from '@bot/utils/embed.utils'; +import { + createInfoEmbed, + createNowPlayingEmbed, + createPlayerErrorEmbed, + createQueuedEmbed, + createTrackErrorEmbed, +} from '@bot/utils/embed.utils'; import { getNowPlayingButtons, getUnplayedTracks, @@ -65,7 +71,7 @@ export class PlayerProvider implements OnModuleInit { this.logger.log(`[audioTrackAdd] ${track.title} by ${track.author} Added to ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; if (isTrackRequestedByClient(track, this.discordClient.user)) return; - const embed = getQueuedEmbed(track); + const embed = createQueuedEmbed(track); await channel.send({ embeds: [embed] }); } @@ -106,7 +112,7 @@ export class PlayerProvider implements OnModuleInit { ): Promise { const playerActionsButtons = getNowPlayingButtons(); return channel.send({ - embeds: [getNowPlayingEmbed(queue, track)], + embeds: [createNowPlayingEmbed(queue, track)], components: [playerActionsButtons], flags: MessageFlags.SuppressNotifications, }); @@ -117,18 +123,11 @@ export class PlayerProvider implements OnModuleInit { 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 - channel.send({ embeds: [getDescriptionEmbed({ description: FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND })] }); + channel.send({ embeds: [createInfoEmbed(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 - for (const track of relatedTracks) - console.log('[PLAYER PROVIDER] Track: ', { - title: track.title, - author: track.author, - views: track.views, - popularity: track.metadata, - }); let filteredTracks = relatedTracks; filteredTracks = removeDuplicatedTracks(filteredTracks); filteredTracks = getUnplayedTracks(queue, filteredTracks); @@ -170,10 +169,14 @@ export class PlayerProvider implements OnModuleInit { } private handleError(queue: GuildQueue, error: Error): void { - this.logger.error(`Error in ${queue.guild.name}: ${error.message}`); + 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(`Error playing ${track.title} in ${queue.guild.name}: ${error.message}`); + 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)] }); } } diff --git a/src/bot/utils/embed.utils.ts b/src/bot/utils/embed.utils.ts index 167f951..e8ee36b 100644 --- a/src/bot/utils/embed.utils.ts +++ b/src/bot/utils/embed.utils.ts @@ -1,18 +1,67 @@ +import { EMBED_COLORS } from '@bot/constants/embeds-props.constant'; import { GuildQueue, Track } from 'discord-player'; -import { EmbedBuilder, inlineCode } from 'discord.js'; +import { Embed, EmbedBuilder, inlineCode } from 'discord.js'; -export const getQueuedEmbed = ({ requestedBy, title, url }: Track): EmbedBuilder => { - return new EmbedBuilder().setDescription( - `Queued - [${inlineCode(title)}](${url})\n-# Requested by <@${requestedBy.id}>`, - ); +type BaseEmbedOptions = Partial>; + +const createBaseEmbed = ({ + description, + author, + footer, + color = EMBED_COLORS.PRIMARY, +}: BaseEmbedOptions): EmbedBuilder => { + // biome-ignore format: prettier + const embed = new EmbedBuilder().setDescription(description).setColor(color); + if (author) embed.setAuthor(author); + if (footer) embed.setFooter(footer); + return embed; +}; + +/* Embeds */ + +export const createInfoEmbed = (description: string): EmbedBuilder => { + return createBaseEmbed({ description, color: EMBED_COLORS.INFO }); +}; + +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 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 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}>`); +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, + }); }; -export const getDescriptionEmbed = ({ description }: { description: string }): EmbedBuilder => { - return new EmbedBuilder().setDescription(description); +/* Embeds utils */ +const createTrackDescription = (track: Track): string => { + return `[${inlineCode(track.title)}](${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 9f3154f..9b3a754 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -44,12 +44,10 @@ export const getUnplayedTracks = (queue: GuildQueue, searchedTracks: Track[]): T export const isAllowedLinkSearch = (url: string): IsAllowedLinkSearchResult => { const { hostname } = new URL(url); for (const [key, value] of ALLOWED_SEARCH_HOSTS.entries()) { - console.log(value, hostname); if (value.includes(hostname)) return { isValid: true, host: key }; } for (const [key, value] of DISALLOWED_SEARCH_HOSTS.entries()) { - console.log(value, hostname); if (value.includes(hostname)) return { isValid: false, host: key }; } From e65be7190c68f4800582f836e66ef490a800db51 Mon Sep 17 00:00:00 2001 From: Maaato Date: Fri, 18 Apr 2025 20:38:51 -0400 Subject: [PATCH 17/26] feat: Add cache deletion for now playing message in handlePlayerFinish --- package.json | 2 +- src/bot/providers/player/player.provider.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3acadb3..b397649 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.2.3", + "version": "2.2.4", "description": "", "author": "", "private": true, diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index ea06e48..1fa351c 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -85,9 +85,10 @@ export class PlayerProvider implements OnModuleInit { private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { this.logger.log(`[playerFinish] ${track.title} by ${track.author} finished playing in ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; - this.updateFinishedTrackMessage(channel, track); const isQueueEmpty = queue.isEmpty(); + this.updateFinishedTrackMessage(channel, track); if (isQueueEmpty) this.handleCustomAutoPlay(queue, track); + this.deleteCacheNowPlayingMessage(track); } private async handlePlayerSkip(queue: GuildQueue, track: Track): Promise { @@ -147,6 +148,11 @@ export class PlayerProvider implements OnModuleInit { await this.cacheManager.add(cacheKey, messageId, trackDurationMS + 60 * SECOND_IN_MS); } + private async deleteCacheNowPlayingMessage(track: Track): Promise { + const cacheKey = `${CACHE_KEYS.NOW_PLAYING_MESSAGE}-${track.id}`; + await this.cacheManager.del(cacheKey); + } + private async updateFinishedTrackMessage(channel: GuildTextBasedChannel, track: Track): Promise { const cacheKey = `${CACHE_KEYS.NOW_PLAYING_MESSAGE}-${track.id}`; const messageId = await this.cacheManager.get(cacheKey); From e9ae68fe00afa30665acbc555733dd5ea9e45774 Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 21 Apr 2025 19:47:36 -0400 Subject: [PATCH 18/26] feat: Add DEBUG_MODE to configuration and update PlayerProvider to utilize it for debug event handling --- .env.sample | 1 + src/bot/providers/player/player.provider.ts | 6 ++---- src/config/config.service.interface.ts | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.sample b/.env.sample index 5b74aee..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 diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index 1fa351c..d00a104 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -34,7 +34,7 @@ export class PlayerProvider implements OnModuleInit { 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(); @@ -56,9 +56,7 @@ export class PlayerProvider implements OnModuleInit { 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 (this.configService.getValue('DEBUG_MODE')) this.player.events.on(GuildQueueEvent.Debug, this.handleDebug.bind(this)); // biome-ignore format: prettier } private handleDebug(queue: GuildQueue, message: string): void { diff --git a/src/config/config.service.interface.ts b/src/config/config.service.interface.ts index f7e460a..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; From e7a0537c4d068051919e9ca3029fce898c3d4ad6 Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 21 Apr 2025 20:01:43 -0400 Subject: [PATCH 19/26] feat: Add DEBUG_MODE environment variable to deployment workflow --- .github/workflows/deploy-to-oci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-to-oci.yml b/.github/workflows/deploy-to-oci.yml index c0611a6..6785147 100644 --- a/.github/workflows/deploy-to-oci.yml +++ b/.github/workflows/deploy-to-oci.yml @@ -63,6 +63,7 @@ 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 From 4a95a7f4fc9fc3786fee0ac8ccf83fa4fcda2441 Mon Sep 17 00:00:00 2001 From: Maaato Date: Mon, 21 Apr 2025 22:51:50 -0400 Subject: [PATCH 20/26] refactor: Update PlayerProvider to improve event handling and cache management for last now playing message --- src/bot/providers/player/player.provider.ts | 71 ++++++++++----------- src/shared/constants/cache.constants.ts | 2 +- 2 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index d00a104..be9230d 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -51,16 +51,16 @@ export class PlayerProvider implements OnModuleInit { this.player.events.on(GuildQueueEvent.PlayerFinish, this.handlePlayerFinish.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.getValue('DEBUG_MODE')) this.player.events.on(GuildQueueEvent.Debug, this.handleDebug.bind(this)); // biome-ignore format: prettier - } - - private handleDebug(queue: GuildQueue, message: string): void { - this.logger.debug(`[${queue.guild.name}] ${message}`); + if (!this.configService.getValue('DEBUG_MODE')) return; + this.player.events.on(GuildQueueEvent.Debug, (queue, message) => + this.logger.debug(`[${queue.guild.name}] ${message}`), + ); } /** EVENTS HANDLERS */ @@ -76,17 +76,16 @@ export class PlayerProvider implements OnModuleInit { private async handlePlayerStart(queue: GuildQueue, track: Track): Promise { 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, track.durationMS + SECOND_IN_MS); } private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { this.logger.log(`[playerFinish] ${track.title} by ${track.author} finished playing in ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; const isQueueEmpty = queue.isEmpty(); - this.updateFinishedTrackMessage(channel, track); + this.updateLastNowPlayingMessage(channel); if (isQueueEmpty) this.handleCustomAutoPlay(queue, track); - this.deleteCacheNowPlayingMessage(track); } private async handlePlayerSkip(queue: GuildQueue, track: Track): Promise { @@ -103,6 +102,24 @@ export class PlayerProvider implements OnModuleInit { nextTrack(nextRandomTrack); } + 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 */ private async sendNowPlayingMessage( channel: GuildTextBasedChannel, @@ -139,28 +156,16 @@ export class PlayerProvider implements OnModuleInit { }); } - 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 + 60 * SECOND_IN_MS); - } - - private async deleteCacheNowPlayingMessage(track: Track): Promise { - const cacheKey = `${CACHE_KEYS.NOW_PLAYING_MESSAGE}-${track.id}`; - await this.cacheManager.del(cacheKey); - } - - 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 { @@ -171,16 +176,4 @@ export class PlayerProvider implements OnModuleInit { }); return relatedTracks[Math.floor(generateNormalizedRandom() * relatedTracks.length)]; } - - 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)] }); - } } diff --git a/src/shared/constants/cache.constants.ts b/src/shared/constants/cache.constants.ts index 2d2226b..c139ebd 100644 --- a/src/shared/constants/cache.constants.ts +++ b/src/shared/constants/cache.constants.ts @@ -1,7 +1,7 @@ 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; From ce62433a285abf8c66f0896fed8d3c154f61d036 Mon Sep 17 00:00:00 2001 From: Maaato Date: Tue, 22 Apr 2025 01:29:26 -0400 Subject: [PATCH 21/26] refactor: Update ttl cache management for last now playing message --- src/bot/providers/player/player.provider.ts | 5 ++--- src/shared/constants/cache.constants.ts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index be9230d..a91d27b 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -19,8 +19,7 @@ import { ConfigService } from '@config/config.service'; import { InjectDiscordClient } from '@discord-nestjs/core'; import { Injectable, Logger, 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 { SpotifyExtractor } from 'discord-player-spotify'; @@ -77,7 +76,7 @@ export class PlayerProvider implements OnModuleInit { this.logger.log(`[playerStart] ${track.title} by ${track.author} started playing in ${queue.guild.name}`); const { channel } = queue.metadata as TrackMetadata; const { id: messageId } = await this.sendNowPlayingMessage(channel, queue, track); - this.cacheManager.add(CACHE_KEYS.LAST_NOW_PLAYING_MESSAGE, messageId, track.durationMS + SECOND_IN_MS); + this.cacheManager.add(CACHE_KEYS.LAST_NOW_PLAYING_MESSAGE, messageId, DISABLED_CACHE_TTL); } private async handlePlayerFinish(queue: GuildQueue, track: Track): Promise { diff --git a/src/shared/constants/cache.constants.ts b/src/shared/constants/cache.constants.ts index c139ebd..574db80 100644 --- a/src/shared/constants/cache.constants.ts +++ b/src/shared/constants/cache.constants.ts @@ -5,3 +5,4 @@ export const CACHE_KEYS = { } as const; export const DEFAULT_CACHE_TTL = SECOND_IN_MS * 60; +export const DISABLED_CACHE_TTL = 0; From 52bb01f9279dafffbf0a689337d3c246b2548132 Mon Sep 17 00:00:00 2001 From: Maaato Date: Wed, 23 Apr 2025 20:44:35 -0400 Subject: [PATCH 22/26] refactor: Simplify SpotifyExtractor registration by removing client credentials --- package.json | 2 +- src/bot/services/player/player.service.spec.ts | 9 +++++---- src/bot/services/player/player.service.ts | 5 +---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index b397649..cee8c3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amzbot", - "version": "2.2.4", + "version": "2.2.5", "description": "", "author": "", "private": true, diff --git a/src/bot/services/player/player.service.spec.ts b/src/bot/services/player/player.service.spec.ts index 7528886..550b9ff 100644 --- a/src/bot/services/player/player.service.spec.ts +++ b/src/bot/services/player/player.service.spec.ts @@ -89,12 +89,13 @@ describe('PlayerService', () => { await service.onModuleInit(); 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, { 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(4); + // expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.SPOTIFY_CLIENT_ID); + // expect(configServiceSpy).toHaveBeenCalledWith(ENVIRONMENT.SPOTIFY_CLIENT_SECRET); + expect(configServiceSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/src/bot/services/player/player.service.ts b/src/bot/services/player/player.service.ts index d449622..234fdef 100644 --- a/src/bot/services/player/player.service.ts +++ b/src/bot/services/player/player.service.ts @@ -42,10 +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, { - clientId: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_ID), - clientSecret: this.configService.getValue(ENVIRONMENT.SPOTIFY_CLIENT_SECRET), - }); + await this.player.extractors.register(SpotifyExtractor, {}); this.logger.debug(this.player.scanDeps()); } catch (error) { this.logger.error(`[PlayerService] Error initializing player: ${error.message}`); From e71d28a5b4d48f0548b05c865cc6aea4e95c8b79 Mon Sep 17 00:00:00 2001 From: Maaato Date: Wed, 23 Apr 2025 20:57:13 -0400 Subject: [PATCH 23/26] refactor: Add numeric check for DEBUG_MODE in event handling --- src/bot/providers/player/player.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index a91d27b..ceefd00 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -56,7 +56,7 @@ export class PlayerProvider implements OnModuleInit { 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.getValue('DEBUG_MODE')) return; + if (!Number(this.configService.getValue('DEBUG_MODE'))) return; this.player.events.on(GuildQueueEvent.Debug, (queue, message) => this.logger.debug(`[${queue.guild.name}] ${message}`), ); From 54b9655001cb8064393dec5c446e45a0ea08e84c Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 24 Apr 2025 20:13:05 -0400 Subject: [PATCH 24/26] refactor: Enhance track description in embed utility to include track author --- src/bot/utils/embed.utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bot/utils/embed.utils.ts b/src/bot/utils/embed.utils.ts index e8ee36b..c5d9307 100644 --- a/src/bot/utils/embed.utils.ts +++ b/src/bot/utils/embed.utils.ts @@ -63,5 +63,6 @@ export const createTrackErrorEmbed = (queue: GuildQueue, track: Track, error: Er /* Embeds utils */ const createTrackDescription = (track: Track): string => { - return `[${inlineCode(track.title)}](${track.url})\n-# Requested by <@${track.requestedBy.id}>`; + const trackTitle = `${track.title} - ${track.author}`; + return `[${inlineCode(trackTitle)}](${track.url})\n-# Requested by <@${track.requestedBy.id}>`; }; From abf6c698e441dc7b63b46f41ad74580883546031 Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 24 Apr 2025 20:16:22 -0400 Subject: [PATCH 25/26] refactor: Consolidate embed options and improve readability --- src/bot/providers/player/player.provider.ts | 7 ++----- src/bot/types/utils.types.ts | 4 ++++ src/bot/utils/embed.utils.ts | 19 +++++++------------ src/bot/utils/player.utils.ts | 5 ++--- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/bot/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index ceefd00..b67a181 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -120,11 +120,8 @@ export class PlayerProvider implements OnModuleInit { } /** HELPER FUNCTIONS */ - private async sendNowPlayingMessage( - channel: GuildTextBasedChannel, - queue: GuildQueue, - track: Track, - ): Promise { + // biome-ignore format: prettier + private async sendNowPlayingMessage(channel: GuildTextBasedChannel, queue: GuildQueue, track: Track): Promise { const playerActionsButtons = getNowPlayingButtons(); return channel.send({ embeds: [createNowPlayingEmbed(queue, track)], 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 c5d9307..e3f3224 100644 --- a/src/bot/utils/embed.utils.ts +++ b/src/bot/utils/embed.utils.ts @@ -1,24 +1,19 @@ import { EMBED_COLORS } from '@bot/constants/embeds-props.constant'; +import { BaseEmbedOptions } from '@bot/types/utils.types'; import { GuildQueue, Track } from 'discord-player'; -import { Embed, EmbedBuilder, inlineCode } from 'discord.js'; +import { EmbedBuilder, inlineCode } from 'discord.js'; -type BaseEmbedOptions = Partial>; - -const createBaseEmbed = ({ - description, - author, - footer, - color = EMBED_COLORS.PRIMARY, -}: BaseEmbedOptions): EmbedBuilder => { - // biome-ignore format: prettier - const embed = new EmbedBuilder().setDescription(description).setColor(color); +// 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; }; /* Embeds */ - export const createInfoEmbed = (description: string): EmbedBuilder => { return createBaseEmbed({ description, color: EMBED_COLORS.INFO }); }; diff --git a/src/bot/utils/player.utils.ts b/src/bot/utils/player.utils.ts index 9b3a754..95a55ad 100644 --- a/src/bot/utils/player.utils.ts +++ b/src/bot/utils/player.utils.ts @@ -6,9 +6,8 @@ import { generateNormalizedRandom } from '@shared/utils/misc.utils'; import { GuildQueue, Track } from 'discord-player'; 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) From 54cb044b5891bb133dcfcb4e9e07354281e87ce4 Mon Sep 17 00:00:00 2001 From: Maaato Date: Thu, 24 Apr 2025 23:12:01 -0400 Subject: [PATCH 26/26] feat: Enhance customAutoPlay embeds handling --- src/bot/constants/messages.constant.ts | 2 ++ src/bot/providers/player/player.provider.ts | 40 +++++++++++++++------ src/bot/utils/embed.utils.ts | 8 +++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/bot/constants/messages.constant.ts b/src/bot/constants/messages.constant.ts index 8e4e2c3..245b1a8 100644 --- a/src/bot/constants/messages.constant.ts +++ b/src/bot/constants/messages.constant.ts @@ -14,4 +14,6 @@ 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/providers/player/player.provider.ts b/src/bot/providers/player/player.provider.ts index b67a181..8026a6e 100644 --- a/src/bot/providers/player/player.provider.ts +++ b/src/bot/providers/player/player.provider.ts @@ -1,8 +1,8 @@ -import { FRIENDLY_ERROR_MESSAGES } from '@bot/constants/messages.constant'; +import { FRIENDLY_ERROR_MESSAGES, FRIENDLY_OK_MESSAGES } from '@bot/constants/messages.constant'; import { PlayerService } from '@bot/services/player/player.service'; -import { TrackMetadata } from '@bot/services/player/player.service.types'; +import { PlayCommandRequest, TrackMetadata } from '@bot/services/player/player.service.types'; import { - createInfoEmbed, + createAutoplayEmbed, createNowPlayingEmbed, createPlayerErrorEmbed, createQueuedEmbed, @@ -17,16 +17,16 @@ import { } 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, 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 { SpotifyExtractor } from 'discord-player-spotify'; -import { Client, GuildTextBasedChannel, Message, MessageFlags } from 'discord.js'; +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; @@ -42,6 +42,7 @@ export class PlayerProvider implements OnModuleInit { async onModuleInit(): Promise { this.registerPlayerEvents(); this.registerErrorEvents(); + this.logger.log('all events registered'); } private registerPlayerEvents(): void { @@ -132,24 +133,38 @@ export class PlayerProvider implements OnModuleInit { 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 - channel.send({ embeds: [createInfoEmbed(FRIENDLY_ERROR_MESSAGES.NO_RELATED_TRACKS_FOUND)] }); + 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); - this.playerService.play({ - tracks: [filteredTracks[0]], + + 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 updateLastNowPlayingMessage(channel: GuildTextBasedChannel): Promise { @@ -172,4 +187,9 @@ export class PlayerProvider implements OnModuleInit { }); return relatedTracks[Math.floor(generateNormalizedRandom() * relatedTracks.length)]; } + + async onModuleDestroy(): Promise { + this.player.events.removeAllListeners(); + this.logger.log('All event listeners cleaned up'); + } } diff --git a/src/bot/utils/embed.utils.ts b/src/bot/utils/embed.utils.ts index e3f3224..4b65435 100644 --- a/src/bot/utils/embed.utils.ts +++ b/src/bot/utils/embed.utils.ts @@ -40,6 +40,14 @@ export const createNowPlayingEmbed = (queue: GuildQueue, track: Track): EmbedBui }); }; +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}`,