From 621f3c5118a1771f4d4d3bff1cfca050086eaaff Mon Sep 17 00:00:00 2001 From: Manuel Date: Wed, 29 Mar 2023 21:58:49 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Strict=20Typescript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/clients/discord/discord.config.service.ts | 2 +- src/clients/discord/discord.voice.service.ts | 31 +++++++- .../jellyfin/jellyfin.search.service.ts | 74 +++++++++++++++---- src/clients/jellyfin/jellyfin.service.ts | 11 ++- .../jellyfin/jellyfin.websocket.service.ts | 13 +--- src/commands/play/play.comands.ts | 20 +++-- .../playlist.interaction-collector.ts | 7 +- src/commands/status.command.ts | 2 +- src/main.ts | 4 + src/models/search/AlbumSearchHint.ts | 8 +- src/models/search/PlaylistSearchHint.ts | 12 ++- src/models/search/SearchHint.ts | 10 +++ src/models/shared/Playlist.ts | 24 ++++-- src/models/shared/Track.ts | 2 +- src/types/websocket.ts | 2 +- src/updates/updates.service.ts | 14 +++- src/utils/trackConverter.ts | 15 ++++ tsconfig.json | 2 +- 18 files changed, 194 insertions(+), 59 deletions(-) create mode 100644 src/utils/trackConverter.ts diff --git a/src/clients/discord/discord.config.service.ts b/src/clients/discord/discord.config.service.ts index 870d5ee..b73cf52 100644 --- a/src/clients/discord/discord.config.service.ts +++ b/src/clients/discord/discord.config.service.ts @@ -9,7 +9,7 @@ import { GatewayIntentBits } from 'discord.js'; export class DiscordConfigService implements DiscordOptionsFactory { createDiscordOptions(): DiscordModuleOption { return { - token: process.env.DISCORD_CLIENT_TOKEN, + token: process.env.DISCORD_CLIENT_TOKEN ?? '', discordClientOptions: { intents: [ GatewayIntentBits.Guilds, diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts index fe86177..5b59abc 100644 --- a/src/clients/discord/discord.voice.service.ts +++ b/src/clients/discord/discord.voice.service.ts @@ -104,6 +104,10 @@ export class DiscordVoiceService { } changeVolume(volume: number) { + if (!this.audioResource || !this.audioResource.volume) { + this.logger.error(`AudioResource or volume was undefined`); + return; + } this.audioResource.volume.setVolume(volume); } @@ -236,6 +240,20 @@ export class DiscordVoiceService { } private attachEventListenersToAudioPlayer() { + if (!this.voiceConnection) { + this.logger.error( + `Unable to attach listener events, because the VoiceConnection was undefined`, + ); + return; + } + + if (!this.audioPlayer) { + this.logger.error( + `Unable to attach listener events, because the AudioPlayer was undefined`, + ); + return; + } + this.voiceConnection.on('debug', (message) => { if (process.env.DEBUG?.toLowerCase() !== 'true') { return; @@ -253,6 +271,13 @@ export class DiscordVoiceService { this.logger.error(message); }); this.audioPlayer.on('stateChange', (previousState) => { + if (!this.audioPlayer) { + this.logger.error( + `Unable to process state change from audio player, because the current audio player in the callback was undefined`, + ); + return; + } + this.logger.debug( `Audio player changed state from ${previousState.status} to ${this.audioPlayer.state.status}`, ); @@ -269,9 +294,11 @@ export class DiscordVoiceService { const playlist = this.playbackService.getPlaylistOrDefault(); const finishedTrack = playlist.getActiveTrack(); - finishedTrack.playing = false; - this.eventEmitter.emit('internal.audio.track.finish', finishedTrack); + if (finishedTrack) { + finishedTrack.playing = false; + this.eventEmitter.emit('internal.audio.track.finish', finishedTrack); + } const hasNextTrack = playlist.hasNextTrackInPlaylist(); diff --git a/src/clients/jellyfin/jellyfin.search.service.ts b/src/clients/jellyfin/jellyfin.search.service.ts index ffe755e..0aefa99 100644 --- a/src/clients/jellyfin/jellyfin.search.service.ts +++ b/src/clients/jellyfin/jellyfin.search.service.ts @@ -1,4 +1,5 @@ import { + BaseItemDto, BaseItemKind, RemoteImageResult, SearchHint as JellyfinSearchHint, @@ -57,9 +58,13 @@ export class JellyfinSearchService { const { SearchHints } = data; - return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter( - (x) => x !== null, - ); + if (!SearchHints) { + throw new Error('SearchHints were undefined'); + } + + return SearchHints.map((hint) => + this.transformToSearchHintFromHint(hint), + ).filter((x) => x !== null) as SearchHint[]; } catch (err) { this.logger.error(`Failed to search on Jellyfin: ${err}`); return []; @@ -82,8 +87,15 @@ export class JellyfinSearchService { return []; } + if (!axiosResponse.data.Items) { + this.logger.error( + `Jellyfin search returned no items: ${axiosResponse.data}`, + ); + return []; + } + return axiosResponse.data.Items.map((hint) => - SearchHint.constructFromHint(hint), + SearchHint.constructFromBaseItem(hint), ); } @@ -104,6 +116,13 @@ export class JellyfinSearchService { return []; } + if (!axiosResponse.data.SearchHints) { + this.logger.error( + `Received an unexpected empty list but expected a list of tracks of the album`, + ); + return []; + } + return [...axiosResponse.data.SearchHints] .reverse() .map((hint) => SearchHint.constructFromHint(hint)); @@ -112,7 +131,7 @@ export class JellyfinSearchService { async getById( id: string, includeItemTypes: BaseItemKind[], - ): Promise | undefined { + ): Promise { const api = this.jellyfinService.getApi(); const searchApi = getItemsApi(api); @@ -122,18 +141,18 @@ export class JellyfinSearchService { includeItemTypes: includeItemTypes, }); - if (data.Items.length !== 1) { + if (!data.Items || data.Items.length !== 1) { this.logger.warn(`Failed to retrieve item via id '${id}'`); - return null; + return undefined; } - return this.transformToSearchHint(data.Items[0]); + return this.transformToSearchHintFromBaseItemDto(data.Items[0]); } async getAllById( ids: string[], includeItemTypes: BaseItemKind[] = [BaseItemKind.Audio], - ): Promise | undefined { + ): Promise { const api = this.jellyfinService.getApi(); const searchApi = getItemsApi(api); @@ -143,12 +162,14 @@ export class JellyfinSearchService { includeItemTypes: includeItemTypes, }); - if (data.Items.length !== 1) { + if (!data.Items || data.Items.length !== 1) { this.logger.warn(`Failed to retrieve item via id '${ids}'`); - return null; + return []; } - return data.Items.map((item) => this.transformToSearchHint(item)); + return data.Items.map((item) => + this.transformToSearchHintFromBaseItemDto(item), + ).filter((searchHint) => searchHint !== undefined) as SearchHint[]; } async getRemoteImageById(id: string, limit = 20): Promise { @@ -204,18 +225,25 @@ export class JellyfinSearchService { recursive: true, }); + if (!response.data.Items) { + this.logger.error( + `Received empty list of items but expected a random list of tracks`, + ); + return []; + } + return response.data.Items.map((item) => { return SearchHint.constructFromBaseItem(item); }); } catch (err) { this.logger.error( - `Unabele to retrieve random items from Jellyfin: ${err}`, + `Unable to retrieve random items from Jellyfin: ${err}`, ); return []; } } - private transformToSearchHint(jellyifnHint: JellyfinSearchHint) { + private transformToSearchHintFromHint(jellyifnHint: JellyfinSearchHint) { switch (jellyifnHint.Type) { case BaseItemKind[BaseItemKind.Audio]: return SearchHint.constructFromHint(jellyifnHint); @@ -227,7 +255,23 @@ export class JellyfinSearchService { this.logger.warn( `Received unexpected item type from Jellyfin search: ${jellyifnHint.Type}`, ); - return null; + return undefined; + } + } + + private transformToSearchHintFromBaseItemDto(baseItemDto: BaseItemDto) { + switch (baseItemDto.Type) { + case BaseItemKind[BaseItemKind.Audio]: + return SearchHint.constructFromBaseItem(baseItemDto); + case BaseItemKind[BaseItemKind.MusicAlbum]: + return AlbumSearchHint.constructFromBaseItem(baseItemDto); + case BaseItemKind[BaseItemKind.Playlist]: + return PlaylistSearchHint.constructFromBaseItem(baseItemDto); + default: + this.logger.warn( + `Received unexpected item type from Jellyfin search: ${baseItemDto.Type}`, + ); + return undefined; } } } diff --git a/src/clients/jellyfin/jellyfin.service.ts b/src/clients/jellyfin/jellyfin.service.ts index 3051f12..83d173d 100644 --- a/src/clients/jellyfin/jellyfin.service.ts +++ b/src/clients/jellyfin/jellyfin.service.ts @@ -33,18 +33,23 @@ export class JellyfinService { }, }); - this.api = this.jellyfin.createApi(process.env.JELLYFIN_SERVER_ADDRESS); + this.api = this.jellyfin.createApi( + process.env.JELLYFIN_SERVER_ADDRESS ?? '', + ); this.logger.debug('Created Jellyfin Client and Api'); } authenticate() { this.api .authenticateUserByName( - process.env.JELLYFIN_AUTHENTICATION_USERNAME, + process.env.JELLYFIN_AUTHENTICATION_USERNAME ?? '', process.env.JELLYFIN_AUTHENTICATION_PASSWORD, ) .then(async (response) => { - if (response.data.SessionInfo === undefined) { + if ( + response.data.SessionInfo === undefined || + response.data.SessionInfo.UserId === undefined + ) { this.logger.error( `Failed to authenticate with response code ${response.status}: '${response.data}'`, ); diff --git a/src/clients/jellyfin/jellyfin.websocket.service.ts b/src/clients/jellyfin/jellyfin.websocket.service.ts index 4d22d37..277eb9e 100644 --- a/src/clients/jellyfin/jellyfin.websocket.service.ts +++ b/src/clients/jellyfin/jellyfin.websocket.service.ts @@ -1,13 +1,12 @@ import { PlaystateCommand, SessionMessageType, - UserItemDataDto, } from '@jellyfin/sdk/lib/generated-client/models'; import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Cron } from '@nestjs/schedule'; -import { Session } from 'inspector'; +import { convertToTracks } from 'src/utils/trackConverter'; import { WebSocket } from 'ws'; @@ -106,15 +105,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy { `Processing ${ids.length} ids received via websocket and adding them to the queue`, ); const searchHints = await this.jellyfinSearchService.getAllById(ids); - - const tracks = await Promise.all( - searchHints.map(async (x) => - ( - await x.toTracks(this.jellyfinSearchService) - ).find((x) => x !== null), - ), - ); - + const tracks = convertToTracks(searchHints, this.jellyfinSearchService); this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks); break; case SessionMessageType[SessionMessageType.Playstate]: diff --git a/src/commands/play/play.comands.ts b/src/commands/play/play.comands.ts index 0e72eb4..a82ab47 100644 --- a/src/commands/play/play.comands.ts +++ b/src/commands/play/play.comands.ts @@ -48,12 +48,12 @@ export class PlayItemCommand { async handler( @InteractionEvent(SlashCommandPipe) dto: PlayCommandParams, @IA() interaction: CommandInteraction, - ): Promise { + ) { await interaction.deferReply({ ephemeral: true }); const baseItems = PlayCommandParams.getBaseItemKinds(dto.type); - let item: SearchHint; + let item: SearchHint | undefined; if (dto.name.startsWith('native-')) { item = await this.jellyfinSearchService.getById( dto.name.replace('native-', ''), @@ -105,8 +105,8 @@ export class PlayItemCommand { this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks); const remoteImage: RemoteImageInfo | undefined = tracks - .flatMap((x) => x.getRemoteImages()) - .find((x) => true); + .flatMap((track) => track.getRemoteImages()) + .find(() => true); await interaction.followUp({ embeds: [ @@ -120,7 +120,7 @@ export class PlayItemCommand { if (!remoteImage) { return embedBuilder; } - return embedBuilder.setThumbnail(remoteImage.Url); + return embedBuilder.setThumbnail(remoteImage.Url ?? ''); }, }), ], @@ -135,7 +135,15 @@ export class PlayItemCommand { } const focusedAutoCompleteAction = interaction.options.getFocused(true); - const typeIndex: number | null = interaction.options.getInteger('type'); + 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 searchQuery = focusedAutoCompleteAction.value; diff --git a/src/commands/playlist/playlist.interaction-collector.ts b/src/commands/playlist/playlist.interaction-collector.ts index 462ad16..f308cfb 100644 --- a/src/commands/playlist/playlist.interaction-collector.ts +++ b/src/commands/playlist/playlist.interaction-collector.ts @@ -30,7 +30,10 @@ export class PlaylistInteractionCollector { @Filter() filter(interaction: ButtonInteraction): boolean { - return this.causeInteraction.id === interaction.message.interaction.id; + return ( + interaction.message.interaction !== null && + this.causeInteraction.id === interaction.message.interaction.id + ); } @On('collect') @@ -55,7 +58,7 @@ export class PlaylistInteractionCollector { await interaction.update(reply as InteractionUpdateOptions); } - private getInteraction(interaction: ButtonInteraction): number | null { + private getInteraction(interaction: ButtonInteraction): number | undefined { const current = this.playlistCommand.pageData.get(this.causeInteraction.id); if (current === undefined) { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a868546..5412dc2 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -39,7 +39,7 @@ export class StatusCommand { const status = Status[this.client.ws.status]; const interval = intervalToDuration({ - start: this.client.uptime, + start: this.client.uptime ?? 0, end: 0, }); const formattedDuration = formatDuration(interval); diff --git a/src/main.ts b/src/main.ts index 088755b..4fc280c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,10 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; function getLoggingLevels(): LogLevel[] { + if (!process.env.LOG_LEVEL) { + return ['error', 'warn', 'log']; + } + switch (process.env.LOG_LEVEL.toLowerCase()) { case 'error': return ['error']; diff --git a/src/models/search/AlbumSearchHint.ts b/src/models/search/AlbumSearchHint.ts index c65d9bb..b51a57e 100644 --- a/src/models/search/AlbumSearchHint.ts +++ b/src/models/search/AlbumSearchHint.ts @@ -11,6 +11,12 @@ export class AlbumSearchHint extends SearchHint { } static constructFromHint(hint: JellyfinSearchHint) { + if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) { + throw new Error( + 'Unable to construct playlist search hint, required properties were undefined', + ); + } + return new AlbumSearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000); } @@ -24,7 +30,7 @@ export class AlbumSearchHint extends SearchHint { (await x.toTracks(searchService)).find((x) => x !== null), ), ); - return tracks.map((track): Track => { + return tracks.map((track: Track): Track => { track.remoteImages = remoteImages; return track; }); diff --git a/src/models/search/PlaylistSearchHint.ts b/src/models/search/PlaylistSearchHint.ts index dd308c4..b1099f9 100644 --- a/src/models/search/PlaylistSearchHint.ts +++ b/src/models/search/PlaylistSearchHint.ts @@ -4,6 +4,7 @@ import { Track } from '../shared/Track'; import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; import { SearchHint } from './SearchHint'; +import { convertToTracks } from 'src/utils/trackConverter'; export class PlaylistSearchHint extends SearchHint { override toString(): string { @@ -11,6 +12,12 @@ export class PlaylistSearchHint extends SearchHint { } static constructFromHint(hint: JellyfinSearchHint) { + if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) { + throw new Error( + 'Unable to construct playlist search hint, required properties were undefined', + ); + } + return new PlaylistSearchHint( hint.Id, hint.Name, @@ -22,9 +29,6 @@ export class PlaylistSearchHint extends SearchHint { searchService: JellyfinSearchService, ): Promise { const playlistItems = await searchService.getPlaylistitems(this.id); - const tracks = playlistItems.map(async (x) => - (await x.toTracks(searchService)).find((x) => x !== null), - ); - return await Promise.all(tracks); + return convertToTracks(playlistItems, searchService); } } diff --git a/src/models/search/SearchHint.ts b/src/models/search/SearchHint.ts index 016acc4..b1355bd 100644 --- a/src/models/search/SearchHint.ts +++ b/src/models/search/SearchHint.ts @@ -26,10 +26,20 @@ export class SearchHint { } static constructFromHint(hint: JellyfinSearchHint) { + if (hint.Id === undefined || !hint.Name || !hint.RunTimeTicks) { + throw new Error( + 'Unable to construct search hint, required properties were undefined', + ); + } return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000); } static constructFromBaseItem(baseItem: BaseItemDto) { + if (baseItem.Id === undefined || !baseItem.Name || !baseItem.RunTimeTicks) { + throw new Error( + 'Unable to construct search hint from base item, required properties were undefined', + ); + } return new SearchHint( baseItem.Id, baseItem.Name, diff --git a/src/models/shared/Playlist.ts b/src/models/shared/Playlist.ts index c16a5b5..d6a4ede 100644 --- a/src/models/shared/Playlist.ts +++ b/src/models/shared/Playlist.ts @@ -1,4 +1,4 @@ -import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Track } from './Track'; @@ -24,7 +24,7 @@ export class Playlist { * @returns active track or undefined if there's none */ getActiveTrack(): Track | undefined { - if (this.isActiveTrackOutOfSync()) { + if (this.isActiveTrackOutOfSync() || this.activeTrackIndex === undefined) { return undefined; } return this.tracks[this.activeTrackIndex]; @@ -51,7 +51,10 @@ export class Playlist { setNextTrackAsActiveTrack(): boolean { this.announceTrackFinishIfSet(); - if (this.activeTrackIndex >= this.tracks.length) { + if ( + this.activeTrackIndex === undefined || + this.activeTrackIndex >= this.tracks.length + ) { return false; } @@ -70,7 +73,7 @@ export class Playlist { setPreviousTrackAsActiveTrack(): boolean { this.announceTrackFinishIfSet(); - if (this.activeTrackIndex <= 0) { + if (this.activeTrackIndex === undefined || this.activeTrackIndex <= 0) { return false; } @@ -120,7 +123,7 @@ export class Playlist { * @returns if there is a track next in the playlist */ hasNextTrackInPlaylist() { - return this.activeTrackIndex + 1 < this.tracks.length; + return (this.activeTrackIndex ?? 0) + 1 < this.tracks.length; } /** @@ -128,7 +131,7 @@ export class Playlist { * @returns if there is a previous track in the playlist */ hasPreviousTrackInPlaylist() { - return this.activeTrackIndex > 0; + return this.activeTrackIndex !== undefined && this.activeTrackIndex > 0; } clear() { @@ -156,13 +159,20 @@ export class Playlist { } const activeTrack = this.getActiveTrack(); + + if (!activeTrack) { + return; + } + activeTrack.playing = true; this.eventEmitter.emit('internal.audio.track.announce', activeTrack); } private isActiveTrackOutOfSync(): boolean { return ( - this.activeTrackIndex < 0 || this.activeTrackIndex >= this.tracks.length + this.activeTrackIndex === undefined || + this.activeTrackIndex < 0 || + this.activeTrackIndex >= this.tracks.length ); } } diff --git a/src/models/shared/Track.ts b/src/models/shared/Track.ts index 0e7cb2d..0168f61 100644 --- a/src/models/shared/Track.ts +++ b/src/models/shared/Track.ts @@ -51,6 +51,6 @@ export class Track { } getRemoteImages(): RemoteImageInfo[] { - return this.remoteImages.Images; + return this.remoteImages?.Images ?? []; } } diff --git a/src/types/websocket.ts b/src/types/websocket.ts index ee4aa8f..43c4edf 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -30,7 +30,7 @@ export class PlayNowCommand { } getSelection(): string[] { - if (this.hasSelection()) { + if (this.hasSelection() && this.StartIndex !== undefined) { return [this.ItemIds[this.StartIndex]]; } diff --git a/src/updates/updates.service.ts b/src/updates/updates.service.ts index cdc3a88..6c90b3e 100644 --- a/src/updates/updates.service.ts +++ b/src/updates/updates.service.ts @@ -30,6 +30,14 @@ export class UpdatesService { this.logger.debug('Checking for available updates...'); const latestGitHubRelease = await this.fetchLatestGithubRelease(); + + if (!latestGitHubRelease) { + this.logger.warn( + `Aborting update check because api request failed. Please check your internet connection or disable the check`, + ); + return; + } + const currentVersion = Constants.Metadata.Version.All(); if (latestGitHubRelease.tag_name <= currentVersion) { @@ -95,21 +103,21 @@ export class UpdatesService { }); } - private async fetchLatestGithubRelease(): Promise { + private async fetchLatestGithubRelease(): Promise { return axios({ method: 'GET', url: Constants.Links.Api.GetLatestRelease, }) .then((response) => { if (response.status !== 200) { - return null; + return undefined; } return response.data as GithubRelease; }) .catch((err) => { this.logger.error('Error while checking for updates', err); - return null; + return undefined; }); } } diff --git a/src/utils/trackConverter.ts b/src/utils/trackConverter.ts new file mode 100644 index 0000000..4754a96 --- /dev/null +++ b/src/utils/trackConverter.ts @@ -0,0 +1,15 @@ +import { JellyfinSearchService } from 'src/clients/jellyfin/jellyfin.search.service'; +import { SearchHint } from 'src/models/search/SearchHint'; +import { Track } from 'src/models/shared/Track'; + +export const convertToTracks = ( + hints: SearchHint[], + jellyfinSearchService: JellyfinSearchService, +): Track[] => { + let tracks: Track[] = []; + hints.forEach(async (hint) => { + const searchedTracks = await hint.toTracks(jellyfinSearchService); + tracks = [...tracks, ...searchedTracks]; + }); + return tracks; +}; diff --git a/tsconfig.json b/tsconfig.json index efc026f..1e9efef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, + "strictNullChecks": true, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false,