feat: Simplify component state updates for start/stop/restart and replace error usage

This commit is contained in:
FoxxMD
2026-08-14 14:34:40 +00:00
parent 5ff9a31fea
commit 0cefc50515
7 changed files with 71 additions and 51 deletions
+4 -2
View File
@@ -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;
}
}
+4
View File
@@ -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 {
@@ -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<Partial<ComponentClientApiJson>>({ 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<Partial<ComponentClientApiJson>>({state: COMPONENT_STATE.STOPPED});
} catch (e) {
throw new Error('Failed to stop Client', { cause: e });
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({state: this.getRunningState()});
throw new StageChangeError('Failed to stop Client', { cause: e });
}
}
+2 -3
View File
@@ -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();
+5 -6
View File
@@ -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');
+29 -21
View File
@@ -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<Partial<ComponentSourceApiJson>>({state: COMPONENT_STATE.STOPPED});
} catch (e) {
throw new Error('Failed to stop Source', { cause: e });
this.emitComponentUpdate<Partial<ComponentSourceApiJson>>({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<PlayObject, PlayObject>({ 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<Partial<ComponentSourceApiJson>>({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'});
+2 -1
View File
@@ -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;
}