diff --git a/package.json b/package.json index e04452c..f76e9c0 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@discord-nestjs/common": "^5.1.5", - "@discord-nestjs/core": "^4.3.1", + "@discord-nestjs/core": "^5.3.0", "@discordjs/opus": "^0.9.0", "@discordjs/voice": "^0.14.0", "@jellyfin/sdk": "^0.7.0", diff --git a/src/clients/discord/discord.module.ts b/src/clients/discord/discord.module.ts index e5f4b4a..682d3b8 100644 --- a/src/clients/discord/discord.module.ts +++ b/src/clients/discord/discord.module.ts @@ -1,9 +1,9 @@ -import { registerFilterGlobally } from '@discord-nestjs/core'; import { Module } from '@nestjs/common'; import { OnModuleDestroy } from '@nestjs/common/interfaces/hooks'; -import { CommandExecutionError } from '../../middleware/command-execution-filter'; -import { PlaybackModule } from '../../playback/playback.module'; + import { JellyfinClientModule } from '../jellyfin/jellyfin.module'; +import { PlaybackModule } from '../../playback/playback.module'; + import { DiscordConfigService } from './discord.config.service'; import { DiscordMessageService } from './discord.message.service'; import { DiscordVoiceService } from './discord.voice.service'; @@ -11,15 +11,7 @@ import { DiscordVoiceService } from './discord.voice.service'; @Module({ imports: [PlaybackModule, JellyfinClientModule], controllers: [], - providers: [ - DiscordConfigService, - DiscordVoiceService, - DiscordMessageService, - { - provide: registerFilterGlobally(), - useClass: CommandExecutionError, - }, - ], + providers: [DiscordConfigService, DiscordVoiceService, DiscordMessageService], exports: [DiscordConfigService, DiscordVoiceService, DiscordMessageService], }) export class DiscordClientModule implements OnModuleDestroy { diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts index 94341d0..acbd5e9 100644 --- a/src/clients/discord/discord.voice.service.ts +++ b/src/clients/discord/discord.voice.service.ts @@ -9,14 +9,19 @@ import { joinVoiceChannel, VoiceConnection, } from '@discordjs/voice'; + import { Injectable } from '@nestjs/common'; import { Logger } from '@nestjs/common/services'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; + import { GuildMember } from 'discord.js'; + +import { JellyfinStreamBuilderService } from '../jellyfin/jellyfin.stream.builder.service'; +import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service'; import { GenericTryHandler } from '../../models/generic-try-handler'; import { PlaybackService } from '../../playback/playback.service'; -import { Track } from '../../types/track'; -import { JellyfinWebSocketService } from '../jellyfin/jellyfin.websocket.service'; +import { GenericTrack } from '../../models/shared/GenericTrack'; + import { DiscordMessageService } from './discord.message.service'; @Injectable() @@ -29,12 +34,15 @@ export class DiscordVoiceService { private readonly discordMessageService: DiscordMessageService, private readonly playbackService: PlaybackService, private readonly jellyfinWebSocketService: JellyfinWebSocketService, + private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService, private readonly eventEmitter: EventEmitter2, ) {} - @OnEvent('playback.newTrack') - handleOnNewTrack(newTrack: Track) { - const resource = createAudioResource(newTrack.streamUrl); + @OnEvent('internal.audio.announce') + handleOnNewTrack(track: GenericTrack) { + const resource = createAudioResource( + track.getStreamUrl(this.jellyfinStreamBuilder), + ); this.playResource(resource); } @@ -96,7 +104,6 @@ export class DiscordVoiceService { /** * Pauses the current audio player */ - @OnEvent('playback.control.pause') pause() { this.createAndReturnOrGetAudioPlayer().pause(); this.eventEmitter.emit('playback.state.pause', true); @@ -105,7 +112,6 @@ export class DiscordVoiceService { /** * Stops the audio player */ - @OnEvent('playback.control.stop') stop(force: boolean): boolean { const stopped = this.createAndReturnOrGetAudioPlayer().stop(force); this.eventEmitter.emit('playback.state.stop'); @@ -143,7 +149,6 @@ export class DiscordVoiceService { * Checks if the current state is paused or not and toggles the states to the opposite. * @returns The new paused state - true: paused, false: unpaused */ - @OnEvent('playback.control.togglePause') togglePaused(): boolean { if (this.isPaused()) { this.unpause(); @@ -193,7 +198,7 @@ export class DiscordVoiceService { private createAndReturnOrGetAudioPlayer() { if (this.audioPlayer === undefined) { this.logger.debug( - `Initialized new instance of Audio Player because it has not been defined yet`, + `Initialized new instance of AudioPlayer because it has not been defined yet`, ); this.audioPlayer = createAudioPlayer(); this.attachEventListenersToAudioPlayer(); @@ -220,7 +225,9 @@ export class DiscordVoiceService { return; } - const hasNextTrack = this.playbackService.hasNextTrack(); + const hasNextTrack = this.playbackService + .getPlaylistOrDefault() + .hasNextTrackInPlaylist(); this.logger.debug( `Deteced audio player status change from ${previousState.status} to ${ @@ -229,11 +236,11 @@ export class DiscordVoiceService { ); if (!hasNextTrack) { - this.logger.debug(`Audio Player has reached the end of the playlist`); + this.logger.debug(`Reached the end of the playlist`); return; } - this.playbackService.nextTrack(); + this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack(); }); } } diff --git a/src/clients/jellyfin/jellyfin.playstate.service.ts b/src/clients/jellyfin/jellyfin.playstate.service.ts index 5021c55..57b1483 100644 --- a/src/clients/jellyfin/jellyfin.playstate.service.ts +++ b/src/clients/jellyfin/jellyfin.playstate.service.ts @@ -1,5 +1,3 @@ -import { Injectable, Logger } from '@nestjs/common'; - import { Api } from '@jellyfin/sdk'; import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api'; import { SessionApi } from '@jellyfin/sdk/lib/generated-client/api/session-api'; @@ -9,9 +7,12 @@ import { } from '@jellyfin/sdk/lib/generated-client/models'; import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'; import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; + +import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { Track } from '../../types/track'; + import { PlaybackService } from '../../playback/playback.service'; +import { Track } from '../../types/track'; @Injectable() export class JellyinPlaystateService { @@ -53,35 +54,4 @@ export class JellyinPlaystateService { }, }); } - - @OnEvent('playback.state.pause') - private async onPlaybackPaused(isPaused: boolean) { - const activeTrack = this.playbackService.getActiveTrack(); - - if (!activeTrack) { - return; - } - - await this.playstateApi.reportPlaybackProgress({ - playbackProgressInfo: { - ItemId: activeTrack.track.jellyfinId, - IsPaused: isPaused, - }, - }); - } - - @OnEvent('playback.state.stop') - private async onPlaybackStopped() { - const activeTrack = this.playbackService.getActiveTrack(); - - if (!activeTrack) { - return; - } - - await this.playstateApi.reportPlaybackStopped({ - playbackStopInfo: { - ItemId: activeTrack.track.jellyfinId, - }, - }); - } } diff --git a/src/clients/jellyfin/jellyfin.stream.builder.service.ts b/src/clients/jellyfin/jellyfin.stream.builder.service.ts index f8d6e8b..2f75515 100644 --- a/src/clients/jellyfin/jellyfin.stream.builder.service.ts +++ b/src/clients/jellyfin/jellyfin.stream.builder.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; + import { JellyfinService } from './jellyfin.service'; @Injectable() @@ -11,7 +12,7 @@ export class JellyfinStreamBuilderService { const api = this.jellyfinService.getApi(); this.logger.debug( - `Attempting to build stream resource for item ${jellyfinItemId} with bitrate ${bitrate}`, + `Building stream for '${jellyfinItemId}' with bitrate ${bitrate}`, ); const accessToken = this.jellyfinService.getApi().accessToken; diff --git a/src/clients/jellyfin/jellyfin.websocket.service.ts b/src/clients/jellyfin/jellyfin.websocket.service.ts index 8e68f08..91876b7 100644 --- a/src/clients/jellyfin/jellyfin.websocket.service.ts +++ b/src/clients/jellyfin/jellyfin.websocket.service.ts @@ -1,19 +1,25 @@ -import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { JellyfinService } from './jellyfin.service'; - import { PlaystateCommand, SessionMessageType, } from '@jellyfin/sdk/lib/generated-client/models'; -import { WebSocket } from 'ws'; -import { PlaybackService } from '../../playback/playback.service'; -import { JellyfinSearchService } from './jellyfin.search.service'; -import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service'; -import { Track } from '../../types/track'; -import { PlayNowCommand, SessionApiSendPlaystateCommandRequest } from '../../types/websocket'; + +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { WebSocket } from 'ws'; + +import { PlaybackService } from '../../playback/playback.service'; +import { + PlayNowCommand, + SessionApiSendPlaystateCommandRequest, +} from '../../types/websocket'; +import { GenericTrack } from '../../models/shared/GenericTrack'; + +import { JellyfinSearchService } from './jellyfin.search.service'; +import { JellyfinService } from './jellyfin.service'; +import { JellyfinStreamBuilderService } from './jellyfin.stream.builder.service'; + @Injectable() export class JellyfinWebSocketService implements OnModuleDestroy { private webSocket: WebSocket; @@ -82,7 +88,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy { return this.webSocket.readyState; } - protected messageHandler(data: any) { + protected async messageHandler(data: any) { const msg: JellyMessage = JSON.parse(data); switch (msg.MessageType) { @@ -102,38 +108,22 @@ export class JellyfinWebSocketService implements OnModuleDestroy { `Adding ${ids.length} ids to the queue using controls from the websocket`, ); - ids.forEach((id, index) => { - this.jellyfinSearchService - .getById(id) - .then((response) => { - const track: Track = { - name: response.Name, - durationInMilliseconds: response.RunTimeTicks / 10000, - jellyfinId: response.Id, - streamUrl: this.jellyfinStreamBuilderService.buildStreamUrl( - response.Id, - 96000, - ), - remoteImages: { - Images: [], - Providers: [], - TotalRecordCount: 0, - }, - }; - - const trackId = this.playbackService.enqueueTrack(track); - - if (index !== 0) { - return; - } - - this.playbackService.setActiveTrack(trackId); - this.playbackService.getActiveTrackAndEmitEvent(); - }) - .catch((err) => { - this.logger.error(err); - }); + const tracks = ids.map(async (id) => { + try { + const hint = await this.jellyfinSearchService.getById(id); + return { + id: id, + name: hint.Name, + duration: hint.RunTimeTicks / 10000, + remoteImages: {}, + } as GenericTrack; + } catch (err) { + this.logger.error('TODO'); + } }); + const resolvedTracks = await Promise.all(tracks); + const playlist = this.playbackService.getPlaylistOrDefault(); + playlist.enqueueTracks(resolvedTracks); break; case SessionMessageType[SessionMessageType.Playstate]: const sendPlaystateCommandRequest = diff --git a/src/commands/disconnect.command.ts b/src/commands/disconnect.command.ts index 073e757..7083cf0 100644 --- a/src/commands/disconnect.command.ts +++ b/src/commands/disconnect.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common/decorators'; @@ -7,18 +7,19 @@ import { CommandInteraction } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +@Injectable() @Command({ name: 'disconnect', description: 'Join your current voice channel', }) -@Injectable() -export class DisconnectCommand implements DiscordCommand { +export class DisconnectCommand { constructor( private readonly discordVoiceService: DiscordVoiceService, private readonly discordMessageService: DiscordMessageService, ) {} - async handler(interaction: CommandInteraction): Promise { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ diff --git a/src/commands/help.command.ts b/src/commands/help.command.ts index e4a3f2c..f9d2993 100644 --- a/src/commands/help.command.ts +++ b/src/commands/help.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common'; @@ -6,15 +6,16 @@ import { CommandInteraction } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +@Injectable() @Command({ name: 'help', description: 'Get help if you're having problems with this bot', }) -@Injectable() -export class HelpCommand implements DiscordCommand { +export class HelpCommand { constructor(private readonly discordMessageService: DiscordMessageService) {} - async handler(interaction: CommandInteraction): Promise { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ diff --git a/src/commands/next.command.ts b/src/commands/next.command.ts index ec679ff..6a64c8a 100644 --- a/src/commands/next.command.ts +++ b/src/commands/next.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common'; @@ -12,14 +12,15 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic description: 'Go to the next track in the playlist', }) @Injectable() -export class SkipTrackCommand implements DiscordCommand { +export class SkipTrackCommand { constructor( private readonly playbackService: PlaybackService, private readonly discordMessageService: DiscordMessageService, ) {} - async handler(interaction: CommandInteraction): Promise { - if (!this.playbackService.nextTrack()) { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { + if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) { await interaction.reply({ embeds: [ this.discordMessageService.buildErrorMessage({ @@ -27,8 +28,10 @@ export class SkipTrackCommand implements DiscordCommand { }), ], }); + return; } + this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack(); await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ diff --git a/src/commands/pause.command.ts b/src/commands/pause.command.ts index dce7702..b80ec10 100644 --- a/src/commands/pause.command.ts +++ b/src/commands/pause.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common'; @@ -7,18 +7,19 @@ import { CommandInteraction } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +@Injectable() @Command({ name: 'pause', description: 'Pause or resume the playback of the current track', }) -@Injectable() -export class PausePlaybackCommand implements DiscordCommand { +export class PausePlaybackCommand { constructor( private readonly discordVoiceService: DiscordVoiceService, private readonly discordMessageService: DiscordMessageService, ) {} - async handler(interaction: CommandInteraction): Promise { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { const shouldBePaused = this.discordVoiceService.togglePaused(); await interaction.reply({ diff --git a/src/commands/play.comands.ts b/src/commands/play.comands.ts index 28fdfe6..91ff964 100644 --- a/src/commands/play.comands.ts +++ b/src/commands/play.comands.ts @@ -1,17 +1,19 @@ +import { SlashCommandPipe } from '@discord-nestjs/common'; import { Command, - DiscordTransformedCommand, + Handler, + IA, + InteractionEvent, On, - Payload, - TransformedCommandExecutionContext, } from '@discord-nestjs/core'; import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models'; -import { Logger } from '@nestjs/common/services'; import { Injectable } from '@nestjs/common'; +import { Logger } from '@nestjs/common/services'; import { + CommandInteraction, ComponentType, Events, GuildMember, @@ -29,17 +31,16 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service'; import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service'; +import { GenericTrack } from '../models/shared/GenericTrack'; import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages'; import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils'; +@Injectable() @Command({ name: 'play', description: 'Search for an item on your Jellyfin instance', }) -@Injectable() -export class PlayItemCommand - implements DiscordTransformedCommand -{ +export class PlayItemCommand { private readonly logger: Logger = new Logger(PlayItemCommand.name); constructor( @@ -50,11 +51,12 @@ export class PlayItemCommand private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService, ) {} + @Handler() async handler( - @Payload() dto: TrackRequestDto, - executionContext: TransformedCommandExecutionContext, + @InteractionEvent(SlashCommandPipe) dto: TrackRequestDto, + @IA() interaction: CommandInteraction, ): Promise { - await executionContext.interaction.deferReply(); + await interaction.deferReply(); const items = await this.jellyfinSearchService.search(dto.search); const parsedItems = await Promise.all( @@ -69,7 +71,7 @@ export class PlayItemCommand ); if (parsedItems.length === 0) { - await executionContext.interaction.followUp({ + await interaction.followUp({ embeds: [ this.discordMessageService.buildErrorMessage({ title: 'No results for your search query found', @@ -109,7 +111,7 @@ export class PlayItemCommand emoji: item.getEmoji(), })); - await executionContext.interaction.followUp({ + await interaction.followUp({ embeds: [ this.discordMessageService.buildMessage({ title: 'Jellyfin Search Results', @@ -184,8 +186,6 @@ export class PlayItemCommand this.logger.debug('Successfully joined the voice channel'); - const bitrate = guildMember.voice.channel.bitrate; - const valueParts = interaction.values[0].split('_'); const type = valueParts[0]; const id = valueParts[1]; @@ -206,7 +206,6 @@ export class PlayItemCommand ); const addedIndex = this.enqueueSingleTrack( item as BaseJellyfinAudioPlayable, - bitrate, remoteImagesOfCurrentAlbum, ); await interaction.editReply({ @@ -234,7 +233,6 @@ export class PlayItemCommand album.SearchHints.forEach((item) => { this.enqueueSingleTrack( item as BaseJellyfinAudioPlayable, - bitrate, remoteImages, ); }); @@ -267,7 +265,6 @@ export class PlayItemCommand addedRemoteImages.Images.concat(remoteImages.Images); this.enqueueSingleTrack( item as BaseJellyfinAudioPlayable, - bitrate, remoteImages, ); } @@ -310,22 +307,15 @@ export class PlayItemCommand private enqueueSingleTrack( jellyfinPlayable: BaseJellyfinAudioPlayable, - bitrate: number, remoteImageResult: RemoteImageResult, ) { - const stream = this.jellyfinStreamBuilder.buildStreamUrl( - jellyfinPlayable.Id, - bitrate, - ); - - const milliseconds = jellyfinPlayable.RunTimeTicks / 10000; - - return this.playbackService.enqueueTrack({ - jellyfinId: jellyfinPlayable.Id, - name: jellyfinPlayable.Name, - durationInMilliseconds: milliseconds, - streamUrl: stream, - remoteImages: remoteImageResult, - }); + return this.playbackService + .getPlaylistOrDefault() + .enqueueTracks([ + GenericTrack.constructFromJellyfinPlayable( + jellyfinPlayable, + remoteImageResult, + ), + ]); } } diff --git a/src/commands/playlist.command.ts b/src/commands/playlist.command.ts index 769de6c..8b14c55 100644 --- a/src/commands/playlist.command.ts +++ b/src/commands/playlist.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common'; @@ -8,24 +8,24 @@ import { PlaybackService } from '../playback/playback.service'; import { Constants } from '../utils/constants'; import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; -import { chooseSuitableRemoteImageFromTrack } from '../utils/remoteImages/remoteImages'; import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils'; +@Injectable() @Command({ name: 'playlist', description: 'Print the current track information', }) -@Injectable() -export class PlaylistCommand implements DiscordCommand { +export class PlaylistCommand { constructor( private readonly discordMessageService: DiscordMessageService, private readonly playbackService: PlaybackService, ) {} - async handler(interaction: CommandInteraction): Promise { - const playList = this.playbackService.getPlaylist(); + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { + const playlist = this.playbackService.getPlaylistOrDefault(); - if (playList.tracks.length === 0) { + if (!playlist || playlist.tracks.length === 0) { await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ @@ -35,15 +35,16 @@ export class PlaylistCommand implements DiscordCommand { }), ], }); + return; } - const tracklist = playList.tracks + const tracklist = playlist.tracks .slice(0, 10) .map((track, index) => { - const isCurrent = track.id === playList.activeTrack; + const isCurrent = track === playlist.getActiveTrack(); let point = this.getListPoint(isCurrent, index); - point += `**${trimStringToFixedLength(track.track.name, 30)}**`; + point += `**${trimStringToFixedLength(track.name, 30)}**`; if (isCurrent) { point += ' :loud_sound:'; @@ -52,16 +53,13 @@ export class PlaylistCommand implements DiscordCommand { point += '\n'; point += Constants.Design.InvisibleSpace.repeat(2); point += 'Duration: '; - point += formatMillisecondsAsHumanReadable( - track.track.durationInMilliseconds, - ); + point += formatMillisecondsAsHumanReadable(track.getDuration()); return point; }) .join('\n'); - - const activeTrack = this.playbackService.getActiveTrack(); - const remoteImage = chooseSuitableRemoteImageFromTrack(activeTrack.track); + // const remoteImage = chooseSuitableRemoteImageFromTrack(playlist.getActiveTrack()); + const remoteImage = undefined; await interaction.reply({ embeds: [ diff --git a/src/commands/previous.command.ts b/src/commands/previous.command.ts index 5c33b1b..a899bc9 100644 --- a/src/commands/previous.command.ts +++ b/src/commands/previous.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common/decorators'; @@ -7,19 +7,20 @@ import { CommandInteraction } from 'discord.js'; import { PlaybackService } from '../playback/playback.service'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; +@Injectable() @Command({ name: 'previous', description: 'Go to the previous track', }) -@Injectable() -export class PreviousTrackCommand implements DiscordCommand { +export class PreviousTrackCommand { constructor( private readonly playbackService: PlaybackService, private readonly discordMessageService: DiscordMessageService, ) {} - async handler(interaction: CommandInteraction): Promise { - if (!this.playbackService.previousTrack()) { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { + if (!this.playbackService.getPlaylistOrDefault().hasActiveTrack()) { await interaction.reply({ embeds: [ this.discordMessageService.buildErrorMessage({ @@ -27,8 +28,10 @@ export class PreviousTrackCommand implements DiscordCommand { }), ], }); + return; } + this.playbackService.getPlaylistOrDefault().setPreviousTrackAsActiveTrack(); await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 0239411..a868546 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,8 +1,4 @@ -import { - Command, - DiscordCommand, - InjectDiscordClient, -} from '@discord-nestjs/core'; +import { Command, Handler, IA, InjectDiscordClient } from '@discord-nestjs/core'; import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; @@ -21,7 +17,7 @@ import { JellyfinService } from '../clients/jellyfin/jellyfin.service'; description: 'Display the current status for troubleshooting', }) @Injectable() -export class StatusCommand implements DiscordCommand { +export class StatusCommand { constructor( @InjectDiscordClient() private readonly client: Client, @@ -29,7 +25,8 @@ export class StatusCommand implements DiscordCommand { private readonly jellyfinService: JellyfinService, ) {} - async handler(interaction: CommandInteraction): Promise { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ diff --git a/src/commands/stop.command.ts b/src/commands/stop.command.ts index 5cd99fd..d9d1579 100644 --- a/src/commands/stop.command.ts +++ b/src/commands/stop.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable } from '@nestjs/common'; @@ -13,15 +13,16 @@ import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; description: 'Stop playback entirely and clear the current playlist', }) @Injectable() -export class StopPlaybackCommand implements DiscordCommand { +export class StopPlaybackCommand { constructor( private readonly playbackService: PlaybackService, private readonly discordMessageService: DiscordMessageService, private readonly discordVoiceService: DiscordVoiceService, ) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async handler(interaction: CommandInteraction): Promise { - const hasActiveTrack = this.playbackService.hasActiveTrack(); + + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { + const hasActiveTrack = this.playbackService.getPlaylistOrDefault(); const title = hasActiveTrack ? 'Playback stopped successfully' : 'Playback failed to stop'; @@ -29,7 +30,7 @@ export class StopPlaybackCommand implements DiscordCommand { ? 'In addition, your playlist has been cleared' : 'There is no active track in the queue'; if (hasActiveTrack) { - this.playbackService.clear(); + this.playbackService.getPlaylistOrDefault().clear(); this.discordVoiceService.stop(false); } diff --git a/src/commands/summon.command.ts b/src/commands/summon.command.ts index 5a4b55f..8344a87 100644 --- a/src/commands/summon.command.ts +++ b/src/commands/summon.command.ts @@ -1,4 +1,4 @@ -import { Command, DiscordCommand } from '@discord-nestjs/core'; +import { Command, Handler, IA } from '@discord-nestjs/core'; import { Injectable, Logger } from '@nestjs/common'; @@ -7,12 +7,12 @@ import { CommandInteraction, GuildMember } from 'discord.js'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; +@Injectable() @Command({ name: 'summon', description: 'Join your current voice channel', }) -@Injectable() -export class SummonCommand implements DiscordCommand { +export class SummonCommand { private readonly logger = new Logger(SummonCommand.name); constructor( @@ -20,7 +20,8 @@ export class SummonCommand implements DiscordCommand { private readonly discordMessageService: DiscordMessageService, ) {} - async handler(interaction: CommandInteraction): Promise { + @Handler() + async handler(@IA() interaction: CommandInteraction): Promise { await interaction.deferReply(); const guildMember = interaction.member as GuildMember; diff --git a/src/middleware/command-execution-filter.ts b/src/middleware/command-execution-filter.ts index 19741a6..8e6c3c2 100644 --- a/src/middleware/command-execution-filter.ts +++ b/src/middleware/command-execution-filter.ts @@ -1,29 +1,18 @@ -import { - Catch, - DiscordArgumentMetadata, - DiscordExceptionFilter, -} from '@discord-nestjs/core'; -import { Logger } from '@nestjs/common'; -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - CommandInteraction, -} from 'discord.js'; -import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; + +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction } from 'discord.js'; + import { Constants } from '../utils/constants'; +import { DiscordMessageService } from '../clients/discord/discord.message.service'; @Catch(Error) -export class CommandExecutionError implements DiscordExceptionFilter { +export class CommandExecutionError implements ExceptionFilter { private readonly logger = new Logger(CommandExecutionError.name); constructor(private readonly discordMessageService: DiscordMessageService) {} - async catch( - exception: Error, - metadata: DiscordArgumentMetadata, - ): Promise { - const interaction: CommandInteraction = metadata.eventArgs[0]; + async catch(exception: Error, host: ArgumentsHost): Promise { + const interaction = host.getArgByIndex(0) as CommandInteraction; if (!interaction.isCommand()) { return; @@ -34,6 +23,10 @@ export class CommandExecutionError implements DiscordExceptionFilter { exception.stack, ); + if (!interaction.isRepliable()) { + return; + } + const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setLabel('Report this issue') diff --git a/src/models/shared/GenericPlaylist.ts b/src/models/shared/GenericPlaylist.ts new file mode 100644 index 0000000..74444e2 --- /dev/null +++ b/src/models/shared/GenericPlaylist.ts @@ -0,0 +1,129 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; + +import { GenericTrack } from './GenericTrack'; + +export class GenericPlaylist { + tracks: GenericTrack[]; + activeTrackIndex?: number; + + constructor(private readonly eventEmitter: EventEmitter2) { + this.tracks = []; + } + + /** + * Returns if the playlist has been started. + * Does not indicate if it's paused. + * @returns if the playlist has been started and has an active track + */ + hasStarted() { + return this.activeTrackIndex !== undefined; + } + + /** + * Checks if the active track is out of bounds + * @returns active track or undefined if there's none + */ + getActiveTrack(): GenericTrack | undefined { + if (this.isActiveTrackOutOfSync()) { + return undefined; + } + return this.tracks[this.activeTrackIndex]; + } + + hasActiveTrack(): boolean { + return ( + this.activeTrackIndex !== undefined && !this.isActiveTrackOutOfSync() + ); + } + + /** + * Go to the next track in the playlist + * @returns if the track has been changed successfully + */ + setNextTrackAsActiveTrack(): boolean { + if (this.activeTrackIndex >= this.tracks.length) { + return false; + } + + this.activeTrackIndex++; + this.eventEmitter.emit('controls.playlist.tracks.next', { + newActive: this.activeTrackIndex, + }); + this.announceTrackChange(); + return true; + } + + /** + * Go to the previous track in the playlist + * @returns if the track has been changed successfully + */ + setPreviousTrackAsActiveTrack(): boolean { + if (this.activeTrackIndex <= 0) { + return false; + } + + this.activeTrackIndex--; + this.eventEmitter.emit('controls.playlist.tracks.previous', { + newActive: this.activeTrackIndex, + }); + this.announceTrackChange(); + return true; + } + + /** + * Add new track(-s) to the playlist + * @param tracks the tracks that should be added + * @returns the new lendth of the tracks in the playlist + */ + enqueueTracks(tracks: GenericTrack[]) { + this.eventEmitter.emit('controls.playlist.tracks.enqueued', { + count: tracks.length, + activeTrack: this.activeTrackIndex, + }); + const length = this.tracks.push(...tracks); + this.announceTrackChange(); + return length; + } + + /** + * Check if there is a next track + * @returns if there is a track next in the playlist + */ + hasNextTrackInPlaylist() { + return this.activeTrackIndex < this.tracks.length; + } + + /** + * Check if there is a previous track + * @returns if there is a previous track in the playlist + */ + hasPreviousTrackInPlaylist() { + return this.activeTrackIndex > 0; + } + + clear() { + this.eventEmitter.emit('controls.playlist.tracks.clear'); + this.tracks = []; + this.activeTrackIndex = undefined; + } + + private announceTrackChange() { + if (!this.activeTrackIndex) { + this.activeTrackIndex = 0; + } + + this.eventEmitter.emit('internal.audio.announce', this.getActiveTrack()); + } + + private isActiveTrackOutOfSync(): boolean { + return ( + this.activeTrackIndex < 0 || this.activeTrackIndex >= this.tracks.length + ); + } +} + +export type PlaylistPlaybackType = + | 'once' + | 'repeat-once' + | 'repeat-indefinetly' + | 'shuffle'; diff --git a/src/models/shared/GenericTrack.ts b/src/models/shared/GenericTrack.ts new file mode 100644 index 0000000..d9ffce7 --- /dev/null +++ b/src/models/shared/GenericTrack.ts @@ -0,0 +1,59 @@ +import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models'; + +import { BaseJellyfinAudioPlayable } from '../jellyfinAudioItems'; +import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service'; + +export class GenericTrack { + /** + * The identifier of this track, structured as a UID. + * This id can be used to build a stream url and send more API requests to Jellyfin + */ + readonly id: string; + + /** + * The name of the track + */ + readonly name: string; + + /** + * The duration of the track + */ + readonly duration: number; + + /** + * A result object that contains a collection of images that are available outside the current network. + */ + readonly remoteImages?: RemoteImageResult; + + constructor( + id: string, + name: string, + duration: number, + remoteImages?: RemoteImageResult, + ) { + this.id = id; + this.name = name; + this.duration = duration; + this.remoteImages = remoteImages; + } + + getDuration() { + return this.duration; + } + + getStreamUrl(streamBuilder: JellyfinStreamBuilderService) { + return streamBuilder.buildStreamUrl(this.id, 96000); + } + + static constructFromJellyfinPlayable( + playable: BaseJellyfinAudioPlayable, + remoteImages: RemoteImageResult | undefined, + ): GenericTrack { + return new GenericTrack( + playable.Id, + playable.Name, + playable.RunTimeTicks / 1000, + remoteImages, + ); + } +} diff --git a/src/playback/playback.service.ts b/src/playback/playback.service.ts index 5e4083d..18a7df4 100644 --- a/src/playback/playback.service.ts +++ b/src/playback/playback.service.ts @@ -1,143 +1,21 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Playlist } from '../types/playlist'; -import { Track } from '../types/track'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { v4 as uuidv4 } from 'uuid'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { GenericPlaylist } from '../models/shared/GenericPlaylist'; @Injectable() export class PlaybackService { private readonly logger = new Logger(PlaybackService.name); - - private readonly playlist: Playlist = { - tracks: [], - activeTrack: null, - }; + private playlist: GenericPlaylist | undefined = undefined; constructor(private readonly eventEmitter: EventEmitter2) {} - getActiveTrack() { - return this.getTrackById(this.playlist.activeTrack); - } - - setActiveTrack(trackId: string) { - const track = this.getTrackById(trackId); - - if (!track) { - throw Error('track is not in playlist'); + getPlaylistOrDefault(): GenericPlaylist { + if (this.playlist) { + return this.playlist; } - this.playlist.activeTrack = track.id; - } - - nextTrack() { - const keys = this.getTrackIds(); - const index = this.getActiveIndex(); - - if (!this.hasActiveTrack() || index + 1 >= keys.length) { - this.logger.debug( - `Unable to go to next track, because playback has reached end of the playlist`, - ); - return false; - } - - const newKey = keys[index + 1]; - this.setActiveTrack(newKey); - this.getActiveTrackAndEmitEvent(); - return true; - } - - previousTrack() { - const index = this.getActiveIndex(); - - if (!this.hasActiveTrack() || index < 1) { - this.logger.debug( - `Unable to go to previous track, because there is no previous track in the playlist`, - ); - return false; - } - - const keys = this.getTrackIds(); - const newKey = keys[index - 1]; - this.setActiveTrack(newKey); - this.getActiveTrackAndEmitEvent(); - return true; - } - - enqueueTrack(track: Track) { - const uuid = uuidv4(); - - const emptyBefore = this.playlist.tracks.length === 0; - - this.playlist.tracks.push({ - id: uuid, - track: track, - }); - - this.logger.debug( - `Added the track '${track.jellyfinId}' to the current playlist`, - ); - - if (emptyBefore) { - this.setActiveTrack(this.playlist.tracks.find((x) => x.id === uuid).id); - this.getActiveTrackAndEmitEvent(); - } - - return uuid; - } - - enqueTrackAndInstantyPlay(track: Track) { - const uuid = uuidv4(); - - this.playlist.tracks.push({ - id: uuid, - track: track, - }); - - this.setActiveTrack(uuid); - this.getActiveTrackAndEmitEvent(); - } - - set(tracks: Track[]) { - this.playlist.tracks = tracks.map((t) => ({ - id: uuidv4(), - track: t, - })); - } - - clear() { - this.playlist.tracks = []; - } - - hasNextTrack() { - return this.getActiveIndex() + 1 < this.getTrackIds().length; - } - - hasActiveTrack() { - return this.playlist.activeTrack !== null; - } - - getPlaylist(): Playlist { + this.playlist = new GenericPlaylist(this.eventEmitter); return this.playlist; } - - private getTrackById(id: string) { - return this.playlist.tracks.find((x) => x.id === id); - } - - private getTrackIds() { - return this.playlist.tracks.map((item) => item.id); - } - - private getActiveIndex() { - return this.getTrackIds().indexOf(this.playlist.activeTrack); - } - - getActiveTrackAndEmitEvent() { - const activeTrack = this.getActiveTrack(); - this.logger.debug( - `A new track (${activeTrack.id}) was requested and will be emmitted as an event`, - ); - this.eventEmitter.emit('playback.newTrack', activeTrack.track); - } } diff --git a/yarn.lock b/yarn.lock index f1b6b2f..3f123db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -368,10 +368,10 @@ class-transformer "0.5.1" class-validator "0.14.0" -"@discord-nestjs/core@^4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-4.3.1.tgz#b0a71834147d2bfac8efe37b7091667ce7f146d6" - integrity sha512-38Bk7V0W+LF2qUbE6K+CfqRR309jU/tbj6ZZLN97nFHcYCcsteKQ7HxJeM15pw09Vvb7xbB+3DIls/YHpq3lRA== +"@discord-nestjs/core@^5.3.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@discord-nestjs/core/-/core-5.3.0.tgz#8e93b0310e8cc2c0cde74c6317d949d6e9d28d2d" + integrity sha512-eHVzuPCu3EbQyTln+ZEH0/Jwe0xPG7Z1eZV655jZoCSpq7RmvxWcTG/REf3XMSgtYFN27HaXa9vPCsgUOnp9xQ== dependencies: class-transformer "0.5.1"