[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: Implement play with lifecycle fixture

FoxxMD (Mar 20, 2026, 6:24 PM UTC) dfb7f43c ed85d351

+430 -216
+11 -157
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 60 60 import { rehydratePlay } from "../utils/CacheUtils.js"; 61 61 import { findAsyncSequential, staggerMapper } from "../utils/AsyncUtils.js"; 62 62 import pMap, { pMapIterable } from "p-map"; 63 - import { comparePlayArtistsNormalized, comparePlayTracksNormalized, lifecyclelessInvariantTransform } from "../utils/PlayComparisonUtils.js"; 63 + import { comparePlayArtistsNormalized, comparePlayTracksNormalized, existingScrobble, ExistingScrobbleOpts } from "../utils/PlayComparisonUtils.js"; 64 + import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js"; 64 65 import { normalizeStr } from "../utils/StringUtils.js"; 65 66 import prom, { Counter, Gauge } from 'prom-client'; 66 67 import { ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.js"; ··· 110 111 nowPlayingTaskInterval: number = 5000; 111 112 npLogger: Logger; 112 113 dupeLogger: Logger; 114 + 115 + existingScrobble: (playObjPre: PlayObject, existingScrobbles: PlayObject[], log?: boolean) => Promise<PlayMatchResult> 113 116 114 117 declare config: CommonClientConfig; 115 118 ··· 178 181 this.queuedGauge = clientMetrics.queued; 179 182 this.deadLetterGauge = clientMetrics.deadLetter; 180 183 this.scrobbledCounter = clientMetrics.scrobbled; 184 + const existingScrobbleOpts: ExistingScrobbleOpts = { 185 + logger: this.dupeLogger, 186 + transformRules: this.transformRules, 187 + transformPlay: this.transformPlay, 188 + existingSubmitted: this.findExistingSubmittedPlayObj 189 + } 190 + this.existingScrobble = (playObjPre: PlayObject, existingScrobbles: PlayObject[], log?: boolean) => existingScrobble(playObjPre, existingScrobbles, existingScrobbleOpts, log) 181 191 } 182 192 183 193 protected getIdentifier() { ··· 450 460 }); 451 461 452 462 return [matchPlayDate, dtInvariantMatches]; 453 - } 454 - 455 - existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[], log: boolean = true): Promise<PlayMatchResult> => { 456 - 457 - const result: PlayMatchResult = { 458 - match: false, 459 - score: 0, 460 - breakdowns: [], 461 - reason: 'No existing scrobble matched with a score higher than 0' 462 - }; 463 - 464 - const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate); 465 - if(this.transformRules.compare?.candidate !== undefined) { 466 - result.transformedPlay = playObj; 467 - } 468 - 469 - const tr = truncateStringToLength(27); 470 - const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}}; 471 - 472 - // return early if we don't care about checking existing 473 - if (false === this.checkExistingScrobbles) { 474 - this.dupeLogger.trace(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`); 475 - result.reason = 'existing scrobble check is FALSE'; 476 - return result; 477 - } 478 - 479 - let existingScrobble; 480 - 481 - // then check if we have already recorded this 482 - const [existingExactSubmitted, existingDataSubmitted = []] = await this.findExistingSubmittedPlayObj(playObjPre); 483 - 484 - // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble 485 - if (existingExactSubmitted !== undefined) { 486 - result.closestMatchedPlay = lifecyclelessInvariantTransform(existingExactSubmitted.play); 487 - result.score = 1; 488 - result.match = true; 489 - result.reason = 'Exact Match found in previously successfully scrobbled plays'; 490 - 491 - existingScrobble = existingExactSubmitted.scrobble; 492 - } 493 - // if not though then we need to check recent scrobbles from scrobble api. 494 - // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start 495 - 496 - if (existingScrobble === undefined) { 497 - 498 - // if no recent scrobbles found then assume we haven't submitted it 499 - // (either user doesnt want to check history or there is no history to check!) 500 - if (existingScrobbles.length === 0) { 501 - this.dupeLogger.trace(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no existing scrobbles returned from API`); 502 - result.reason = 'no recent scrobbles returned from API'; 503 - return result; 504 - } 505 - 506 - // only check for fuzzy if we know this play is NOT a repeat 507 - // otherwise we may get a false positive on the previously played track ending time == repeat start time 508 - // -- this is info we only know if play was generated from MS player so we can be reasonably sure 509 - // 510 - // OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources) 511 - // then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble 512 - const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING]; 513 - 514 - 515 - existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => { 516 - 517 - const x = await this.transformPlay(xPre, TRANSFORM_HOOK.existing); 518 - 519 - //const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); 520 - 521 - 522 - const temporalComparison = comparePlayTemporally(x, playObj); 523 - let timeMatch = 0; 524 - if(hasAcceptableTemporalAccuracy(temporalComparison.match)) { 525 - timeMatch = 1; 526 - } else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) { 527 - timeMatch = 0.6; 528 - } 529 - 530 - const [titleMatch, titleResults] = comparePlayTracksNormalized(x, playObj); 531 - 532 - const [artistMatch, wholeMatches] = comparePlayArtistsNormalized(x, playObj); 533 - 534 - let artistScore = ARTIST_WEIGHT * artistMatch; 535 - const titleScore = TITLE_WEIGHT * titleMatch; 536 - const timeScore = TIME_WEIGHT * timeMatch; 537 - //const referenceScore = REFERENCE_WEIGHT * (referenceMatch ? 1 : 0); 538 - let score = artistScore + titleScore + timeScore; 539 - 540 - let artistWholeMatchBonus = 0; 541 - let artistBreakdown = `Artist: ${artistMatch.toFixed(2)} * ${ARTIST_WEIGHT} = ${artistScore.toFixed(2)}`; 542 - 543 - if(score < 1 && timeMatch > 0 && titleMatch > 0.98 && artistMatch > 0.1 && wholeMatches > 0 && comparingMultipleArtists(x, playObj)) { 544 - // address scenario where: 545 - // * title is very close 546 - // * time falls within plausible dup range 547 - // * artist is not totally different 548 - // * AND score is still not high enough for a dup 549 - // 550 - // if we detect the plays have multiple artists and we have at least one whole match (stricter comparison than regular score) 551 - // then bump artist score a little to see if it gets it over the fence 552 - // 553 - // EX: Source: The Bongo Hop - Sonora @ 2023-09-28T10:54:06-04:00 => Closest Scrobble: Nidia Gongora / The Bongo Hop - Sonora @ 2023-09-28T10:59:34-04:00 => Score 0.83 => No Match 554 - // one play is only returning primary artist, and timestamp is at beginning instead of end of play 555 - 556 - const scoreBonus = artistMatch * 0.5; 557 - const scoreGapBonus = (1 - artistMatch) * 0.75; 558 - // use the smallest bump or 0.1 559 - artistWholeMatchBonus = Math.max(scoreBonus, scoreGapBonus, 0.1); 560 - artistScore = (ARTIST_WEIGHT + 0.05) * (artistMatch + artistWholeMatchBonus); 561 - score = artistScore + titleScore + timeScore; 562 - artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`; 563 - } 564 - 565 - const scoreBreakdowns = [ 566 - //`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`, 567 - artistBreakdown, 568 - `Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`, 569 - `Time: (${capitalize(temporalAccuracyToString(temporalComparison.match))}) ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`, 570 - `Time Detail => ${temporalPlayComparisonSummary(temporalComparison, x, playObj)}`, 571 - `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}` 572 - ]; 573 - 574 - const confidence = `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}` 575 - 576 - if (result.score <= score && score > 0) { 577 - result.reason = confidence; 578 - result.closestMatchedPlay = x; 579 - result.match = score >= DUP_SCORE_THRESHOLD; 580 - result.breakdowns = scoreBreakdowns; 581 - result.score = score; 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 - } 591 - } 592 - 593 - return score >= DUP_SCORE_THRESHOLD; 594 - }); 595 - } 596 - 597 - const closestScrobbleParts: string[] = []; 598 - if(result.closestMatchedPlay !== undefined) { 599 - closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(result.closestMatchedPlay, scoreTrackOpts)}`); 600 - } 601 - closestScrobbleParts.push(result.reason); 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 - } 608 - return result; 609 463 } 610 464 611 465 public scrobble = async (playObj: PlayObject, opts?: { delay?: number | false }): Promise<PlayObject> => {
+13
src/backend/tests/utilitiesTests/playFixtures.test.ts
··· 1 + import { loggerTest } from "@foxxmd/logging"; 2 + import { assert, expect } from 'chai'; 3 + import clone from "clone"; 4 + import { describe, it } from 'mocha'; 5 + import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../../../core/tests/utils/fixtures.js"; 6 + 7 + describe('#PlayFixtures', function () { 8 + 9 + it('Generates a Play with lifecycle', async function () { 10 + await assert.isFulfilled(playWithLifecycleScrobble(generatePlayWithLifecycle())) 11 + }); 12 + 13 + });
+183 -22
src/backend/utils/PlayComparisonUtils.ts
··· 1 1 import { getListDiff, ListDiff } from "@donedeal0/superdiff"; 2 - import { PlayObject, PlayObjectLifecycleless, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; 3 - import { buildTrackString } from "../../core/StringUtils.js"; 4 - import { playObjDataMatch, setIntersection } from "../utils.js"; 5 - import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js"; 2 + import { PlayMatchResult, PlayObject, PlayObjectLifecycleless, SOURCE_SOT, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy, TrackStringOptions } from "../../core/Atomic.js"; 3 + import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 4 + import { comparingMultipleArtists, playObjDataMatch, setIntersection } from "../utils.js"; 5 + import { comparePlayTemporally, hasAcceptableTemporalAccuracy, temporalAccuracyToString, TemporalPlayComparisonOptions, temporalPlayComparisonSummary } from "./TimeUtils.js"; 6 6 import { compareNormalizedStrings, compareScrobbleArtists, compareScrobbleTracks, compareTracks, normalizeStr, TrackSamenessResults } from "./StringUtils.js"; 7 - import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js"; 7 + import { ARTIST_WEIGHT, DUP_SCORE_THRESHOLD, ScrobbledPlayObject, TIME_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js"; 8 8 import { StringSamenessResult } from "@foxxmd/string-sameness"; 9 9 import { Duration } from "dayjs/plugin/duration.js"; 10 + import { PlayTransformRules, TRANSFORM_HOOK, TransformHook } from "../common/infrastructure/Transform.js"; 11 + import { Logger } from "@foxxmd/logging"; 12 + import { loggerNoop } from "../common/logging.js"; 13 + import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js"; 14 + import { findAsyncSequential } from "./AsyncUtils.js"; 10 15 11 16 12 17 export const metaInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { ··· 55 60 meta: {} 56 61 } 57 62 } 58 - 59 - export const lifecyclelessInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 60 - const { 61 - meta: { 62 - lifecycle, 63 - ...rest 64 - } = {}, 65 - } = play; 66 - return { 67 - ...play, 68 - meta: { 69 - ...rest 70 - } 71 - } 72 - } 73 - 74 63 75 64 export type PlayTransformer = (play: PlayObject) => PlayObjectLifecycleless; 76 65 export type ListTransformers = PlayTransformer[]; ··· 382 371 383 372 export const playDateWithinDurationOfAny = (play: PlayObject, plays: PlayObject[], dur: Duration): PlayObject | undefined => { 384 373 return plays.find(x => Math.abs(x.data.playDate.diff(play.data.playDate, 's')) <= dur.asSeconds()); 385 - } 374 + } 375 + 376 + export interface ExistingScrobbleOpts { 377 + transformPlay?: (play: PlayObject, hookType: TransformHook) => Promise<PlayObject> 378 + existingSubmitted?: (play: PlayObject) => Promise<[ScrobbledPlayObject?, ScrobbledPlayObject[]?]> 379 + transformRules?: PlayTransformRules 380 + checkExistingScrobbles?: boolean 381 + logger?: Logger 382 + } 383 + 384 + export const existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[], opts: ExistingScrobbleOpts = {}, log?: boolean): Promise<PlayMatchResult> => { 385 + 386 + const { 387 + transformPlay = (play, hook) => play, 388 + existingSubmitted = (play) => [undefined, undefined], 389 + transformRules, 390 + checkExistingScrobbles = true, 391 + logger = loggerNoop 392 + } = opts; 393 + 394 + const result: PlayMatchResult = { 395 + match: false, 396 + score: 0, 397 + breakdowns: [], 398 + reason: 'No existing scrobble matched with a score higher than 0' 399 + }; 400 + 401 + const playObj = await transformPlay(playObjPre, TRANSFORM_HOOK.candidate); 402 + if(transformRules?.compare?.candidate !== undefined) { 403 + result.transformedPlay = playObj; 404 + } 405 + 406 + const tr = truncateStringToLength(27); 407 + const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}}; 408 + 409 + // return early if we don't care about checking existing 410 + if (false === checkExistingScrobbles) { 411 + logger.trace(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`); 412 + result.reason = 'existing scrobble check is FALSE'; 413 + return result; 414 + } 415 + 416 + let existingScrobble; 417 + 418 + // then check if we have already recorded this 419 + const [existingExactSubmitted, existingDataSubmitted = []] = await existingSubmitted(playObjPre); 420 + 421 + // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble 422 + if (existingExactSubmitted !== undefined) { 423 + result.closestMatchedPlay = lifecyclelessInvariantTransform(existingExactSubmitted.play); 424 + result.score = 1; 425 + result.match = true; 426 + result.reason = 'Exact Match found in previously successfully scrobbled plays'; 427 + 428 + existingScrobble = existingExactSubmitted.scrobble; 429 + } 430 + // if not though then we need to check recent scrobbles from scrobble api. 431 + // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start 432 + 433 + if (existingScrobble === undefined) { 434 + 435 + // if no recent scrobbles found then assume we haven't submitted it 436 + // (either user doesnt want to check history or there is no history to check!) 437 + if (existingScrobbles.length === 0) { 438 + logger.trace(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no existing scrobbles returned from API`); 439 + result.reason = 'no recent scrobbles returned from API'; 440 + return result; 441 + } 442 + 443 + // only check for fuzzy if we know this play is NOT a repeat 444 + // otherwise we may get a false positive on the previously played track ending time == repeat start time 445 + // -- this is info we only know if play was generated from MS player so we can be reasonably sure 446 + // 447 + // OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources) 448 + // then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble 449 + const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING]; 450 + 451 + 452 + existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => { 453 + 454 + const x = await transformPlay(xPre, TRANSFORM_HOOK.existing); 455 + 456 + //const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); 457 + 458 + 459 + const temporalComparison = comparePlayTemporally(x, playObj); 460 + let timeMatch = 0; 461 + if(hasAcceptableTemporalAccuracy(temporalComparison.match)) { 462 + timeMatch = 1; 463 + } else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) { 464 + timeMatch = 0.6; 465 + } 466 + 467 + const [titleMatch, titleResults] = comparePlayTracksNormalized(x, playObj); 468 + 469 + const [artistMatch, wholeMatches] = comparePlayArtistsNormalized(x, playObj); 470 + 471 + let artistScore = ARTIST_WEIGHT * artistMatch; 472 + const titleScore = TITLE_WEIGHT * titleMatch; 473 + const timeScore = TIME_WEIGHT * timeMatch; 474 + //const referenceScore = REFERENCE_WEIGHT * (referenceMatch ? 1 : 0); 475 + let score = artistScore + titleScore + timeScore; 476 + 477 + let artistWholeMatchBonus = 0; 478 + let artistBreakdown = `Artist: ${artistMatch.toFixed(2)} * ${ARTIST_WEIGHT} = ${artistScore.toFixed(2)}`; 479 + 480 + if(score < 1 && timeMatch > 0 && titleMatch > 0.98 && artistMatch > 0.1 && wholeMatches > 0 && comparingMultipleArtists(x, playObj)) { 481 + // address scenario where: 482 + // * title is very close 483 + // * time falls within plausible dup range 484 + // * artist is not totally different 485 + // * AND score is still not high enough for a dup 486 + // 487 + // if we detect the plays have multiple artists and we have at least one whole match (stricter comparison than regular score) 488 + // then bump artist score a little to see if it gets it over the fence 489 + // 490 + // EX: Source: The Bongo Hop - Sonora @ 2023-09-28T10:54:06-04:00 => Closest Scrobble: Nidia Gongora / The Bongo Hop - Sonora @ 2023-09-28T10:59:34-04:00 => Score 0.83 => No Match 491 + // one play is only returning primary artist, and timestamp is at beginning instead of end of play 492 + 493 + const scoreBonus = artistMatch * 0.5; 494 + const scoreGapBonus = (1 - artistMatch) * 0.75; 495 + // use the smallest bump or 0.1 496 + artistWholeMatchBonus = Math.max(scoreBonus, scoreGapBonus, 0.1); 497 + artistScore = (ARTIST_WEIGHT + 0.05) * (artistMatch + artistWholeMatchBonus); 498 + score = artistScore + titleScore + timeScore; 499 + artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`; 500 + } 501 + 502 + const scoreBreakdowns = [ 503 + //`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`, 504 + artistBreakdown, 505 + `Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`, 506 + `Time: (${capitalize(temporalAccuracyToString(temporalComparison.match))}) ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`, 507 + `Time Detail => ${temporalPlayComparisonSummary(temporalComparison, x, playObj)}`, 508 + `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}` 509 + ]; 510 + 511 + const confidence = `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}` 512 + 513 + if (result.score <= score && score > 0) { 514 + result.reason = confidence; 515 + result.closestMatchedPlay = x; 516 + result.match = score >= DUP_SCORE_THRESHOLD; 517 + result.breakdowns = scoreBreakdowns; 518 + result.score = score; 519 + 520 + if(result.match === false && temporalComparison.match === TA_EXACT && score >= 0.90) { 521 + // if we have a score >= 90 and time is an exact match 522 + // it's likely the differences are due to source-scrobbler data presentation, or deficiencies, 523 + // rather than actually being unique 524 + // so force match in this instance 525 + result.match = true; 526 + 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.`; 527 + } 528 + } 529 + 530 + return score >= DUP_SCORE_THRESHOLD; 531 + }); 532 + } 533 + 534 + const closestScrobbleParts: string[] = []; 535 + if(result.closestMatchedPlay !== undefined) { 536 + closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(result.closestMatchedPlay, scoreTrackOpts)}`); 537 + } 538 + closestScrobbleParts.push(result.reason); 539 + let summaryStart = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`; 540 + const summary = `${summaryStart}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}` 541 + result.summary = summary; 542 + if(log) { 543 + logger.trace(summary); 544 + } 545 + return result; 546 + }
+35 -27
src/core/PlayTestUtils.ts
··· 5 5 import relativeTime from "dayjs/plugin/relativeTime.js"; 6 6 import timezone from "dayjs/plugin/timezone.js"; 7 7 import utc from "dayjs/plugin/utc.js"; 8 - import { FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js"; 8 + import { BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js"; 9 9 import { genGroupIdStr } from './PlayUtils.js'; 10 10 import { sortByNewestPlayDate } from './PlayUtils.js'; 11 11 import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js'; ··· 146 146 } 147 147 } 148 148 149 - export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}): PlayObject => { 150 - return { 149 + export interface GeneratePlayOpts { 150 + playDateCompleted?: boolean 151 + } 152 + export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject => { 153 + const { 154 + playDateCompleted = false 155 + } = opts; 156 + 157 + const play: PlayObject = { 151 158 data: { 152 159 track: faker.music.songName(), 153 160 artists: faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}}), ··· 168 175 } 169 176 } 170 177 } 178 + 179 + if(play.data.playDateCompleted === undefined && playDateCompleted) { 180 + play.data.playDateCompleted = play.data.playDate.add(play.data.duration) 181 + } 182 + 183 + return play; 171 184 } 172 185 173 186 export const generateJsonPlay = (...args: Parameters<typeof generatePlay>): JsonPlayObject => { ··· 183 196 export interface WithBrainzOptions { 184 197 include: ('track' | 'artist' | 'album')[] 185 198 } 186 - export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => { 199 + export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => { 187 200 const {include} = opts; 201 + const brainz: BrainzMeta = {}; 188 202 for(const i of include) { 189 203 switch(i) { 190 204 case 'track': 191 205 if(play.data.meta?.brainz?.recording === undefined) { 192 - play.data.meta = { 193 - ...(play.data.meta ?? {}), 194 - brainz: { 195 - ...(play.data.meta?.brainz ?? {}), 196 - recording: generateMbid() 197 - } 198 - } 206 + brainz.recording = generateMbid(); 199 207 } 200 208 break; 201 209 case 'album': 202 - if(play.data.meta?.brainz?.album === undefined) { 203 - play.data.meta = { 204 - ...(play.data.meta ?? {}), 205 - brainz: { 206 - ...(play.data.meta?.brainz ?? {}), 207 - album: generateMbid() 208 - } 209 - } 210 + if(play.data.meta?.brainz?.album === undefined && play.data.album !== undefined) { 211 + brainz.album = generateMbid(); 210 212 } 211 213 break; 212 214 case 'artist': 213 - if(play.data.meta?.brainz?.artist === undefined) { 215 + if(play.data.meta?.brainz?.artist === undefined && (play.data.artists ?? []).length > 0) { 214 216 const artistMbids = play.data.artists.map(x => generateMbid()); 215 - play.data.meta = { 216 - ...(play.data.meta ?? {}), 217 - brainz: { 218 - ...(play.data.meta?.brainz ?? {}), 219 - artist: artistMbids 220 - } 221 - } 217 + brainz.artist = artistMbids; 222 218 } 223 219 break; 224 220 } 225 221 } 226 222 223 + return brainz 224 + } 225 + 226 + export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => { 227 + const brainz = generateBrainz(play, opts); 228 + play.data.meta = { 229 + ...(play.data.meta ?? {}), 230 + brainz : { 231 + ...(play.data.meta?.brainz ?? {}), 232 + ...brainz 233 + } 234 + } 227 235 return play; 228 236 } 229 237
+14 -1
src/core/PlayUtils.ts
··· 1 1 import { PlayPlatformId } from "../backend/common/infrastructure/Atomic.js"; 2 - import { PlayObject } from "./Atomic.js"; 2 + import { PlayObject, PlayObjectLifecycleless } from "./Atomic.js"; 3 3 4 4 5 5 /** sorts playObj formatted objects by playDate in descending (newest first) order */ ··· 26 26 return aPlayDate.isBefore(bPlayDate) ? 1 : -1; 27 27 };export const genGroupIdStr = (id: PlayPlatformId) => { 28 28 return `${id[0]}-${id[1]}`; 29 + }; 30 + export const lifecyclelessInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 31 + const { 32 + meta: { 33 + lifecycle, ...rest 34 + } = {}, 35 + } = play; 36 + return { 37 + ...play, 38 + meta: { 39 + ...rest 40 + } 41 + }; 29 42 }; 30 43
+174 -9
src/core/tests/utils/fixtures.ts
··· 1 1 import { Traverse, TraverseContext } from 'neotraverse/modern'; 2 2 import { faker } from '@faker-js/faker'; 3 3 import dayjs from 'dayjs'; 4 - import { AmbPlayObject, JsonPlayObject, ObjectPlayData, PlayMeta, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from '../../Atomic.js'; 4 + import { AmbPlayObject, JsonPlayObject, LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE, ScrobbleResult } from '../../Atomic.js'; 5 5 import { ListenRange } from '../../../backend/sources/PlayerState/ListenRange.js'; 6 6 import { ListenProgressPositional, ListenProgressTS } from '../../../backend/sources/PlayerState/ListenProgress.js'; 7 - import { clone } from 'jsondiffpatch'; 8 7 import { MarkOptional } from 'ts-essentials'; 8 + import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js'; 9 + import { lifecyclelessInvariantTransform } from '../../PlayUtils.js'; 10 + import clone from 'clone'; 11 + import { jdiff } from '../../DataUtils.js'; 12 + import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.js'; 13 + import { playToListenPayload } from '../../../backend/common/vendor/ListenbrainzApiClient.js'; 14 + import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js'; 9 15 10 16 interface BlockPath { key: string, parent: string }; 11 17 type BlockPaths = BlockPath[]; ··· 82 88 return cloned as unknown as PlayObject; 83 89 } 84 90 91 + export interface ScrobbleMatchOptions { 92 + match?: boolean 93 + warnings?: boolean 94 + error?: boolean 95 + } 85 96 export interface GeneratePlayWithLifecycleOptions { 86 - original: { 97 + original?: { 87 98 data?: ObjectPlayData, 88 99 meta?: MarkOptional<PlayMeta, 'lifecycle'> 100 + opts?: GeneratePlayOpts 89 101 } 90 102 } 91 - export const generatePlayWithLifecycle = (opts: GeneratePlayWithLifecycleOptions) => { 103 + 104 + export const generatePlayWithLifecycle = (opts: GeneratePlayWithLifecycleOptions = {}) => { 105 + 106 + const { 107 + original: originalOpts = {}, 108 + } = opts; 109 + 110 + const original = generatePlay(originalOpts.data, originalOpts.meta, originalOpts.opts); 111 + 112 + const lplay: PlayObject = { 113 + data: {}, 114 + meta: { 115 + ...original.meta 116 + } 117 + }; 118 + lplay.meta.lifecycle.original = lifecyclelessInvariantTransform(original); 119 + lplay.meta.lifecycle.input = generateRandomObj(); 120 + 121 + let steps: LifecycleStep[] = []; 122 + let transformedPlay = clone(original); 123 + const firstStep = faker.datatype.boolean(0.7) ? generateLifecycleStep(transformedPlay, {name: 'preCompare'}) : undefined; 124 + if(firstStep !== undefined) { 125 + transformedPlay = firstStep[1]; 126 + steps.push(firstStep[0]); 127 + while(faker.datatype.boolean(0.2)) { 128 + const [pc, modified] = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); 129 + steps.push(pc); 130 + transformedPlay = modified; 131 + } 132 + } 133 + 134 + const lastStep = faker.datatype.boolean(0.5) ? generateLifecycleStep(transformedPlay, {name: 'postCompare'}) : undefined; 135 + if(lastStep !== undefined) { 136 + transformedPlay = lastStep[1]; 137 + steps.push(lastStep[0]); 138 + while(faker.datatype.boolean(0.1)) { 139 + const [pc, modified] = generateLifecycleStep(transformedPlay, {name: 'postCompare'}); 140 + steps.push(pc); 141 + transformedPlay = modified; 142 + } 143 + } 144 + 145 + lplay.meta.lifecycle.steps = steps; 146 + lplay.data = transformedPlay.data; 147 + 148 + return lplay; 149 + } 150 + 151 + export const playWithLifecycleScrobble = async (play: PlayObject, opts: ScrobbleMatchOptions = {}): Promise<PlayObject> => { 152 + const { 153 + match = false, 154 + warnings = false, 155 + error = false, 156 + } = opts; 157 + 158 + const scrobbleRes: ScrobbleResult = {}; 159 + 160 + const existingPlays = generatePlays(2); 161 + 162 + if(match) { 163 + existingPlays.push(play); 164 + } 165 + 166 + const res = await existingScrobble(play, existingPlays); 167 + scrobbleRes.match = res; 168 + if(res.match) { 169 + play.meta.lifecycle.scrobble = scrobbleRes; 170 + return play; 171 + } 172 + 173 + scrobbleRes.payload = playToListenPayload(play); 174 + if(error) { 175 + scrobbleRes.error = new Error('Failed to scrobble to client', {cause: new UpstreamError('Client returned a 400 or something')}); 176 + play.meta.lifecycle.scrobble = scrobbleRes; 177 + return play; 178 + } 179 + 180 + if(warnings) { 181 + scrobbleRes.warnings = faker.helpers.multiple(faker.lorem.sentence, {count: {min: 1, max: 3}}); 182 + } 183 + 184 + scrobbleRes.response = generateRandomObj(2); 185 + scrobbleRes.mergedScrobble = lifecyclelessInvariantTransform(play); 186 + play.meta.lifecycle.scrobble = scrobbleRes; 187 + 188 + return play; 189 + } 190 + 191 + export const generateLifecycleInput = (typeName?: string): LifecycleInput => { 192 + return { type: typeName ?? `${faker.string.alpha(2)}-${faker.hacker.noun()}`, input: generateRandomObj(2) }; 193 + } 194 + 195 + export interface GenerateLifecycleOptions { 196 + name?: string 197 + source?: string 198 + equal?: boolean 199 + inputCount?: number 200 + } 201 + 202 + const modifiableKeys: PropertyKey[] = ['track', 'album', 'albumArtists', 'artists', 'duration', 'meta','brainz']; 203 + export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleOptions = {}): [LifecycleStep, PlayObject] => { 204 + 205 + const { 206 + name = ['preCompare', 'postCompare'][faker.number.int({ min: 0, max: 1 })], 207 + source = `${play.meta.source}-${faker.word.noun()}`, 208 + equal = faker.datatype.boolean(0.1), 209 + inputCount 210 + } = opts; 211 + 212 + const inputs = faker.helpers.multiple(() => generateLifecycleInput(), { count: inputCount ?? { min: 0, max: 2 } }); 213 + 214 + const step: LifecycleStep = { 215 + name, 216 + source, 217 + inputs 218 + } 219 + 220 + if (equal) { 221 + return [step, play]; 222 + } 223 + 224 + play.data.meta = { 225 + ...(play.data?.meta ?? {}), 226 + brainz: { 227 + ...(play.data?.meta?.brainz ?? {}) 228 + } 229 + } 230 + 231 + const modifiedPlay = clone(play); 232 + const randomPlay = generatePlay(); 233 + let somethingModified = false; 234 + while (!somethingModified) { 235 + new Traverse(modifiedPlay).forEach((ctx, x) => { 236 + if (modifiableKeys.includes(ctx.key)) { 237 + if (faker.datatype.boolean(0.3)) { 238 + somethingModified = true; 239 + if(ctx.key === 'brainz' && Object.keys(x).length === 0) { 240 + ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track']}), true); 241 + } else if (ctx.parent !== undefined && ctx.parent.key === 'brainz') { 242 + if (Array.isArray(x)) { 243 + ctx.update(faker.helpers.multiple(generateMbid, { count: { min: 1, max: 3 } })); 244 + } else { 245 + ctx.update(generateMbid()); 246 + } 247 + } else { 248 + ctx.update(randomPlay.data[ctx.key]); 249 + } 250 + } 251 + } 252 + }); 253 + } 92 254 255 + step.patch = jdiff.diff(play, modifiedPlay); 256 + 257 + return [step, modifiedPlay]; 93 258 } 94 259 95 260 export interface RandomObjOptions { ··· 127 292 128 293 const keyCount = opt.keyCount ?? faker.number.int({ min: 1, max: 13 }); 129 294 130 - for (let i = 0; i < keyCount; i++) { 131 - const key = faker.lorem.slug({ min: 1, max: 3 }) 132 - if (tgrt[key] === undefined) { 133 - tgrt[key] = generateRandomVal(depth + 1, opt) 134 - } 295 + for (let i = 0; i < keyCount; i++) { 296 + const key = faker.lorem.slug({ min: 1, max: 3 }) 297 + if (tgrt[key] === undefined) { 298 + tgrt[key] = generateRandomVal(depth + 1, opt) 135 299 } 300 + } 136 301 return tgrt 137 302 }