[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: Simplify init process by using abort controller to detect if init is occurring

FoxxMD (May 13, 2026, 7:55 PM UTC) 5228d489 e6709c44

+86 -78
+53 -45
src/backend/common/AbstractInitializable.ts
··· 5 5 import { WebhookPayload } from "./infrastructure/config/health/webhooks.js"; 6 6 import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError, TransformRulesError } from "./errors/MSErrors.js"; 7 7 import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js"; 8 + import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever, AbortError, throwIfAborted } from 'abort-controller-x'; 8 9 9 10 export default abstract class AbstractInitializable { 10 11 requiresAuth: boolean = false; 11 12 requiresAuthInteraction: boolean = false; 12 13 authed: boolean = false; 13 14 authFailure?: boolean; 15 + 16 + private initController: AbortController | undefined; 14 17 15 18 buildOK?: boolean | null; 16 19 databaseOK?: boolean | null; ··· 32 35 33 36 protected abstract getIdentifier(): string; 34 37 35 - initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => { 38 + initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string, internalOnly?: boolean} = {}) => { 39 + 40 + if(this.initController !== undefined) { 41 + throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`); 42 + } 43 + 44 + this.initController = new AbortController(); 45 + return spawn(this.initController.signal, async (signal, {defer, fork}) => { 46 + 47 + defer(async () => { 48 + this.initializing = false; 49 + this.initController = undefined; 50 + }); 36 51 37 - const {force = false, notify = false, notifyTitle = 'Init Error'} = options; 52 + const {force = false, notify = false, notifyTitle = 'Init Error', internalOnly = false} = options; 38 53 39 - this.logger.debug('Attempting to initialize...'); 40 - try { 41 - this.initializing = true; 42 - if(this.componentLogger === undefined) { 43 - await this.buildComponentLogger(); 44 - } 45 - await this.buildDatabase(force); 46 - await this.buildInitData(force); 47 - await this.parseCache(force); 54 + this.logger.debug('Attempting to initialize...'); 48 55 try { 49 - await this.postCache(); 50 - } catch (e) { 51 - if(e instanceof StageError) { 52 - throw e; 53 - } else { 54 - throw new Error('Error occurred during post-cache hook', {cause: e}); 56 + this.initializing = true; 57 + if(this.componentLogger === undefined) { 58 + await this.buildComponentLogger(); 55 59 } 56 - } 57 - await this.checkConnection(force); 58 - await this.testAuth(force); 59 - this.logger.info('Fully Initialized!'); 60 - try { 61 - await this.postInitialize(); 62 - } catch (e) { 63 - throw new PostInitError('Error occurred during post-initialization hook', {cause: e}); 64 - } 65 - return true; 66 - } catch(e) { 67 - if(notify) { 68 - await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'}); 60 + await this.buildDatabase(force); 61 + await this.buildInitData(force); 62 + await this.parseCache(force); 63 + try { 64 + await this.postCache(); 65 + } catch (e) { 66 + if(e instanceof StageError) { 67 + throw e; 68 + } else { 69 + throw new Error('Error occurred during post-cache hook', {cause: e}); 70 + } 71 + } 72 + if(internalOnly) { 73 + return true; 74 + } 75 + await this.checkConnection(force); 76 + await this.testAuth(force); 77 + this.logger.info('Fully Initialized!'); 78 + try { 79 + await this.postInitialize(); 80 + } catch (e) { 81 + throw new PostInitError('Error occurred during post-initialization hook', {cause: e}); 82 + } 83 + return true; 84 + } catch(e) { 85 + if(notify) { 86 + await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'}); 87 + } 88 + throw new Error('Initialization failed', {cause: e}); 89 + } finally { 90 + this.initializing = false; 69 91 } 70 - throw new Error('Initialization failed', {cause: e}); 71 - } finally { 72 - this.initializing = false; 73 - } 92 + }); 74 93 } 75 94 76 95 protected async buildComponentLogger() { ··· 80 99 81 100 protected async doBuildComponentLogger() { 82 101 return; 83 - } 84 - 85 - tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => { 86 - if(this.initializing) { 87 - throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`) 88 - } 89 - try { 90 - return await this.initialize(options); 91 - } catch (e) { 92 - throw e; 93 - } 94 102 } 95 103 96 104 public async parseCache(force: boolean = false) {
+1 -1
src/backend/common/transforms/TransformerManager.ts
··· 96 96 transformer.logger.warn({ label: 'Heartbeat' }, 'Transformer is not ready but will not try to initialize because auth state is not good and cannot be correct unattended.'); 97 97 } 98 98 try { 99 - await transformer.tryInitialize({ force: false, notify: true, notifyTitle: 'Could not initialize automatically' }); 99 + await transformer.initialize({ force: false, notify: true, notifyTitle: 'Could not initialize automatically' }); 100 100 } catch (e) { 101 101 transformer.logger.error(new Error('Could not initialize source automatically', { cause: e })); 102 102 }
+2 -1
src/backend/index.ts
··· 186 186 if(nameColl.length > 0) { 187 187 logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`); 188 188 } 189 - const clientInitOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined}; for(const c of scrobbleClients.clients) { 189 + const clientInitOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined}; 190 + for(const c of scrobbleClients.clients) { 190 191 c.initTasks(clientInitOptions); 191 192 const res = await Promise.race([ 192 193 sleep(2200),
+2 -2
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 298 298 return false; 299 299 } 300 300 try { 301 - await this.tryInitialize({force: false, notify: true, notifyTitle: 'Could not initialize automatically'}); 301 + await this.initialize({force: false, notify: true, notifyTitle: 'Could not initialize automatically'}); 302 302 } catch (e) { 303 303 this.logger.error(new Error('Could not initialize automatically', {cause: e})); 304 304 return false; ··· 823 823 824 824 if(!this.isReady() || force) { 825 825 try { 826 - await this.tryInitialize(options); 826 + await this.initialize(options); 827 827 } catch (e) { 828 828 this.logger.error(new Error('Cannot start monitoring because Client is not ready', {cause: e})); 829 829 if(notify) {
+1 -1
src/backend/server/auth.ts
··· 113 113 if(entity instanceof AbstractSource) { 114 114 entity.poll().catch((e) => logger.error(e)); 115 115 } else { 116 - entity.tryInitialize() 116 + entity.initialize() 117 117 .catch((e) => logger.error(e)) 118 118 .then(() => { 119 119 entity.initScrobbleMonitoring().catch((e) => logger.error(e));
+2 -3
src/backend/sources/AbstractSource.ts
··· 176 176 return false; 177 177 } 178 178 try { 179 - await this.tryInitialize({force: false, notify: true, notifyTitle: 'Could not initialize automatically'}); 179 + await this.initialize({force: false, notify: true, notifyTitle: 'Could not initialize automatically'}); 180 180 } catch (e) { 181 181 this.logger.error(new Error('Could not initialize automatically', {cause: e})); 182 182 return false; ··· 471 471 return; 472 472 } 473 473 474 - // TODO refactor to only use tryInitialize 475 474 if(!this.isReady() || force) { 476 475 try { 477 - await this.tryInitialize(options); 476 + await this.initialize(options); 478 477 } catch (e) { 479 478 this.logger.error(new Error('Cannot start polling because Source is not ready', {cause: e})); 480 479 if(notify) {
+3 -3
src/backend/tests/component/transformers.test.ts
··· 419 419 it('Uses artist parsing functions', async function() { 420 420 421 421 const t = new NativeTransformer({name: 'test', type: 'native'}, {logger: loggerTest, cache: memorycache()}); 422 - await t.tryInitialize(); 422 + await t.initialize(); 423 423 424 424 const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); 425 425 const play = generatePlay({artists: artistNamesToCredits([str])}); ··· 434 434 435 435 const t = new NativeTransformer({name: 'test', type: 'native', defaults: {artistsIgnore: [str]}}, {logger: loggerTest, cache: memorycache()}); 436 436 437 - await t.tryInitialize(); 437 + await t.initialize(); 438 438 439 439 const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); 440 440 ··· 455 455 456 456 const t = new NativeTransformer({name: 'test', type: 'native', defaults: {delimitersExtra: ['•']}}, {logger: loggerTest, cache: memorycache()}); 457 457 458 - await t.tryInitialize(); 458 + await t.initialize(); 459 459 460 460 const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); 461 461
+13 -13
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 70 70 lifecycle: defaultLifecycle() 71 71 } 72 72 } 73 - await mbTransformer.tryInitialize(); 73 + await mbTransformer.initialize(); 74 74 75 75 const res = await mbTransformer.getTransformerData(play, { 76 76 type: "musicbrainz", ··· 101 101 lifecycle: defaultLifecycle() 102 102 } 103 103 }; 104 - await mbTransformer.tryInitialize(); 104 + await mbTransformer.initialize(); 105 105 106 106 const res = await mbTransformer.getTransformerData(play, { 107 107 type: "musicbrainz", ··· 131 131 lifecycle: defaultLifecycle() 132 132 } 133 133 } 134 - await mbTransformer.tryInitialize(); 134 + await mbTransformer.initialize(); 135 135 136 136 const res = await mbTransformer.getTransformerData(play, { 137 137 type: "musicbrainz", ··· 160 160 lifecycle: defaultLifecycle() 161 161 } 162 162 } 163 - await mbTransformer.tryInitialize(); 163 + await mbTransformer.initialize(); 164 164 165 165 const res = await mbTransformer.getTransformerData(play, { 166 166 type: "musicbrainz", ··· 191 191 lifecycle: defaultLifecycle() 192 192 } 193 193 } 194 - await mbTransformer.tryInitialize(); 194 + await mbTransformer.initialize(); 195 195 196 196 const stageConfig: MusicbrainzTransformerDataStage = { 197 197 type: "musicbrainz", ··· 225 225 lifecycle: defaultLifecycle() 226 226 } 227 227 } 228 - await mbTransformer.tryInitialize(); 228 + await mbTransformer.initialize(); 229 229 230 230 const stageConfig: MusicbrainzTransformerDataStage = { 231 231 type: "musicbrainz", ··· 254 254 lifecycle: defaultLifecycle() 255 255 } 256 256 } 257 - await mbTransformer.tryInitialize(); 257 + await mbTransformer.initialize(); 258 258 259 259 const res = await mbTransformer.getTransformerData(play, { 260 260 type: "musicbrainz", ··· 278 278 lifecycle: defaultLifecycle() 279 279 } 280 280 } 281 - await mbTransformer.tryInitialize(); 281 + await mbTransformer.initialize(); 282 282 283 283 const res = await mbTransformer.getTransformerData(play, { 284 284 type: "musicbrainz", ··· 304 304 lifecycle: defaultLifecycle() 305 305 } 306 306 } 307 - await mbTransformer.tryInitialize(); 307 + await mbTransformer.initialize(); 308 308 309 309 const res = await mbTransformer.getTransformerData(play, { 310 310 type: "musicbrainz", ··· 329 329 lifecycle: defaultLifecycle() 330 330 } 331 331 } 332 - await mbTransformer.tryInitialize(); 332 + await mbTransformer.initialize(); 333 333 334 334 const stageConfig: MusicbrainzTransformerDataStage = { 335 335 type: "musicbrainz", ··· 359 359 lifecycle: defaultLifecycle() 360 360 } 361 361 } 362 - await mbTransformer.tryInitialize(); 362 + await mbTransformer.initialize(); 363 363 364 364 const res = await mbTransformer.getTransformerData(play, { 365 365 type: "musicbrainz", ··· 403 403 lifecycle: defaultLifecycle() 404 404 } 405 405 } 406 - await multiMb.tryInitialize(); 406 + await multiMb.initialize(); 407 407 408 408 const res = await multiMb.getTransformerData(play, { 409 409 type: "musicbrainz", ··· 437 437 lifecycle: defaultLifecycle() 438 438 } 439 439 } 440 - await multiMb.tryInitialize(); 440 + await multiMb.initialize(); 441 441 442 442 const res = await multiMb.getTransformerData(play, { 443 443 type: "musicbrainz",
+5 -5
src/backend/tests/scrobbler/scrobblers.test.ts
··· 52 52 confidenceBreakdown: true 53 53 } 54 54 }; 55 - await testScrobbler.tryInitialize(); 55 + await testScrobbler.initialize(); 56 56 return testScrobbler; 57 57 } 58 58 ··· 523 523 const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); 524 524 await using scrobbler = await generateTestScrobbler(); 525 525 scrobbler.testRecentScrobbles = []; 526 - await scrobbler.tryInitialize(); 526 + await scrobbler.initialize(); 527 527 scrobbler.testRecentScrobbles = existingPlays; 528 528 529 529 const sp = spy(scrobbler, 'getScrobblesForTimeRange'); ··· 542 542 const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); 543 543 await using scrobbler = await generateTestScrobbler(); 544 544 scrobbler.testRecentScrobbles = []; 545 - await scrobbler.tryInitialize(); 545 + await scrobbler.initialize(); 546 546 scrobbler.testRecentScrobbles = existingPlays; 547 547 548 548 const sp = spy(scrobbler, 'getScrobblesForTimeRange'); ··· 562 562 const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); 563 563 await using scrobbler = await generateTestScrobbler(); 564 564 scrobbler.testRecentScrobbles = []; 565 - await scrobbler.tryInitialize(); 565 + await scrobbler.initialize(); 566 566 scrobbler.testRecentScrobbles = existingPlays; 567 567 568 568 const sp = spy(scrobbler, 'getScrobblesForTimeRange'); ··· 583 583 const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')}); 584 584 await using scrobbler = await generateTestScrobbler(); 585 585 scrobbler.testRecentScrobbles = []; 586 - await scrobbler.tryInitialize(); 586 + await scrobbler.initialize(); 587 587 scrobbler.testRecentScrobbles = existingPlays; 588 588 589 589 const sp = spy(scrobbler, 'getScrobblesForTimeRange');
+4 -4
src/backend/tests/source/source.test.ts
··· 27 27 const emitter = new EventEmitter(); 28 28 const generateSource = async () => { 29 29 const source = new TestSource('spotify', 'test-basic', {}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); 30 - await source.tryInitialize(); 30 + await source.initialize(); 31 31 return source; 32 32 } 33 33 const generateMemorySource = async (config: SourceConfig = {}) => { 34 34 const s = new TestMemorySource('spotify', 'test-memory', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); 35 - await s.tryInitialize(); 35 + await s.initialize(); 36 36 // s.buildTransformRules(); 37 37 s.scheduler.stop(); 38 38 return s; ··· 40 40 41 41 const generateMemoryPositionalSource = async (config: SourceConfig = {}) => { 42 42 const s = new TestMemoryPositionalSource('spotify', 'test-positional', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); 43 - await s.tryInitialize(); 43 + await s.initialize(); 44 44 //s.buildTransformRules(); 45 45 s.scheduler.stop(); 46 46 return s; ··· 437 437 438 438 const generateDeezerSource = async (options: DeezerInternalSourceOptions = {}) => { 439 439 const source = new DeezerTestSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'}, emitter); 440 - await source.tryInitialize(); 440 + await source.initialize(); 441 441 return source; 442 442 } 443 443 const firstPlayDate = dayjs().subtract(1, 'hour');