From 47ad7a3b747904576b9a5e94670c8ca7c09a0caa Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 15 Jul 2025 19:23:49 +0000 Subject: [PATCH] feat: Track if play is a repeat and use in duplicate scrobble detection #318 * Use MS Player to mark Play as a repeat if session repeat is detected * Remove FUZZY temporal accuracy as acceptable for detecting duplicate if candidate play is marked as repeat --- .../scrobblers/AbstractScrobbleClient.ts | 7 +++- .../PlayerState/AbstractPlayerState.ts | 8 ++++- src/backend/tests/player/player.test.ts | 34 +++++++++++++++++++ .../tests/scrobbler/scrobblers.test.ts | 17 ++++++++++ src/core/Atomic.ts | 1 + 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index e64ecd5e..27ca1c2b 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -539,6 +539,11 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i // in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined; + // only check for fuzzy if we know this play is NOT a repeat + // otherwise we may get a false positive on the previously played track ending time == repeat start time + // -- this is info we only know if play was generated from MS player so we can be reasonably sure + const looseTimeAccuracy = playObj.data.repeat ? [TA_DURING] : [TA_FUZZY, TA_DURING]; + existingScrobble = this.recentScrobbles.find((xPre) => { const x = this.transformPlay(xPre, TRANSFORM_HOOK.existing); @@ -550,7 +555,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i let timeMatch = 0; if(hasAcceptableTemporalAccuracy(temporalComparison.match)) { timeMatch = 1; - } else if(hasAcceptableTemporalAccuracy(temporalComparison.match, [TA_FUZZY, TA_DURING])) { + } else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) { timeMatch = 0.6; } diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 3135578a..6e7aa7bd 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -75,6 +75,7 @@ export abstract class AbstractPlayerState { currentPlay?: PlayObject playFirstSeenAt?: Dayjs playLastUpdatedAt?: Dayjs + isRepeatPlay?: boolean = false; currentListenRange?: ListenRange listenRanges: ListenRange[] = []; createdAt: Dayjs = dayjs(); @@ -181,6 +182,7 @@ export abstract class AbstractPlayerState { this.logger.debug(`Incoming play state (${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}) does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`) this.currentListenSessionEnd(); const played = this.getPlayedObject(true); + this.isRepeatPlay = false; this.setCurrentPlay(state, {reportedTS}); if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) { this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; @@ -194,6 +196,7 @@ export abstract class AbstractPlayerState { this.currentListenSessionEnd(); const played = this.getPlayedObject(true); play.data.playDate = dayjs(); + this.isRepeatPlay = true; this.setCurrentPlay(state, {reportedTS}); return [this.getPlayedObject(), played]; } else { @@ -212,6 +215,7 @@ export abstract class AbstractPlayerState { this.currentListenSessionContinue(state.position, reportedTS); } } else { + this.isRepeatPlay = false; this.setCurrentPlay(state); this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; } @@ -231,6 +235,7 @@ export abstract class AbstractPlayerState { this.playFirstSeenAt = undefined; this.listenRanges = []; this.currentListenRange = undefined; + this.isRepeatPlay = false; } protected stopPlayer() { @@ -255,7 +260,8 @@ export abstract class AbstractPlayerState { playDate: this.playFirstSeenAt, listenedFor: this.getListenDuration(), listenRanges: ranges, - playDateCompleted: completed ? dayjs() : undefined + playDateCompleted: completed ? dayjs() : undefined, + repeat: this.isRepeatPlay }, meta: this.currentPlay.meta } diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index 609cad21..b0f9822d 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -366,6 +366,7 @@ describe('Player listen ranges', function () { const [curr, prevPlay] = player.update(testState({play: positioned, position: 5}), dayjs().add(22, 'seconds')); assert.isDefined(prevPlay); + assert.isTrue(curr.data.repeat) assert.equal(player.getListenDuration(), 0); }); @@ -389,6 +390,7 @@ describe('Player listen ranges', function () { player.currentListenRange.rtPlayer.setPosition(381000); const [curr, prevPlay] = player.update(testState({play: positioned, position: 20}), dayjs().add(30, 'seconds')); + assert.isTrue(curr.data.repeat); assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); }); @@ -411,9 +413,41 @@ describe('Player listen ranges', function () { player.currentListenRange.rtPlayer.setPosition(50000); const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds')); + assert.isTrue(curr.data.repeat) assert.isDefined(prevPlay); assert.equal(player.getListenDuration(), 0); }); + + it('Resets repeat status when updated with non-matching play', function () { + const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]); + + const positioned = clone(newPlay); + positioned.data.duration = 70; + + player.update(testState({play: positioned, position: 0})); + + player.currentListenRange.rtPlayer.setPosition(40000); + player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds')); + + const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds')); + assert.isTrue(isRepeat); + + positioned.meta.trackProgressPosition = 2; + player.currentListenRange.rtPlayer.setPosition(50000); + const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds')); + + assert.isTrue(curr.data.repeat) + assert.isDefined(prevPlay); + assert.equal(player.getListenDuration(), 0); + + player.currentListenRange.rtPlayer.setPosition(55000); + const [currNew, prevPlayRepeat] = player.update(testState({play: generatePlay(), position: 1}), dayjs().add(55, 'seconds')); + + assert.isDefined(prevPlayRepeat) + assert.isTrue(prevPlayRepeat.data.repeat); + assert.isDefined(currNew); + assert.isFalse(currNew.data.repeat); + }); }); }); }); diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index abbd8a65..be5c4a95 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -246,6 +246,23 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu assert.isFalse(await testScrobbler.alreadyScrobbled(ballad2)); }); + + it('Is not detected as duplicate when play date matches fuzzy but play is marked as repeat', async function () { + + const recent = normalizePlays(normalizedWithDur, { + initialDate: firstPlayDate, + defaultMeta: {source: 'jellyfin'} + }); + testScrobbler.recentScrobbles = recent; + + const repeatPlay = clone(recent[recent.length - 1]); + repeatPlay.data.playDate = repeatPlay.data.playDate.add(repeatPlay.data.duration + 2, 's'); + + assert.isTrue(await testScrobbler.alreadyScrobbled(repeatPlay)); + + repeatPlay.data.repeat = true; + assert.isFalse(await testScrobbler.alreadyScrobbled(repeatPlay)); + }); }); }); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index cc998ccc..6799efea 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -106,6 +106,7 @@ export interface PlayData extends TrackData { listenedFor?: number listenRanges?: ListenRangeData[] playDateCompleted?: Dayjs | string + repeat?: boolean } export interface PlayMeta { -- 2.51.2