[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.

feat(player): Skip stale updates based on state timestamp and prevent dead players from being created from stale updates

#480

FoxxMD (Apr 30, 2026, 4:39 PM UTC) df0da705 272a336a

+46 -7
+14 -2
src/backend/sources/JellyfinApiSource.ts
··· 508 508 for(const sessionData of nonMSSessions) { 509 509 const validPlay = this.isActivityValid(sessionData[0], sessionData[1]); 510 510 if(validPlay === true) { 511 + if(isDebugMode()) { 512 + let stateIdentifyingInfo: string = genGroupIdStr(getPlatformIdFromData(sessionData[0])); 513 + this.logger.trace(`${stateIdentifyingInfo} => Activity Date ${sessionData[1].LastActivityDate} | Playback Checkin: ${sessionData[1].LastPlaybackCheckIn} `) 514 + } 511 515 validSessions.push(sessionData[0]); 512 516 } else if(this.logFilterFailure !== false) { 513 517 let stateIdentifyingInfo: string = genGroupIdStr(getPlatformIdFromData(sessionData[0])); ··· 535 539 DeviceName, 536 540 Client, 537 541 LastActivityDate, 542 + LastPlaybackCheckIn, 538 543 PlayState: { 539 544 PositionTicks, 540 - IsPaused 545 + IsPaused, 546 + CanSeek 541 547 } 542 548 } = obj; 543 549 ··· 557 563 ...sessionPlay.meta, 558 564 user: UserName ?? UserId, 559 565 deviceId: msDeviceId, 560 - trackProgressPosition: playerPosition 566 + trackProgressPosition: playerPosition, 567 + //lastActivityDate: LastActivityDate, 568 + //lastPlaybackCheckin: LastPlaybackCheckIn 561 569 } 562 570 } 563 571 ··· 572 580 reportedStatus = IsPaused ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing; 573 581 } 574 582 583 + const sessionUpdatedAt = LastActivityDate !== undefined ? dayjs(LastActivityDate) : undefined; 584 + 575 585 return { 576 586 platformId: [msDeviceId, UserName ?? UserId], 577 587 play, 578 588 status: reportedStatus, 579 589 position: playerPosition, 590 + stateUpdatedAt: sessionUpdatedAt, 591 + playUpdatedAt: sessionUpdatedAt 580 592 //timestamp: LastActivityDate !== undefined ? dayjs(LastActivityDate) : undefined 581 593 } 582 594 }
+28 -1
src/backend/sources/MemorySource.ts
··· 48 48 playerState: Map<string, string> = new Map(); 49 49 playerCleanupDiscoveryAttempt: Map<string, boolean> = new Map(); 50 50 51 + deceasedPlayers: Map<string, Dayjs> = new Map(); 52 + 51 53 scheduler: ToadScheduler = new ToadScheduler(); 52 54 53 55 protected isPositional: boolean = false; ··· 196 198 return this.players.has(id); 197 199 } 198 200 201 + isZombiePlayer = (id: string, lastUpdated: Dayjs): boolean => { 202 + return this.deceasedPlayers.has(id) && this.deceasedPlayers.get(id).isSame(lastUpdated); 203 + } 204 + 199 205 genPlayerId = (data: PlayObject | PlayerStateDataMaybePlay): string => { 200 206 return genGroupIdStr(getPlatformIdFromData(data)); 201 207 } ··· 208 214 this.players.get(id)?.logger.debug(reason); 209 215 } 210 216 using player = this.players.get(id); 217 + this.deceasedPlayers.set(id, player.stateLastUpdatedAt); 211 218 player[Symbol.dispose](); 212 219 this.players.delete(id); 213 220 this.playerState.delete(id); ··· 236 243 const id = getPlatformIdFromData(data); 237 244 const idStr = this.genPlayerId(data); 238 245 if (!this.players.has(idStr)) { 246 + if(asPlayerStateDataMaybePlay(data) && data.stateUpdatedAt !== undefined) { 247 + if(this.isZombiePlayer(idStr, data.stateUpdatedAt)) { 248 + this.logger.trace(`Not creating player for ${idStr} because last state update timestamp has not changed since it was deleted.`); 249 + continue; 250 + } else { 251 + // cleaning up in case it already existed but has new timestamp 252 + this.deceasedPlayers.delete(idStr); 253 + } 254 + } 239 255 this.setNewPlayer(idStr, this.logger, id); 240 256 241 257 if(!this.multiPlatform && this.players.size > 1) { ··· 257 273 return player.platformEquals(id); 258 274 }); 259 275 260 - // we've received some form of communication from the source for this player 276 + let hasFreshState = false; 261 277 if (relevantDatas.length > 0) { 278 + hasFreshState = true; 279 + this.lastActivityAt = dayjs(); 280 + incomingData = this.pickPlatformSession(relevantDatas, player); 281 + if(asPlayerStateDataMaybePlay(incomingData) && incomingData.stateUpdatedAt !== undefined && player.stateLastUpdatedAt.isSame(incomingData.stateUpdatedAt)) { 282 + hasFreshState = false; 283 + player.logger.trace('Skipping update because it has the same timestamp as the previous update'); 284 + } 285 + } 286 + 287 + // we've received some form of communication from the source for this player 288 + if (hasFreshState) { 262 289 this.lastActivityAt = dayjs(); 263 290 264 291 // reset any player cleanup state since we got fresh data
+4 -4
src/backend/sources/PlayerState/AbstractPlayerState.ts
··· 171 171 } 172 172 173 173 update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) { 174 - this.stateLastUpdatedAt = dayjs(); 174 + this.stateLastUpdatedAt = state.stateUpdatedAt ?? dayjs(); 175 175 176 176 const {play, status} = state; 177 177 ··· 194 194 } 195 195 196 196 protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] { 197 - const {play, status, sessionId} = state; 198 - this.playLastUpdatedAt = reportedTS ?? dayjs(); 197 + const {play, status, sessionId, playUpdatedAt} = state; 198 + this.playLastUpdatedAt = reportedTS ?? playUpdatedAt ?? dayjs(); 199 199 if (status !== undefined) { 200 200 this.reportedStatus = status; 201 201 } ··· 208 208 const played = this.getPlayedObject(true); 209 209 this.isRepeatPlay = false; 210 210 this.lastPlay = played; 211 - this.lastPlayUpdatedAt = dayjs(); 211 + this.lastPlayUpdatedAt = playUpdatedAt ?? dayjs(); 212 212 this.setCurrentPlay(state, {reportedTS}); 213 213 if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) { 214 214 this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;