From d110dcbdcfa160cbc564446af71939e25658950c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 11 Mar 2026 13:54:24 +0000 Subject: [PATCH] fix(sources): Add a confidence signal to plays generated from authoratiative sources to avoid fuzzy matching repeats https://github.com/FoxxMD/multi-scrobbler/issues/430#issuecomment-4035670777 --- .../scrobblers/AbstractScrobbleClient.ts | 12 +++++------ src/backend/sources/EndpointLastfmSource.ts | 1 + .../sources/EndpointListenbrainzSource.ts | 1 + src/backend/sources/LastfmSource.ts | 7 ++++--- src/backend/sources/ListenbrainzSource.ts | 6 +++++- src/backend/sources/WebScrobblerSource.ts | 1 + .../tests/scrobbler/scrobblers.test.ts | 21 ++++++++++++++++++- src/core/Atomic.ts | 1 + 8 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 959685ea..809ab08e 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -12,7 +12,8 @@ import { QueuedScrobble, ScrobbleActionResult, PlayMatchResult, SourcePlayerObj, TA_DURING, TA_FUZZY, TrackStringOptions, - TA_EXACT + TA_EXACT, + SOURCE_SOT } from "../../core/Atomic.js"; import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; import AbstractComponent from "../common/AbstractComponent.js"; @@ -502,14 +503,13 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return result; } - // we have found an existing submission but without an exact date - // 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]; + // + // 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]; existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => { diff --git a/src/backend/sources/EndpointLastfmSource.ts b/src/backend/sources/EndpointLastfmSource.ts index 13e4d7aa..7e17ee17 100644 --- a/src/backend/sources/EndpointLastfmSource.ts +++ b/src/backend/sources/EndpointLastfmSource.ts @@ -87,6 +87,7 @@ export class EndpointLastfmSource extends MemorySource { export const playStateFromRequest = (obj: LastFMScrobbleRequestPayload): PlayerStateData => { const play = scrobblePayloadToPlay(obj); + play.meta.sourceSOT = SOURCE_SOT.HISTORY; return { platformId: [play.meta.deviceId, NO_USER], play, diff --git a/src/backend/sources/EndpointListenbrainzSource.ts b/src/backend/sources/EndpointListenbrainzSource.ts index b215a865..a9e7d2bd 100644 --- a/src/backend/sources/EndpointListenbrainzSource.ts +++ b/src/backend/sources/EndpointListenbrainzSource.ts @@ -111,6 +111,7 @@ export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => { } = obj; const play = listenPayloadToPlay(payload[0], listen_type === 'playing_now'); + play.meta.sourceSOT = SOURCE_SOT.HISTORY; return { platformId: [play.meta.deviceId, NO_USER], play, diff --git a/src/backend/sources/LastfmSource.ts b/src/backend/sources/LastfmSource.ts index b1041d8c..18d88c9a 100644 --- a/src/backend/sources/LastfmSource.ts +++ b/src/backend/sources/LastfmSource.ts @@ -72,12 +72,13 @@ export default class LastfmSource extends MemorySource { const {limit = 20} = options; try { const {data: plays} = await this.api.getPaginatedTimeRangeListens({limit, cursor: 1}, {includeNowPlaying: true}); - plays.sort(sortByOldestPlayDate); + const mappedPlayed: PlayObject[] = plays.map(x => ({...x, meta: {...x.meta, sourceSOT: SOURCE_SOT.HISTORY}})); + mappedPlayed.sort(sortByOldestPlayDate); // if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing // and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp // so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway - const history = plays.filter(x => x.meta.nowPlaying !== true); - const now = plays.filter(x => x.meta.nowPlaying === true); + const history = mappedPlayed.filter(x => x.meta.nowPlaying !== true); + const now = mappedPlayed.filter(x => x.meta.nowPlaying === true); return [history, now]; } catch (e) { throw e; diff --git a/src/backend/sources/ListenbrainzSource.ts b/src/backend/sources/ListenbrainzSource.ts index 9eb1cd53..0fba021d 100644 --- a/src/backend/sources/ListenbrainzSource.ts +++ b/src/backend/sources/ListenbrainzSource.ts @@ -42,7 +42,11 @@ export default class ListenbrainzSource extends MemorySource { this.getScrobblesForTimeRange = createGetScrobblesForTimeRangeFunc(this.api, this.api.logger); } - static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ return ListenbrainzApiClient.formatPlayObj(obj, options); } + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}){ + const play = ListenbrainzApiClient.formatPlayObj(obj, options); + play.meta.sourceSOT = SOURCE_SOT.HISTORY; + return play; + } protected async doCheckConnection(): Promise { try { diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index a7aa84ee..5d8c768f 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -85,6 +85,7 @@ export class WebScrobblerSource extends MemorySource { } = obj; const play = WebScrobblerSource.formatPlayObj(obj.data.song, {nowPlaying: eventName !== 'scrobble'}); + play.meta.sourceSOT = SOURCE_SOT.HISTORY; return { platformId: [play.meta.deviceId, NO_USER], play, diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 3a6e41d7..9ce34b91 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -6,7 +6,7 @@ import dayjs from "dayjs"; import { after, before, describe, it } from 'mocha'; import { http, HttpResponse } from 'msw'; import pEvent from 'p-event'; -import { PlayObject } from "../../../core/Atomic.js"; +import { PlayObject, SOURCE_SOT } from "../../../core/Atomic.js"; import { genGroupIdStr, sleep, sortByOldestPlayDate } from "../../utils.js"; import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' }; import withDuration from '../plays/withDuration.json' with { type: 'json' }; @@ -265,6 +265,25 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu repeatPlay.data.repeat = true; assert.isFalse((await testScrobbler.alreadyScrobbled(repeatPlay))[0]); }); + + it('Is not detected as duplicate when play date matches fuzzy and play source SOT is history', async function () { + + const play = generatePlay({ + artists: ['Nejad'], + track: 'CODE', + album: undefined, + playDate: dayjs().subtract(179, 's'), + duration: 179 + }); + testScrobbler.testRecentScrobbles = [play]; + + const newPlay = clone(play); + newPlay.data.playDate = dayjs(); + newPlay.meta.sourceSOT = SOURCE_SOT.HISTORY; + + const res = await testScrobbler.existingScrobble(newPlay, [play]); + expect(res.match).is.false; + }); }); }); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 5e6f3786..ad77b06d 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -195,6 +195,7 @@ export interface ArtMeta { export interface PlayMeta { source?: string + sourceSOT?: SOURCE_SOT_TYPES /* * If applicable, the name of the Service providing the track (Spotify, Tidal, etc...) -- 2.51.2