diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 7e1f9ad2..6881e41d 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -7,7 +7,7 @@ import {MONITORING_ORIGIN_SYSTEM, MONITORING_ORIGIN_USER, type ComponentType, ty import { buildTrackString } from "../../core/StringUtils.ts"; import type {CommonClientConfig} from "./infrastructure/config/client/index.ts"; import type {CommonSourceConfig} from "./infrastructure/config/source/index.ts"; -import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts"; +import { mergeSimpleError, SimpleError, SkipTransformStageError, StageChangeError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts"; import { FLOW_CONTROL_TERM, type PlayTransformRules, @@ -229,7 +229,9 @@ export default abstract class AbstractComponent extends AbstractInitializable { await this.stop(opts); await this.start(opts); } catch (e) { - throw new Error('Failed to restart', { cause: e }); + const err = new StageChangeError('Failed to restart', { cause: e }); + this.replaceErrors(err); + throw err; } } diff --git a/src/backend/common/errors/MSErrors.ts b/src/backend/common/errors/MSErrors.ts index b10f72c7..924cf426 100644 --- a/src/backend/common/errors/MSErrors.ts +++ b/src/backend/common/errors/MSErrors.ts @@ -61,6 +61,10 @@ export class PostInitError extends StageError { } addKnownErrorConstructor(PostInitError); +export class StageChangeError extends NamedError { + override name = 'Stage Change'; +} + const STACK_AT_REGEX = new RegExp(/[\n\r]\s*at/); export class SimpleError extends Error implements HasSimpleError { diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 24facdb0..2f317e72 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -61,7 +61,7 @@ import { existingScrobble, type ExistingScrobbleOpts } from "../utils/PlayCompar import { statefulInvariantTransform } from "../../core/PlayUtils.ts"; import { normalizeStr } from "../utils/StringUtils.ts"; import type { Counter, Gauge } from 'prom-client'; -import { generateLoggableAbortReason, ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.ts"; +import { generateLoggableAbortReason, ScrobbleSubmitError, SimpleError, StageChangeError } from "../common/errors/MSErrors.ts"; import {isErrorLike, serializeError} from 'serialize-error'; import { DEFAULT_NEW_PADDING, groupPlaysToTimeRanges } from "../utils/ListenFetchUtils.ts"; import { spawn, isAbortError, delay } from 'abort-controller-x'; @@ -330,25 +330,31 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i } public async start(opts: {forceInit?: boolean} = {}) { - if(opts.forceInit) { - if(!this.canAuthUnattended()) { - this.logger.warn({labels: 'Heartbeat'}, 'Client is not ready but will not try to initialize because auth state is not good and cannot be corrected unattended.') - return false; - } - try { - await this.initialize({force: true, notify: true, notifyTitle: 'Could not initialize automatically'}); - } catch (e) { - this.logger.error(new Error('Could not initialize automatically', {cause: e})); - return false; - } + try { + if (opts.forceInit) { + if (!this.canAuthUnattended()) { + this.logger.warn({ labels: 'Heartbeat' }, 'Client is not ready but will not try to initialize because auth state is not good and cannot be corrected unattended.') + return false; + } + try { + await this.initialize({ force: true, notify: true, notifyTitle: 'Could not initialize automatically' }); + } catch (e) { + this.logger.error(new Error('Could not initialize automatically', { cause: e })); + return false; + } - if(!this.canAuthUnattended()) { - this.logger.warn({label: 'Heartbeat'}, 'Should be monitoring scrobbles but will not attempt to start because auth state is not good and cannot be correct unattended.'); - return false; + if (!this.canAuthUnattended()) { + this.logger.warn({ label: 'Heartbeat' }, 'Should be monitoring scrobbles but will not attempt to start because auth state is not good and cannot be correct unattended.'); + return false; + } } + this.initTasks(); + return true; + } catch (e) { + throw new StageChangeError('Failed to start', { cause: e }); + } finally { + this.emitComponentUpdate>({ state: this.getRunningState() }); } - this.initTasks(); - return true; } public async stop(opts: { reason?: string | Error } = {}) { @@ -365,7 +371,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i this.setStatus('Stopped'); this.emitComponentUpdate>({state: COMPONENT_STATE.STOPPED}); } catch (e) { - throw new Error('Failed to stop Client', { cause: e }); + this.emitComponentUpdate>({state: this.getRunningState()}); + throw new StageChangeError('Failed to stop Client', { cause: e }); } } diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index c2bfb910..4fdbebdd 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -360,12 +360,11 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro try { logger.verbose('User requested auth test'); await component.testAuth(true); - component.errors = component.errors.filter(x => !findAuthIssue(x)); + component.clearErrors({predicate: x => findAuthIssue(x) !== undefined}); didAuth = true; return res.sendStatus(200); } catch (e) { - component.errors = component.errors.filter(x => !findAuthIssue(x)); - component.errors.push(e); + component.replaceErrors(e, {predicate: x => findAuthIssue(x) !== undefined}); return res.status(500).json({error: serializeError(e)}); } finally { const data = component.getApiData(); diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index df899910..282cd8e2 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -125,7 +125,7 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr throw error; } await entity.api.authenticate(token); - entity.errors = entity.errors.filter(x => !findAuthIssue(x)); + entity.clearErrors({predicate: x => findAuthIssue(x) !== undefined}); if(entity instanceof AbstractSource) { entity.poll().catch((e) => logger.error(e)); } else { @@ -167,22 +167,21 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr try { const tokenResult = await source.handleAuthCodeCallback(req.query); if (tokenResult === true) { - source.errors = source.errors.filter(x => !findAuthIssue(x)); + source.clearErrors({predicate: x => findAuthIssue(x) !== undefined}); source.poll().catch((e) => logger.error(e)); - } else { if (tokenResult instanceof Error) { - source.errors.push(tokenResult); + source.replaceErrors(tokenResult, {predicate: (x) => x.message === tokenResult.message}); source.logger.error(tokenResult); } else if (typeof tokenResult === 'string') { const e = new SimpleError(`Token result was unexpected: ${tokenResult}`); - source.errors.push(e); + source.replaceErrors(e, {predicate: (x) => x.message === e.message}); source.logger.error(e); } } } catch (e) { const err = new SimpleError('Unexpected error while trying to authorize code, or save file', { cause: e }); - source.errors.push(err); + source.replaceErrors(err, {predicate: (x) => err.message === x.message}); source.logger.error(err); } return res.redirect('/next'); diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index ce2a88fa..1ed8245f 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -40,7 +40,7 @@ import pMap, {pMapIterable} from 'p-map'; import type { Counter } from 'prom-client'; import { normalizeStr } from '../utils/StringUtils.ts'; import { spawn, isAbortError, delay, throwIfAborted } from 'abort-controller-x'; -import { generateLoggableAbortReason } from '../common/errors/MSErrors.ts'; +import { generateLoggableAbortReason, StageChangeError } from '../common/errors/MSErrors.ts'; import { DrizzlePlayRepository, playToRepositoryCreatePlayOpts, type QueryPlaysOpts, type RequestPlayQuery, type WithPlayRelation } from '../common/database/drizzle/repositories/PlayRepository.ts'; import { asPlay } from '../../core/PlayMarshalUtils.ts'; import { AsyncTask, SimpleIntervalJob, ToadScheduler } from 'toad-scheduler'; @@ -216,26 +216,33 @@ export default abstract class AbstractSource extends AbstractComponent implement } public async start(opts: {forceInit?: boolean} = {}) { - if(opts.forceInit) { - if(!this.canAuthUnattended()) { - this.logger.warn({labels: 'Heartbeat'}, 'Source is not ready but will not try to initialize because auth state is not good and cannot be corrected unattended.') - return false; - } - try { - this.setStatus('Attempting to initialize...'); - await this.initialize({force: true, notify: true, notifyTitle: 'Could not initialize automatically'}); - } catch (e) { - this.logger.error(new Error('Could not initialize automatically', {cause: e})); - this.setStatus('Could not initialize automatically'); - return false; - } + try { + if (opts.forceInit) { + if (!this.canAuthUnattended()) { + this.logger.warn({ labels: 'Heartbeat' }, 'Source is not ready but will not try to initialize because auth state is not good and cannot be corrected unattended.') + return false; + } + try { + this.setStatus('Attempting to initialize...'); + await this.initialize({ force: true, notify: true, notifyTitle: 'Could not initialize automatically' }); + } catch (e) { + this.logger.error(new Error('Could not initialize automatically', { cause: e })); + this.setStatus('Could not initialize automatically'); + return false; + } - if('discoverDevices' in this && typeof this.discoverDevices === 'function') { - this.discoverDevices(); + if ('discoverDevices' in this && typeof this.discoverDevices === 'function') { + this.discoverDevices(); + } } + this.initTasks(); + + return true; + } catch (e) { + throw new StageChangeError('Failed to start', { cause: e }); + } finally { + this.emitComponentUpdate({state: this.getRunningState()}); } - this.initTasks(); - return true; } public async stop(opts: { reason?: string | Error } = {}) { @@ -251,7 +258,8 @@ export default abstract class AbstractSource extends AbstractComponent implement this.setStatus('Stopped'); this.emitComponentUpdate>({state: COMPONENT_STATE.STOPPED}); } catch (e) { - throw new Error('Failed to stop Source', { cause: e }); + this.emitComponentUpdate>({state: this.getRunningState()}); + throw new StageChangeError('Failed to stop', { cause: e }); } } @@ -283,7 +291,7 @@ export default abstract class AbstractSource extends AbstractComponent implement const t = this.transformManager.getTransformerByStage({ type: hook.type, name: hook.name }); pcInits.push(t.staggerOpts?.initialInterval ?? 0); pcMaxStagger.push(t.staggerOpts?.maxRandomStagger ?? 0) - } + } this.staggerMappers.preCompare = staggerMapper({ initialInterval: Math.max(...pcInits), maxRandomStagger: Math.max(...pcMaxStagger), concurrency: 2 }); } @@ -568,7 +576,7 @@ 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.errors.push(err); + this.replaceErrors(err, {predicate: (x) => x.message === err.message}); 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'}); diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index 49816c95..243892a3 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -12,6 +12,7 @@ import { readJson } from '../utils/DataUtils.ts'; import { joinedUrl } from "../utils/NetworkUtils.ts"; import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; +import { SimpleError } from "../common/errors/MSErrors.ts"; export default class DeezerSource extends AbstractSource { workingCredsPath; @@ -243,7 +244,7 @@ export default class DeezerSource extends AbstractSource { return true; } else { this.logger.warn('Callback contained an error! User may have denied access?') - this.errors = error; + this.errors.push(error); this.logger.error(error); return error; }