[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(scrobbler): Improved dupe detection

* Check queue before adding so that previously cached or re-discovered plays from a source are not added multiple times to the queue (should only be unique plays)
* Add timestamp heuristic to dupe matching -- if score is *close* to matching and timestamp is *exact* then nudge it into a match

FoxxMD (Mar 10, 2026, 8:37 PM UTC) d4113b46 753dfe1f

+32 -9
+30 -8
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 11 11 PlayObjectLifecycleless, 12 12 QueuedScrobble, ScrobbleActionResult, PlayMatchResult, SourcePlayerObj, TA_DURING, 13 13 TA_FUZZY, 14 - TrackStringOptions 14 + TrackStringOptions, 15 + TA_EXACT 15 16 } from "../../core/Atomic.js"; 16 17 import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 17 18 import AbstractComponent from "../common/AbstractComponent.js"; ··· 450 451 return [matchPlayDate, dtInvariantMatches]; 451 452 } 452 453 453 - existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[]): Promise<PlayMatchResult> => { 454 + existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[], log: boolean = true): Promise<PlayMatchResult> => { 454 455 455 456 const result: PlayMatchResult = { 456 457 match: false, ··· 578 579 result.match = score >= DUP_SCORE_THRESHOLD; 579 580 result.breakdowns = scoreBreakdowns; 580 581 result.score = score; 581 - //closestMatch = scoreInfo 582 + 583 + if(result.match === false && temporalComparison.match === TA_EXACT && score >= 0.90) { 584 + // if we have a score >= 90 and time is an exact match 585 + // it's likely the differences are due to source-scrobbler data presentation, or deficiencies, 586 + // rather than actually being unique 587 + // so force match in this instance 588 + result.match = true; 589 + result.reason = `Score ${score.toFixed(2)} is not greater than threshold (${DUP_SCORE_THRESHOLD}) but it is very close and timestamp is an exact match, vibe matching.`; 590 + } 582 591 } 583 592 584 593 return score >= DUP_SCORE_THRESHOLD; ··· 590 599 closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(result.closestMatchedPlay, scoreTrackOpts)}`); 591 600 } 592 601 closestScrobbleParts.push(result.reason); 593 - let summary = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`; 594 - this.dupeLogger.trace(`${summary}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}`); 595 - 602 + let summaryStart = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`; 603 + const summary = `${summaryStart}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}` 604 + result.summary = summary; 605 + if(log) { 606 + this.dupeLogger.trace(summary); 607 + } 596 608 return result; 597 609 } 598 610 ··· 767 779 } 768 780 } 769 781 if(historicalError === undefined) { 770 - const matchResult = await this.existingScrobble(currQueuedPlay.play, historicalPlays); 782 + const {summary, ...matchResult} = await this.existingScrobble(currQueuedPlay.play, historicalPlays); 771 783 const { 772 784 scrobble = {}, 773 785 ...lifeRest ··· 907 919 await sleep(1000); 908 920 } 909 921 } 910 - const matchResult = await this.existingScrobble(deadScrobble.play, historicalPlays); 922 + const {summary, ...matchResult} = await this.existingScrobble(deadScrobble.play, historicalPlays); 911 923 const { 912 924 scrobble = {}, 913 925 ...lifeRest ··· 984 996 const plays = Array.isArray(data) ? data : [data]; 985 997 const sm = staggerMapper<PlayObject, PlayObject>({concurrency: 2}); 986 998 for await(const play of pMapIterable(plays, sm(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 2})) { 999 + try { 1000 + const existingQueued = await this.existingScrobble(play, this.queuedScrobbles.map(x => x.play), false); 1001 + // want to be very confident of this 1002 + if(existingQueued.match && existingQueued.score > 0.99) { 1003 + this.logger.trace(`Not adding to queue because it is already in the queue\n${existingQueued.summary}`); 1004 + return; 1005 + } 1006 + } catch (e) { 1007 + this.logger.warn(new SimpleError('Failed to check queued scrobble for existing before adding', {cause: e})); 1008 + } 987 1009 const queuedPlay = {id: nanoid(), source, play: play} 988 1010 this.emitEvent('scrobbleQueued', {queuedPlay: queuedPlay}); 989 1011 this.queuedScrobbles.push(queuedPlay);
+1 -1
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 116 116 { 117 117 token: 'test', 118 118 username: 'test' 119 - }, {logger: loggerDebug}); 119 + }, {logger: loggerTest}); 120 120 121 121 it('Should recognize bad requests as non-showstopping',withRequestInterception( 122 122 [
+1
src/core/Atomic.ts
··· 327 327 reason?: string 328 328 closestMatchedPlay?: PlayObjectLifecycleless 329 329 transformedPlay?: PlayObjectLifecycleless 330 + summary?: String 330 331 } 331 332 332 333 export type ScrobbleTsSOC = 1 | 2;