Merge pull request #149 from manuel-rw/refactor/strict-typescript

♻️ Strict Typescript
This commit is contained in:
Manuel 2023-04-02 17:41:14 +02:00 committed by GitHub
commit 65d401fdbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 204 additions and 61 deletions

View File

@ -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,

View File

@ -103,6 +103,12 @@ export class DiscordVoiceService {
}
changeVolume(volume: number) {
if (!this.audioResource || !this.audioResource.volume) {
this.logger.error(
`Failed to change audio volume, AudioResource or volume was undefined`,
);
return;
}
this.audioResource.volume.setVolume(volume);
}
@ -235,6 +241,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;
@ -252,6 +272,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}`,
);
@ -268,9 +295,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();

View File

@ -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<SearchHint> | undefined {
): Promise<SearchHint | undefined> {
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<SearchHint[]> | undefined {
): Promise<SearchHint[]> {
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<RemoteImageResult> {
@ -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;
}
}
}

View File

@ -33,18 +33,20 @@ 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?.UserId === undefined) {
this.logger.error(
`Failed to authenticate with response code ${response.status}: '${response.data}'`,
);

View File

@ -6,6 +6,7 @@ import {
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Cron } from '@nestjs/schedule';
import { convertToTracks } from 'src/utils/trackConverter';
import { WebSocket } from 'ws';
@ -104,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]:

View File

@ -48,12 +48,12 @@ export class PlayItemCommand {
async handler(
@InteractionEvent(SlashCommandPipe) dto: PlayCommandParams,
@IA() interaction: CommandInteraction,
): Promise<InteractionReplyOptions | string> {
) {
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-', ''),
@ -104,9 +104,9 @@ export class PlayItemCommand {
);
this.playbackService.getPlaylistOrDefault().enqueueTracks(tracks);
const remoteImage: RemoteImageInfo | undefined = tracks
.flatMap((track) => track.getRemoteImages())
.find(() => true);
const remoteImages = tracks.flatMap((track) => track.getRemoteImages());
const remoteImage: RemoteImageInfo | undefined =
remoteImages.length > 0 ? remoteImages[0] : undefined;
await interaction.followUp({
embeds: [
@ -117,7 +117,7 @@ export class PlayItemCommand {
reducedDuration,
)})`,
mixin(embedBuilder) {
if (!remoteImage) {
if (!remoteImage?.Url) {
return embedBuilder;
}
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;

View File

@ -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) {

View File

@ -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);

View File

@ -1,3 +1,4 @@
import { InjectionToken } from '@nestjs/common';
import {
HealthCheckResult,
HealthCheckService,
@ -42,10 +43,14 @@ describe('HealthController', () => {
}
if (token === HealthCheckService) {
return new HealthCheckService(new HealthCheckExecutor(), null, null);
return new HealthCheckService(
new HealthCheckExecutor(),
{ getErrorMessage: jest.fn() },
{ log: jest.fn(), error: jest.fn(), warn: jest.fn() },
);
}
return useDefaultMockerToken(token);
return useDefaultMockerToken(token as InjectionToken);
})
.compile();

View File

@ -1,3 +1,4 @@
import { InjectionToken } from '@nestjs/common';
import { HealthIndicatorResult } from '@nestjs/terminus';
import { Test } from '@nestjs/testing';
import { JellyfinService } from '../../clients/jellyfin/jellyfin.service';
@ -16,7 +17,7 @@ describe('JellyfinHealthIndicator', () => {
if (token === JellyfinService) {
return { isConnected: jest.fn() };
}
return useDefaultMockerToken(token);
return useDefaultMockerToken(token as InjectionToken);
})
.compile();

View File

@ -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'];

View File

@ -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;
});

View File

@ -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<Track[]> {
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);
}
}

View File

@ -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,

View File

@ -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
);
}
}

View File

@ -51,6 +51,6 @@ export class Track {
}
getRemoteImages(): RemoteImageInfo[] {
return this.remoteImages.Images;
return this.remoteImages?.Images ?? [];
}
}

View File

@ -30,7 +30,7 @@ export class PlayNowCommand {
}
getSelection(): string[] {
if (this.hasSelection()) {
if (this.hasSelection() && this.StartIndex !== undefined) {
return [this.ItemIds[this.StartIndex]];
}

View File

@ -6,6 +6,7 @@ import { DiscordMessageService } from '../clients/discord/discord.message.servic
import { GithubRelease } from '../models/github-release';
import { useDefaultMockerToken } from '../utils/tests/defaultMockerToken';
import { UpdatesService } from './updates.service';
import { InjectionToken } from '@nestjs/common';
// mock axios: https://stackoverflow.com/questions/51275434/type-of-axios-mock-using-jest-typescript/55351900#55351900
jest.mock('axios');
@ -49,7 +50,7 @@ describe('UpdatesService', () => {
};
}
return useDefaultMockerToken(token);
return useDefaultMockerToken(token as InjectionToken);
})
.compile();

View File

@ -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<null | GithubRelease> {
private async fetchLatestGithubRelease(): Promise<GithubRelease | undefined> {
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;
});
}
}

View File

@ -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;
};

View File

@ -12,7 +12,7 @@
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,