[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.

fix: Guard against old/bad cached listen range data

#601

FoxxMD (May 27, 2026, 7:25 PM UTC) 3f1a952e 405ee4f6

+30 -66
+1 -1
src/backend/common/database/drizzle/repositories/PlayRepository.ts
··· 659 659 return undefined; 660 660 } 661 661 return res.map(x => ({...x, play: hydratePlaySelect(x)})).find(x => { 662 - const temporalComparison = comparePlayTemporally(x.play, play); 662 + const temporalComparison = comparePlayTemporally(x.play, play, {logger: this.logger}); 663 663 return hasAcceptableTemporalAccuracy(temporalComparison.match, taAccuracy) 664 664 }) 665 665 }
+1 -2
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 64 64 import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; 65 65 import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; 66 66 import { getRoot } from "../ioc.js"; 67 - import { rehydratePlay } from "../utils/CacheUtils.js"; 68 67 import { findAsyncSequential, staggerMapper, StaggerOptions } from "../utils/AsyncUtils.js"; 69 68 import pMap, { pMapIterable } from "p-map"; 70 69 import { comparePlayArtistsNormalized, comparePlayTracksNormalized, existingScrobble, ExistingScrobbleOpts } from "../utils/PlayComparisonUtils.js"; ··· 830 829 } 831 830 832 831 const matchPlayDate = dtInvariantMatches.find((x: PlayObject) => { 833 - const temporalComparison = comparePlayTemporally(x, playObj); 832 + const temporalComparison = comparePlayTemporally(x, playObj, {logger: this.logger}); 834 833 return hasAcceptableTemporalAccuracy(temporalComparison.match) 835 834 }); 836 835
+1 -1
src/backend/sources/AbstractSource.ts
··· 39 39 import { componentFileLogger } from '../common/logging.js'; 40 40 import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js';; 41 41 import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.js"; 42 - import { existingScrobble, ExistingScrobbleOpts, genericSourcePlayMatch } from '../utils/PlayComparisonUtils.js'; 42 + import { existingScrobble, ExistingScrobbleOpts } from '../utils/PlayComparisonUtils.js'; 43 43 import { findAsync, staggerMapper, StaggerOptions } from '../utils/AsyncUtils.js'; 44 44 import pMap, {pMapIterable} from 'p-map'; 45 45 import prom, { Counter, Gauge } from 'prom-client';
+2 -1
src/backend/sources/DeezerInternalSource.ts
··· 348 348 if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') { 349 349 temporalOptions = { 350 350 fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5), 351 - duringReferences: ['duration', 'listenedFor', 'range'] 351 + duringReferences: ['duration', 'listenedFor', 'range'], 352 + logger: this.logger 352 353 } 353 354 temporalAccuracy.push(TA_DURING); 354 355 }
-48
src/backend/utils/CacheUtils.ts
··· 1 - import dayjs from 'dayjs'; 2 - import duration from 'dayjs/plugin/duration.js'; 3 - import isBetween from 'dayjs/plugin/isBetween.js'; 4 - import relativeTime from 'dayjs/plugin/relativeTime.js'; 5 - import isToday from 'dayjs/plugin/isToday.js'; 6 - import timezone from 'dayjs/plugin/timezone.js'; 7 - import utc from 'dayjs/plugin/utc.js'; 8 - import { AmbPlayObject, ListenRangeData, ListenRangeDataAmb, PlayObject, PlayProgress, PlayProgressAmb } from '../../core/Atomic.js'; 9 - 10 - dayjs.extend(utc) 11 - dayjs.extend(isBetween); 12 - dayjs.extend(relativeTime); 13 - dayjs.extend(duration); 14 - dayjs.extend(timezone); 15 - dayjs.extend(isToday); 16 - 17 - export const rehydratePlay = (obj: AmbPlayObject): PlayObject => { 18 - if(obj.data?.playDate !== undefined && typeof obj.data.playDate === 'string') { 19 - obj.data.playDate = dayjs(obj.data.playDate); 20 - if(obj.data.playDateCompleted !== undefined) { 21 - obj.data.playDateCompleted = dayjs(obj.data.playDateCompleted); 22 - } 23 - } 24 - if(obj.data.listenRanges !== undefined) { 25 - obj.data.listenRanges = obj.data.listenRanges.map(rehydrateListenRangeData).filter(x => x !== undefined); 26 - } 27 - return obj as PlayObject; 28 - } 29 - 30 - // this may become problematic since we aren't re-instantiating Progress class, just implementing interface 31 - // but that may only be an issue if rehydrating source data which isn't in scope so far 32 - export const rehydrateListenRangeData = (obj: ListenRangeDataAmb): ListenRangeData => { 33 - // workaround for incompatible rehydration 34 - if(obj.start === undefined || obj.end === undefined) { 35 - return undefined; 36 - } 37 - return { 38 - start: rehydratePlayProgress(obj.start), 39 - end: rehydratePlayProgress(obj.end) 40 - } as ListenRangeData; 41 - } 42 - 43 - export const rehydratePlayProgress = (obj: PlayProgressAmb): PlayProgress => { 44 - if(typeof obj.timestamp === 'string') { 45 - obj.timestamp = dayjs(obj.timestamp); 46 - } 47 - return obj as PlayProgress; 48 - }
+1 -1
src/backend/utils/PlayComparisonUtils.ts
··· 503 503 //const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); 504 504 505 505 506 - const temporalComparison = comparePlayTemporally(x, playObj); 506 + const temporalComparison = comparePlayTemporally(x, playObj, {logger}); 507 507 let timeMatch = 0; 508 508 if(hasAcceptableTemporalAccuracy(temporalComparison.match)) { 509 509 timeMatch = 1;
+24 -12
src/backend/utils/TimeUtils.ts
··· 35 35 import { NamedGroup, parseRegex } from "@foxxmd/regex-buddy-core"; 36 36 import { Duration } from "dayjs/plugin/duration.js"; 37 37 import { SourceType } from "../common/infrastructure/config/source/sources.js"; 38 + import { Logger } from "@foxxmd/logging"; 39 + import { loggerNoop } from "../common/MaybeLogger.js"; 38 40 39 41 //dayjs.extend(isToday); 40 42 ··· 73 75 fuzzyDuration?: boolean, 74 76 fuzzyDiffThreshold?: number 75 77 duringReferences?: AcceptableTemporalDuringReference 78 + logger?: Logger 76 79 } 77 80 78 81 export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: PlayObject, options: TemporalPlayComparisonOptions = {}): TemporalPlayComparison => { ··· 113 116 diffThreshold = getTemporalAccuracyCloseVal(source as SourceType), 114 117 fuzzyDuration = false, 115 118 fuzzyDiffThreshold = 10, 116 - duringReferences = ['range'] 119 + duringReferences = ['range'], 120 + logger = loggerNoop 117 121 } = options; 118 122 119 123 const result: TemporalPlayComparison = { ··· 151 155 152 156 if(duringReferences.length > 0) { 153 157 154 - if (duringReferences.includes('range') && existingRanges !== undefined) { 155 - // since we know when the existing track was listened to 156 - // we can check if the new track play date took place while the existing one was being listened to 157 - // which would indicate (assuming same source) the new track is a duplicate 158 - for (const range of existingRanges) { 159 - if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) { 160 - result.range = { 161 - type: 'range', 162 - timestamps: [range.start.timestamp, range.end.timestamp] 158 + // guard against old, badly cached/marshalled range data: 159 + // this should not be an issue in 0.14.0+ since listenprogress has been updated to be a plain object 160 + // but for folks migrating with very old cached data it could end up being an issue 161 + try { 162 + if (duringReferences.includes('range') && existingRanges !== undefined) { 163 + // since we know when the existing track was listened to 164 + // we can check if the new track play date took place while the existing one was being listened to 165 + // which would indicate (assuming same source) the new track is a duplicate 166 + for (const range of existingRanges) { 167 + 168 + if (candidateTsSOCDate.isBetween(range.start.timestamp, range.end.timestamp)) { 169 + result.range = { 170 + type: 'range', 171 + timestamps: [range.start.timestamp, range.end.timestamp] 172 + } 173 + result.match = TA_DURING; 174 + return result; 163 175 } 164 - result.match = TA_DURING; 165 - return result; 166 176 } 167 177 } 178 + } catch (e) { 179 + logger.warn(new Error('Failed to compare plays based on range but will continue', {cause: e})); 168 180 } 169 181 170 182 if(duringReferences.includes('listenedFor') && existingPlay.data.listenedFor !== undefined) {