Skip to content

Commit 78abfc2

Browse files
committed
refactor: Temporal comparison result data improvements #121
* Refactor using 'close' boolean to 'match' granularity * Makes using granularity for future logic easier * Easier logging for granularity in summary * Remove intermediate temporal functions in classes for DRY and so we can use comparison results * Add Time Detail to match breakdown for more visibility during logging
1 parent 0f721b9 commit 78abfc2

File tree

8 files changed

+127
-88
lines changed

8 files changed

+127
-88
lines changed

src/backend/scrobblers/AbstractScrobbleClient.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,26 @@ import {Logger} from '@foxxmd/winston';
1919
import { CommonClientConfig } from "../common/infrastructure/config/client/index";
2020
import { Notifiers } from "../notifier/Notifiers";
2121
import {FixedSizeList} from 'fixed-size-list';
22-
import {DeadLetterScrobble, PlayObject, QueuedScrobble, SourceScrobble, TrackStringOptions} from "../../core/Atomic";
22+
import {
23+
DeadLetterScrobble,
24+
PlayObject,
25+
QueuedScrobble,
26+
TA_CLOSE, TA_FUZZY,
27+
TrackStringOptions
28+
} from "../../core/Atomic";
2329
import {buildTrackString, capitalize, truncateStringToLength} from "../../core/StringUtils";
2430
import EventEmitter from "events";
2531
import {compareScrobbleArtists, compareScrobbleTracks, normalizeStr} from "../utils/StringUtils";
2632
import {hasUpstreamError, UpstreamError} from "../common/errors/UpstreamError";
2733
import {nanoid} from "nanoid";
2834
import {ErrorWithCause, messageWithCauses} from "pony-cause";
2935
import {hasNodeNetworkException} from "../common/errors/NodeErrors";
30-
import {isPlayTemporallyClose} from "../utils/TimeUtils";
36+
import {
37+
comparePlayTemporally,
38+
temporalAccuracyIsAtLeast,
39+
temporalAccuracyToString,
40+
temporalPlayComparisonSummary
41+
} from "../utils/TimeUtils";
3142

3243
export default abstract class AbstractScrobbleClient implements Authenticatable {
3344

@@ -246,21 +257,13 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
246257
}
247258

248259
const matchPlayDate = dtInvariantMatches.find((x: ScrobbledPlayObject) => {
249-
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x.play, playObj);
250-
return closeTime;
260+
const temporalComparison = comparePlayTemporally(x.play, playObj);
261+
return temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)
251262
});
252263

253264
return [matchPlayDate, dtInvariantMatches];
254265
}
255266

256-
protected compareExistingScrobbleTime = (existing: PlayObject, candidate: PlayObject): [boolean, boolean?] => {
257-
let closeTime = isPlayTemporallyClose(existing, candidate);
258-
let fuzzyTime = false;
259-
if(!closeTime) {
260-
fuzzyTime = isPlayTemporallyClose(existing, candidate, {fuzzyDuration: true});
261-
}
262-
return [closeTime, fuzzyTime];
263-
}
264267
protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => {
265268
return Math.min(compareScrobbleTracks(existing, candidate)/100, 1);
266269
}
@@ -335,8 +338,13 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
335338
//const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
336339

337340

338-
const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj);
339-
const timeMatch = (closeTime ? 1 : (fuzzyTime ? 0.6 : 0));
341+
const temporalComparison = comparePlayTemporally(x, playObj);
342+
let timeMatch = 0;
343+
if(temporalAccuracyIsAtLeast(TA_CLOSE, temporalComparison.match)) {
344+
timeMatch = 1;
345+
} else if(temporalComparison.match === TA_FUZZY) {
346+
timeMatch = 0.6;
347+
}
340348

341349
const titleMatch = this.compareExistingScrobbleTitle(x, playObj);
342350

