From 10325fd4e622922e30d99391c35b65a1a1a2f0c4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 6 Aug 2026 14:08:29 +0000 Subject: [PATCH] refactor(now playing): Use more efficient solution for ingress now playing Re-queue valid ingress NP update on client rather than spamming all clients with duplicate updates from memory source This should have the same functional affect with way less log noise --- .../scrobblers/AbstractScrobbleClient.ts | 54 +++++++++++++------ src/backend/sources/MemorySource.ts | 14 ----- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index bd36ede5..ed178be6 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -15,7 +15,8 @@ import { type PlayOriginal, type PlayLifecycle, type SourcePlayerJson, - QUEUE_STATUS_COMPLETED + QUEUE_STATUS_COMPLETED, + SOURCE_SOT } from "../../core/Atomic.ts"; import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.ts"; import AbstractComponent from "../common/AbstractComponent.ts"; @@ -73,11 +74,18 @@ import assert from "node:assert"; import { COMPONENT_STATE, type ComponentClientApiJson, type PlayApiCommonDetailed } from "../../core/Api.ts"; import type {ComponentState} from "react"; -type PlatformMappedPlays = Map; +type SourceMappedPlayer = {player: SourcePlayerObj, source: SourceIdentifier}; +type PlatformMappedPlays = Map; type NowPlayingQueue = Map; const platformTruncate = truncateStringToLength(10); +const bufferNPUpdateReasonFragments: string[] = [ + 'previous update play data does not match current', + 'player in valid update state', + 'less than min threshold' +]; + export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable { declare type: ClientType; @@ -116,7 +124,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i nowPlayingIsRealtime: boolean = false; nowPlayingInit: boolean = false; nowPlayingEnabled: boolean; - nowPlayingFilter: (queue: NowPlayingQueue) => SourcePlayerObj | undefined; + nowPlayingFilter: (queue: NowPlayingQueue) => SourceMappedPlayer | undefined; nowPlayingMinThreshold: NowPlayingUpdateThreshold = (_) => 10; nowPlayingMaxThreshold: NowPlayingUpdateThreshold = (_) => 30; nowPlayingLastUpdated?: Dayjs; @@ -542,7 +550,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i } } - this.nowPlayingFilter = (queue: NowPlayingQueue): SourcePlayerObj => { + this.nowPlayingFilter = (queue: NowPlayingQueue): SourceMappedPlayer => { if (queue.size === 0) { return undefined; } @@ -555,7 +563,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i // if only one player then return it const plays = Array.from(platformPlays); if (plays.length === 1) { - return plays[0][1].player; + return plays[0][1]; } // else we need to sort players to determine which to report @@ -568,7 +576,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i if (platform === this.nowPlayingLastPlay.platformId // only keep using sticky platform if it hasn't gone stale/orphaned && (!(data.player.status?.stale ?? false) && !(data.player.status?.orphaned ?? false))) { - return data.player; + return data; } } } @@ -582,7 +590,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i // otherwise sort platform alphabetically and take first preferredPlays.sort((a, b) => a[0].localeCompare(b[0])); - return preferredPlays[0][1].player; + return preferredPlays[0][1]; } } } @@ -1638,14 +1646,14 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return; } // eslint-disable-next-line prefer-const - let [shouldUpdate, npUpdateTopReason] = this.shouldUpdatePlayingNow(sourcePlayerData); + let [shouldUpdate, npUpdateTopReason] = this.shouldUpdatePlayingNow(sourcePlayerData.player); let clientReason: string | undefined; if(!shouldUpdate) { this.npLogger.trace(`Not updating, ${npUpdateTopReason}`); } if(shouldUpdate) { - const [clientUpdate, clientUpdateReason, level] = await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData); + const [clientUpdate, clientUpdateReason, level] = await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData.player); clientReason = clientUpdateReason; shouldUpdate = clientUpdate; if(!clientUpdate) { @@ -1653,29 +1661,45 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i } } + const cleanNowPlayingQueue = new Map(); + // finally, do the update if(shouldUpdate) { this.npLogger.verbose(`Updating because ${npUpdateTopReason}${clientReason !== undefined ? ` --AND-- ${clientReason}` : ''}`); - const isClearing = this.nowPlayingIsRealtime && shouldClearNPStatus(sourcePlayerData); + const isClearing = this.nowPlayingIsRealtime && shouldClearNPStatus(sourcePlayerData.player); try { - await this.doPlayingNow(sourcePlayerData); + await this.doPlayingNow(sourcePlayerData.player); this.npLogger.trace(`Now Playing updated.`); this.setStatus('Now Playing updated'); if(!isClearing) { - this.nowPlayingExpirationDate = dayjs().add(nowPlayingExpirationDuration(sourcePlayerData)); + this.nowPlayingExpirationDate = dayjs().add(nowPlayingExpirationDuration(sourcePlayerData.player)); this.emitEvent('playerUpdate', {...sourcePlayerData, expiration: this.nowPlayingExpirationDate}); } else { this.nowPlayingExpirationDate = undefined; - this.emitEvent('playerDelete', {platformId: sourcePlayerData.platformId}); + this.emitEvent('playerDelete', {platformId: sourcePlayerData.player.platformId}); } this.emitEvent('nowPlayingUpdated', sourcePlayerData); } catch (e) { this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e})); } - this.nowPlayingLastPlay = sourcePlayerData; + this.nowPlayingLastPlay = sourcePlayerData.player; this.nowPlayingLastUpdated = dayjs(); + } else { + if(sourcePlayerData.player.play?.meta?.sourceSOT === SOURCE_SOT.INGRESS && bufferNPUpdateReasonFragments.every((x) => npUpdateTopReason.includes(x))) { + // update is for an ingress Source and is valid + // but time since last update was less than client threshold interval + // + // Ingress Sources may not send any additional updates to MS until a scrobble event + // so, otherwise, NP would never be updated until that happens + // + // to prevent that we want client NP to update with this *valid* update after min threshold is met + // so we will re-queue the update so that it gets used in a subsequent NP processing run + this.npLogger.debug('Re-queuing valid NP update from ingress Source that did not meet min threshold'); + const sourceId = `${sourcePlayerData.source.name}-${sourcePlayerData.source.type}`; + cleanNowPlayingQueue.set(sourceId, this.nowPlayingQueue.get(sourceId)); + } } - this.nowPlayingQueue = new Map(); + this.nowPlayingQueue = cleanNowPlayingQueue; } } diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index 41b920fb..e7e04d7f 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -68,21 +68,7 @@ export default class MemorySource extends AbstractSource { .withConcurrency(1) .for(this.players.keys()) .process(async (key) => { - await this.cleanupPlayer(key); - if(this.playerSourceOfTruth === SOURCE_SOT.INGRESS) { - const player = this.players.get(key); - if(![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(player.calculatedStatus)) { - // if player isn't stale and this is an ingress source then we want to keep pushing playerUpdates - // so that any downstream Clients can update Now Playing in a timely manner - this.emitEvent('playerUpdate', { - ...player.getApiState(), - options: { - scrobbleTo: this.clients - } - }); - } - } }); }))); } -- 2.51.2