diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index c363e5c3..cba73725 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -29,7 +29,7 @@ export class EndpointLastfmSource extends MemorySource { constructor(name: any, config: LastFMEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('endpointlfm', name, config, internal, emitter); this.multiPlatform = false; - this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + this.playerSourceOfTruth = SOURCE_SOT.INGRESS; const { data = {}, @@ -95,7 +95,7 @@ export class EndpointLastfmSource extends MemorySource { export const playStateFromRequest = (obj: Record): PlayerStateData[] => ingressPayloads(obj).map(x => { const play = scrobblePayloadToPlay(x); - play.meta.sourceSOT = SOURCE_SOT.HISTORY; + play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { platformId: [play.meta.deviceId, NO_USER], play, diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts index 44a22c5f..27eebeab 100644 --- a/src/backend/sources/EndpointListenbrainzSource.ts +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -32,7 +32,7 @@ export class EndpointListenbrainzSource extends MemorySource { constructor(name: any, config: ListenbrainzEndpointSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('endpointlz', name, config, internal, emitter); this.multiPlatform = false; - this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + this.playerSourceOfTruth = SOURCE_SOT.INGRESS; const { data = {}, @@ -127,7 +127,7 @@ export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData[] => { const playStates: PlayerStateData[] = payload.map((x) => { const play = listenPayloadToPlay(x, listen_type === 'playing_now'); - play.meta.sourceSOT = SOURCE_SOT.HISTORY; + play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { platformId: [play.meta.deviceId, NO_USER], play, diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index d601e086..41b920fb 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -60,7 +60,7 @@ export default class MemorySource extends AbstractSource { // player cleanup on *schedule* is needed when the Source is non-polling (ingress) // because if the source stops sending updates then processRecentPlays() was never called so we never remove old players - this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ seconds: 15 }, new AsyncTask('Player Cleanup', (): Promise => { + this.scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ seconds: 10 }, new AsyncTask('Player Cleanup', (): Promise => { if (this.canPoll) { return Promise.resolve(); } @@ -70,6 +70,19 @@ export default class MemorySource extends AbstractSource { .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 + } + }); + } + } }); }))); } diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index 2ea3a087..ba1ba464 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -34,7 +34,7 @@ export class WebScrobblerSource extends MemorySource { constructor(name: any, config: WebScrobblerSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('webscrobbler', name, config, internal, emitter); this.multiPlatform = true; - this.playerSourceOfTruth = SOURCE_SOT.HISTORY; + this.playerSourceOfTruth = SOURCE_SOT.INGRESS; this.logger.info(`Note: The player for this source is an analogue for the 'Now Playing' status exposed by ${this.type} which is NOT used for scrobbling. Instead, the 'recently played' or 'history' information provided by this source is used for scrobbles.`) const { @@ -88,7 +88,7 @@ export class WebScrobblerSource extends MemorySource { } = obj; const play = WebScrobblerSource.formatPlayObj(obj.data.song, {nowPlaying: eventName !== 'scrobble'}); - play.meta.sourceSOT = SOURCE_SOT.HISTORY; + play.meta.sourceSOT = SOURCE_SOT.INGRESS; return { platformId: [play.meta.deviceId, NO_USER], play, diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index 8473e647..5d2c3e5e 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -1,5 +1,5 @@ import { getListDiff, type ListDiff } from "@donedeal0/superdiff"; -import { type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy, type TrackStringOptions } from "../../core/Atomic.ts"; +import { type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, type SOURCE_SOT_TYPES, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy, type TrackStringOptions } from "../../core/Atomic.ts"; import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.ts"; import { comparingMultipleArtists, playObjDataMatch, setIntersection } from "../utils.ts"; import { comparePlayTemporally, hasAcceptableTemporalAccuracy, temporalAccuracyToString, type TemporalPlayComparisonOptions, temporalPlayComparisonSummary } from "./TimeUtils.ts"; @@ -496,7 +496,7 @@ export const existingScrobble = async (playObjPre: PlayObject, existingScrobbles // // OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources) // then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble - const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING]; + const looseTimeAccuracy = playObj.data.repeat || ([SOURCE_SOT.HISTORY, SOURCE_SOT.INGRESS] as SOURCE_SOT_TYPES[]).includes(playObj.meta.sourceSOT) ? [TA_DURING] : [TA_FUZZY, TA_DURING]; existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => { diff --git a/src/client/components/player/Player.tsx b/src/client/components/player/Player.tsx index 44fe5a22..e21502f3 100644 --- a/src/client/components/player/Player.tsx +++ b/src/client/components/player/Player.tsx @@ -83,7 +83,7 @@ art = {}, return (
- {sot === SOURCE_SOT.HISTORY ? diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index b8c9e3c6..7ef1611e 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -469,12 +469,13 @@ export interface TemporalPlayComparison { } | { type: 'none' } } -export type SOURCE_SOT_TYPES = 'player' | 'history'; +export type SOURCE_SOT_TYPES = 'player' | 'history' | 'ingress'; export const SOURCE_SOT = { - PLAYER : 'player' as SOURCE_SOT_TYPES, - HISTORY: 'history' as SOURCE_SOT_TYPES -} -export const sourceSotTypes: SOURCE_SOT_TYPES[] = ['player','history']; + PLAYER : 'player', + HISTORY: 'history', + INGRESS: 'ingress' +} as const satisfies Record +export const sourceSotTypes: SOURCE_SOT_TYPES[] = ['player','history','ingress']; export interface URLData { url: URL -- 2.51.2 From 10325fd4e622922e30d99391c35b65a1a1a2f0c4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 6 Aug 2026 14:08:29 +0000 Subject: [PATCH 2/4] 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 From 5dee49938134f47e10cf4b1083a5079c09b5460a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 6 Aug 2026 16:14:29 +0000 Subject: [PATCH 3/4] test: fix failing tests from np data structure change --- src/backend/tests/scrobbler/scrobblers.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 382ab00c..c52a0740 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -865,7 +865,7 @@ describe('Now Playing', function() { const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform)); + expect(toReport.player.play.meta.deviceId).eq(genGroupIdStr(firstPlatform)); }); @@ -886,7 +886,7 @@ describe('Now Playing', function() { const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform)); + expect(toReport.player.play.meta.deviceId).eq(genGroupIdStr(firstPlatform)); }); @@ -910,7 +910,7 @@ describe('Now Playing', function() { const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.play.meta.deviceId).eq(genGroupIdStr(secondPlatform)); + expect(toReport.player.play.meta.deviceId).eq(genGroupIdStr(secondPlatform)); }); @@ -927,7 +927,7 @@ describe('Now Playing', function() { const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.play.meta.deviceId).eq(a.play.meta.deviceId); + expect(toReport.player.play.meta.deviceId).eq(a.play.meta.deviceId); }); @@ -944,7 +944,7 @@ describe('Now Playing', function() { const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue); - expect(toReport.play.meta.deviceId).eq(b.play.meta.deviceId); + expect(toReport.player.play.meta.deviceId).eq(b.play.meta.deviceId); }); -- 2.51.2 From 124e857d9ff1e1ba68ccc498eb8fbc687be6de61 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 7 Aug 2026 12:39:26 +0000 Subject: [PATCH 4/4] fix: now playing playerUpdate event data --- src/backend/scrobblers/AbstractScrobbleClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index ed178be6..753c482f 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -1673,7 +1673,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i this.setStatus('Now Playing updated'); if(!isClearing) { this.nowPlayingExpirationDate = dayjs().add(nowPlayingExpirationDuration(sourcePlayerData.player)); - this.emitEvent('playerUpdate', {...sourcePlayerData, expiration: this.nowPlayingExpirationDate}); + this.emitEvent('playerUpdate', {...sourcePlayerData.player, expiration: this.nowPlayingExpirationDate}); } else { this.nowPlayingExpirationDate = undefined; this.emitEvent('playerDelete', {platformId: sourcePlayerData.player.platformId});