diff --git a/package.json b/package.json index a95f8cd..0794b9e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "rimraf": "^4.4.1", "rxjs": "^7.2.0", "uuid": "^9.0.0", - "ws": "^8.13.0" + "ws": "^8.13.0", + "zod": "^3.21.4" }, "devDependencies": { "@nestjs/cli": "^9.3.0", diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts index 0b350ba..db81906 100644 --- a/src/clients/discord/discord.voice.service.ts +++ b/src/clients/discord/discord.voice.service.ts @@ -14,6 +14,7 @@ import { import { Injectable } from '@nestjs/common'; import { Logger } from '@nestjs/common/services'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { Interval } from '@nestjs/schedule'; import { GuildMember } from 'discord.js'; @@ -224,7 +225,7 @@ export class DiscordVoiceService { if (this.audioPlayer === undefined) { this.logger.debug( - "Initialized new instance of AudioPlayer because it has not been defined yet", + 'Initialized new instance of AudioPlayer because it has not been defined yet', ); this.audioPlayer = createAudioPlayer({ debug: process.env.DEBUG?.toLowerCase() === 'true', @@ -291,7 +292,7 @@ export class DiscordVoiceService { return; } - this.logger.debug("Audio player finished playing old resource"); + this.logger.debug('Audio player finished playing old resource'); const playlist = this.playbackService.getPlaylistOrDefault(); const finishedTrack = playlist.getActiveTrack(); @@ -308,11 +309,43 @@ export class DiscordVoiceService { ); if (!hasNextTrack) { - this.logger.debug("Reached the end of the playlist"); + this.logger.debug('Reached the end of the playlist'); return; } this.playbackService.getPlaylistOrDefault().setNextTrackAsActiveTrack(); }); } + + @Interval(500) + private checkAudioResourcePlayback() { + if (!this.audioResource) { + return; + } + + const progress = this.audioResource.playbackDuration; + + const playlist = this.playbackService.getPlaylistOrDefault(); + + if (!playlist) { + this.logger.error( + `Failed to update ellapsed audio time because playlist was unexpectitly undefined`, + ); + return; + } + + const activeTrack = playlist.getActiveTrack(); + + if (!activeTrack) { + this.logger.error( + `Failed to update ellapsed audio time because active track was unexpectitly undefined`, + ); + return; + } + + activeTrack.updatePlaybackProgress(progress); + this.logger.verbose( + `Reporting progress: ${progress} on track ${activeTrack.id}`, + ); + } } diff --git a/src/clients/jellyfin/jellyfin.playstate.service.ts b/src/clients/jellyfin/jellyfin.playstate.service.ts index 465322c..ae56dc2 100644 --- a/src/clients/jellyfin/jellyfin.playstate.service.ts +++ b/src/clients/jellyfin/jellyfin.playstate.service.ts @@ -10,6 +10,7 @@ import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { Interval } from '@nestjs/schedule'; import { Track } from '../../models/shared/Track'; import { PlaybackService } from '../../playback/playback.service'; @@ -52,6 +53,7 @@ export class JellyinPlaystateService { await this.playstateApi.reportPlaybackStart({ playbackStartInfo: { ItemId: track.id, + PositionTicks: 0, }, }); } @@ -60,7 +62,7 @@ export class JellyinPlaystateService { private async onPlaybackFinished(track: Track) { if (!track) { this.logger.error( - "Unable to report playback because finished track was undefined", + 'Unable to report playback because finished track was undefined', ); return; } @@ -68,6 +70,7 @@ export class JellyinPlaystateService { await this.playstateApi.reportPlaybackStopped({ playbackStopInfo: { ItemId: track.id, + PositionTicks: track.playbackProgress * 10000, }, }); } @@ -78,7 +81,7 @@ export class JellyinPlaystateService { if (!track) { this.logger.error( - "Unable to report changed playstate to Jellyfin because no track was active", + 'Unable to report changed playstate to Jellyfin because no track was active', ); return; } @@ -87,7 +90,28 @@ export class JellyinPlaystateService { playbackProgressInfo: { IsPaused: paused, ItemId: track.id, + PositionTicks: track.playbackProgress * 10000, }, }); } + + @Interval(1000) + private async onPlaybackProgress() { + const track = this.playbackService.getPlaylistOrDefault().getActiveTrack(); + + if (!track) { + return; + } + + await this.playstateApi.reportPlaybackProgress({ + playbackProgressInfo: { + ItemId: track.id, + PositionTicks: track.playbackProgress * 10000, + }, + }); + + this.logger.verbose( + `Reported playback progress ${track.playbackProgress} to Jellyfin for item ${track.id}`, + ); + } } diff --git a/src/commands/play/play.comands.ts b/src/commands/play/play.comands.ts index 41ae5b0..94acf84 100644 --- a/src/commands/play/play.comands.ts +++ b/src/commands/play/play.comands.ts @@ -70,7 +70,8 @@ export class PlayItemCommand { embeds: [ this.discordMessageService.buildMessage({ title: 'No results found', - description: "- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters", + description: + '- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters', }), ], ephemeral: true, @@ -136,15 +137,8 @@ export class PlayItemCommand { const focusedAutoCompleteAction = interaction.options.getFocused(true); const typeIndex = interaction.options.getInteger('type'); - - if (typeIndex === null) { - this.logger.error( - `Failed to get type integer from play command interaction autocomplete`, - ); - return; - } - - const type = Object.values(SearchType)[typeIndex] as SearchType; + const type = + typeIndex !== null ? Object.values(SearchType)[typeIndex] : undefined; const searchQuery = focusedAutoCompleteAction.value; if (!searchQuery || searchQuery.length < 1) { diff --git a/src/commands/playlist/playlist.command.ts b/src/commands/playlist/playlist.command.ts index d244041..2cd41cf 100644 --- a/src/commands/playlist/playlist.command.ts +++ b/src/commands/playlist/playlist.command.ts @@ -22,16 +22,19 @@ import { InteractionUpdateOptions, } from 'discord.js'; -import { PlaybackService } from '../../playback/playback.service'; -import { chunkArray } from '../../utils/arrayUtils'; -import { Constants } from '../../utils/constants'; -import { formatMillisecondsAsHumanReadable } from '../../utils/timeUtils'; import { DiscordMessageService } from '../../clients/discord/discord.message.service'; import { Track } from '../../models/shared/Track'; -import { trimStringToFixedLength } from '../../utils/stringUtils/stringUtils'; +import { PlaybackService } from '../../playback/playback.service'; +import { chunkArray } from '../../utils/arrayUtils'; +import { trimStringToFixedLength, zeroPad } from '../../utils/stringUtils/stringUtils'; +import { Interval } from '@nestjs/schedule'; +import { lightFormat } from 'date-fns'; import { PlaylistInteractionCollector } from './playlist.interaction-collector'; import { PlaylistCommandParams } from './playlist.params'; +import { PlaylistTempCommandData } from './playlist.types'; +import { tr } from 'date-fns/locale'; +import { takeCoverage } from 'v8'; @Injectable() @Command({ @@ -41,7 +44,7 @@ import { PlaylistCommandParams } from './playlist.params'; @UseInterceptors(CollectorInterceptor) @UseCollectors(PlaylistInteractionCollector) export class PlaylistCommand { - public pageData: Map = new Map(); + public pageData: Map = new Map(); private readonly logger = new Logger(PlaylistCommand.name); constructor( @@ -61,7 +64,10 @@ export class PlaylistCommand { this.getReplyForPage(page) as InteractionReplyOptions, ); - this.pageData.set(interaction.id, page); + this.pageData.set(interaction.id, { + page, + interaction, + }); this.logger.debug( `Added '${interaction.id}' as a message id for page storage`, ); @@ -82,6 +88,36 @@ export class PlaylistCommand { return chunkArray(playlist.tracks, 10); } + private createInterval(interaction: CommandInteraction) { + return setInterval(async () => { + const tempData = this.pageData.get(interaction.id); + + if (!tempData) { + this.logger.warn( + `Failed to update from interval, because temp data was not found`, + ); + return; + } + + await interaction.editReply(this.getReplyForPage(tempData.page)); + }, 2000); + } + + @Interval(2 * 1000) + private async updatePlaylists() { + if (this.pageData.size === 0) { + return; + } + + this.logger.verbose( + `Updating playlist for ${this.pageData.size} playlist datas`, + ); + + this.pageData.forEach(async (value) => { + await value.interaction.editReply(this.getReplyForPage(value.page)); + }); + } + public getReplyForPage( page: number, ): InteractionReplyOptions | InteractionUpdateOptions { @@ -176,26 +212,34 @@ export class PlaylistCommand { ); } + const paddingNumber = playlist.getLength() >= 100 ? 3 : 2; + const content = chunk .map((track, index) => { const isCurrent = track === playlist.getActiveTrack(); - // use the offset for the page, add the current index and offset by one because the array index is used - let point = `${offset + index + 1}. `; - point += `**${trimStringToFixedLength(track.name, 30)}**`; - + let line = `\`\`${zeroPad(offset + index + 1, paddingNumber)}.\`\` `; + line += this.getTrackName(track, isCurrent) + ' • '; if (isCurrent) { - point += ' :loud_sound:'; + line += lightFormat(track.getPlaybackProgress(), 'mm:ss') + ' / '; } - - point += '\n'; - point += Constants.Design.InvisibleSpace.repeat(2); - point += formatMillisecondsAsHumanReadable(track.getDuration()); - - return point; + line += lightFormat(track.getDuration(), 'mm:ss'); + if (isCurrent) { + line += ' • (:play_pause:)'; + } + return line; }) .join('\n'); return new EmbedBuilder().setTitle('Your playlist').setDescription(content); } + + private getTrackName(track: Track, active: boolean) { + const trimmedTitle = trimStringToFixedLength(track.name, 30); + if (active) { + return `**${trimmedTitle}**`; + } + + return trimmedTitle; + } } diff --git a/src/commands/playlist/playlist.interaction-collector.ts b/src/commands/playlist/playlist.interaction-collector.ts index 0619c3e..49e3ca8 100644 --- a/src/commands/playlist/playlist.interaction-collector.ts +++ b/src/commands/playlist/playlist.interaction-collector.ts @@ -15,6 +15,7 @@ import { } from 'discord.js'; import { PlaylistCommand } from './playlist.command'; +import { PlaylistTempCommandData } from './playlist.types'; @Injectable({ scope: Scope.REQUEST }) @InteractionEventCollector({ time: 60 * 1000 }) @@ -40,7 +41,7 @@ export class PlaylistInteractionCollector { async onCollect(interaction: ButtonInteraction): Promise { const targetPage = this.getInteraction(interaction); this.logger.verbose( - `Extracted the target page ${targetPage} from the button interaction`, + `Extracted the target page '${targetPage?.page}' from the button interaction`, ); if (targetPage === undefined) { @@ -51,14 +52,16 @@ export class PlaylistInteractionCollector { } this.logger.debug( - `Updating current page for interaction ${this.causeInteraction.id} to ${targetPage}`, + `Updating current page for interaction ${this.causeInteraction.id} to ${targetPage.page}`, ); this.playlistCommand.pageData.set(this.causeInteraction.id, targetPage); - const reply = this.playlistCommand.getReplyForPage(targetPage); + const reply = this.playlistCommand.getReplyForPage(targetPage.page); await interaction.update(reply as InteractionUpdateOptions); } - private getInteraction(interaction: ButtonInteraction): number | undefined { + private getInteraction( + interaction: ButtonInteraction, + ): PlaylistTempCommandData | undefined { const current = this.playlistCommand.pageData.get(this.causeInteraction.id); if (current === undefined) { @@ -78,12 +81,18 @@ export class PlaylistInteractionCollector { switch (interaction.customId) { case 'playlist-controls-next': - return current + 1; + return { + ...current, + page: current.page + 1, + }; case 'playlist-controls-previous': - return current - 1; + return { + ...current, + page: current.page - 1, + }; default: this.logger.error( - "Unable to map button interaction from collector to target page", + 'Unable to map button interaction from collector to target page', ); return undefined; } diff --git a/src/commands/playlist/playlist.types.ts b/src/commands/playlist/playlist.types.ts new file mode 100644 index 0000000..1858151 --- /dev/null +++ b/src/commands/playlist/playlist.types.ts @@ -0,0 +1,6 @@ +import { CommandInteraction } from 'discord.js'; + +export type PlaylistTempCommandData = { + page: number; + interaction: CommandInteraction; +}; diff --git a/src/models/search/SearchHint.ts b/src/models/search/SearchHint.ts index b1355bd..09a382e 100644 --- a/src/models/search/SearchHint.ts +++ b/src/models/search/SearchHint.ts @@ -2,6 +2,7 @@ import { BaseItemDto, SearchHint as JellyfinSearchHint, } from '@jellyfin/sdk/lib/generated-client/models'; +import { z } from 'zod'; import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; import { Track } from '../shared/Track'; @@ -26,12 +27,27 @@ export class SearchHint { } static constructFromHint(hint: JellyfinSearchHint) { - if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) { + const schema = z.object({ + Id: z.string(), + Name: z.string(), + RunTimeTicks: z.number(), + }); + + const result = schema.safeParse(hint); + + if (!result.success) { throw new Error( - 'Unable to construct search hint, required properties were undefined', + `Unable to construct search hint, required properties were undefined: ${JSON.stringify( + hint, + )}`, ); } - return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000); + + return new SearchHint( + result.data.Id, + result.data.Name, + result.data.RunTimeTicks / 10000, + ); } static constructFromBaseItem(baseItem: BaseItemDto) { diff --git a/src/models/shared/Track.ts b/src/models/shared/Track.ts index 0168f61..c3cfd8d 100644 --- a/src/models/shared/Track.ts +++ b/src/models/shared/Track.ts @@ -29,6 +29,8 @@ export class Track { playing: boolean; + playbackProgress: number; + constructor( id: string, name: string, @@ -40,6 +42,7 @@ export class Track { this.duration = duration; this.remoteImages = remoteImages; this.playing = false; + this.playbackProgress = 0; } getDuration() { @@ -53,4 +56,12 @@ export class Track { getRemoteImages(): RemoteImageInfo[] { return this.remoteImages?.Images ?? []; } + + getPlaybackProgress() { + return this.playbackProgress; + } + + updatePlaybackProgress(progress: number) { + this.playbackProgress = progress; + } } diff --git a/src/utils/stringUtils/stringUtils.ts b/src/utils/stringUtils/stringUtils.ts index b9637a3..18fb29e 100644 --- a/src/utils/stringUtils/stringUtils.ts +++ b/src/utils/stringUtils/stringUtils.ts @@ -11,3 +11,6 @@ export const trimStringToFixedLength = (value: string, maxLength: number) => { return value.substring(0, upperBound) + '...'; }; + +export const zeroPad = (num: number, places: number) => + String(num).padStart(places, '0'); diff --git a/yarn.lock b/yarn.lock index 1cd51a4..9e9bebc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5403,3 +5403,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.21.4: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" + integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==