[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(client): Move heartbeat responsibility into client class

Will allow more flexibility in startup and stopping/silencing component by user (in the future)

FoxxMD (May 6, 2026, 7:42 PM UTC) 092cf907 562b388e

+98 -15
+14 -15
src/backend/index.ts
··· 156 156 logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`); 157 157 } 158 158 159 - const clientTask = createHeartbeatClientsTask(scrobbleClients, logger); 160 - clientTask.execute(); 161 - try { 162 - await retry(() => { 163 - if(clientTask.isExecuting) { 164 - throw new Error('Waiting') 165 - } 166 - return true; 167 - },{retries: scrobbleClients.clients.length + 1, retryIntervalMs: 2000}); 168 - } catch (e) { 169 - logger.warn('Waited too long for clients to start! Moving ahead with sources init...'); 159 + for(const c of scrobbleClients.clients) { 160 + c.initHeartbeat(); 161 + const res = await Promise.race([ 162 + sleep(2200), 163 + (async () => { 164 + while(!c.isReady()) { 165 + await sleep(400) 166 + } 167 + return true; 168 + })() 169 + ]); 170 + if(res === undefined) { 171 + logger.debug(`Not waiting for ${c.name} to finish init, moving on to the next client...`); 172 + } 170 173 } 171 - scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ 172 - minutes: 20, 173 - runImmediately: false 174 - }, clientTask, {id: 'clients_heart'})); 175 174 176 175 const sourceTask = createHeartbeatSourcesTask(scrobbleSources, logger); 177 176 scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
+84
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 90 90 declare type: ClientType; 91 91 92 92 scheduler: ToadScheduler = new ToadScheduler(); 93 + protected initDeadTimeout: NodeJS.Timeout | undefined; 93 94 94 95 protected MAX_STORED_SCROBBLES = 40; 95 96 protected MAX_INITIAL_SCROBBLES_FETCH = this.MAX_STORED_SCROBBLES; ··· 231 232 async [Symbol.asyncDispose]() { 232 233 this[Symbol.dispose](); 233 234 await this.tryStopScrobbling(); 235 + } 236 + 237 + public initHeartbeat() { 238 + if(this.scheduler.existsById('heartbeat') === false) { 239 + this.logger.info('Adding Heartbeat Task and running immediately'); 240 + this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ 241 + minutes: 20, 242 + runImmediately: true 243 + }, new AsyncTask( 244 + 'Heartbeat', 245 + (): Promise<any> => { 246 + return this.heartbeatTask().then(() => null).catch((err) => { 247 + this.logger.error(err); 248 + }); 249 + }, 250 + (err: Error) => { 251 + this.logger.error(err); 252 + } 253 + ), {id: 'heartbeat'})); 254 + } else { 255 + this.logger.warn('Heartbeat task is already added to scheduler.'); 256 + } 257 + 258 + if(this.scheduler.existsById('dead') === false && this.initDeadTimeout === undefined) { 259 + this.logger.verbose('Delaying Dead Scrobbler Processing Task by 2 minutes'); 260 + this.initDeadTimeout = setTimeout(() => { 261 + this.logger.info('Adding Dead Scrobbler Processing Task and running immediately'); 262 + this.initDeadTimeout = undefined; 263 + this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ 264 + minutes: 20, 265 + runImmediately: true 266 + }, new AsyncTask( 267 + 'Dead', 268 + (): Promise<any> => { 269 + if(this.isReady()) { 270 + return this.processDeadLetterQueue().then(() => null).catch((e) => { 271 + this.logger.error(e); 272 + }) 273 + } 274 + return new Promise((resolve, reject) => resolve); 275 + }, 276 + (err: Error) => { 277 + this.logger.error(err); 278 + } 279 + ), {id: 'dead'})); 280 + // delay for 2 minutes 281 + }, 120 * 1000); 282 + 283 + } else { 284 + if(this.initDeadTimeout !== undefined) { 285 + this.logger.warn('Dead scrobble task timeout is already set'); 286 + } else { 287 + this.logger.warn('Dead scrobble task is already added to the scheduler'); 288 + } 289 + } 290 + } 291 + 292 + protected async heartbeatTask(): Promise<boolean> { 293 + if(!this.isReady()) { 294 + if(!this.canAuthUnattended()) { 295 + 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.') 296 + return false; 297 + } 298 + try { 299 + await this.tryInitialize({force: false, notify: true, notifyTitle: 'Could not initialize automatically'}); 300 + } catch (e) { 301 + this.logger.error(new Error('Could not initialize automatically', {cause: e})); 302 + return false; 303 + } 304 + 305 + if(!this.canAuthUnattended()) { 306 + 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.'); 307 + return false; 308 + } 309 + 310 + //await client.processDeadLetterQueue(); 311 + if(!this.scrobbling) { 312 + this.logger.info({labels: 'Heartbeat'}, 'Should be processing scrobbles! Attempting to restart scrobbling...'); 313 + this.initScrobbleMonitoring().catch((e) => this.logger.error('Failed to initialize scrobbler monitoring during heartbeat')); 314 + return true; 315 + } 316 + } 317 + return true; 234 318 } 235 319 236 320 protected async postCache(): Promise<void> {