From 61ffaf06522df734b70f2ccb93301e856d732c8b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 25 Sep 2026 10:42:18 -0400 Subject: [PATCH] fix: Various simple fixes for typed guards and variables extraction to remove non-null assertions --- src/backend/common/AbstractComponent.ts | 24 ++--- .../repositories/ComponentRepository.ts | 4 +- .../drizzle/repositories/PlayRepository.ts | 35 ++++--- .../transforms/AtomicPartsTransformer.ts | 5 +- .../common/transforms/NativeTransformer.ts | 22 +++-- .../common/transforms/TransformerManager.ts | 39 ++++---- .../common/transforms/UserTransformer.ts | 5 +- .../CoverArtArchiveTransformer.ts | 40 ++++---- src/backend/common/vendor/JRiverApiClient.ts | 2 +- src/backend/common/vendor/KodiApiClient.ts | 14 ++- src/backend/common/vendor/LastfmApiClient.ts | 42 +++++---- src/backend/common/vendor/RockSkyApiClient.ts | 80 +++++++++------- src/backend/common/vendor/atproto/atUtils.ts | 6 +- .../vendor/discord/DiscordAbstractClient.ts | 4 +- .../common/vendor/discord/DiscordIPCClient.ts | 14 +-- .../common/vendor/discord/DiscordUtils.ts | 10 +- .../common/vendor/discord/DiscordWSClient.ts | 36 ++++--- .../common/vendor/koito/KoitoApiClient.ts | 2 +- .../common/vendor/maloja/MalojaApiClient.ts | 19 ++-- .../common/vendor/maloja/interfaces.ts | 4 +- .../vendor/musicbrainz/CoverArtApiClient.ts | 8 +- .../vendor/musicbrainz/MusicbrainzApi.ts | 10 +- .../common/vendor/teal/TealApiClient.ts | 12 ++- src/backend/index.ts | 3 +- src/backend/notifier/NtfyWebhookNotifier.ts | 8 +- .../AbstractHistoricalScrobbleClient.ts | 14 ++- src/backend/scrobblers/DiscordScrobbler.ts | 4 +- src/backend/scrobblers/LastfmScrobbler.ts | 2 +- src/backend/scrobblers/MalojaScrobbler.ts | 4 +- src/backend/scrobblers/RockskyScrobbler.ts | 37 ++++++-- src/backend/scrobblers/TealfmScrobbler.ts | 8 +- src/backend/sources/AbstractSource.ts | 55 ++++++----- src/backend/sources/ChromecastSource.ts | 33 ++++--- src/backend/sources/DeezerInternalSource.ts | 17 ++-- src/backend/sources/EndpointLastfmSource.ts | 3 +- .../sources/EndpointListenbrainzSource.ts | 11 ++- src/backend/sources/ExampleSource.ts | 7 +- src/backend/sources/IcecastSource.ts | 6 +- src/backend/sources/MPDSource.ts | 16 ++-- src/backend/sources/MPRISSource.ts | 8 +- src/backend/sources/MemorySource.ts | 40 ++++---- src/backend/sources/MopidySource.ts | 19 ++-- src/backend/sources/PlexApiSource.ts | 93 +++++++++++-------- src/backend/sources/ScrobbleSources.ts | 10 +- src/backend/sources/SonosSource.ts | 27 +++--- src/backend/sources/SpotifySource.ts | 27 +++--- src/backend/sources/SubsonicSource.ts | 16 ++-- src/backend/sources/VLCSource.ts | 25 +++-- src/backend/sources/WebScrobblerSource.ts | 28 +++--- 49 files changed, 573 insertions(+), 385 deletions(-) diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index b5bfe218..090246e3 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -439,9 +439,8 @@ export default abstract class AbstractComponent extends AbstractInitializable { transformedPlay.lifecycle = steps; isNew = true; } - const { - lifecycle = [] - } = transformedPlay; + const lifecycle = transformedPlay.lifecycle ?? []; + transformedPlay.lifecycle = lifecycle; steps.forEach((s, index) => { if(diffFailure) { return; @@ -451,9 +450,9 @@ export default abstract class AbstractComponent extends AbstractInitializable { if(!isNew) { const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier()); if(existingStepIndex !== -1) { - transformedPlay.lifecycle![existingStepIndex] = s; + lifecycle[existingStepIndex] = s; } else { - transformedPlay.lifecycle!.push(s); + lifecycle.push(s); } } @@ -496,7 +495,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { diffs.push(`${last.name} => ${curr.name} -- No Change`); } else { try { - const formattedDiff = diffObjectsConsoleOutput(lastTransformed.data!, curr.data); + const formattedDiff = diffObjectsConsoleOutput(lastTransformed.data ?? {}, curr.data); diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`); lastTransformed = curr; } catch(e) { @@ -564,7 +563,8 @@ export default abstract class AbstractComponent extends AbstractInitializable { //const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}` const existingStepIndex = lifecycle.findIndex(x => x.hook === hookType && hookItem.name === x.stageName && x.stageType === hookItem.type && x.source === this.getIdentifier()); const step: LifecycleStep = existingStepIndex !== -1 && lifecycle[existingStepIndex] !== undefined ? lifecycle[existingStepIndex] : { - stageName: hookItem.name!, + // overwritten below with resolved stage name + stageName: hookItem.name ?? 'Unnamed', hook: hookType, stageType: hookItem.type, source: this.getIdentifier(), @@ -700,15 +700,15 @@ export default abstract class AbstractComponent extends AbstractInitializable { public emitComponentUpdate = >>(payload: T) => { if('errors' in payload) { - if(payload.errors!.length > 0) { - payload.errors = payload.errors!.map(x => x instanceof Error ? serializeError(x) : x); + if(payload.errors !== undefined && payload.errors.length > 0) { + payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x); } else { payload.errors = []; } } if('warnings' in payload) { - if(payload.warnings!.length > 0) { - payload.warnings = payload.warnings!.map(x => x instanceof Error ? serializeError(x) : x); + if(payload.warnings !== undefined && payload.warnings.length > 0) { + payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x); } else { payload.warnings = []; } @@ -864,7 +864,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { const playRow = await this.playRepo.createPlays([createPlayData]); const queueState = await this.queueRepo.create({ componentId: this.dbComponent.id, playId: playRow[0].id, queueName: INGRESS_QUEUE, context }) as QueueStateSelect; const createdEvents = await this.playEventsRepo.createMany([ - { playId: playRow[0].id, ...stateChangeToPlayEvent({ state: 'queued' }), createdAt: playRow[0].seenAt!.add(1, 'ms') }, + { playId: playRow[0].id, ...stateChangeToPlayEvent({ state: 'queued' }), createdAt: (playRow[0].seenAt ?? dayjs()).add(1, 'ms') }, { playId: playRow[0].id, ...queueStateToPlayEvent(queueState), createdAt: queueState.createdAt } ]); createdQueuedPlays.push(playRow[0]); diff --git a/src/backend/common/database/drizzle/repositories/ComponentRepository.ts b/src/backend/common/database/drizzle/repositories/ComponentRepository.ts index e336a935..f7f2efc3 100644 --- a/src/backend/common/database/drizzle/repositories/ComponentRepository.ts +++ b/src/backend/common/database/drizzle/repositories/ComponentRepository.ts @@ -11,7 +11,7 @@ export class DrizzleComponentRepository extends DrizzleBaseRepository<'component super(db, 'components', 'Component', opts); } - findOrInsert = async (data: { mode: ComponentType, type: string, uid?: string, name?: string }): Promise => { + findOrInsert = async (data: { mode: ComponentType, type: string, uid?: string, name: string }): Promise => { const where: FindWhere<'components'> = { mode: data.mode, type: data.type, @@ -31,7 +31,7 @@ export class DrizzleComponentRepository extends DrizzleBaseRepository<'component uid: data.uid ?? data.name, mode: data.mode, type: data.type, - name: data.name! + name: data.name })).returning())[0] as ComponentSelect; componentNew.migrations = []; return componentNew; diff --git a/src/backend/common/database/drizzle/repositories/PlayRepository.ts b/src/backend/common/database/drizzle/repositories/PlayRepository.ts index 4e49f789..91f4d46d 100644 --- a/src/backend/common/database/drizzle/repositories/PlayRepository.ts +++ b/src/backend/common/database/drizzle/repositories/PlayRepository.ts @@ -286,7 +286,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { return results.map(identifierExtractor[identifier]); } - findPlaysPaginated = async >(args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise> => { + findPlaysPaginated = async >(args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise & {meta: {total: number}}> => { const { limit = 100, offset = 0, @@ -471,7 +471,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { return x; }); if(compactedInput) { - await this.db.update(playEvents).set({data: transformEvent.data}).where(eq(playEvents.id, transformEvent.id!)); + await this.db.update(playEvents).set({data: transformEvent.data}).where(eq(playEvents.id, ev.id)); } } } @@ -748,22 +748,23 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { * it's also how we ignore exact plays from history-polling sources * since those plays are always the same */ + const playHashOrConds: NonNullable>['OR']> = [ + { + playHash: hash + } + ]; const playHashOr: ElementOf> = { - OR: [ - { - playHash: hash - } - ] + OR: playHashOrConds }; const mbidId = playMbidIdentifier(play); if (mbidId !== undefined || inputHash !== undefined) { // }]; if (mbidId !== undefined) { - playHashOr.OR!.push({ mbidIdentifier: mbidId }); + playHashOrConds.push({ mbidIdentifier: mbidId }); } if (inputHash !== undefined) { - playHashOr.OR!.push({ input: { playHash: typeof inputHash === 'string' ? inputHash : hashObject(playContentBasicInvariantTransform(inputHash).data) } }); + playHashOrConds.push({ input: { playHash: typeof inputHash === 'string' ? inputHash : hashObject(playContentBasicInvariantTransform(inputHash).data) } }); } } @@ -787,7 +788,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { */ if (parentId !== undefined) { - playHashOr.OR!.push({ + playHashOrConds.push({ parent: { id: parentId, playHash: hash @@ -853,9 +854,17 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { })) as PlayWith<'queueStates'>[]).map(x => ({...x, play: hydratePlaySelect(x)})); } + protected requireComponentId = (componentId?: number): number => { + const cid = componentId ?? this.componentId; + if(cid === undefined) { + throw new Error('A componentId must be provided when repository has no default componentId'); + } + return cid; + } + public getComponentPlayCountForStates = async (states: string[], componentId?: number): Promise => ( await this.db.$count(plays, and( - eq(plays.componentId, (componentId ?? this.componentId)!), + eq(plays.componentId, this.requireComponentId(componentId)), inArray(plays.state, states as PlaySelect['state'][]) )) ) @@ -873,7 +882,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> { .leftJoin(queueStates, eq(plays.id, queueStates.playId)) .where( and( - eq(plays.componentId, (componentId ?? this.componentId)!), + eq(plays.componentId, this.requireComponentId(componentId)), eq(plays.state, 'failed'), isNull(queueStates.id) ) @@ -917,7 +926,7 @@ group by componentId,compacted;`); if(data.event === true) { if(data.state !== undefined) { try { - await this.db.insert(playEvents).values({...stateChangeToPlayEvent(removeUndefinedKeys({state: data.state, reason: data.reason, error: data.error})!), playId: id}); + await this.db.insert(playEvents).values({...stateChangeToPlayEvent(removeUndefinedKeys({state: data.state, reason: data.reason, error: data.error}, false)), playId: id}); } catch (e) { this.logger.warn(new Error(`Failed to create Play Event for state change ${data.state} on Play ${id}`)); } diff --git a/src/backend/common/transforms/AtomicPartsTransformer.ts b/src/backend/common/transforms/AtomicPartsTransformer.ts index 8c74dcaf..72e81848 100644 --- a/src/backend/common/transforms/AtomicPartsTransformer.ts +++ b/src/backend/common/transforms/AtomicPartsTransformer.ts @@ -156,13 +156,14 @@ export default abstract class AtomicPartsTransformer 1 && artistsParseMonolithicOnly === false)) { + const playArtists = play.data.artists ?? []; + if(playArtists.length === 1 || (playArtists.length > 1 && artistsParseMonolithicOnly === false)) { - for(const artist of play.data.artists!) { + for(const artist of playArtists) { - const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist.name)})).filter(x => x.res !== undefined); + const matchedIgnoreArtists = ignoreArtistsRegex.flatMap(x => { + const res = parseRegexSingle(x, artist.name); + return res !== undefined ? [{reg: x.toString(), res}] : []; + }); if(matchedIgnoreArtists.length > 0) { - logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res!.match}`).join('\n')}`); + logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res.match}`).join('\n')}`); artists.push(artist); } else { const artistCredits = parseArtistCredits(artist.name, delimiters); @@ -225,7 +229,7 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr artists.push({name: artistCredits.primary}); } if (artistCredits.secondary !== undefined) { - artists = artists.concat(artistCredits.secondary.map((x) => artistNameToCredit(x)!)); + artists = artists.concat(artistCredits.secondary.map((x) => artistNameToCredit(x))); } } else { // couldn't parse anything from artist string, use as-is @@ -238,22 +242,22 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr } else { // user does not want to try to parse artists when we already have more than one artist string // -- likely this is because the user knows the artist data is already good and shouldn't be modified - artists = play.data.artists!; + artists = playArtists; } } if(artistsParseFrom.includes('title')) { - const trackArtists = parseTrackCredits(play.data.track!, delimiters); + const trackArtists = play.data.track !== undefined ? parseTrackCredits(play.data.track, delimiters) : undefined; if (trackArtists !== undefined && trackArtists.secondary !== undefined) { - artists = artists.concat(trackArtists.secondary.map((x) => artistNameToCredit(x)!)); + artists = artists.concat(trackArtists.secondary.map((x) => artistNameToCredit(x))); if(titleClean) { track = trackArtists.primary; } } } - artists = (uniqueNormalizedStrArr([...artists.map((x) => artistCreditToName(x)!)])).map((x) => artistNameToCredit(x)!); + artists = (uniqueNormalizedStrArr([...artists.map((x) => artistCreditToName(x))])).map((x) => artistNameToCredit(x)); return { ...play, diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts index 7f509fe4..0a5ef151 100644 --- a/src/backend/common/transforms/TransformerManager.ts +++ b/src/backend/common/transforms/TransformerManager.ts @@ -65,11 +65,10 @@ export default class TransformerManager { } public async register(config: TransformerCommonConfig): Promise { - let transformers: AbstractTransformer[] = []; - if (!this.transformers.has(config.type)) { - this.transformers.set(config.type, []); - } else { - transformers = this.transformers.get(config.type)!; + let transformers = this.transformers.get(config.type); + if (transformers === undefined) { + transformers = []; + this.transformers.set(config.type, transformers); } if (config.name !== undefined && transformers.some(x => x.config.name === config.name)) { @@ -200,14 +199,11 @@ export default class TransformerManager { } } else { // otherwise we try to get *any* transform of this type, starting with non-default - let configToUse: TransformerCommonConfig; - const nonDefault = this.transformerConfigs.find(x => x.type === data.type && x.name !== DEFAULT_TRANSFORMER_NAME); - if(nonDefault !== undefined) { - // use first non-default, if there is one - configToUse = nonDefault; - } else { - // otherwise use first found - configToUse = this.transformerConfigs.find(x => x.type === data.type)!; + // use first non-default, if there is one, otherwise use first found + const configToUse = this.transformerConfigs.find(x => x.type === data.type && x.name !== DEFAULT_TRANSFORMER_NAME) + ?? this.transformerConfigs.find(x => x.type === data.type); + if(configToUse === undefined) { + throw new Error(`No transformer configurations of type '${data.type}' exist.`); } await this.registerByIdentifiers(configToUse.type, configToUse.name); await this.initTransformers(); @@ -215,21 +211,26 @@ export default class TransformerManager { } } - if(data.name === undefined) { - if(list!.length > 1) { + if (list === undefined || list.length === 0) { + throw new Error(`No transformers of type '${data.type}' could be registered.`); + } + + const name = data.name; + if(name === undefined) { + if(list.length > 1) { this.logger.warn(`More than one '${data.type}' transformer is registered but name was not specified, using first found`); - return list![0]; + return list[0]; } - return list![0] + return list[0] } - let namedTransformers = list!.find(x => x.name.toLocaleLowerCase().trim() === data.name!.toLocaleLowerCase().trim()); + let namedTransformers = list.find(x => x.name.toLocaleLowerCase().trim() === name.toLocaleLowerCase().trim()); if(namedTransformers === undefined) { if(this.hasTransformerConfigByIdentifiers(data.type, data.name)) { await this.registerByIdentifiers(data.type, data.name); await this.initTransformers(); list = this.transformers.get(data.type); - namedTransformers = list!.find(x => x.name.toLocaleLowerCase().trim() === data.name!.toLocaleLowerCase().trim()); + namedTransformers = list?.find(x => x.name.toLocaleLowerCase().trim() === name.toLocaleLowerCase().trim()); if(namedTransformers === undefined) { // this shouldn't really happen but just covering bases throw new SimpleError(`Component wanted transformer type ${data.type} with name ${data.name}. Transforms of this type are registered but none have this name.`); diff --git a/src/backend/common/transforms/UserTransformer.ts b/src/backend/common/transforms/UserTransformer.ts index 3a5e4902..25fcea75 100644 --- a/src/backend/common/transforms/UserTransformer.ts +++ b/src/backend/common/transforms/UserTransformer.ts @@ -60,7 +60,10 @@ export default class UserTransformer extends AtomicPartsTransformer ({ ...x, test: (x.when !== undefined ? () => testWhenConditions(x.when!, play, { testMaybeRegex: this.regex.testMaybeRegex }) : undefined) }); + return (x: ConditionalSearchAndReplaceRegExp): ConditionalSearchAndReplaceRegExp => { + const {when} = x; + return { ...x, test: (when !== undefined ? () => testWhenConditions(when, play, { testMaybeRegex: this.regex.testMaybeRegex }) : undefined) }; + }; } protected async handleTitle(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { diff --git a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts index 60f92364..06fb8756 100644 --- a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts +++ b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts @@ -13,11 +13,9 @@ import { hasArtFields, type CAAMissingType, type CoverArtArchiveTransformData, t import { CoverArtClientPool } from "../../vendor/musicbrainz/CovertArtApiPool.ts"; import { coverImageHas, type CoverArtReleaseResponse } from "../../vendor/musicbrainz/CoverArtApiTypes.ts"; -export interface CoverArtArchiveTransformDataStrong extends CoverArtArchiveTransformData { -} +export type CoverArtArchiveTransformDataStrong = CoverArtArchiveTransformData & Required>; -export interface CoverArtArchiveTransformerDataStage extends CoverArtArchiveTransformDataStrong,PlayTransformMetadataStage { -} +export type CoverArtArchiveTransformerDataStage = CoverArtArchiveTransformData & PlayTransformMetadataStage; type MSCoverArtReleaseResponse = CoverArtReleaseResponse & {type?: 'album' | 'releaseGroup', requestQuery: string, lifecycleInputs?: LifecycleInput[]}; @@ -37,9 +35,9 @@ export const parseStageConfig = (data: CoverArtArchiveTransformData | undefined ...data, }; - logger.debug(`Will search if missing: ${config.searchWhenMissing === true ? 'all' : config.searchWhenMissing!.join(', ')}`); + logger.debug(`Will search if missing: ${config.searchWhenMissing === true ? 'all' : config.searchWhenMissing.join(', ')}`); - logger.debug(`Allowed image types: ${config.allowedTypes!.join(',')}`); + logger.debug(`Allowed image types: ${config.allowedTypes.join(',')}`); return config; } @@ -112,13 +110,13 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer 0) { this.logger.debug(`Play is missing desired fields: ${missing.join(', ')}`); } else if(forceSearch) { this.logger.debug(`All desired fields exist but forceSearch = true`); } else { - throw new SkipTransformStageError(`No desired fields (${searchWhenMissing!.join(',')}) are missing`, {shortStack: true}); + throw new SkipTransformStageError(`No desired fields (${searchWhenMissing.join(',')}) are missing`, {shortStack: true}); } } @@ -137,19 +135,19 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { const hasFields = coverImageHas(x); - if(!allowedTypes!.includes('any') && difference(allowedTypes!, hasFields.types).length > 0) { + if(!allowedTypes.includes('any') && difference(allowedTypes, hasFields.types).length > 0) { return false; } - if(!allowedSizes!.includes('any') && difference(allowedSizes!, hasFields.sizes).length > 0) { + if(!allowedSizes.includes('any') && difference(allowedSizes, hasFields.sizes).length > 0) { return false; } return true; @@ -179,7 +177,7 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { + public async searchByMbid(play: PlayObject, mbidType: 'album' | 'releaseGroup', stageConfig: CoverArtArchiveTransformerDataStage, opts: OptionalCacheUsage = {}): Promise { const mbid = play.data.meta?.brainz?.[mbidType]; if(mbid === undefined) { throw new SearchPrerequisiteError(`Play does not have ${mbidType} MBID`); @@ -213,7 +211,9 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { const hasFields = coverImageHas(x); - if(!allowedTypes!.includes('any') && difference(allowedTypes!, hasFields.types).length > 0) { + if(!allowedTypes.includes('any') && difference(allowedTypes, hasFields.types).length > 0) { return false; } - if(!allowedSizes!.includes('any') && difference(allowedSizes!, hasFields.sizes).length > 0) { + if(!allowedSizes.includes('any') && difference(allowedSizes, hasFields.sizes).length > 0) { return false; } return true; @@ -246,12 +246,16 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { diff --git a/src/backend/common/vendor/JRiverApiClient.ts b/src/backend/common/vendor/JRiverApiClient.ts index f210136e..44a96e18 100644 --- a/src/backend/common/vendor/JRiverApiClient.ts +++ b/src/backend/common/vendor/JRiverApiClient.ts @@ -83,7 +83,7 @@ const jriverResponseTransform = (val: JRiverResponse): JRiverTransformedRespo }); return { status, - data: items!.reduce((acc: Record, curr) => { + data: (items ?? []).reduce((acc: Record, curr) => { acc[curr[0]] = curr[1]; return acc; }, {}) as T diff --git a/src/backend/common/vendor/KodiApiClient.ts b/src/backend/common/vendor/KodiApiClient.ts index fa8a6d3f..5f3ce380 100644 --- a/src/backend/common/vendor/KodiApiClient.ts +++ b/src/backend/common/vendor/KodiApiClient.ts @@ -138,10 +138,10 @@ export class KodiApiClient extends AbstractApiClient { await this.client.connect(); // https://kodi.wiki/view/JSON-RPC_API/v12#JSONRPC.Version - const jsonInfo = await this.client.JSONRPC!.Version(); + const jsonInfo = await requireKodiNamespace(this.client.JSONRPC, 'JSONRPC').Version(); // https://kodi.wiki/view/JSON-RPC_API/v12#Application.GetProperties - const applicationInfo = await this.client.Application!.GetProperties(['version']); + const applicationInfo = await requireKodiNamespace(this.client.Application, 'Application').GetProperties(['version']); this.version = `${applicationInfo.version.major}.${applicationInfo.version.minor}`; this.logger.info(`Found Kodi v${applicationInfo.version.major}.${applicationInfo.version.minor} (JSONRPC v${jsonInfo.version.major}.${jsonInfo.version.minor})`); @@ -171,7 +171,7 @@ export class KodiApiClient extends AbstractApiClient { getActivePlayers = async (): Promise => { // https://kodi.wiki/view/JSON-RPC_API/v12#Player.GetActivePlayers - const players = await this.client.Player!.GetActivePlayers(); + const players = await requireKodiNamespace(this.client.Player, 'Player').GetActivePlayers(); return players as ActivePlayer[]; } @@ -194,3 +194,11 @@ export class KodiApiClient extends AbstractApiClient { return [play]; } } + +/** kodi client namespaces are only populated after the client has connected */ +const requireKodiNamespace = (ns: T | undefined, name: string): T => { + if (ns === undefined) { + throw new Error(`Kodi client ${name} namespace is not available, is the client connected?`); + } + return ns; +} diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index d981099d..dfd2f1a5 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -63,6 +63,8 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina declare config: LastfmData; sessionKey?: string; lastRefreshed?: number + // always set in constructor + declare redirectUri: string; path!: string; upstreamName: string = 'Last.fm'; @@ -144,7 +146,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina let shouldThrow = false; if (error instanceof LastFMResponseError) { - const status = error.response!.statusCode; + const status = error.response?.statusCode; parts.push(`HTTP ${status}`); parts.push(error.message); if (status === 429) { @@ -208,11 +210,11 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina let authUrl: string; if(isLastFm) { this.logger.debug('Detected last.fm host'); - authUrl = `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri!)}`; + authUrl = `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`; } else { this.logger.debug('Detected non-last.fm host (probably libre.fm)'); const aUrl = new URL(this.url.url.toString()); - aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri!)}`; + aUrl.search = `?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}`; aUrl.pathname = '/api/auth'; authUrl = aUrl.toString(); } @@ -236,8 +238,11 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina name, // username } = {} } = sessionRes; + if(sessionKey === undefined) { + throw new Error(`${this.upstreamName} did not return a session key`); + } this.lastRefreshed = dayjs().unix(); - this.logger.debug(`Created session for user ${name}! Session key: ${redactString(sessionKey!, 6)} | Refreshed At ${this.lastRefreshed}`); + this.logger.debug(`Created session for user ${name}! Session key: ${redactString(sessionKey, 6)} | Refreshed At ${this.lastRefreshed}`); this.sessionKey = sessionKey; this.user = name; @@ -252,7 +257,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina } protected initApi = () => { - this.logger.debug(`Creating new API instances with: API Key ${redactString(this.config.apiKey, 6)} | Secret: ${redactString(this.config.secret, 6)} | Session Key ${redactString(this.sessionKey!, 6)} | Host ${this.url.url.host} | Path ${this.url.url.pathname}`); + this.logger.debug(`Creating new API instances with: API Key ${redactString(this.config.apiKey, 6)} | Secret: ${redactString(this.config.secret, 6)} | Session Key ${this.sessionKey !== undefined ? redactString(this.sessionKey, 6) : 'N/A'} | Host ${this.url.url.host} | Path ${this.url.url.pathname}`); this.userApi = new LastFMUser(this.config.apiKey, this.config.secret, this.sessionKey, { hostname: this.url.url.host, path: this.url.url.pathname @@ -340,7 +345,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina let unrecoverable: boolean | undefined; if(e instanceof LastFMResponseError) { try { - const errorContent = JSON.parse(e.content!); + const errorContent = e.content !== undefined ? JSON.parse(e.content) : {}; if(`error` in errorContent) { unrecoverable = unrecoverableAuthErrorCodes.includes(errorContent.error); } @@ -351,7 +356,8 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina if(unrecoverable === undefined) { const errorWithMessage = findCauseByFunc(e, (ee) => `response` in ee) as Error & {response: IncomingMessage} | undefined; if(errorWithMessage !== undefined) { - unrecoverable = [401,403].includes(errorWithMessage.response?.statusCode!); + const statusCode = errorWithMessage.response?.statusCode; + unrecoverable = statusCode !== undefined && [401,403].includes(statusCode); } } throw new AuthError('Testing auth failed', {cause: e, unrecoverable}); @@ -498,7 +504,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina recenttracks: { track: [], '@attr': { - user: this.user!, + user: this.user ?? '', totalPages: '0', total: '0', page: '1', @@ -559,7 +565,9 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina const modifiedPlay = clone(playObj); delete modifiedPlay.data.playDateCompleted; - modifiedPlay.data.playDate = dayjs.unix(timestamp!); + if(timestamp !== undefined) { + modifiedPlay.data.playDate = dayjs.unix(timestamp); + } if(trackName !== undefined) { modifiedPlay.data.track = trackName; } @@ -595,7 +603,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina } = {}, } = {} } = response; - if (ignoreCode! > 0) { + if (ignoreCode !== undefined && ignoreCode > 0) { this.logger.warn({payload: rest}, `Service ignored this scrobble => (Code ${ignoreCode}) ${(ignoreMsg === '' ? '(No error message returned)' : ignoreMsg)} -- See https://www.last.fm/api/show/track.updateNowPlaying for more information`); } return response; @@ -712,7 +720,9 @@ export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload const rawPayload: LastFMScrobblePayload = { artist: artist, - track: track!, + // track is required by LFM, an empty value will be rejected upstream + // but we don't throw here since this is also used to build payloads for logging failed scrobbles + track: track ?? '', album, timestamp: getScrobbleTsSOCDate(playObj).unix(), mbid, @@ -1007,7 +1017,7 @@ export const ingressPayloads = (obj: LastfmScrobbleMaybeMultiPayload): LastfmScr if(typeof cleaned.duration === 'string') { cleaned.duration = Number.parseInt(cleaned.duration); } - if(isNaN(cleaned.duration!) || cleaned.duration! <= 0) { + if(cleaned.duration === undefined || isNaN(cleaned.duration) || cleaned.duration <= 0) { cleaned.duration = undefined; } if(typeof cleaned.timestamp === 'string') { @@ -1030,7 +1040,7 @@ export const playToScrobbleApiResponseJson = (play: PlayObject) => { scrobble: { track: { corrected: 0, - '#text': play.data.track! + '#text': play.data.track ?? '' }, artist: { corrected: 0, @@ -1038,7 +1048,7 @@ export const playToScrobbleApiResponseJson = (play: PlayObject) => { }, album: { corrected: 0, - '#text': play.data.album! + '#text': play.data.album ?? '' }, albumArtist: { corrected: 0, @@ -1060,7 +1070,7 @@ export const playToNowPlayingApiResponseJson = (play: PlayObject) => { nowplaying: { track: { corrected: 0, - '#text': play.data.track + '#text': play.data.track ?? '' }, artist: { corrected: 0, @@ -1068,7 +1078,7 @@ export const playToNowPlayingApiResponseJson = (play: PlayObject) => { }, album: { corrected: 0, - '#text': play.data.album + '#text': play.data.album ?? '' }, albumArtist: { corrected: 0, diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 4553a4f3..71d9467f 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -57,10 +57,10 @@ export class RockSkyApiClient extends AbstractApiClient { cache: MSCache; userData?: HandleData - rsClient?: RockskyClient; + rsClient: RockskyClient; rsPool: RockskyClientPool; rsAgent?: Agent; - rsIndex?: RockskyIndex; + rsIndex: RockskyIndex; protected configDir: string; @@ -84,14 +84,14 @@ export class RockSkyApiClient extends AbstractApiClient { async [Symbol.asyncDispose]() { try { - await this.rsIndex!.close(); + await this.rsIndex.close(); } catch (e) { this.logger.warn(e); } } public async buildData() { - await this.rsIndex!.open(); + await this.rsIndex.open(); try { const atProtoHandleData: ATProtoUserIdentifierData = { identifier: this.config.handle @@ -131,7 +131,7 @@ export class RockSkyApiClient extends AbstractApiClient { if((this.userData !== undefined || this.config.handle !== undefined) && this.config.appPassword !== undefined) { try { this.rsAgent = await Agent.login(this.userData?.did ?? this.userData?.handle ?? this.config.handle, this.config.appPassword); - this.rsAgent.useIndex(this.rsIndex!); + this.rsAgent.useIndex(this.rsIndex); return true; } catch (e) { throw new AuthError('Could not login using handle/did and appPassword', {cause: e, unrecoverable: true}); @@ -142,14 +142,14 @@ export class RockSkyApiClient extends AbstractApiClient { // so we can use the client for write operations later if(this.config.token !== undefined) { try { - await this.rsClient!.apikeys(); + await this.rsClient.apikeys(); // const req = request.get('https://api.rocksky.app/profile').set('Authorization', `Bearer ${this.config.token}`); // await req; return true; } catch (e) { const upstreamErr = new UpstreamError('Failed to get apikeys() to test auth validity of token', {cause: e}); const cause = findCauseByFunc(e, (ee) => ee instanceof RockskyError); - throw new AuthError('Failed to get /profile with given token', {cause: upstreamErr, unrecoverable: cause !== undefined && [401,403].includes(cause.status!)}); + throw new AuthError('Failed to get /profile with given token', {cause: upstreamErr, unrecoverable: cause?.status !== undefined && [401,403].includes(cause.status)}); } } throw new AuthError('No required credentials provided to try an authentication method.', {unrecoverable: true}); @@ -194,7 +194,7 @@ export class RockSkyApiClient extends AbstractApiClient { if(log) { this.logger.debug(`Submit Payload: ${JSON.stringify(payload)}`); } - const resp = await this.rsClient!.createScrobble(payload); + const resp = await this.rsClient.createScrobble(payload); return {payload, response: resp, createdAt: dayjs().toISOString()} } @@ -235,7 +235,7 @@ export class RockSkyApiClient extends AbstractApiClient { merged.meta.user = uData.user; } } - return removeUndefinedKeys({payload, response: res, mergedScrobble: merged, createdAt: dayjs().toISOString(), warnings: warnings.length === 0 ? undefined : warnings})!; + return removeUndefinedKeys({payload, response: res, mergedScrobble: merged, createdAt: dayjs().toISOString(), warnings: warnings.length === 0 ? undefined : warnings}, false); } catch (e) { throw new ScrobbleSubmitError(`Error occurred while writing scrobble to PDS`, {cause: e, payload: payload}); } @@ -249,7 +249,7 @@ export class RockSkyApiClient extends AbstractApiClient { await this.rsAgent.clearNowPlaying(); return; } - await this.rsAgent.setNowPlaying(playToActorTrackView(play!)); + await this.rsAgent.setNowPlaying(playToActorTrackView(play)); } getRockskySongMatch = async (play: PlayObject): Promise => { @@ -276,15 +276,22 @@ export class RockSkyApiClient extends AbstractApiClient { async fetchCarToFile() { // TODO use `since` to get CAR diff instead of entire repo // can use last import date from migrations table + if(this.userData === undefined) { + throw new Error('Cannot fetch CAR because atproto identifier has not been resolved'); + } + const {did} = this.userData; const filename = path.resolve(this.configDir, `${this.getSafeExternalId()}-${dayjs().unix()}.car`); const atClient = new ATProtoUnauthenticatedApiClient('rocksky', { handleData: this.userData, identifier: this.config.handle }, { logger: this.logger }); await atClient.initClient(); - await fsPromise.writeFile(filename, Buffer.from(await atClient.getCAR(this.userData!.did))); + await fsPromise.writeFile(filename, Buffer.from(await atClient.getCAR(did))); return filename; } async syncSdkRepo(filename: string) { - await this.rsIndex!.indexCar(this.userData!.did, await fsPromise.readFile(filename)); + if(this.userData === undefined) { + throw new Error('Cannot sync repo because atproto identifier has not been resolved'); + } + await this.rsIndex.indexCar(this.userData.did, await fsPromise.readFile(filename)); } public getSafeExternalId() { @@ -332,8 +339,8 @@ export const hasScrobbleConfidenceFields = (play: PlayObject): RockskyConfidence } const playToMatchSongInput = (play: PlayObject): RsMatchSongInput => ({ - title: play.data.track!, - artist: play.data.artists!.map(x => x.name).join(', '), + title: requirePlayTrack(play), + artist: artistCreditsToNames(play.data.artists).join(', '), mbId: play.data.meta?.brainz?.track ?? play.data.meta?.brainz?.recording, isrc: play.data.isrc, album: play.data.album @@ -354,7 +361,7 @@ const mergeSongViewWithPlay = (song: SongViewDetailedMS, play: PlayObject): Play ...play.meta } }; - if (svPlay.data.meta?.brainz!.recording !== undefined) { + if (svPlay.data.meta?.brainz?.recording !== undefined) { const { brainz, ...rest @@ -362,7 +369,7 @@ const mergeSongViewWithPlay = (song: SongViewDetailedMS, play: PlayObject): Play mergedPlay.data.meta = { ...rest, brainz: { - recording: svPlay.data.meta?.brainz.recording + recording: svPlay.data.meta.brainz.recording } } } else { @@ -379,7 +386,7 @@ export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { if(song.mbArtists !== undefined && song.mbArtists !== null && song.mbArtists.length > 0) { artists = song.mbArtists.map(x => ({name: x.name, mbid: x.mbid})); } else if(song.artists !== undefined && song.artists !== null && song.artists.length > 0) { - artists = song.artists.map(x => ({name: x.name!})) + artists = song.artists.flatMap(x => x.name !== undefined ? [{name: x.name}] : []) } else if(song.artist !== undefined && song.artist !== null) { artists = [{name: song.artist}]; } @@ -400,7 +407,7 @@ export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { artists, albumArtists, album: song.album !== '' ? song.album : undefined, - duration: song.duration !== 0 ? song.duration! / 1000 : undefined, + duration: song.duration !== undefined && song.duration !== 0 ? song.duration / 1000 : undefined, isrc: song.isrc }, meta: { @@ -423,7 +430,7 @@ export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { let albumArt = song.albumArt; if(albumArt === undefined) { - const match = (song.matches ?? []).length > 0 ? song.matches![0] : undefined; + const match = song.matches?.[0]; if(match !== undefined && match.albumArt !== undefined) { albumArt = match.albumArt; } @@ -445,8 +452,8 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble, opts: {playId?: stri const play: PlayObjectMinimal = { data: { track: obj.title, - artists: artistNamesToCredits(nonEmptyStringOrDefault(obj.artist) ? [obj.artist!] : []), - albumArtists: artistNamesToCredits(nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist!] : []), + artists: artistNamesToCredits(obj.artist !== undefined && nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), + albumArtists: artistNamesToCredits(obj.albumArtist !== undefined && nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), album: nonEmptyStringOrDefault(obj.album), playDate: dayjs.utc(obj.createdAt).local() }, @@ -492,14 +499,14 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble, opts: {playId?: stri type RealCreateScrobbleInput = CreateScrobbleInput & {albumArtist: string}; export const playToRockskyClientRecord = (play: PlayObject): RealCreateScrobbleInput => { - const artistStr = artistCreditsToNames(play.data.artists!).join(', '); + const artistStr = artistCreditsToNames(play.data.artists).join(', '); const csi: RealCreateScrobbleInput = { - title: play.data.track!, + title: requirePlayTrack(play), artist: artistStr, // albumArtist is a required field on rocksky server-side // tsiry's advice is that if there really is no album artists then just use the same value as artist - albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : artistCreditsToNames(play.data.albumArtists!).join(', '), + albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : artistCreditsToNames(play.data.albumArtists).join(', '), album: play.data.album, mbId: play.data.meta?.brainz?.recording, isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, @@ -511,15 +518,19 @@ export const playToRockskyClientRecord = (play: PlayObject): RealCreateScrobbleI } export const playToRockskyAgentRecord = (play: PlayObject): ScrobbleInput => { - const artistStr = artistCreditsToNames(play.data.artists!).join(', '); + const artistStr = artistCreditsToNames(play.data.artists).join(', '); + const {album} = play.data; + if(album === undefined) { + throw new SimpleError('Play must have an album to be converted to a Rocksky record'); + } const csi: ScrobbleInput = { - title: play.data.track!, + title: requirePlayTrack(play), artist: artistStr, // albumArtist is a required field on rocksky server-side // tsiry's advice is that if there really is no album artists then just use the same value as artist - albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : artistCreditsToNames(play.data.albumArtists!).join(', '), - album: play.data.album!, + albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : artistCreditsToNames(play.data.albumArtists).join(', '), + album, mbid: play.data.meta?.brainz?.recording, isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, duration: play.data.duration !== undefined ? play.data.duration * 1000 : 0, @@ -530,14 +541,21 @@ export const playToRockskyAgentRecord = (play: PlayObject): ScrobbleInput => { } export const playToActorTrackView = (play: PlayObject): ActorTrackView => removeUndefinedKeys({ - name: play.data.track!, - artist: artistCreditsToNames(play.data.artists!).join(', '), + name: requirePlayTrack(play), + artist: artistCreditsToNames(play.data.artists).join(', '), album: play.data.album, albumCoverUrl: play.meta.art?.track ?? play.meta.art?.album ?? play.meta.art?.artist, durationMs: play.data.duration !== undefined ? Math.floor(play.data.duration * 1000) : undefined, source: play.meta.musicService, recordingMbId: play.data.meta?.brainz?.recording - })! + }, false) + +const requirePlayTrack = (play: PlayObject): string => { + if(play.data.track === undefined) { + throw new SimpleError('Play must have a track title to be converted to a Rocksky record'); + } + return play.data.track; +} const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?(?did.*?)\/app\.rocksky\.scrobble\/(?.*))/); diff --git a/src/backend/common/vendor/atproto/atUtils.ts b/src/backend/common/vendor/atproto/atUtils.ts index 78babfa3..1cf3d01b 100644 --- a/src/backend/common/vendor/atproto/atUtils.ts +++ b/src/backend/common/vendor/atproto/atUtils.ts @@ -142,7 +142,11 @@ export const getATProtoIdentifier = async (data: ATProtoUserIdentifierData, opts if (typeof doc.service[0].serviceEndpoint !== 'string') { throw new Error(`Do not know how to handle this serviceEndpoint data structure!\n${JSON.stringify(doc.service[0].serviceEndpoint)}`); } - hd = { did, pds: getPdsEndpoint(doc)!, handle: getAtprotoHandle(doc) as string }; + const pds = getPdsEndpoint(doc); + if (pds === undefined) { + throw new Error('did document did not include a PDS endpoint'); + } + hd = { did, pds, handle: getAtprotoHandle(doc) as string }; if (cache !== undefined) { cache.set(`${key}-handleData`, hd, '1d'); diff --git a/src/backend/common/vendor/discord/DiscordAbstractClient.ts b/src/backend/common/vendor/discord/DiscordAbstractClient.ts index e16db0ec..b66021f3 100644 --- a/src/backend/common/vendor/discord/DiscordAbstractClient.ts +++ b/src/backend/common/vendor/discord/DiscordAbstractClient.ts @@ -31,7 +31,7 @@ export class DiscordAbstractClient extends AbstractApiClient { getArtworkUrl = async (artUrl: string): Promise => { - if (this.config.applicationId === undefined) { + if (this.config.applicationId === undefined || this.config.token === undefined) { return; } if(this.artFail) { @@ -45,7 +45,7 @@ export class DiscordAbstractClient extends AbstractApiClient { try { const imgResp = await request.post(`https://discord.com/api/v10/applications/${this.config.applicationId}/external-assets`) - .set('Authorization', this.config.token!) + .set('Authorization', this.config.token) .type('json') .send({ "urls": [artUrl] }); this.artFailCount = 0; diff --git a/src/backend/common/vendor/discord/DiscordIPCClient.ts b/src/backend/common/vendor/discord/DiscordIPCClient.ts index 1e80d0be..6ec27b6b 100644 --- a/src/backend/common/vendor/discord/DiscordIPCClient.ts +++ b/src/backend/common/vendor/discord/DiscordIPCClient.ts @@ -79,11 +79,11 @@ export class DiscordIPCClient extends DiscordAbstractClient { this.logger.debug(e); }); this.client.transport.on('close', (e) => { - const closeError = typeof e === 'string' ? e : `${e!.code} - ${e!.message}`; + const closeError = e === undefined ? 'Unknown reason' : (typeof e === 'string' ? e : `${e.code} - ${e.message}`); this.closeErrors.push(closeError); this.logger.warn(`Closed by transport: ${closeError}`); - if(typeof e !== 'string') { - if(e!.code === 4000) { + if(e !== undefined && typeof e !== 'string') { + if(e.code === 4000) { this.appError = true; this.emitter.emit('stopped', { authFailure: true }); } @@ -117,12 +117,12 @@ export class DiscordIPCClient extends DiscordAbstractClient { } async sendActivity(data?: SourcePlayerObj | undefined) { - if (data === undefined) { + if (data === undefined || data.play === undefined) { await this.sendClearActivity(); return; } const { activity: msActivity, artUrl } = playStateToActivityData(data); - const assets = await this.getArtAsset(data.play!, artUrl, false); + const assets = await this.getArtAsset(data.play, artUrl, false); if (assets !== undefined) { const { assets: msAssets = {} @@ -135,7 +135,7 @@ export class DiscordIPCClient extends DiscordAbstractClient { const activity = activityDataToSetActivity(msActivity); await this.client.user?.setActivity(activity); - const play = (isPlayObject(data) ? data : data.play)!; + const play = (isPlayObject(data) ? data : data.play); let clearTime = dayjs().add(260, 'seconds'); // funny number if (msActivity.timestamps?.end !== undefined) { @@ -156,7 +156,7 @@ export class DiscordIPCClient extends DiscordAbstractClient { clearTimeout(this.activityTimeout); this.activityTimeout = undefined; } - await this.client.user!.clearActivity(); + await this.client.user?.clearActivity(); } async checkOkToSend(): Promise<[boolean, string?, string?]> { diff --git a/src/backend/common/vendor/discord/DiscordUtils.ts b/src/backend/common/vendor/discord/DiscordUtils.ts index 273acb1e..c30cda62 100644 --- a/src/backend/common/vendor/discord/DiscordUtils.ts +++ b/src/backend/common/vendor/discord/DiscordUtils.ts @@ -13,7 +13,10 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?: let startTime: number | undefined, endTime: number | undefined; - const play: PlayObject = data.play!; + const play: PlayObject | undefined = data.play; + if(play === undefined) { + throw new Error('Cannot build activity data from player state without a Play'); + } const position = data.position ?? play.meta?.trackProgressPosition; if(position !== undefined && play.data.duration !== undefined) { @@ -125,10 +128,13 @@ export const configToStrong = (data: DiscordData): DiscordStrongData => { ipcLocations } = data; + const saRaw = parseArrayFromMaybeString(statusOverrideAllow); + const strongConfig: DiscordStrongData = { token, applicationId, listeningActivityAllow: parseArrayFromMaybeString(listeningActivityAllow), + statusOverrideAllow: saRaw.map(statusStringToType), artworkDefaultUrl, }; @@ -142,8 +148,6 @@ export const configToStrong = (data: DiscordData): DiscordStrongData => { } } - const saRaw = parseArrayFromMaybeString(statusOverrideAllow); - strongConfig.statusOverrideAllow = saRaw.map(statusStringToType); if (ipcLocations !== undefined) { if (typeof ipcLocations === 'string') { diff --git a/src/backend/common/vendor/discord/DiscordWSClient.ts b/src/backend/common/vendor/discord/DiscordWSClient.ts index 7dfcdeac..2e0d90c0 100644 --- a/src/backend/common/vendor/discord/DiscordWSClient.ts +++ b/src/backend/common/vendor/discord/DiscordWSClient.ts @@ -60,7 +60,7 @@ export class DiscordWSClient extends DiscordAbstractClient { authOK?: boolean reconnecting?: boolean = false; - lastActiveStatus?: PresenceUpdateStatus = PresenceUpdateStatus.Offline; + lastActiveStatus: PresenceUpdateStatus = PresenceUpdateStatus.Offline; lastActivities: GatewayActivity[] = []; activityTimeout?: NodeJS.Timeout; @@ -113,14 +113,14 @@ export class DiscordWSClient extends DiscordAbstractClient { let discordImmediateStop = false; - if ([ + if (e.code !== undefined && [ GatewayCloseCodes.AuthenticationFailed, GatewayCloseCodes.InvalidShard, GatewayCloseCodes.ShardingRequired, GatewayCloseCodes.InvalidAPIVersion, GatewayCloseCodes.InvalidIntents, GatewayCloseCodes.DisallowedIntents - ].includes(e.code!)) { + ].includes(e.code)) { this.canResume = false; } // don't attempt to reconnect, will always fail @@ -331,10 +331,11 @@ export class DiscordWSClient extends DiscordAbstractClient { this.logger.warn('Did not recieve Heartbeat ACK! May be a zombie so trying to reconnect.'); return this.handleReconnect().then(() => null).catch((e) => this.logger.error(e)); } else { - if(this.lastHeartbeatIntervalSentAt !== undefined) { + const interval = this.heartbeatInterval; + if(this.lastHeartbeatIntervalSentAt !== undefined && interval !== undefined) { const diff = dayjs().diff(this.lastHeartbeatIntervalSentAt, 'ms'); - if(diff < this.heartbeatInterval! && this.heartbeatInterval! - diff > 2000) { - this.logger.warn(`Time since last heartbeat interval sent is ${this.heartbeatInterval! - diff}ms shorter than interval (${this.heartbeatInterval})`) + if(diff < interval && interval - diff > 2000) { + this.logger.warn(`Time since last heartbeat interval sent is ${interval - diff}ms shorter than interval (${interval})`) } } const sent = this.sendHeartbeat(true); @@ -448,10 +449,14 @@ export class DiscordWSClient extends DiscordAbstractClient { } sendResume() { + if(this.session_id === undefined || this.sequence === undefined) { + this.logger.warn(`Cannot send resume because there is no previous session/sequence`); + return; + } const data: GatewayResumeData = { token: this.config.token, - session_id: this.session_id!, - seq: this.sequence! + session_id: this.session_id, + seq: this.sequence } if(this.client.OPEN !== this.client.readyState) { @@ -644,7 +649,8 @@ export class DiscordWSClient extends DiscordAbstractClient { playStateToActivity = async (data: SourcePlayerObj): Promise => { const {activity: msActivity, artUrl} = playStateToActivityData(data); - const assets = await this.getArtAsset(data.play!, artUrl); + // playStateToActivityData throws if there is no play + const assets = data.play !== undefined ? await this.getArtAsset(data.play, artUrl) : undefined; if(assets !== undefined) { const { assets: msAssets = {} @@ -653,7 +659,7 @@ export class DiscordWSClient extends DiscordAbstractClient { ...msAssets, ...assets } - } else if(Object.keys(msActivity.assets ?? {}).length === 1 && msActivity.assets!.largeText !== undefined) { + } else if(msActivity.assets !== undefined && Object.keys(msActivity.assets).length === 1 && msActivity.assets.largeText !== undefined) { // this means we can't set any artwork, likely because there is no applicationId. So delete all assets to ensure activity is accepted delete msActivity.assets; } @@ -662,7 +668,7 @@ export class DiscordWSClient extends DiscordAbstractClient { } sendActivity = async (data: SourcePlayerObj | undefined) => { - if(data === undefined) { + if(data === undefined || data.play === undefined) { this.sendClearActivity(); return; } @@ -676,7 +682,7 @@ export class DiscordWSClient extends DiscordAbstractClient { const activity = await this.playStateToActivity(data); - const play = (isPlayObject(data) ? data : data.play)!; + const play = (isPlayObject(data) ? data : data.play); let clearTime = dayjs().add(260, 'seconds'); // funny number if (activity.timestamps?.end !== undefined) { @@ -729,7 +735,7 @@ export class DiscordWSClient extends DiscordAbstractClient { return { since: null, activities: [], - status: this.lastActiveStatus!, + status: this.lastActiveStatus, // TODO determine this? afk: this.lastActiveStatus === PresenceUpdateStatus.Idle } @@ -750,7 +756,7 @@ export class DiscordWSClient extends DiscordAbstractClient { } presenceIsAllowedByStatus = (status?: PresenceUpdateStatus | StatusType): [boolean, string?] => { - if (!this.config.statusOverrideAllow!.includes(status as StatusType ?? this.lastActiveStatus as StatusType)) { + if (!this.config.statusOverrideAllow.includes(status as StatusType ?? this.lastActiveStatus as StatusType)) { return [false, `most active session has a disallowed status: ${status ?? this.lastActiveStatus}`]; } return [true]; @@ -760,7 +766,7 @@ export class DiscordWSClient extends DiscordAbstractClient { const activities = manualActivities ?? this.lastActivities; const listeningActivities = activities.filter(x => x.type === ACTIVITY_TYPE.Listening); if (listeningActivities.length !== 0) { - const disallowedActivityName = activities.find(x => !this.config.listeningActivityAllow!.some(y => x.name.toLocaleLowerCase().includes(y.toLocaleLowerCase()))); + const disallowedActivityName = activities.find(x => !this.config.listeningActivityAllow.some(y => x.name.toLocaleLowerCase().includes(y.toLocaleLowerCase()))); if (disallowedActivityName !== undefined) { return [false, `a session has a listening activity MS is not allowed to broadcast at the same time as: ${disallowedActivityName.name}`]; } diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts index af8832be..c44c5dd3 100644 --- a/src/backend/common/vendor/koito/KoitoApiClient.ts +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -138,7 +138,7 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa return true; } catch (e) { const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); - throw new AuthError('Could not validate Koito API Key', { cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status!)}); + throw new AuthError('Could not validate Koito API Key', { cause: e, unrecoverable: superagentError?.status !== undefined && [401,403].includes(superagentError.status)}); } } diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts index ecb98a84..813ae03d 100644 --- a/src/backend/common/vendor/maloja/MalojaApiClient.ts +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -180,7 +180,7 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR } } catch (e) { const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); - throw new AuthError('Failed to test Maloja API with apikey', {cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status!)}); + throw new AuthError('Failed to test Maloja API with apikey', {cause: e, unrecoverable: superagentError?.status !== undefined && [401,403].includes(superagentError.status)}); } } @@ -200,7 +200,7 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR perpage: params.limit, page: params.cursor, from: params.from !== undefined ? dayjs.unix(params.from).format('YYYY/MM/DD') : undefined, - until: params.to !== undefined ? dayjs.unix(params.to!).format('YYYY/MM/DD') : undefined + until: params.to !== undefined ? dayjs.unix(params.to).format('YYYY/MM/DD') : undefined }; const resp = await this.getScrobbles(opts); @@ -263,18 +263,19 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR } = responseBody; if (status === 'success') { if (track !== undefined) { - scrobbleResponse = { + const trackResponse = { time: pd.unix(), track: { ...track, - length: duration! + length: duration }, - } + }; + scrobbleResponse = trackResponse; if (album !== undefined) { const { album: malojaAlbum = {}, } = track; - scrobbleResponse.track.album = { + trackResponse.track.album = { name: album, artists: artistCreditsToNames(albumArtists), ...malojaAlbum, @@ -309,7 +310,7 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR this.logger.error({ playInfo: buildTrackString(playObj), payload: scrobbleData }, `Scrobble Error (${sType})`); const responseError = getMalojaResponseError(e); if (responseError !== undefined) { - if (responseError.status! < 500 && e instanceof UpstreamError) { + if (responseError.status !== undefined && responseError.status < 500 && e instanceof UpstreamError) { e.showStopper = false; } if (responseError.response?.text !== undefined) { @@ -449,7 +450,9 @@ export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): Mal const [pd, scrobbleTsSOC] = getScrobbleTsSOCDateWithContext(playObj); const scrobbleData: MalojaScrobbleV3RequestData = { - title: track!, + // title is required by maloja, an empty value will be rejected upstream + // but we don't throw here since this is also used to build payloads for logging failed scrobbles + title: track ?? '', artists: artistCreditsToNames(artists), album, key: apiKey, diff --git a/src/backend/common/vendor/maloja/interfaces.ts b/src/backend/common/vendor/maloja/interfaces.ts index 528e8cc4..1e1af75f 100644 --- a/src/backend/common/vendor/maloja/interfaces.ts +++ b/src/backend/common/vendor/maloja/interfaces.ts @@ -21,7 +21,7 @@ export interface MalojaV3ScrobbleData { /** * length of the track * */ - length: number + length?: number } /** * how long the track was listened to before it was scrobbled @@ -94,7 +94,7 @@ export const getMalojaResponseError = (e: Error, asObject: boolean = false): Res if (!isSuperAgentResponseError(err)) { return false; } - return isMalojaAPIErrorBody(err.response!.body); + return err.response !== undefined && isMalojaAPIErrorBody(err.response.body); }) as ResponseError | undefined; } diff --git a/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts b/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts index d61ab12a..d34c4441 100644 --- a/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts +++ b/src/backend/common/vendor/musicbrainz/CoverArtApiClient.ts @@ -106,8 +106,8 @@ export class CoverArtApiClient extends AbstractApiClient { } catch (e) { if (isSuperAgentResponseError(e)) { if (e.status === 302) { - return e.response!.header['location']; - } else if ([404].includes(e.status!)) { + return e.response?.header['location']; + } else if (e.status === 404) { return undefined; } else { throw new UpstreamError(`Unexpected response when trying to get album art`, { cause: e }); @@ -144,7 +144,7 @@ export class CoverArtApiClient extends AbstractApiClient { return resp.body as CoverArtReleaseResponse; } catch (e) { if (isSuperAgentResponseError(e)) { - if ([404].includes(e.status!)) { + if (e.status === 404) { // no image } else { this.logger.warn(new UpstreamError(`Unexpected response when trying to get album art`, { cause: e })); @@ -158,5 +158,5 @@ export class CoverArtApiClient extends AbstractApiClient { } const logUnexpectedStatus = (context: RetryContext): boolean => { - return !isSuperAgentResponseError(context.error) || ![404,302,307].includes(context.error.status!); + return !isSuperAgentResponseError(context.error) || context.error.status === undefined || ![404,302,307].includes(context.error.status); } \ No newline at end of file diff --git a/src/backend/common/vendor/musicbrainz/MusicbrainzApi.ts b/src/backend/common/vendor/musicbrainz/MusicbrainzApi.ts index 6b547663..80c7cbc6 100644 --- a/src/backend/common/vendor/musicbrainz/MusicbrainzApi.ts +++ b/src/backend/common/vendor/musicbrainz/MusicbrainzApi.ts @@ -12,17 +12,17 @@ import { loggerNoop } from "../../MaybeLogger.ts"; type MusicbrainzApiWrappedOptions = ConstructorParameters[0] & { rate?: Partial hostname: string - asyncStore: AsyncLocalStorage + asyncStore: AsyncLocalStorage logger?: Logger }; export class MusicbrainzApiWrapped extends MusicBrainzApi { public rateLimiterQueue: RateLimiterQueue; - protected asyncStore: AsyncLocalStorage; + protected asyncStore: AsyncLocalStorage; public hostname: string; logger: Logger; - constructor(config?: MusicbrainzApiWrappedOptions) { + constructor(config: MusicbrainzApiWrappedOptions) { const { rate: { points = 1, @@ -31,7 +31,7 @@ export class MusicbrainzApiWrapped extends MusicBrainzApi { hostname, asyncStore, logger = loggerNoop - } = config!; + } = config; super(config); this.rateLimiterQueue = new RateLimiterQueue(new RateLimiterMemory({points, duration}), {maxQueueSize: 20}); this.asyncStore = asyncStore; @@ -50,7 +50,7 @@ export class MusicbrainzApiWrapped extends MusicBrainzApi { this.logger.trace(`Rate Tokens => Used 1 | Remaining ${remainingTokens}`); try { - const res = await this.asyncStore.run(cacheKey!, async () => { + const res = await this.asyncStore.run(cacheKey, async () => { return await Promise.race([ func(this), sleep(timeout) diff --git a/src/backend/common/vendor/teal/TealApiClient.ts b/src/backend/common/vendor/teal/TealApiClient.ts index 2d3afff4..78b7e971 100644 --- a/src/backend/common/vendor/teal/TealApiClient.ts +++ b/src/backend/common/vendor/teal/TealApiClient.ts @@ -9,7 +9,7 @@ import type {ListRecord, RecordOptions, TealClientData, TealData} from "../../in import AbstractApiClient from "../AbstractApiClient.ts"; import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.ts"; import type { FmTealActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts"; -import { ScrobbleSubmitError } from "../../errors/MSErrors.ts"; +import { ScrobbleSubmitError, SimpleError } from "../../errors/MSErrors.ts"; import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.ts"; import { musicServiceToCononical } from "../listenbrainz/lzUtils.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; @@ -185,10 +185,11 @@ export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {} } }; + const artistMbids = artists.flatMap(x => x.artistMbId !== undefined ? [x.artistMbId] : []); const brainz = removeUndefinedKeys({ recording: record.recordingMbId, album: record.releaseMbId, - artist: artists.filter(x => x.artistMbId !== undefined).length > 0 ? artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId!) : undefined + artist: artistMbids.length > 0 ? artistMbids : undefined }); if (brainz !== undefined) { @@ -198,8 +199,8 @@ export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {} return baseFormatPlayObj(record, play); } -export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealActorStatus.Main => { - const item = notPlaying +export const playToStatusRecord = (play: PlayObject | undefined, notPlaying: boolean, position?: number): FmTealActorStatus.Main => { + const item = notPlaying || play === undefined ? { trackName: "", artists: [] } : (() => { const { @@ -248,6 +249,9 @@ export type MBIDURI = `mbid:${MBID}`; export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => { const musicService = musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService; + if(play.data.track === undefined) { + throw new SimpleError('Play must have a track title to be converted to a teal.fm record'); + } const record: FmTealFeedPlay.Main = { $type: "fm.teal.feed.play", trackName: play.data.track, diff --git a/src/backend/index.ts b/src/backend/index.ts index ad412c5a..c88257a1 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -207,7 +207,8 @@ const dataDir = getDataDir(); if(nameColl.length > 0) { logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`); } - const initOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY!) : undefined}; + const deadDelay = nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined); + const initOptions = {deadDelay: deadDelay !== undefined ? Number.parseInt(deadDelay) : undefined}; for(const c of scrobbleClients.clients) { c.initTasks(initOptions); const res = await Promise.race([ diff --git a/src/backend/notifier/NtfyWebhookNotifier.ts b/src/backend/notifier/NtfyWebhookNotifier.ts index 46a721ea..c0fbc093 100644 --- a/src/backend/notifier/NtfyWebhookNotifier.ts +++ b/src/backend/notifier/NtfyWebhookNotifier.ts @@ -37,8 +37,10 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier { this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`); if(this.config.token !== undefined) { this.logger.verbose(`Using Access Token '${redactString(this.config.token, 3)}' for authentication`); + } else if(this.config.username !== undefined && this.config.password !== undefined) { + this.logger.verbose(`Using Username/Password '${redactString(this.config.username, 3)}/${redactString(this.config.password, 3)}' for authentication`); } else if(this.config.username !== undefined) { - this.logger.verbose(`Using Username/Password '${redactString(this.config.username, 3)}/${redactString(this.config.password!, 3)}' for authentication`); + this.logger.warn('Username was provided without a password, will not be able to push to protected topics'); } else { this.logger.verbose('No authentication provided, will not be able to push to protected topics'); } @@ -82,10 +84,10 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier { }; if(this.config.token !== undefined) { req.authorization = `Bearer ${this.config.token.replace(/Bearer/i, '').trim()}`; - } else if (this.config.username !== undefined) { + } else if (this.config.username !== undefined && this.config.password !== undefined) { req.authorization = { username: this.config.username, - password: this.config.password!, + password: this.config.password, } } await publish(req); diff --git a/src/backend/scrobblers/AbstractHistoricalScrobbleClient.ts b/src/backend/scrobblers/AbstractHistoricalScrobbleClient.ts index 272425b1..66979d16 100644 --- a/src/backend/scrobblers/AbstractHistoricalScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractHistoricalScrobbleClient.ts @@ -42,8 +42,10 @@ export default abstract class AbstractHistoricalScrobbleClient extends AbstractS if(this.importAbortController !== undefined) { throw new Error('Cannot start a new import while one is already running'); } - this.importAbortController = new AbortController(); - this.importPromise = spawn(this.importAbortController.signal, async (signal, {defer, fork}) => { + // keep local reference since cleanup unsets importAbortController before catch runs + const importAbortController = new AbortController(); + this.importAbortController = importAbortController; + this.importPromise = spawn(importAbortController.signal, async (signal, {defer, fork}) => { defer(async () => { if(cleanup) { @@ -81,7 +83,7 @@ export default abstract class AbstractHistoricalScrobbleClient extends AbstractS this.dbComponent.migrations.push(newImport); }).catch((e) => { if (isAbortError(e)) { - const err = generateLoggableAbortReason('Import processing stopped', this.importAbortController!.signal); + const err = generateLoggableAbortReason('Import processing stopped', importAbortController.signal); this.logger.info(err); this.logger.trace(e) } else { @@ -101,7 +103,8 @@ export default abstract class AbstractHistoricalScrobbleClient extends AbstractS } // vibing this duration for now... - if(this.dbComponent.lastActiveAt!.diff(dayjs(), 'minutes') > 60 && imports[0].attemptedAt.isBefore(this.dbComponent.lastActiveAt)) { + const {lastActiveAt} = this.dbComponent; + if(lastActiveAt !== null && lastActiveAt.diff(dayjs(), 'minutes') > 60 && imports[0].attemptedAt.isBefore(lastActiveAt)) { return [true, 'component was inactive for more than an hour and last import was before last activity. There may be missed plays during the period of inactivity.']; } @@ -164,6 +167,7 @@ export default abstract class AbstractHistoricalScrobbleClient extends AbstractS } return [recent, gapSynced]; } catch (e) { + this.logger.warn(new Error('Recent plays sync failed', {cause: e})); this.setStatus('Recent plays sync failed.'); } } @@ -190,7 +194,7 @@ export default abstract class AbstractHistoricalScrobbleClient extends AbstractS if(shouldSync){ // pull latest plays into database - const [_, gapSynced] = (await this.syncRecentHistoricalScrobbles())!; + const [_, gapSynced = false] = (await this.syncRecentHistoricalScrobbles()) ?? []; if(this.syncedReason !== undefined && this.syncedReason.includes('component was inactive')) { if(gapSynced) { this.syncedReason = undefined; diff --git a/src/backend/scrobblers/DiscordScrobbler.ts b/src/backend/scrobblers/DiscordScrobbler.ts index 81e6a5c4..41ea2694 100644 --- a/src/backend/scrobblers/DiscordScrobbler.ts +++ b/src/backend/scrobblers/DiscordScrobbler.ts @@ -51,8 +51,8 @@ export default class DiscordScrobbler extends AbstractScrobbleClient { this.logger.info('Detected token, using WS (Headless) Discord Client'); this.apiMode = 'ws'; - this.logger.verbose(`Allow override statuses: ${this.config.data.statusOverrideAllow!.join(', ')}`); - this.logger.verbose(`Allow broadcasting during other listening activities: ${this.config.data.listeningActivityAllow!.join(', ')}`); + this.logger.verbose(`Allow override statuses: ${this.config.data.statusOverrideAllow.join(', ')}`); + this.logger.verbose(`Allow broadcasting during other listening activities: ${this.config.data.listeningActivityAllow.join(', ')}`); this.api = new DiscordWSClient(this.name, { ...this.config.data, ...this.config.options }, { logger: this.logger }); } else if(applicationId !== undefined) { this.logger.info('Detected applicationId, using IPC Discord Client'); diff --git a/src/backend/scrobblers/LastfmScrobbler.ts b/src/backend/scrobblers/LastfmScrobbler.ts index d9feb62c..86142dc7 100644 --- a/src/backend/scrobblers/LastfmScrobbler.ts +++ b/src/backend/scrobblers/LastfmScrobbler.ts @@ -67,7 +67,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { track, } = {}, } = playObj; - return track!.toLocaleLowerCase().trim(); + return (track ?? '').toLocaleLowerCase().trim(); } public playToClientPayload(playObject: PlayObject): object { diff --git a/src/backend/scrobblers/MalojaScrobbler.ts b/src/backend/scrobblers/MalojaScrobbler.ts index 98cf332a..158377f6 100644 --- a/src/backend/scrobblers/MalojaScrobbler.ts +++ b/src/backend/scrobblers/MalojaScrobbler.ts @@ -96,8 +96,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { try { const result = await this.api.scrobble(playObj); const scrobbleInfo = `Scrobbled (${newFromSource ? 'New' : 'Backlog'}) => (${source}) ${buildTrackString(playObj)}`; - if ((result.warnings?.length ?? 0) > 0) { - this.logger.warn(`${scrobbleInfo} | ${result.warnings!.join(' | ')}`); + if (result.warnings !== undefined && result.warnings.length > 0) { + this.logger.warn(`${scrobbleInfo} | ${result.warnings.join(' | ')}`); } else { this.logger.info(scrobbleInfo); } diff --git a/src/backend/scrobblers/RockskyScrobbler.ts b/src/backend/scrobblers/RockskyScrobbler.ts index 92bed4a6..105363b4 100644 --- a/src/backend/scrobblers/RockskyScrobbler.ts +++ b/src/backend/scrobblers/RockskyScrobbler.ts @@ -23,7 +23,6 @@ import { ATProtoUnauthenticatedApiClient } from "../common/vendor/atproto/ATProt import { playToRepositoryCreatePlayHistoricalOpts, type RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts"; import { isAbortError } from "abort-controller-x"; import { shouldClearNPStatus } from "./AbstractScrobbleClient.ts"; -import { removeUndefinedKeys } from "../../core/DataUtils.ts"; export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { @@ -60,8 +59,16 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { transformPlay: this.transformPlay, existingSubmitted: this.findExistingSubmittedPlayObj, existingExternal: async (play) => { - const createRecord = removeUndefinedKeys(playToRockskyClientRecord(play)); - const existingUri = await this.api.rsIndex!.scrobbleUri(this.api.userData!.did, createRecord!.title, createRecord!.artist, createRecord!.album!, createRecord!.timestamp!); + const {userData} = this.api; + if(userData === undefined) { + return {match: false, reason: 'atproto identifier is not resolved so existing scrobble records cannot be checked'}; + } + const {title, artist, album, timestamp} = playToRockskyClientRecord(play); + if(album === undefined || timestamp === undefined) { + // rocksky requires album so a record without one could not have been scrobbled + return {match: false}; + } + const existingUri = await this.api.rsIndex.scrobbleUri(userData.did, title, artist, album, timestamp); if(existingUri) { return { match: true, @@ -69,6 +76,9 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { data: existingUri } } + if(this.api.rsAgent === undefined) { + return {match: false, reason: 'must be using PDS auth to use local mirror, no matches will ever be found'} + } return {match: false}; } } @@ -194,15 +204,24 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { async fetchCarToFile() { // TODO use `since` to get CAR diff instead of entire repo // can use last import date from migrations table + const {userData} = this.api; + if(userData === undefined) { + throw new Error('Cannot fetch CAR because atproto identifier has not been resolved'); + } const filename = path.resolve(this.configDir, `${this.getSafeExternalId()}-${dayjs().unix()}.car`); - const atClient = new ATProtoUnauthenticatedApiClient('rocksky', { handleData: this.api.userData, identifier: this.config.data.handle }, { logger: this.logger }); + const atClient = new ATProtoUnauthenticatedApiClient('rocksky', { handleData: userData, identifier: this.config.data.handle }, { logger: this.logger }); await atClient.initClient(); - await fsPromise.writeFile(filename, Buffer.from(await atClient.getCAR(this.api.userData!.did))); + await fsPromise.writeFile(filename, Buffer.from(await atClient.getCAR(userData.did))); return filename; } async parseScrobblesFromCar(filename: string, batchSize: number, opts: { allowFailures?: boolean, logger?: Logger, signal?: AbortSignal } = {}) { + if(this.api.userData === undefined) { + throw new Error('Cannot parse CAR because atproto identifier has not been resolved'); + } + const {did} = this.api.userData; + if(this.api.rsAgent !== undefined) { await this.api.syncSdkRepo(filename); } @@ -229,7 +248,7 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { if (entry.collection === 'app.rocksky.scrobble') { let play: PlayObject; try { - play = rockskyScrobbleToPlay(entry.record as RockskyScrobble, {user: this.api.userData!.did, playId: entry.rkey, web: `${this.api.userData!.did}/app.rocksky.scrobble/${entry.rkey}`}) + play = rockskyScrobbleToPlay(entry.record as RockskyScrobble, {user: did, playId: entry.rkey, web: `${did}/app.rocksky.scrobble/${entry.rkey}`}) if (isDebugMode()) { logger.trace(`(${count}) rKey ${entry.rkey} => ${buildTrackString(play)}`); } @@ -290,7 +309,11 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { const unseenPlays: PlayObject[] = []; let syncGapFilled = false; for (const p of recentPlays) { - if(!(await this.playsHistoricalRepo.hasByUid(p.meta.playId!))) { + if(p.meta.playId === undefined) { + this.logger.warn(`Cannot determine if play has been seen because it has no playId, skipping => ${buildTrackString(p)}`); + continue; + } + if(!(await this.playsHistoricalRepo.hasByUid(p.meta.playId))) { unseenPlays.push(p); } else { syncGapFilled = true; diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index 9886b9e5..04c084fe 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -160,7 +160,7 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { } try { - await this.client.updateStatusRecord(playToStatusRecord(data.play!, isClearing, data.position)); + await this.client.updateStatusRecord(playToStatusRecord(data.play, isClearing, data.position)); } catch (e) { throw e; } @@ -303,7 +303,11 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { const unseenPlays: PlayObject[] = []; let syncGapFilled = false; for(const p of recentPlays) { - if(!(await this.playsHistoricalRepo.hasByUid(p.meta.playId!))) { + if(p.meta.playId === undefined) { + this.logger.warn(`Cannot determine if play has been seen because it has no playId, skipping => ${buildTrackString(p)}`); + continue; + } + if(!(await this.playsHistoricalRepo.hasByUid(p.meta.playId))) { unseenPlays.push(p); } else { syncGapFilled = true; diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 52fc766b..28a06c36 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -416,7 +416,7 @@ export default abstract class AbstractSource extends AbstractComponent implement * * ...for INGRESS we skip check because the assumption is whatever client is sending requests is very intentional and the user wants to see that their activity was recieved */ - if (([PARSED_FROM.history, PARSED_FROM.backlog] as PARSED_FROM_TYPE[]).includes(queueablePlay.meta.parsedFrom!)) { + if (queueablePlay.meta.parsedFrom !== undefined && ([PARSED_FROM.history, PARSED_FROM.backlog] as PARSED_FROM_TYPE[]).includes(queueablePlay.meta.parsedFrom)) { // we should be adding Plays to the queue without any transforms // so run on "raw" play input // if we have seen a play with close temporality with the exact input hash then skip it entirely @@ -521,15 +521,15 @@ export default abstract class AbstractSource extends AbstractComponent implement for(const p of postCompareMapped) { const {lifecycle = []} = p; const psLifecycle = lifecycle.filter(x => x.hook === TRANSFORM_HOOK.postCompare); - if(psLifecycle.length > 0) { - events.push({...transformToPlayEvent(psLifecycle), playId: p.id!, createdAt: dayjs()}); + if(psLifecycle.length > 0 && p.id !== undefined) { + events.push({...transformToPlayEvent(psLifecycle), playId: p.id, createdAt: dayjs()}); } } this.emitEvent('discoveredToScrobble', { data: postCompareMapped, options: { ...options, - checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate!.add(2, 'second'), + checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate?.add(2, 'second'), scrobbleFrom: this.getIdentifier(), scrobbleTo: this.clients } @@ -749,8 +749,8 @@ export default abstract class AbstractSource extends AbstractComponent implement const checkActiveFor = 120; let maxInterval = DEFAULT_POLLING_MAX_INTERVAL; - if('maxInterval' in this.config.data!) { - maxInterval = this.config.data.maxInterval!; + if(this.config.data !== undefined && 'maxInterval' in this.config.data && this.config.data.maxInterval !== undefined) { + maxInterval = this.config.data.maxInterval; } let isInactive = false; @@ -777,7 +777,7 @@ export default abstract class AbstractSource extends AbstractComponent implement if(playObjs.length > 0) { const now = dayjs().unix(); - const closeToInterval = playObjs.some(x => now - x.data.playDate!.unix() < 5); + const closeToInterval = playObjs.some(x => x.data.playDate !== undefined && now - x.data.playDate.unix() < 5); if (playObjs.length > 0 && closeToInterval) { // because the interval check was so close to the play date we are going to delay client calls for a few secs // this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups) @@ -802,8 +802,9 @@ export default abstract class AbstractSource extends AbstractComponent implement if(playObjs.length > 0) { playObjs.sort(sortByNewestPlayDate); // only update date if the play date is after the current activity date (in the case of backlogged plays) - if(playObjs[0].data.playDate!.isAfter(this.lastActivityAt)) { - this.lastActivityAt = playObjs[0].data.playDate!; + const newestPlayDate = playObjs[0].data.playDate; + if(newestPlayDate !== undefined && newestPlayDate.isAfter(this.lastActivityAt)) { + this.lastActivityAt = newestPlayDate; } checksOverThreshold = 0; } @@ -875,7 +876,7 @@ export default abstract class AbstractSource extends AbstractComponent implement const componentUpdate: Partial = { }; if (isAbortError(e)) { - const err = generateLoggableAbortReason('Discovery queue processing stopped', this.ingressQueueAbortController!.signal); + const err = generateLoggableAbortReason('Discovery queue processing stopped', ingressQueueAbortController.signal); this.logger.info(err); //this.logger.trace(e); componentUpdate.status = 'Discovery queue processing cancelled'; @@ -1029,10 +1030,10 @@ export default abstract class AbstractSource extends AbstractComponent implement // that way we don't accidentally mark the "original" of some N number of duplicate inputs as a dupe as well // // IE the "oldest" play of a set of duplicates should not itself be marked as a dupe of the "newer" duplicates - seenAt: { + seenAt: playEntity.seenAt !== null ? { type: 'lt', - date: playEntity.seenAt! - } + date: playEntity.seenAt + } : undefined }); if (cheapExisting !== undefined) { events.push(dupeCheckToPlayEvent({ match: true, reason: `Matched hash on existing Play ${cheapExisting.uid} with close temporality` })); @@ -1057,10 +1058,13 @@ export default abstract class AbstractSource extends AbstractComponent implement this.emitEvent('discovered', {play: preCompared}); await this.scrobble([{...playEntity.play, id: playEntity.id, uid: playEntity.uid}]); } else { - await this.playRepo.updateById(existing.id!, {updatedAt: dayjs()}); + // existing plays are always from the db so should always have an id + if(existing.id !== undefined) { + await this.playRepo.updateById(existing.id, {updatedAt: dayjs()}); + } playEntity.state = 'duped'; events.push(stateChangeToPlayEvent({state: 'duped'})); - playEntity.parentId = existing.id!; + playEntity.parentId = existing.id ?? null; } const recentPlays = await this.getRecentPlays(false); @@ -1130,7 +1134,7 @@ export default abstract class AbstractSource extends AbstractComponent implement //const processable = await this.queueRepo.getQueueCount(this.dbComponent.id, [INGRESS_QUEUE], [QUEUE_STATUS_FAILED], retries); const processableArgs: QueryPlaysOpts = {queues: [{queueName: INGRESS_QUEUE, queueStatus: QUEUE_STATUS_FAILED, retries}], with: ['queues']}; let processable = await this.playRepo.findPlaysPaginated(processableArgs); - this.deadLetterQueued = processable.meta.total!; + this.deadLetterQueued = processable.meta.total; const total = await this.queueRepo.getQueueCount(this.dbComponent.id, [INGRESS_QUEUE], {queueStatus: [QUEUE_STATUS_FAILED], retries: 10000}); this.deadLetterLength = total; @@ -1192,8 +1196,8 @@ export default abstract class AbstractSource extends AbstractComponent implement protected getInterval(log?: boolean) { let interval = DEFAULT_POLLING_INTERVAL; - if('interval' in this.config.data!) { - interval = this.config.data.interval!; + if(this.config.data !== undefined && 'interval' in this.config.data && this.config.data.interval !== undefined) { + interval = this.config.data.interval; } return interval; } @@ -1201,8 +1205,8 @@ export default abstract class AbstractSource extends AbstractComponent implement protected getMaxBackoff() { let maxInterval = DEFAULT_POLLING_MAX_INTERVAL; - if('maxInterval' in this.config.data!) { - maxInterval = this.config.data.maxInterval!; + if(this.config.data !== undefined && 'maxInterval' in this.config.data && this.config.data.maxInterval !== undefined) { + maxInterval = this.config.data.maxInterval; } return maxInterval - this.getInterval(); } @@ -1271,12 +1275,17 @@ export default abstract class AbstractSource extends AbstractComponent implement const root = getRoot(); const stream = root.get('loggerStream'); const logConfig = root.get('loggingConfig'); + if(stream === undefined) { + this.logger.warn('No logger stream is available, cannot build component logger'); + return; + } const cLogger = await componentFileLogger(this.type, this.name, true, logConfig); - this.componentLogger = childLogger(cLogger, this.logger.labels); - stream!.on('data', (d: LogDataPretty) => { + const componentLogger = childLogger(cLogger, this.logger.labels); + this.componentLogger = componentLogger; + stream.on('data', (d: LogDataPretty) => { const {level, msg, line, labels, ...rest} = d; if(d.labels.includes(this.loggerLabel)) { - this.componentLogger![this.componentLogger!.levels.labels[d.level] as LogLevel]({...rest, labels: difference(labels, this.logger.labels)}, msg as string); + componentLogger[componentLogger.levels.labels[d.level] as LogLevel]({...rest, labels: difference(labels, this.logger.labels)}, msg as string); } }); } diff --git a/src/backend/sources/ChromecastSource.ts b/src/backend/sources/ChromecastSource.ts index bccbe2c5..93ed7fa4 100644 --- a/src/backend/sources/ChromecastSource.ts +++ b/src/backend/sources/ChromecastSource.ts @@ -5,7 +5,6 @@ import { childLogger, type Logger } from "@foxxmd/logging"; import { Client as CastClient } from 'castv2'; import dayjs from "dayjs"; import type { EventEmitter } from "events"; -import e from "express"; import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.ts"; import { NETWORK_ERROR_FAILURE_CODES } from "../common/errors/NodeErrors.ts"; @@ -16,7 +15,7 @@ import { type PlayerStateData, type SourceData, } from "../common/infrastructure/Atomic.ts"; -import { NO_USER } from '../../core/Atomic.ts'; +import { NO_DEVICE, NO_USER } from '../../core/Atomic.ts'; import type {ChromecastSourceConfig} from "../common/infrastructure/config/source/chromecast.ts"; import { MaybeLogger } from '../common/MaybeLogger.ts'; import { @@ -229,7 +228,7 @@ export class ChromecastSource extends MemoryPositionalSource { } try { - const [castClient, client, platform] = (await this.initializeClientPlatform(device))!; + const [castClient, client, platform] = (await this.initializeClientPlatform(device)); this.logger.info(`${discovered} => Connected!`); const applications = new Map(); this.devices.set(device.name, { @@ -286,7 +285,7 @@ export class ChromecastSource extends MemoryPositionalSource { } if(event === "reconnect") { if(payload instanceof Error) { - info.logger.warn(new Error(`Failed to reconnect, will retry ${5 - info.retries} more times`, {cause: e})) + info.logger.warn(new Error(`Failed to reconnect, will retry ${5 - info.retries} more times`, {cause: payload})) } else { info.logger.verbose(`Reconnected`); info.retries = 0; @@ -409,7 +408,10 @@ export class ChromecastSource extends MemoryPositionalSource { const storedApps = Array.from(v.applications.keys()); const storedStale = difference(storedApps, currApps); for(const staleId of storedStale) { - const staleApp = v.applications.get(staleId)!; + const staleApp = v.applications.get(staleId); + if(staleApp === undefined) { + continue; + } if(staleApp.filtered || !staleApp.validAppType) { staleApp.logger.verbose(`Became stale and is unused, removing immediately.`); //staleApp.logger.close(); @@ -428,7 +430,10 @@ export class ChromecastSource extends MemoryPositionalSource { if(reason !== undefined) { this.logger.warn(reason); } - const device = this.devices.get(deviceName)!; + const device = this.devices.get(deviceName); + if(device === undefined) { + return; + } device.platform.close(); device.client.close(); this.devices.delete(deviceName); @@ -441,7 +446,7 @@ export class ChromecastSource extends MemoryPositionalSource { return; } for(const [tId, app] of deviceInfo.applications) { - app.controller!.dispose(); + app.controller?.dispose(); this.deletePlayer(app.playerId, reason) //app.logger.close(); deviceInfo.applications.delete(tId); @@ -457,13 +462,13 @@ export class ChromecastSource extends MemoryPositionalSource { const forDeletion: [string, string][] = []; for(const [tId, app] of v.applications.entries()) { - if(app.stale && Math.abs(app.staleAt!.diff(dayjs(), 's')) > 60) { + if(app.stale && app.staleAt !== undefined && Math.abs(app.staleAt.diff(dayjs(), 's')) > 60) { app.logger.info(`Removing due to being stale for 60 seconds`); //app.logger.close(); - app.controller!.dispose(); + app.controller?.dispose(); v.applications.delete(tId); forDeletion.push([app.playerId, 'No updates for 60 seconds']); - } else if(app.badData && Math.abs(app.badDataAt!.diff(dayjs(), 's')) > 60 && this.players.has(app.playerId)) { + } else if(app.badData && app.badDataAt !== undefined && Math.abs(app.badDataAt.diff(dayjs(), 's')) > 60 && this.players.has(app.playerId)) { forDeletion.push([app.playerId, 'Bad data for 60 seconds']); } } @@ -551,7 +556,7 @@ export class ChromecastSource extends MemoryPositionalSource { // its fine just do error without play string } application.logger.verbose(`Skipping status for ${maybePlay !== undefined ? buildTrackString(maybePlay) : 'unknown media'} because it is buffering.`); - if (this.config.options!.logPayload || isDebugMode()) { + if (this.config.options?.logPayload || isDebugMode()) { application.logger.debug(`Media Status Payload:\n ${status[0] === undefined || status[0] === null ? 'undefined' : JSON.stringify(status[0])}`); } continue; @@ -563,7 +568,7 @@ export class ChromecastSource extends MemoryPositionalSource { throw e; } - if (this.config.options!.logPayload || isDebugMode()) { + if (this.config.options?.logPayload || isDebugMode()) { application.logger.debug(`Media Status Payload:\n ${mediaStatus === undefined || mediaStatus === null ? 'undefined' : JSON.stringify(mediaStatus)}`); } @@ -575,7 +580,7 @@ export class ChromecastSource extends MemoryPositionalSource { }); } - if (play === undefined || play.data.artists!.length === 0 || play.data.track === undefined) { + if (play === undefined || (play.data.artists ?? []).length === 0 || play.data.track === undefined) { if (!application.badData) { application.logger.warn(`Media information either did not return artists or track. This isn't scrollable! Skipping this update and marking App as having bad data (to be removed after 60 seconds)`); application.badData = true; @@ -623,7 +628,7 @@ export class ChromecastSource extends MemoryPositionalSource { } const playerState: PlayerStateData = { - platformId: [play.meta.deviceId!, NO_USER], + platformId: [play.meta.deviceId ?? NO_DEVICE, NO_USER], play, position: play.meta.trackProgressPosition, status: chromePlayerStateToReported(mediaStatus.playerState) diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index 18bfce5e..ec6582e9 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -70,7 +70,7 @@ export default class DeezerInternalSource extends MemorySource { authedAccount!: DeezerAuthedUserData; - accounts?: DeezerAccountData[] = [] + accounts: DeezerAccountData[] = [] csrfToken?: string; @@ -180,18 +180,19 @@ export default class DeezerInternalSource extends MemorySource { } } else { const enumerated = await this.enumerateChildAccounts(); - if(this.config.data.accountId !== undefined) { + const {accountId} = this.config.data; + if(accountId !== undefined) { if(!enumerated) { this.logger.warn('Unable to verify if account history is available for accountId due to enumeration issue.'); } else { - const requestedAccount = this.accounts!.find(x => x.USER_ID === this.config.data.accountId); + const requestedAccount = this.accounts.find(x => x.USER_ID === accountId); if(requestedAccount === undefined) { this.logger.warn(`Could not find a linked account matching ${this.config.data.accountId}. History fetching may fail.`); } else { - const authedAccount = this.accounts!.find(x => x.USER_ID === this.authedAccount.USER.USER_ID); - if(!authedAccount!.EXTRA_FAMILY!.IS_LOGGABLE_AS && this.config.data.accountId !== this.authedAccount.USER.USER_ID) { + const authedAccount = this.accounts.find(x => x.USER_ID === this.authedAccount.USER.USER_ID); + if(authedAccount !== undefined && !authedAccount.EXTRA_FAMILY?.IS_LOGGABLE_AS && accountId !== this.authedAccount.USER.USER_ID) { this.logger.warn(`Authed Account (${this.authedAccount.USER.USER_ID}) is private and specified accountId is not the same (${this.config.data.accountId}), likely history returned will not be correct.`); - } else if(!requestedAccount.EXTRA_FAMILY!.IS_LOGGABLE_AS) { + } else if(!requestedAccount.EXTRA_FAMILY?.IS_LOGGABLE_AS) { this.logger.warn('Account specified by accountId is private, likely returned will not be correct!'); } } @@ -258,7 +259,7 @@ export default class DeezerInternalSource extends MemorySource { this.accounts = resp; const accountSummaries: string[] = []; for(const a of this.accounts) { - accountSummaries.push(`Name: ${a.BLOG_NAME} | ID: ${a.USER_ID} | Private?: ${a.EXTRA_FAMILY!.IS_LOGGABLE_AS ? 'No' : 'Yes'}`); + accountSummaries.push(`Name: ${a.BLOG_NAME} | ID: ${a.USER_ID} | Private?: ${a.EXTRA_FAMILY?.IS_LOGGABLE_AS ? 'No' : 'Yes'}`); } this.logger.verbose(`Linked Accounts:\n${accountSummaries.join('\n')}`) return true; @@ -361,7 +362,7 @@ export default class DeezerInternalSource extends MemorySource { const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY]; if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') { temporalOptions = { - fuzzyDiffThreshold: Math.max(100, x.data.duration! * 0.5), + fuzzyDiffThreshold: x.data.duration !== undefined ? Math.max(100, x.data.duration * 0.5) : 100, duringReferences: ['duration', 'listenedFor', 'range'], logger: this.logger } diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index 5b90fd9a..bba96ad5 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -53,7 +53,8 @@ export class EndpointLastfmSource extends MemorySource { return false; } - return (this.config.data!.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data!.slug !== undefined && this.config.data!.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); + const configSlug = this.config.data?.slug; + return (configSlug === undefined && slug === undefined) || (slug !== undefined && configSlug !== undefined && configSlug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); } static formatPlayObj(obj: LastFmSingleSubmitPayload, options: FormatPlayObjectOptions = {}): PlayObject { diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts index 1c545417..044e318c 100644 --- a/src/backend/sources/EndpointListenbrainzSource.ts +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -54,14 +54,15 @@ export class EndpointListenbrainzSource extends MemorySource { } matchRequest(req: Pick): boolean { - let matchesToken = this.config.data!.token === undefined; + const {token: configToken, slug: configSlug} = this.config.data ?? {}; + let matchesToken = configToken === undefined; const reqToken = parseTokenFromRequest(req, requestMatchers); if (reqToken === false) { return false; } - matchesToken = this.config.data!.token === undefined && reqToken === undefined || - (reqToken !== undefined && this.config.data!.token !== undefined - && this.config.data!.token.toLowerCase().trim() === reqToken.toLowerCase().trim()); + matchesToken = configToken === undefined && reqToken === undefined || + (reqToken !== undefined && configToken !== undefined + && configToken.toLowerCase().trim() === reqToken.toLowerCase().trim()); if (!matchesToken) { return false; @@ -72,7 +73,7 @@ export class EndpointListenbrainzSource extends MemorySource { if (slug === false) { return false; } else { - matchesPath = (this.config.data!.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data!.slug !== undefined && this.config.data!.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); + matchesPath = (configSlug === undefined && slug === undefined) || (slug !== undefined && configSlug !== undefined && configSlug.toLowerCase().trim() === slug.toLocaleLowerCase().trim()); } return matchesToken && matchesPath; diff --git a/src/backend/sources/ExampleSource.ts b/src/backend/sources/ExampleSource.ts index bf2d23b0..8a186532 100644 --- a/src/backend/sources/ExampleSource.ts +++ b/src/backend/sources/ExampleSource.ts @@ -52,12 +52,15 @@ export class CoolPlayerSource extends MemorySource { } = {} } = this.config; - if(!baseUrl!.includes('/api')) { + if(baseUrl === undefined) { + throw new Error('baseUrl must be defined'); + } + if(!baseUrl.includes('/api')) { this.logger.warn('Cool Player connections usually have an /api prefix but none was detected!'); // may also want to throw here instead if this is show stopping: // throw new Error(`Cool Player connections require an /api prefix but none was detected. Given: ${baseUrl}`); } - this.urlData = normalizeWebAddress(baseUrl!); + this.urlData = normalizeWebAddress(baseUrl); // if everything is ok then return true return true; } diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index 5ef7509d..dd59dc23 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -172,14 +172,14 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = track = oggTitle; album = oggAlbum; - const artistCred = parseArtistCredits(oggArtist!); + const artistCred = oggArtist !== undefined ? parseArtistCredits(oggArtist) : undefined; if (artistCred !== undefined) { artists.push(artistCred.primary); if (artistCred.secondary !== undefined) { artists = artists.concat(artistCred.secondary); } - } else { - artists.push(oggArtist!); + } else if (oggArtist !== undefined) { + artists.push(oggArtist); } } else if(obj.icy?.StreamTitle !== undefined) { const value: string = obj.icy.StreamTitle; diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 56693f50..042560b5 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -83,9 +83,9 @@ export class MPDSource extends MemoryPositionalSource { } protected async doCheckConnection(): Promise { - if(this.host !== undefined) { + if(this.host !== undefined && this.port !== undefined) { try { - await isPortReachable(this.port!, {host: this.host}); + await isPortReachable(this.port, {host: this.host}); return `${this.host}:${this.port} is reachable.`; } catch (e) { throw e; @@ -112,7 +112,11 @@ export class MPDSource extends MemoryPositionalSource { throw new Error('Timed out waiting for TCP response from MPD'); } } else { - await this.mpc.connectUnixSocket(this.config.data.path!); + const socketPath = this.config.data?.path; + if(socketPath === undefined) { + throw new Error('Neither a host nor a socket path is configured'); + } + await this.mpc.connectUnixSocket(socketPath); } if(this.config.data.password !== undefined) { @@ -233,8 +237,8 @@ export class MPDSource extends MemoryPositionalSource { } = obj; trackName = title ?? name; - if(trackName === undefined) { - const pathSplit = file!.split(path.sep); + if(trackName === undefined && file !== undefined) { + const pathSplit = file.split(path.sep); if(pathSplit.length > 1) { trackName = pathSplit[pathSplit.length - 1]; } else { @@ -321,7 +325,7 @@ export class MPDSource extends MemoryPositionalSource { const playerState: PlayerStateDataMaybePlay = { platformId: SINGLE_USER_PLATFORM_ID, - status: CLIENT_PLAYER_STATE[mpcStatus.state!], + status: mpcStatus.state !== undefined ? CLIENT_PLAYER_STATE[mpcStatus.state] : REPORTED_PLAYER_STATUSES.unknown, play, position: play?.meta?.trackProgressPosition } diff --git a/src/backend/sources/MPRISSource.ts b/src/backend/sources/MPRISSource.ts index 6712a17b..21dab8d7 100644 --- a/src/backend/sources/MPRISSource.ts +++ b/src/backend/sources/MPRISSource.ts @@ -88,7 +88,7 @@ export class MPRISSource extends MemorySource { trackId: trackid, newFromSource, url: { - web: url! + web: url }, trackProgressPosition: position, deviceId: name, @@ -108,7 +108,7 @@ export class MPRISSource extends MemorySource { } protected getDBus = async () => { - const conn = new Connection(createStream({})!); + const conn = new Connection(createStream({})); try { const res = await Promise.race([ pEvent(conn, 'error'), @@ -269,7 +269,7 @@ const convertDBusExceptionToError = (e: any): Error => { // unfortunately had to recreate this function from dbus-ts/Connection // in order to be able to create the Connection class without immediate init // so we can catch errors -const createStream = (opts: ConnectOpts): (Readable&Writable) | undefined => { +const createStream = (opts: ConnectOpts): (Readable&Writable) => { if (typeof opts !== 'object') { opts = {}; } @@ -318,4 +318,6 @@ const createStream = (opts: ConnectOpts): (Readable&Writable) | undefined => { } } } + // not reachable since last address either returns or throws + throw new Error('unknown bus address'); } \ No newline at end of file diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index ffe060b5..0792e1e3 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -76,7 +76,9 @@ export default class MemorySource extends AbstractSource { [Symbol.dispose]() { this.scheduler.stop(); for(const job of this.scheduler.getAllJobs()) { - this.scheduler.removeById(job.id!); + if(job.id !== undefined) { + this.scheduler.removeById(job.id); + } } for(const p of this.players.keys()) { this.deletePlayer(p); @@ -87,7 +89,9 @@ export default class MemorySource extends AbstractSource { await super[Symbol.asyncDispose](); this.scheduler.stop(); for(const job of this.scheduler.getAllJobs()) { - this.scheduler.removeById(job.id!); + if(job.id !== undefined) { + this.scheduler.removeById(job.id); + } } this[Symbol.dispose](); } @@ -183,12 +187,14 @@ export default class MemorySource extends AbstractSource { getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions): AbstractPlayerState => new GenericPlayerState(logger, id, opts) - setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => { - this.players.set(idStr, this.getNewPlayer(this.logger, id, { + setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}): AbstractPlayerState => { + const player = this.getNewPlayer(this.logger, id, { ...createPlayerOptions(this.config.data as Partial, this.playerSourceOfTruth, this.logger), ...opts - })); + }); + this.players.set(idStr, player); this.playerState.set(idStr, ''); + return player; } hasPlayer = (data: string | PlayerStateDataMaybePlay): boolean => { @@ -202,7 +208,7 @@ export default class MemorySource extends AbstractSource { } isZombiePlayer = (id: string, lastUpdated: Dayjs): boolean => { - return this.deceasedPlayers.has(id) && this.deceasedPlayers.get(id)!.isSame(lastUpdated); + return this.deceasedPlayers.get(id)?.isSame(lastUpdated) === true; } genPlayerId = (data: PlayObject | PlayerStateDataMaybePlay): string => { @@ -210,13 +216,13 @@ export default class MemorySource extends AbstractSource { } deletePlayer = (id: string, reason?: string) => { - if(!this.players.has(id)) { + using player = this.players.get(id); + if(player === undefined) { return; } if(reason !== undefined) { - this.players.get(id)?.logger.debug(reason); + player.logger.debug(reason); } - using player = this.players.get(id)!; this.deceasedPlayers.set(id, player.stateLastUpdatedAt); player[Symbol.dispose](); this.players.delete(id); @@ -253,13 +259,12 @@ export default class MemorySource extends AbstractSource { this.deceasedPlayers.delete(idStr); } } - this.setNewPlayer(idStr, this.logger, id); + const newPlayer = this.setNewPlayer(idStr, this.logger, id); if(!this.multiPlatform && this.players.size > 1) { // new platform should have old platform data transferred const [id,firstPlayer] = Array.from(this.players.entries())[0]; - const newPlayer = this.players.get(idStr); - firstPlayer.transferToNewPlayer(newPlayer!); + firstPlayer.transferToNewPlayer(newPlayer); this.deletePlayer(id, 'Removed due to player transfer'); } } @@ -360,7 +365,7 @@ export default class MemorySource extends AbstractSource { } = this.config.options ?? {}; const stPrefix = `${buildTrackString(candidate, {include: ['trackId', 'artist', 'track']})}`; - const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor!, candidate.data.duration); + const thresholdResults = timePassesScrobbleThreshold(scrobbleThresholds, candidate.data.listenedFor ?? 0, candidate.data.duration); if (thresholdResults.passes) { const matchingRecent = await this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); @@ -369,10 +374,13 @@ export default class MemorySource extends AbstractSource { } else { const {data: {playDate, duration}} = candidate; // existingPlay always sets closestMatchedPlay when match is true - const rplayDate = matchingRecent.closestMatchedPlay!.data.playDate; - if (!playDate!.isSame(rplayDate)) { + const rplayDate = matchingRecent.closestMatchedPlay?.data.playDate; + if (playDate === undefined || rplayDate === undefined) { + return [false, `${stPrefix} matched a prior play but could not compare timestamps because a play date is missing`]; + } + if (!playDate.isSame(rplayDate)) { if (duration !== undefined) { - if (playDate!.isAfter(rplayDate!.add(duration, 's'))) { + if (playDate.isAfter(rplayDate.add(duration, 's'))) { return [true,`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and having a different timestamp than a prior play`]; } return [false, `${stPrefix} ${EXPECTED_NON_DISCOVERED_REASON}`] diff --git a/src/backend/sources/MopidySource.ts b/src/backend/sources/MopidySource.ts index a8a019c6..3d35ebc1 100644 --- a/src/backend/sources/MopidySource.ts +++ b/src/backend/sources/MopidySource.ts @@ -166,7 +166,7 @@ export class MopidySource extends MemoryPositionalSource { album: albumName, albumArtists: artistNamesToCredits(actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : []), artists: artistNamesToCredits(artists.length > 0 ? artists.map(x => x.name) : []), - duration: Math.round(length! / 1000), + duration: length !== undefined ? Math.round(length / 1000) : undefined, playDate: dayjs() }, meta: { @@ -187,17 +187,22 @@ export class MopidySource extends MemoryPositionalSource { return []; } - const state = await this.client.playback!.getState(); - const currTrack = await this.client.playback!.getCurrentTrack(); - const playback = await this.client.playback!.getTimePosition(); + const playbackApi = this.client.playback; + if (playbackApi === undefined) { + this.logger.warn('Cannot actively poll since client playback API is not available.'); + return []; + } + const state = await playbackApi.getState(); + const currTrack = await playbackApi.getCurrentTrack(); + const playback = await playbackApi.getTimePosition(); let play: PlayObject | undefined = currTrack === null ? undefined : this.formatPlayObj(currTrack, {trackProgressPosition: playback}); - if(play !== undefined) { + if(play !== undefined && currTrack !== null) { if (this.uriWhitelist.length > 0) { - const match = this.uriWhitelist.find(x => currTrack!.uri.includes(x)); + const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); if (match === undefined) { - this.logger.debug(`URI for currently playing (${currTrack!.uri}) did not match any in whitelist. Will not track play ${buildTrackString(play)}`); + this.logger.debug(`URI for currently playing (${currTrack.uri}) did not match any in whitelist. Will not track play ${buildTrackString(play)}`); play = undefined; } } else if (this.uriBlacklist.length > 0) { diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 209689b6..6c91311c 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -7,7 +7,7 @@ import { type InternalConfig, MBID_VARIOUS_ARTISTS, type PlayerStateDataMaybePlay} from "../common/infrastructure/Atomic.ts"; -import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; +import { COMPONENT_AUTH_TYPE, NO_DEVICE, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {PlayPlatformId} from '../../core/Atomic.ts'; import { getFirstNonEmptyString, isDebugMode, } from "../utils.ts"; import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.ts"; @@ -192,7 +192,10 @@ export default class PlexApiSource extends MemoryPositionalSource { try { const tokenDetails = await this.plexApi.authentication.getTokenDetails(); - userPlexAccount = tokenDetails.userPlexAccount!; + if(tokenDetails.userPlexAccount === undefined) { + throw new Error('Token details did not include a Plex Account'); + } + userPlexAccount = tokenDetails.userPlexAccount; } catch (e) { if(e instanceof SDKValidationError && 'UserPlexAccount' in (e.rawValue as object)) { userPlexAccount = (e.rawValue as {UserPlexAccount: GetTokenDetailsUserPlexAccount}).UserPlexAccount as GetTokenDetailsUserPlexAccount; @@ -201,14 +204,18 @@ export default class PlexApiSource extends MemoryPositionalSource { } } - this.plexUser = getFirstNonEmptyString([userPlexAccount.username, userPlexAccount.title, userPlexAccount.friendlyName, userPlexAccount.email])!; + const plexUser = getFirstNonEmptyString([userPlexAccount.username, userPlexAccount.title, userPlexAccount.friendlyName, userPlexAccount.email]); + if(plexUser === undefined) { + throw new Error('Could not determine authenticated username from Plex Account details'); + } + this.plexUser = plexUser; if(this.usersAllow.length === 0) { this.usersAllow.push(this.plexUser.toLocaleLowerCase()); this.usersAllow.push(LOCAL_USER.toLocaleLowerCase()); } - this.logger.info(`Authenticated on behalf of user ${this.plexUser} on Server ${server.object!.mediaContainer!.friendlyName} (version ${server.object!.mediaContainer!.version})`); + this.logger.info(`Authenticated on behalf of user ${this.plexUser} on Server ${server.object?.mediaContainer?.friendlyName} (version ${server.object?.mediaContainer?.version})`); return true; } catch (e: any) { if(e.message.includes('401') && e.message.includes('API error occurred')) { @@ -222,7 +229,11 @@ export default class PlexApiSource extends MemoryPositionalSource { try { const libraries = await this.plexApi.library.getAllLibraries(); - this.libraries = libraries.object!.mediaContainer!.directory!.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid})); + const directory = libraries.object?.mediaContainer?.directory; + if(directory === undefined) { + throw new Error('Plex libraries response did not include a list of libraries'); + } + this.libraries = directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid})); } catch (e) { if(e instanceof SDKValidationError) { if((e.rawValue as any).object?.MediaContainer?.Directory !== undefined) { @@ -282,16 +293,17 @@ export default class PlexApiSource extends MemoryPositionalSource { } - if(state.play !== undefined) { + const statePlay = state.play; + if(statePlay !== undefined) { const allowedLibraries = this.getAllowedLibraries(); - if(allowedLibraries.length > 0 && !allowedLibraries.some(x => (state.play!.meta.library ?? '').toLocaleLowerCase() === x.name.toLocaleLowerCase())) { + if(allowedLibraries.length > 0 && !allowedLibraries.some(x => (statePlay.meta.library ?? '').toLocaleLowerCase() === x.name.toLocaleLowerCase())) { return `media not included in librariesAllow`; } if(allowedLibraries.length === 0) { const blockedLibraries = this.getBlockedLibraries(); if(blockedLibraries.length > 0) { - const blockedLibrary = blockedLibraries.find(x => (state.play!.meta.library ?? '').toLocaleLowerCase() === x.name.toLocaleLowerCase()); + const blockedLibrary = blockedLibraries.find(x => (statePlay.meta.library ?? '').toLocaleLowerCase() === x.name.toLocaleLowerCase()); if(blockedLibrary !== undefined) { return `media included in librariesBlock '${blockedLibrary.name}'`; } @@ -300,7 +312,7 @@ export default class PlexApiSource extends MemoryPositionalSource { // this is inside this block because we SHOULD allow non-music libraries if // user specified name in librariesAllow // -- so only check for this if nothing is specified - if(!this.getValidLibraries().some(x => (state.play!.meta.library ?? '') === x.name)) { + if(!this.getValidLibraries().some(x => (statePlay.meta.library ?? '') === x.name)) { return `media not included in a valid library`; } } @@ -369,9 +381,11 @@ export default class PlexApiSource extends MemoryPositionalSource { if(trackArtist !== undefined) { realArtists.push(trackArtist); - albumArtists.push(artist!); - } else { - realArtists.push(artist!); + if(artist !== undefined) { + albumArtists.push(artist); + } + } else if(artist !== undefined) { + realArtists.push(artist); } const play: PlayObjectMinimal = { @@ -381,7 +395,7 @@ export default class PlexApiSource extends MemoryPositionalSource { album, track, // albumArtists: AlbumArtists !== undefined ? AlbumArtists.map(x => x.Name) : undefined, - duration: duration! / 1000 + duration: duration !== undefined ? duration / 1000 : undefined }, meta: { // If a user does not have to login to Plex (local IP and no Home Management(?)) then the User node is never populated @@ -394,7 +408,7 @@ export default class PlexApiSource extends MemoryPositionalSource { library, deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]), sessionId: sessionKey, - trackProgressPosition: viewOffset! / 1000, + trackProgressPosition: viewOffset !== undefined ? viewOffset / 1000 : undefined, } } return baseFormatPlayObj(obj, play); @@ -404,28 +418,32 @@ export default class PlexApiSource extends MemoryPositionalSource { const result = await this.plexApi.sessions.getSessions(); - const allSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object!.mediaContainer?.metadata ?? []) + const allSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object?.mediaContainer?.metadata ?? []) .map(x => [this.sessionToPlayerState(x), x]); const validSessions: PlayerStateDataMaybePlay[] = []; for(const sessionData of allSessions) { const validPlay = this.isActivityValid(sessionData[0], sessionData[1]); - if(validPlay === true) { + const sessionPlay = sessionData[0].play; + if(validPlay === true && sessionPlay === undefined) { + validSessions.push(sessionData[0]); + } else if(validPlay === true && sessionPlay !== undefined) { // Pull MBIDs for track, album, and artist. const [trackMbId, albumMbId, albumArtistMbId] = await Promise.all([ this.getMusicBrainzId(sessionData[1].ratingKey), this.getMusicBrainzId(sessionData[1].parentRatingKey), this.getMusicBrainzId(sessionData[1].grandparentRatingKey), ]); - - if (!sessionData[0].play!.data.meta) { - sessionData[0].play!.data.meta = {}; - } - + + const playMeta = sessionPlay.data.meta ?? {}; + sessionPlay.data.meta = playMeta; + const playArtists = sessionPlay.data.artists ?? []; + const playAlbumArtists = sessionPlay.data.albumArtists ?? []; + const computedBrainz: BrainzMeta = { - ...(sessionData[0].play!.data.meta.brainz ?? {}), - track: trackMbId ?? sessionData[0].play!.data?.meta?.brainz?.track, - album: albumMbId ?? sessionData[0].play!.data?.meta?.brainz?.album, + ...(playMeta.brainz ?? {}), + track: trackMbId ?? playMeta.brainz?.track, + album: albumMbId ?? playMeta.brainz?.album, } // Plex doesn't store MBIDs for track artists, so we use the // album artist MBID instead BUT ONLY if @@ -436,14 +454,14 @@ export default class PlexApiSource extends MemoryPositionalSource { // otherwise we might accidentally set "Various Artists" like MBIDs as actual artist if(albumArtistMbId !== undefined && albumArtistMbId !== MBID_VARIOUS_ARTISTS - && (sessionData[0].play!.data.albumArtists!.length === 0 - || sessionData[0].play!.data.artists!.every(y => (sessionData[0].play!.data.albumArtists ?? []).includes(y)))) { + && (playAlbumArtists.length === 0 + || playArtists.every(y => playAlbumArtists.includes(y)))) { computedBrainz.artist = [...new Set([...(computedBrainz.artist ?? []), albumArtistMbId])]; // since we don't get artist and mbid at the same time we only be sure these are actually associated // if there is only one of each - if(computedBrainz.artist.length === 1 && sessionData[0].play!.data.artists!.length === 1) { - sessionData[0].play!.data.artists![0].mbid = computedBrainz.artist[0]; + if(computedBrainz.artist.length === 1 && playArtists.length === 1) { + playArtists[0].mbid = computedBrainz.artist[0]; } } @@ -452,12 +470,12 @@ export default class PlexApiSource extends MemoryPositionalSource { // since we don't get albumartist and mbid at the same time we only be sure these are actually associated // if there is only one of each - if(computedBrainz.albumArtist.length === 1 && sessionData[0].play!.data.albumArtists!.length === 1) { - sessionData[0].play!.data.albumArtists![0].mbid = computedBrainz.albumArtist[0]; + if(computedBrainz.albumArtist.length === 1 && playAlbumArtists.length === 1) { + playAlbumArtists[0].mbid = computedBrainz.albumArtist[0]; } } - sessionData[0].play!.data.meta.brainz = computedBrainz; + playMeta.brainz = computedBrainz; // need to add this to original object since lifecycle has already been set in sessionToPlayerState //sessionData[0].play.meta.lifecycle.original = clone(sessionData[0].play); @@ -501,7 +519,7 @@ export default class PlexApiSource extends MemoryPositionalSource { // then choose the player state with the "latest" session key if(sessions.every(x => asPlayerStateDataMaybePlay(x) && 'sessionId' in x)) { const pStateSessions = sessions as PlayerStateDataMaybePlay[]; - pStateSessions.sort((a, b) => parseInt(a.sessionId!) - parseInt(b.sessionId!)); + pStateSessions.sort((a, b) => parseInt(a.sessionId ?? '0') - parseInt(b.sessionId ?? '0')); const validSession = pStateSessions[sessions.length - 1]; const droppingSessions = pStateSessions.filter(x => x.sessionId !== validSession.sessionId).map(x => buildStatePlayerPlayIdententifyingInfo(x)).join('\n'); @@ -533,20 +551,21 @@ export default class PlexApiSource extends MemoryPositionalSource { const play: PlayObject = this.formatPlayObjAware(obj); - if((this.config.options!.logPayload || isDebugMode()) && !this.mediaIdsSeen.data.includes(play.meta.trackId!)) { + const {trackId} = play.meta; + if((this.config.options?.logPayload || isDebugMode()) && trackId !== undefined && !this.mediaIdsSeen.data.includes(trackId)) { this.logger.debug(`First time seeing media ${play.meta.trackId} on ${msDeviceId} => ${JSON.stringify(play)} Plex Payload: ${JSON.stringify(obj)}`); - this.mediaIdsSeen.add(play.meta.trackId!); + this.mediaIdsSeen.add(trackId); } const reportedStatus = state !== 'playing' ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing; return { - platformId: [msDeviceId!, play.meta.user!], + platformId: [msDeviceId ?? NO_DEVICE, play.meta.user ?? LOCAL_USER], sessionId: sessionKey, play, status: reportedStatus, - position: viewOffset! / 1000 + position: viewOffset !== undefined ? viewOffset / 1000 : undefined } } @@ -576,7 +595,7 @@ ${JSON.stringify(obj)}`); { method: "GET", headers: { - "X-Plex-Token": this.config.data.token!, + "X-Plex-Token": this.token, "Accept": "application/json", }, signal diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 9e97bfeb..b805d969 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -95,8 +95,9 @@ export default class ScrobbleSources { } = fileDefaults; buildDefaults.scrobbleThresholds = {...scrobbleThresholds}; - if(duration === undefined && nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined) { - const envDur = Number.parseInt(scrobbleDurationEnv!); + const durationEnvVal = nonEmptyStringOrDefault(scrobbleDurationEnv); + if(duration === undefined && durationEnvVal !== undefined) { + const envDur = Number.parseInt(durationEnvVal); if(Number.isNaN(envDur)) { this.logger.warn(`Ignoring value '${scrobbleDurationEnv}' for env SOURCE_SCROBBLE_DURATION because it is not a number`); } else { @@ -104,8 +105,9 @@ export default class ScrobbleSources { this.logger.verbose(`Set default scrobble threshold duration to '${scrobbleDurationEnv}' based on env SOURCE_SCROBBLE_DURATION`); } } - if(percent === undefined && nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) { - const envPercent = Number.parseInt(scrobblePercentEnv!); + const percentEnvVal = nonEmptyStringOrDefault(scrobblePercentEnv); + if(percent === undefined && percentEnvVal !== undefined) { + const envPercent = Number.parseInt(percentEnvVal); if(Number.isNaN(envPercent)) { this.logger.warn(`Ignoring value '${scrobblePercentEnv}' for env SOURCE_SCROBBLE_PERCENT because it is not a number`); } else { diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 7a115a0f..9bb7190f 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -131,10 +131,10 @@ export class SonosSource extends MemoryPositionalSource { if(this.devicesBlock.length > 0 && this.devicesBlock.some(x => data.device.Name.toLocaleLowerCase().includes(x))) { return `'devicesBlock includes a phrase found in ${data.device.Name}`; } - if(this.groupsAllow.length > 0 && !this.groupsAllow.some(x => data.device.GroupName!.toLocaleLowerCase().includes(x))) { + if(this.groupsAllow.length > 0 && !this.groupsAllow.some(x => (data.device.GroupName ?? '').toLocaleLowerCase().includes(x))) { return `'groupsAllow does not include a phrase found in ${data.device.GroupName}`; } - if(this.groupsBlock.length > 0 && this.groupsBlock.some(x => data.device.GroupName!.toLocaleLowerCase().includes(x))) { + if(this.groupsBlock.length > 0 && this.groupsBlock.some(x => (data.device.GroupName ?? '').toLocaleLowerCase().includes(x))) { return `'groupsBlock includes a phrase found in ${data.device.GroupName}`; } if (typeof data.state.positionInfo?.TrackMetaData === 'string') { @@ -161,9 +161,10 @@ export class SonosSource extends MemoryPositionalSource { if(e instanceof Error) { let muted = false, seen = false; - if(this.badDeviceError[d.Name] !== undefined) { - seen = this.badDeviceError[d.Name].err === e.message; - if(seen && this.badDeviceError[d.Name].time !== undefined && Math.abs(this.badDeviceError[d.Name].time!.diff(dayjs(), 's')) < 60) { + const badDevice = this.badDeviceError[d.Name]; + if(badDevice !== undefined) { + seen = badDevice.err === e.message; + if(seen && badDevice.time !== undefined && Math.abs(badDevice.time.diff(dayjs(), 's')) < 60) { muted = true; } } @@ -267,7 +268,7 @@ export class SonosSource extends MemoryPositionalSource { let allowOneNonProgress = false; const playerId = this.genPlayerId(playerState); - if(this.hasPlayer(playerId) && this.players.get(playerId)!.isProgressing()) { + if(this.players.get(playerId)?.isProgressing() === true) { // update player state with a stopped/paused/unknown reported state so that player scrobbles any existing play allowOneNonProgress = true; } @@ -322,11 +323,11 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions const metadatas: Track[] = []; - if (typeof CurrentURIMetaData !== 'string') { - metadatas.push(CurrentURIMetaData!); + if (CurrentURIMetaData !== undefined && typeof CurrentURIMetaData !== 'string') { + metadatas.push(CurrentURIMetaData); } - if (typeof TrackMetaData !== 'string') { - metadatas.push(TrackMetaData!); + if (TrackMetaData !== undefined && typeof TrackMetaData !== 'string') { + metadatas.push(TrackMetaData); } let titleStr: string | undefined; @@ -358,14 +359,14 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions let dur: number | undefined; if (Duration !== undefined && Duration !== "NOT_IMPLEMENTED") { - dur = parseDurationFromTimestamp(Duration)!.asSeconds(); + dur = parseDurationFromTimestamp(Duration)?.asSeconds(); } else if (TrackDuration !== undefined && TrackDuration !== "NOT_IMPLEMENTED") { - dur = parseDurationFromTimestamp(TrackDuration)!.asSeconds(); + dur = parseDurationFromTimestamp(TrackDuration)?.asSeconds(); } let progress: number | undefined; if (RelTime !== undefined && RelTime !== "NOT_IMPLEMENTED") { - progress = parseDurationFromTimestamp(RelTime)!.asSeconds(); + progress = parseDurationFromTimestamp(RelTime)?.asSeconds(); } if (titleStr === undefined && Title !== undefined && Title !== 'Spotify') { diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 2ee5ff60..6090ff63 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -95,7 +95,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag let played_at: Dayjs; let playDateCompleted: Dayjs | undefined; let id: string; - let url: string; + let url: string | undefined; let playbackPosition: number | undefined; let deviceId: string | undefined; let isrcString: string | undefined; @@ -138,7 +138,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag id = i; duration_ms = dm; album = a; - url = spotify!; + url = spotify; isrcString = isrc; trackNumber = track_number; @@ -180,8 +180,8 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag id = i; duration_ms = dm; album = a; - url = spotify!; - playbackPosition = progress_ms! / 1000; + url = spotify; + playbackPosition = progress_ms !== null && progress_ms !== undefined ? progress_ms / 1000 : undefined; deviceId = combinePartsToString([shortDeviceId(deviceIdentifier), deviceName]); isrcString = isrc; trackNumber = track_number @@ -206,7 +206,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag let imageData: {url: string} | undefined; if(images.length > 0) { - imageData = images.find(x => x.height! < 640); + imageData = images.find(x => x.height !== undefined && x.height < 640); if(imageData === undefined) { imageData = images[0]; } @@ -251,7 +251,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag brainz.trackNumber = trackNumber; } if(Object.keys(brainz).length > 0) { - play.data.meta!.brainz = brainz; + play.data.meta = {...play.data.meta, brainz}; } if(imageData !== undefined) { @@ -392,8 +392,8 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag if(this.canGetState) { const state = await this.getCurrentPlaybackState(); if(state.playerState !== undefined) { - if(state.device!.is_private_session) { - this.logger.debug(`Will not track play on Device ${state.device!.name} because it is in a private session.`); + if(state.device?.is_private_session) { + this.logger.debug(`Will not track play on Device ${state.device.name} because it is in a private session.`); } else { plays.push(state.playerState); } @@ -445,7 +445,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag if(to !== undefined) { const toDate = dayjs.unix(to); - plays = plays.filter(x => x.data.playDate!.isBefore(toDate)); + plays = plays.filter(x => x.data.playDate !== undefined && x.data.playDate.isBefore(toDate)); } // if no plays returned // or if filtered plays are less than results then we've hit the to date @@ -559,7 +559,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag return { device, playerState: { - platformId: [combinePartsToString([shortDeviceId(device.id), device.name])!, NO_USER], + platformId: [combinePartsToString([shortDeviceId(device.id), device.name]) ?? NO_DEVICE, NO_USER], status, play, stateUpdatedAt: dayjs(), @@ -609,11 +609,14 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag token_type } = {} } = tokenResponse; - this.spotifyApi.setAccessToken(access_token!); + if(access_token === undefined || expires_in === undefined) { + throw new SimpleError('Refresh token response did not include an access token and/or expiration'); + } + this.spotifyApi.setAccessToken(access_token); await writeFile(this.workingCredsPath, JSON.stringify({ token: access_token, refreshToken: refresh_token, - expires: Date.now() + (expires_in! * 1000), + expires: Date.now() + (expires_in * 1000), expiresIn: expires_in, grant: token_type })); diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index cc9498ca..bbfaad89 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -134,15 +134,19 @@ export class SubsonicSource extends MemoryPositionalSource { } = {}, } = this.config; + if(user === undefined || password === undefined) { + throw new Error(`'user' and 'password' must be defined in config data`); + } + const queryOpts: Record = { - u: user!, + u: user, v: '1.15.0', c: `multi-scrobbler - ${this.name}`, f: 'json' }; if((this.config?.data?.legacyAuthentication ?? false)) { //queryOpts.p = password; - queryOpts.p = `enc:${Buffer.from(password!).toString('hex')}` + queryOpts.p = `enc:${Buffer.from(password).toString('hex')}` } else { const salt = crypto.randomBytes(10).toString('hex'); const hash = crypto.createHash('md5').update(`${password}${salt}`).digest('hex') @@ -280,9 +284,9 @@ export class SubsonicSource extends MemoryPositionalSource { } catch (e: any) { const subResponseError = getSubsonicResponseFromError(e); - if(subResponseError !== undefined) { - const resp = getSubsonicResponse(subResponseError.response!) - this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp!)}`); + const resp = subResponseError?.response !== undefined ? getSubsonicResponse(subResponseError.response) : undefined; + if(resp !== undefined) { + this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); this.sourceData = resp as SourceIdentifierData; await this.discoverPlaybackReportSupport(); return true; @@ -345,7 +349,7 @@ export class SubsonicSource extends MemoryPositionalSource { return true; } catch (e) { const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); - throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: superagentError !== undefined && [403,401].includes(superagentError.status!)}) + throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: superagentError?.status !== undefined && [403,401].includes(superagentError.status)}) } } diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 15d50aee..1d384d5d 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -79,7 +79,8 @@ export class VLCSource extends MemoryPositionalSource { this.client = new VLC.Client({ ip: host, port: this.port, - password: password! + // VLC http interface requires a password, empty will fail during auth + password: password ?? '' }); let fp = filenamePatterns; @@ -101,7 +102,10 @@ export class VLCSource extends MemoryPositionalSource { protected async doCheckConnection(): Promise { try { - await isPortReachable(this.port!, {host: this.host!}); + if(this.port === undefined || this.host === undefined) { + throw new Error('host and port have not been initialized'); + } + await isPortReachable(this.port, {host: this.host}); return `${this.host}:${this.port} is reachable.`; } catch (e) { throw e; @@ -145,8 +149,8 @@ export class VLCSource extends MemoryPositionalSource { let artists: string[] = []; let albumArtists: string[] = []; const validArtist = firstNonEmptyStr([artist, StreamArtist, ALBUMARTIST, Writer]); - if(artist !== undefined) { - artists.push(validArtist!); + if(validArtist !== undefined) { + artists.push(validArtist); } const aa = firstNonEmptyStr([ALBUMARTIST]); if(aa !== undefined) { @@ -174,17 +178,18 @@ export class VLCSource extends MemoryPositionalSource { const matchedPatternDebug: Record = {}; if (result !== undefined) { anyMatched = true; - if (result.named.title !== undefined) { - trackName = result.named.title; - matchedPatternDebug.title = trackName!; + const {title: namedTitle, album: namedAlbum} = result.named; + if (namedTitle !== undefined) { + trackName = namedTitle; + matchedPatternDebug.title = namedTitle; } if (result.named.artist !== undefined) { artists.push(result.named.artist); matchedPatternDebug.artist = result.named.artist; } - if (result.named.album !== undefined) { - album = result.named.album; - matchedPatternDebug.album = album!; + if (namedAlbum !== undefined) { + album = namedAlbum; + matchedPatternDebug.album = namedAlbum; } if (logFilenamePatterns) { diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index 1d6a8ec8..0ee56ce6 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -6,7 +6,7 @@ import { type InternalConfig, type PlayerStateData, } from "../common/infrastructure/Atomic.ts"; -import { NO_USER } from '../../core/Atomic.ts'; +import { NO_DEVICE, NO_USER } from '../../core/Atomic.ts'; import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {ReportedPlayerStatus} from '../../core/Atomic.ts'; import type {PlayPlatformId} from '../../core/Atomic.ts'; @@ -56,16 +56,17 @@ export class WebScrobblerSource extends MemorySource { } protected async doBuildInitData(): Promise { - this.logger.info(`Accepting requests at ${joinedUrl(this.localUrl, 'api/webscrobbler', this.config.data!.slug ?? '')}`); + this.logger.info(`Accepting requests at ${joinedUrl(this.localUrl, 'api/webscrobbler', this.config.data?.slug ?? '')}`); return true; } matchSlug(slug: string | undefined) { - if (this.config.data!.slug === undefined) { + const configSlug = this.config.data?.slug; + if (configSlug === undefined || configSlug === null) { return slug === undefined; } - return slug!.toLowerCase() === this.config.data!.slug!.toLowerCase().trim(); + return slug !== undefined && slug.toLowerCase() === configSlug.toLowerCase().trim(); } static webhookEventAsPlayerStatus(event: WebScrobblerHookEvent): ReportedPlayerStatus { @@ -90,7 +91,7 @@ export class WebScrobblerSource extends MemorySource { const play = WebScrobblerSource.formatPlayObj(obj.data.song, {nowPlaying: eventName !== 'scrobble'}); play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { - platformId: [play.meta.deviceId!, NO_USER], + platformId: [play.meta.deviceId ?? NO_DEVICE, NO_USER], play, status: WebScrobblerSource.webhookEventAsPlayerStatus(eventName), stateUpdatedAt: dayjs.unix(time) @@ -142,16 +143,16 @@ export class WebScrobblerSource extends MemorySource { } }, meta: { - trackId: uniqueID!, + trackId: uniqueID ?? undefined, parsedFrom: PARSED_FROM.ingress, url: { - web: trackUrl!, - origin: originUrl! + web: trackUrl, + origin: originUrl ?? undefined }, deviceId: `${connectorLabel}-${controllerTabId}`, musicService: connectorL, source: 'WebScrobbler', - scrobbleAllowed: isScrobblingAllowed!, + scrobbleAllowed: isScrobblingAllowed ?? undefined, nowPlaying: options.nowPlaying ?? false } } @@ -168,14 +169,15 @@ export class WebScrobblerSource extends MemorySource { if (playObj.meta.musicService !== undefined) { const lowerSource = playObj.meta.musicService.toLowerCase(); - if (Array.isArray(this.config.data!.blacklist) && this.config.data!.blacklist.length > 0) { - if (this.config.data!.blacklist.some(x => x === lowerSource)) { + const {blacklist, whitelist} = this.config.data ?? {}; + if (Array.isArray(blacklist) && blacklist.length > 0) { + if (blacklist.some(x => x === lowerSource)) { this.logger.debug(`Will not scrobble play because it is from a blacklisted connector '${lowerSource}'`); return false; } } - if (Array.isArray(this.config.data!.whitelist) && this.config.data!.whitelist.length > 0) { - if (!this.config.data!.whitelist.some(x => x === lowerSource)) { + if (Array.isArray(whitelist) && whitelist.length > 0) { + if (!whitelist.some(x => x === lowerSource)) { this.logger.debug(`Will not scrobble play because it is not from a whitelisted connector '${lowerSource}'`); return false; } -- 2.51.2