From eb43fa04fdf27d4a26977c808956d8a756fc2dfd Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Aug 2026 23:28:08 +0000 Subject: [PATCH] feat: Implement multiple stateful errors/warnings on component --- src/backend/common/AbstractComponent.ts | 24 ++++++++++++------ src/backend/common/AbstractInitializable.ts | 10 ++++---- .../scrobblers/AbstractScrobbleClient.ts | 25 ++++++++++--------- src/backend/server/deezerRoutes.ts | 4 +-- src/backend/sources/AbstractSource.ts | 12 ++++----- src/backend/sources/DeezerSource.ts | 2 +- .../tests/component/transformers.test.ts | 6 +++++ .../msComponent/MSComponentDetailed.tsx | 8 +++--- src/core/Api.ts | 4 +-- src/core/Atomic.ts | 2 ++ 10 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 6dd202cf..d672b20d 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -245,7 +245,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { this.setStatus('Retention cleanup finished'); } catch (e) { const retentionErr = new Error('Failed to do retention cleanup', {cause: e}); - this.warning = retentionErr; + this.warnings.push(retentionErr); this.logger.warn(retentionErr); this.setStatus('Retention cleanup failed'); } @@ -545,7 +545,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { public abstract getRunningState(): ComponentState - public getApiData(): Omit & Pick { + public getApiData(): Omit & Pick { let state: ComponentState; if(!this.initializedOnce || this.initializing) { state = COMPONENT_STATE.INITIALIZING; @@ -565,8 +565,8 @@ export default abstract class AbstractComponent extends AbstractInitializable { createdAt: this.dbComponent.createdAt?.toISOString(), lastReadyAt: this.lastReadyAt?.toISOString(), lastActiveAt: this.lastActiveAt?.toISOString(), - error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error, - warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning, + errors: this.errors.map(x => x instanceof Error ? serializeError(x) : x), + warnings: this.warnings.map(x => x instanceof Error ? serializeError(x) : x), ...this.additionalApiData() } } @@ -582,11 +582,19 @@ export default abstract class AbstractComponent extends AbstractInitializable { } protected emitComponentUpdate = >>(payload: T) => { - if('error' in payload && payload.error instanceof Error) { - payload.error = serializeError(payload.error); + if('errors' in payload) { + if(payload.errors.length > 0) { + payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x); + } else { + payload.errors = []; + } } - if('warning' in payload && payload.warning instanceof Error) { - payload.warning = serializeError(payload.warning); + if('warnings' in payload) { + if(payload.warnings.length > 0) { + payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x); + } else { + payload.warnings = []; + } } this.emitEvent('componentUpdate', payload); } diff --git a/src/backend/common/AbstractInitializable.ts b/src/backend/common/AbstractInitializable.ts index fce0ef75..72cfdeee 100644 --- a/src/backend/common/AbstractInitializable.ts +++ b/src/backend/common/AbstractInitializable.ts @@ -19,8 +19,8 @@ export default abstract class AbstractInitializable { databaseOK?: boolean | null; connectionOK?: boolean | null; cacheOK?: boolean | null; - error?: Error; - warning?: Error; + errors?: Error[] = []; + warnings?: Error[] = []; protected initializedOnce: boolean = false; initializing: boolean = false; @@ -88,15 +88,15 @@ export default abstract class AbstractInitializable { } catch (e) { throw new PostInitError('Error occurred during post-initialization hook', {cause: e}); } - this.error = undefined; - this.warning = undefined; + this.errors = []; + this.warnings = []; return true; } catch(e) { if(notify) { await this.notify({identifier: this.getIdentifier(), title: notifyTitle, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'}); } const initError = new Error('Initialization failed', {cause: e}); - this.error = initError; + this.errors = [initError]; throw initError; } finally { this.initializing = false; diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 1b936e6c..531b938f 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -249,12 +249,12 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i 'Heartbeat', (): Promise => { return this.heartbeatTask().then(() => null).catch((err) => { - this.error = err; + this.errors.push(err); this.logger.error(err); }); }, (err: Error) => { - this.error = err; + this.errors.push(err); this.logger.error(err); } ), {id: 'heartbeat'})); @@ -278,14 +278,14 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i (): Promise => { if(this.isReady()) { return this.processDeadLetterQueue().then(() => null).catch((e) => { - this.warning = e; + this.warnings = e; this.logger.error(e); }) } return new Promise((resolve, reject) => resolve); }, (err: Error) => { - this.warning = err; + this.warnings.push(err); this.logger.error(err); } ), {id: 'dead'})); @@ -547,8 +547,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i }, (err: Error) => { const npErr = new Error('Unexpected error while processing Now Playing queue', {cause: err}); this.npLogger.error(npErr); - this.warning = npErr; - this.emitComponentUpdate>({warning: npErr}); + this.warnings.push(npErr); + this.emitComponentUpdate>({warnings: this.warnings}); }); // even though we are processing every 5 seconds the interval that Now Playing is updated at, and that the queue is cleared on, @@ -856,8 +856,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i } } catch (e) { const preloadErr = new SimpleError('Could not preload scrobbles', {cause: e, shortStack: true}); - this.warning = preloadErr; - this.emitComponentUpdate>({warning: preloadErr}); + this.warnings.push(preloadErr); + this.emitComponentUpdate>({warnings: this.warnings}); this.logger.warn(preloadErr); } } @@ -1044,7 +1044,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i const err = new Error('Scrobble processing stopped with error', { cause: e }); this.logger.warn(err); componentUpdate.status = 'Processing stopped with error'; - componentUpdate.warning = err; + this.warnings.push(err); + componentUpdate.warnings = this.warnings; } this.emitComponentUpdate>(componentUpdate); }).finally(() => { @@ -1169,10 +1170,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i if(nextQueued !== undefined) { while (nextQueued !== undefined) { await this.processQueueCurrentScrobble(nextQueued, signal); - if(this.error !== undefined) { + if(this.errors.length > 0) { // we made it through a scrobble without any issues so clear any issue we may have previously had - this.error = undefined; - this.emitComponentUpdate>({error: null}); + this.errors = []; + this.emitComponentUpdate>({errors: []}); } nextQueued = await this.playRepo.getQueueNext(CLIENT_INGRESS_QUEUE) } diff --git a/src/backend/server/deezerRoutes.ts b/src/backend/server/deezerRoutes.ts index ca8e1530..5dd56c0c 100644 --- a/src/backend/server/deezerRoutes.ts +++ b/src/backend/server/deezerRoutes.ts @@ -27,9 +27,7 @@ export const setupDeezerRoutes = (app: Express, logger: Logger, scrobbleSources: // @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message const entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource; for(let i = 0; i < 3; i++) { - if(entity.error !== undefined) { - return res.send('Error with deezer credentials storage'); - } else if(entity.config.data.accessToken !== undefined) { + if(entity.config.data.accessToken !== undefined) { // start polling await entity.doAuthentication(); entity.poll() diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 2f6cd657..7078c71f 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -155,13 +155,13 @@ export default abstract class AbstractSource extends AbstractComponent implement 'Heartbeat', (): Promise => { return this.heartbeatTask().then(() => null).catch((err) => { - this.error = err; + this.errors.push(err); this.logger.error(err); }); }, (err: Error) => { this.logger.error(err); - this.error = err; + this.errors.push(err); } ), {id: 'heartbeat'})); } else { @@ -556,8 +556,8 @@ export default abstract class AbstractSource extends AbstractComponent implement const err = new Error('Cannot start polling because Source is not ready', {cause: e}); this.logger.error(err); this.setStatus('Polling Error'); - this.emitComponentUpdate>({error: err}); - this.error = err; + this.errors.push(err); + this.emitComponentUpdate>({errors: this.errors}); if(notify) { await this.notify( {title: `Polling Error`, message: `Cannot start polling because Source is not ready: ${truncateStringToLength(500)(messageWithCausesTruncatedDefault(e))}`, priority: 'error'}); } @@ -609,8 +609,8 @@ export default abstract class AbstractSource extends AbstractComponent implement const err = new Error('Polling stopped with error', { cause: e }); this.logger.warn(err); componentUpdate.status = 'Polling stopped with error'; - componentUpdate.warning = err; - this.warning = err; + this.warnings.push(err); + componentUpdate.warnings = this.warnings; } this.emitComponentUpdate>(componentUpdate); }).finally(() => { diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index 517c6b3a..770f534b 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -242,7 +242,7 @@ export default class DeezerSource extends AbstractSource { return true; } else { this.logger.warn('Callback contained an error! User may have denied access?') - this.error = error; + this.errors = error; this.logger.error(error); return error; } diff --git a/src/backend/tests/component/transformers.test.ts b/src/backend/tests/component/transformers.test.ts index 0a99dbb1..b582d42c 100644 --- a/src/backend/tests/component/transformers.test.ts +++ b/src/backend/tests/component/transformers.test.ts @@ -34,6 +34,12 @@ class TestComponent extends AbstractComponent { protected getIdentifier(): string { return 'test'; } + public async start(opts?: { forceInit?: boolean; }): Promise { + return true; + } + public async stop(opts?: { reason?: string | Error; }): Promise { + return; + } constructor(config?: Omit) { super({id: `test-${Date.now()}`, ...(config ?? {})}); } diff --git a/src/client/components/msComponent/MSComponentDetailed.tsx b/src/client/components/msComponent/MSComponentDetailed.tsx index 5eb1adf4..1c3410d3 100644 --- a/src/client/components/msComponent/MSComponentDetailed.tsx +++ b/src/client/components/msComponent/MSComponentDetailed.tsx @@ -165,8 +165,8 @@ export const ComponentDetailedDesktop = (props: {data?: ComponentCommonApiJson, const { data, data: { - warning, - error + warnings = [], + errors = [] } = {} } = props; const isSource = isComponentSourceApiJson(data) @@ -207,8 +207,8 @@ export const ComponentDetailedDesktop = (props: {data?: ComponentCommonApiJson, - {error !== undefined && error !== null ? : undefined} - {warning !== undefined && warning !== null ? : undefined} + {errors.length > 0 ? <>{errors.map(x => )} : undefined } + {warnings.length > 0 ? <>{warnings.map(x => )} : undefined } {props.live ? : } diff --git a/src/core/Api.ts b/src/core/Api.ts index d813fafc..4f88a5df 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -80,8 +80,8 @@ export type ComponentCommonApi = { /** More specific, live activity state like "sleeping", "hydrating historical scrobbles", "processing dead scrobbles", etc... */ status?: string players: Record - error?: ErrorIsh - warning?: ErrorIsh + errors?: ErrorIsh[] + warnings?: ErrorIsh[] monitoringStatus?: MonitoringStatus } & Omit diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 89e014d5..033a5039 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -808,6 +808,8 @@ export const NO_DEVICE = 'NoDevice';export const NO_USER = 'SingleUser'; export const SINGLE_USER_PLATFORM_ID: PlayPlatformId = [NO_DEVICE, NO_USER]; export const SINGLE_USER_PLATFORM_ID_STR = `${NO_DEVICE}-${NO_USER}`; +export type ComponentAuthType = 'none' | 'interactive' | 'unattended'; + export type EmittedMSEvent, K = Record,Y = ClientType | SourceType> = { type: Y name: string