@@ -377,7 +385,8 @@ export default abstract class AbstractScrobbleClient implements Authenticatable
377385
//`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`,
378386
artistBreakdown,
379387
`Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`,
380-
`Time: ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
388+
`Time: (${capitalize(temporalAccuracyToString(temporalComparison.match))}) ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
389+
`Time Detail => ${temporalPlayComparisonSummary(temporalComparison, x, playObj)}`,
381390
`Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
382391
];
383392

src/backend/scrobblers/MalojaScrobbler.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import request from 'superagent';
33
import dayjs from 'dayjs';
44
import compareVersions from 'compare-versions';
55
import {
6-
playObjDataMatch,
7-
setIntersection,
86
sleep,
9-
sortByOldestPlayDate,
107
parseRetryAfterSecsFromObj,
118

129
} from "../utils";
@@ -25,9 +22,7 @@ import {buildTrackString, capitalize} from "../../core/StringUtils";
2522
import EventEmitter from "events";
2623
import normalizeUrl from "normalize-url";
2724
import {UpstreamError} from "../common/errors/UpstreamError";
28-
import {ar} from "@faker-js/faker";
2925
import {ErrorWithCause} from "pony-cause";
30-
import {isPlayTemporallyClose} from "../utils/TimeUtils";
3126

3227
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
3328

src/backend/sources/AbstractSource.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ import { SourceConfig } from "../common/infrastructure/config/source/sources";
2929
import {EventEmitter} from "events";
3030
import {FixedSizeList} from "fixed-size-list";
3131
import TupleMap from "../common/TupleMap";
32-
import { PlayObject } from "../../core/Atomic";
32+
import {PlayObject, TA_CLOSE} from "../../core/Atomic";
3333
import {buildTrackString, capitalize} from "../../core/StringUtils";
3434
import {isNodeNetworkException} from "../common/errors/NodeErrors";
3535
import {ErrorWithCause} from "pony-cause";
36-
import {isPlayTemporallyClose} from "../utils/TimeUtils";
36+
import {comparePlayTemporally, temporalAccuracyIsAtLeast} from "../utils/TimeUtils";
3737

3838
export interface RecentlyPlayedOptions {
3939
limit?: number
@@ -176,7 +176,7 @@ export default abstract class AbstractSource implements Authenticatable {
176176
});
177177
}
178178
for(const list of lists) {
179-
const existing = list.find(x => playObjDataMatch(x, play) && isPlayTemporallyClose(x, play));
179+
const existing = list.find(x => playObjDataMatch(x, play) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(x, play).match));
180180
if(existing) {
181181
return existing;
182182
}

src/backend/sources/JellyfinSource.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ import EventEmitter from "events";
1313
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState";
1414
import {Logger} from "@foxxmd/winston";
1515
import { JellyfinPlayerState } from "./PlayerState/JellyfinPlayerState";
16-
import { PlayObject } from "../../core/Atomic";
16+
import {PlayObject, TA_CLOSE} from "../../core/Atomic";
1717
import {buildTrackString, splitByFirstFound, truncateStringToLength} from "../../core/StringUtils";
1818
import {source} from "common-tags";
19-
import {comparePlayTemporally, isPlayTemporallyClose, temporalPlayComparisonSummary} from "../utils/TimeUtils";
19+
import {
20+
comparePlayTemporally,
21+
temporalAccuracyIsAtLeast,
22+
temporalPlayComparisonSummary
23+
} from "../utils/TimeUtils";
2024

2125
const shortDeviceId = truncateStringToLength(10, '');
2226

@@ -308,7 +312,7 @@ export default class JellyfinSource extends MemorySource {
308312
309313
Temporal Comparison => ${temporalPlayComparisonSummary(temporalResult, currPlay, playObj)}`);
310314
}
311-
if(temporalResult.close) {
315+
if(temporalAccuracyIsAtLeast(TA_CLOSE,temporalResult.match)) {
312316
existingTracked = currPlay;
313317
}
314318
break;

src/backend/sources/MemorySource.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import {
44
sortByOldestPlayDate,
55
toProgressAwarePlayObject,
66
getProgress,
7-
playPassesScrobbleThreshold,
8-
timePassesScrobbleThreshold,
97
thresholdResultSummary,
108
genGroupId,
119
genGroupIdStr,
@@ -32,6 +30,7 @@ import {SimpleIntervalJob, Task, ToadScheduler} from "toad-scheduler";
3230
import {SourceConfig} from "../common/infrastructure/config/source/sources";
3331
import {EventEmitter} from "events";
3432
import objectHash from 'object-hash';
33+
import {timePassesScrobbleThreshold} from "../utils/TimeUtils";
3534

3635
export default class MemorySource extends AbstractSource {
3736

src/backend/utils.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import {TimeoutError, WebapiError} from "spotify-web-api-node/src/response-error
77
import Ajv, {Schema} from 'ajv';
88
import {
99
asPlayerStateData,
10-
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
11-
DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
1210
NO_DEVICE,
1311
NO_USER,
1412
numberFormatOptions,
@@ -23,7 +21,6 @@ import {Request} from "express";
2321
import pathUtil from "path";
2422
import {ErrorWithCause, getErrorCause} from "pony-cause";
2523
import backoffStrategies from '@kenyip/backoff-strategies';
26-
import {ScrobbleThresholds} from "./common/infrastructure/config/source";
2724
import {replaceResultTransformer, stripIndentTransformer, TemplateTag, trimResultTransformer} from 'common-tags';
2825
import {Duration} from "dayjs/plugin/duration.js";
2926
import {PlayObject} from "../core/Atomic";
@@ -476,41 +473,6 @@ export const getProgress = (initial: ProgressAwarePlayObject, curr: PlayObject):
476473
return undefined;
477474
}
478475

479-
export const playPassesScrobbleThreshold = (play: PlayObject, thresholds: ScrobbleThresholds): ScrobbleThresholdResult => {
480-
const progressed = Math.round(Math.abs(dayjs().diff(play.data.playDate, 's')));
481-
return timePassesScrobbleThreshold(thresholds, progressed, play.data.duration);
482-
}
483-
484-
export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, secondsTracked: number, playDuration?: number): ScrobbleThresholdResult => {
485-
let durationPasses = undefined,
486-
durationThreshold: number | null = thresholds.duration ?? DEFAULT_SCROBBLE_DURATION_THRESHOLD,
487-
percentPasses = undefined,
488-
percentThreshold: number | null = thresholds.percent ?? DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
489-
percent: number | undefined;
490-
491-
if (percentThreshold !== null && playDuration !== undefined && playDuration !== 0) {
492-
percent = Math.round(((secondsTracked / playDuration) * 100));
493-
percentPasses = percent >= percentThreshold;
494-
}
495-
if (durationThreshold !== null || percentPasses === undefined) {
496-
durationPasses = secondsTracked >= durationThreshold;
497-
}
498-
499-
return {
500-
passes: (durationPasses ?? false) || (percentPasses ?? false),
501-
duration: {
502-
passes: durationPasses,
503-
threshold: durationThreshold,
504-
value: secondsTracked
505-
},
506-
percent: {
507-
passes: percentPasses,
508-
value: percent,
509-
threshold: percentThreshold
510-
}
511-
}
512-
}
513-
514476
export const thresholdResultSummary = (result: ScrobbleThresholdResult) => {
515477
const parts: string[] = [];
516478
if(result.duration.passes !== undefined) {

src/backend/utils/TimeUtils.ts

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
import {PlayObject, TemporalPlayComparison} from "../../core/Atomic";
2-
import {lowGranularitySources} from "../common/infrastructure/Atomic";
1+
import {
2+
PlayObject,
3+
TA_CLOSE,
4+
TA_EXACT,
5+
TA_FUZZY,
6+
TA_NONE,
7+
TemporalAccuracy,
8+
TemporalPlayComparison
9+
} from "../../core/Atomic";
10+
import {
11+
DEFAULT_SCROBBLE_DURATION_THRESHOLD,
12+
DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
13+
lowGranularitySources,
14+
ScrobbleThresholdResult
15+
} from "../common/infrastructure/Atomic";
316
import {formatNumber} from "../utils";
4-
5-
export const isPlayTemporallyClose = (existingPlay: PlayObject, candidatePlay: PlayObject, options: {
6-
diffThreshold?: number,
7-
fuzzyDuration?: boolean,
8-
useListRanges?: boolean
9-
} = {}): boolean => {
10-
return comparePlayTemporally(existingPlay, candidatePlay, options).close;
11-
}
17+
import {ScrobbleThresholds} from "../common/infrastructure/config/source";
18+
import {capitalize} from "../../core/StringUtils";
1219

1320
export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, existingPlay?: PlayObject, candidatePlay?: PlayObject) => {
1421
const parts: string[] = [];
@@ -19,7 +26,7 @@ export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, exis
1926
parts.push(`Existing: ${existingPlay.data.playDate.toISOString()} - Candidate: ${candidatePlay.data.playDate.toISOString()}`);
2027
}
2128
}
22-
parts.push(`Close: ${data.close ? 'YES' : 'NO'}`);
29+
parts.push(`Temporal Sameness: ${capitalize(temporalAccuracyToString(data.match))}`);
2330
if (data.date !== undefined) {
2431
parts.push(`Play Diff: ${formatNumber(data.date.diff, {toFixed: 0})}s (Needed <${data.date.threshold}s)`)
2532
}
@@ -33,10 +40,10 @@ export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, exis
3340
if (data.range === false) {
3441
parts.push('Candidate not played during Existing tracked listening');
3542
} else {
36-
parts.push(`Candidate played during tracked listening range from existing: ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
43+
parts.push(`Candidate played during tracked listening range from Existing ${data.range[0].timestamp.format('HH:mm:ssZ')} => ${data.range[1].timestamp.format('HH:mm:ssZ')}`);
3744
}
3845
} else {
39-
parts.push('One or both Plays did not have have tracked listening to compare');
46+
parts.push('Range Comparison N/A');
4047
}
4148
return parts.join(' | ');
4249
}
@@ -47,7 +54,7 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
4754
} = {}): TemporalPlayComparison => {
4855

4956
const result: TemporalPlayComparison = {
50-
close: false
57+
match: TA_NONE
5158
};
5259

5360
const {
@@ -94,8 +101,10 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
94101
diff: scrobblePlayDiff
95102
};
96103

97-
if (scrobblePlayDiff <= playDiffThreshold) {
98-
result.close = true;
104+
if(scrobblePlayDiff <= 1) {
105+
result.match = TA_EXACT;
106+
} else if (scrobblePlayDiff <= playDiffThreshold) {
107+
result.match = TA_CLOSE;
99108
}
100109

101110
if (useListRanges && existingRanges !== undefined) {
@@ -105,7 +114,9 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
105114
for (const range of existingRanges) {
106115
if (newPlayDate.isBetween(range.start.timestamp, range.end.timestamp)) {
107116
result.range = range;
108-
result.close = true;
117+
if(!temporalAccuracyIsAtLeast(TA_CLOSE, result.match)) {
118+
result.match = TA_CLOSE;
119+
}
109120
break;
110121
}
111122
}
@@ -116,21 +127,73 @@ export const comparePlayTemporally = (existingPlay: PlayObject, candidatePlay: P
116127

117128
// if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end
118129
// so check if the duration matches the diff between the two play dates
119-
if (result.close === false && referenceDuration !== undefined && fuzzyDuration) {
130+
if (result.match === TA_NONE && referenceDuration !== undefined) {
120131
result.date.fuzzyDurationDiff = Math.abs(scrobblePlayDiff - referenceDuration);
121132
if (result.date.fuzzyDurationDiff < 10) { // TODO use finer comparison for this?
122-
result.close = true;
133+
result.match = TA_FUZZY;
123134
}
124135
}
125136
// if the source has listened duration (maloja) it may differ from actual track duration
126137
// and its possible (spotify) the candidate play date is set at the end of this duration
127138
// so check if there is a close match between candidate play date and source + listened for
128-
if (result.close === false && referenceListenedFor !== undefined && fuzzyDuration) {
139+
if (result.match === TA_NONE && referenceListenedFor !== undefined && fuzzyDuration) {
129140
result.date.fuzzyListenedDiff = Math.abs(scrobblePlayDiff - referenceListenedFor);
130141
if (result.date.fuzzyListenedDiff < 10) { // TODO use finer comparison for this?
131-
result.close = true;
142+
result.match = TA_FUZZY
132143
}
133144
}
134145

135146
return result;
136147
}
148+
export const timePassesScrobbleThreshold = (thresholds: ScrobbleThresholds, secondsTracked: number, playDuration?: number): ScrobbleThresholdResult => {
149+
let durationPasses = undefined,
150+
durationThreshold: number | null = thresholds.duration ?? DEFAULT_SCROBBLE_DURATION_THRESHOLD,
151+
percentPasses = undefined,
152+
percentThreshold: number | null = thresholds.percent ?? DEFAULT_SCROBBLE_PERCENT_THRESHOLD,
153+
percent: number | undefined;
154+
155+
if (percentThreshold !== null && playDuration !== undefined && playDuration !== 0) {
156+
percent = Math.round(((secondsTracked / playDuration) * 100));
157+
percentPasses = percent >= percentThreshold;
158+
}
159+
if (durationThreshold !== null || percentPasses === undefined) {
160+
durationPasses = secondsTracked >= durationThreshold;
161+
}
162+
163+
return {
164+
passes: (durationPasses ?? false) || (percentPasses ?? false),
165+
duration: {
166+
passes: durationPasses,
167+
threshold: durationThreshold,
168+
value: secondsTracked
169+
},
170+
percent: {
171+
passes: percentPasses,
172+
value: percent,
173+
threshold: percentThreshold
174+
}
175+
}
176+
}
177+
178+
export const temporalAccuracyIsAtLeast = (expected: TemporalAccuracy, found: TemporalAccuracy): boolean => {
179+
if(typeof expected === 'number') {
180+
if(typeof found === 'number') {
181+
return found <= expected;
182+
}
183+
return false;
184+
}
185+
return found === false;
186+
}
187+
188+
export const temporalAccuracyToString = (acc: TemporalAccuracy): string => {
189+
switch(acc) {
190+
case 1:
191+
return 'exact';
192+
case 2:
193+
return 'close';
194+
case 3:
195+
return 'fuzzy';
196+
case false:
197+
return 'no correlation';
198+
}
199+
}

0 commit comments

Comments
 (0)