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

refactor: Move all lifecycle properties to top-level Play

And remove lifecycle from meta

FoxxMD (Jun 10, 2026, 1:54 AM UTC) e949da38 e13d86b1

+117 -1152
+22 -22
src/backend/common/AbstractComponent.ts
··· 309 309 if(step.error !== undefined && step.flowKnownState !== 'skip' && !step.returnPartial) { 310 310 // revert to original play but keep steps for paper trail 311 311 transformedPlay = play; 312 - transformedPlay.meta.lifecycle.steps = steps; 312 + transformedPlay.lifecycle = steps; 313 313 } else { 314 314 transformedPlay = stepPlay; 315 315 } ··· 323 323 const historyToDiff: {name: string, data?: PlayData}[] = [ 324 324 {name: 'Pre Transform', data: play.data} 325 325 ]; 326 - if(steps.length > 0 && transformedPlay.meta?.lifecycle?.steps === undefined) { 327 - const { 328 - meta: { 329 - lifecycle: { 330 - steps = [], 331 - ...lifecycleRest 332 - }, 333 - } = {} 334 - } = transformedPlay; 335 - transformedPlay.meta.lifecycle = { 336 - ...lifecycleRest, 337 - steps 338 - } 326 + let isNew = false; 327 + if(steps.length > 0 && transformedPlay.lifecycle === undefined) { 328 + transformedPlay.lifecycle = steps; 329 + isNew = true; 339 330 } 331 + const { 332 + lifecycle = [] 333 + } = transformedPlay; 340 334 steps.forEach((s, index) => { 341 - const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.stageName === s.stageName && x.stageType && s.stageType && x.hook === s.hook && x.source === this.getIdentifier()); 342 - if(existingStepIndex !== -1) { 343 - transformedPlay.meta.lifecycle.steps[existingStepIndex] = s; 344 - } else { 345 - transformedPlay.meta.lifecycle.steps.push(s); 335 + if(!isNew) { 336 + const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier()); 337 + if(existingStepIndex !== -1) { 338 + transformedPlay.lifecycle[existingStepIndex] = s; 339 + } else { 340 + transformedPlay.lifecycle.push(s); 341 + } 346 342 } 347 343 348 344 if(shouldLog === 'all') { ··· 412 408 asyncId = nanoid(6) 413 409 } = opts; 414 410 415 - const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}` 416 - const existingStepIndex = playTruth.meta.lifecycle.steps.findIndex(x => x.hook === hookType && hookItem.name === x.stageName && x.stageType === hookItem.type && x.source === this.getIdentifier()); 417 - const step: LifecycleStep = existingStepIndex !== -1 && playTruth.meta.lifecycle.steps[existingStepIndex] !== undefined ? playTruth.meta.lifecycle.steps[existingStepIndex] : { 411 + const { 412 + lifecycle = [] 413 + } = playTruth; 414 + 415 + //const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}` 416 + const existingStepIndex = lifecycle.findIndex(x => x.hook === hookType && hookItem.name === x.stageName && x.stageType === hookItem.type && x.source === this.getIdentifier()); 417 + const step: LifecycleStep = existingStepIndex !== -1 && lifecycle[existingStepIndex] !== undefined ? lifecycle[existingStepIndex] : { 418 418 stageName: hookItem.name, 419 419 hook: hookType, 420 420 stageType: hookItem.type,
+2 -21
src/backend/common/database/drizzle/repositories/PlayHistoricalRepository.ts
··· 314 314 } 315 315 316 316 export const playToRepositoryCreatePlayHistoricalOpts = (data: MarkOptional<RepositoryCreatePlayHistoricalOpts, 'componentId'>): RepositoryCreatePlayHistoricalOpts => { 317 - const { 318 - play: { 319 - meta: { 320 - lifecycle, 321 - ...metaRest 322 - }, 323 - ...playRest 324 - }, 325 - ...rest 326 - } = data; 327 - 328 317 return { 329 - play: { 330 - ...playRest, 331 - meta: { 332 - ...metaRest, 333 - // @ts-expect-error 334 - lifecycle: { 335 - } 336 - } 337 - }, 318 + play: data.play, 338 319 uid: data.play.meta?.playId, 339 - ...rest 320 + ...data 340 321 } 341 322 }
+1 -16
src/backend/common/database/drizzle/repositories/PlayRepository.ts
··· 404 404 } 405 405 406 406 compactedPlay = playRow.play; 407 - compactedPlay.meta.lifecycle.steps = compactedPlay.meta.lifecycle.steps.map(x => { 407 + compactedPlay.lifecycle = compactedPlay.lifecycle.map(x => { 408 408 if(x.inputs == undefined) { 409 409 return x; 410 410 } ··· 831 831 export const playToRepositoryCreatePlayOpts = (data: MarkOptional<RepositoryCreatePlayOpts, 'input' | 'componentId'>): RepositoryCreatePlayOpts => { 832 832 const { 833 833 play: { 834 - meta: { 835 - lifecycle: { 836 - //input, 837 - //original, 838 - ...lifecycleRest 839 - } = {}, 840 - ...metaRest 841 - }, 842 834 original: { 843 835 play: playOriginal, 844 836 data: playData ··· 851 843 return { 852 844 play: { 853 845 ...playRest, 854 - meta: { 855 - ...metaRest, 856 - // @ts-expect-error 857 - lifecycle: { 858 - ...lifecycleRest 859 - } 860 - } 861 846 }, 862 847 ...rest, 863 848 input: {
+47 -42
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 16 16 SOURCE_SOT, 17 17 ErrorLike, 18 18 CLIENT_INGRESS_QUEUE, 19 - CLIENT_DEAD_QUEUE 19 + CLIENT_DEAD_QUEUE, 20 + PlayOriginal 20 21 } from "../../core/Atomic.js"; 21 22 import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 22 23 import AbstractComponent from "../common/AbstractComponent.js"; ··· 68 69 import { findAsyncSequential, staggerMapper, StaggerOptions } from "../utils/AsyncUtils.js"; 69 70 import pMap, { pMapIterable } from "p-map"; 70 71 import { comparePlayArtistsNormalized, comparePlayTracksNormalized, existingScrobble, ExistingScrobbleOpts } from "../utils/PlayComparisonUtils.js"; 71 - import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js"; 72 + import { statefulInvariantTransform } from "../../core/PlayUtils.js"; 72 73 import { normalizeStr } from "../utils/StringUtils.js"; 73 74 import prom, { Counter, Gauge } from 'prom-client'; 74 75 import { generateLoggableAbortReason, ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.js"; ··· 579 580 const play = asPlay(cachedQueuedScrobble.play); 580 581 const { 581 582 meta: { 582 - lifecycle, 583 + lifecycle = {}, 583 584 ...metaRest 584 585 }, 585 586 data: { ··· 597 598 albumArtists: albumArtists === undefined ? undefined : artistNamesToCredits(albumArtists as unknown as string[]), 598 599 ...dataRest 599 600 }, 600 - meta: { 601 - ...metaRest, 602 - lifecycle: { 603 - steps: [] 604 - } 605 - } 601 + meta: metaRest, 602 + // @ts-expect-error 603 + lifecycle: lifecycle.steps 604 + } 605 + if('scrobble' in lifecycle) { 606 + updatedPlay.scrobble = lifecycle.scrobble; 607 + } 608 + if('input' in lifecycle || 'original' in lifecycle) { 609 + updatedPlay.original = removeUndefinedKeys<PlayOriginal>({ 610 + // @ts-expect-error 611 + data: lifecycle.input, 612 + // @ts-expect-error 613 + play: lifecycle.original 614 + }) 606 615 } 607 616 // return play object without going through transform since it was (presumably) already transformed before being cached 608 617 const res = await this.queueScrobble(updatedPlay, updatedPlay.meta.source, async (x) => x); ··· 636 645 const play = asPlay(cDeadScrobble.play); 637 646 const { 638 647 meta: { 639 - lifecycle, 648 + lifecycle = {}, 640 649 ...metaRest 641 650 }, 642 651 data: { ··· 646 655 ...dataRest 647 656 } = {}, 648 657 } = play; 658 + const updatedDeadPlay: PlayObject = { 659 + ...play, 660 + data: { 661 + artists: artists === undefined ? undefined : artistNamesToCredits(artists as unknown as string[]), 662 + albumArtists: albumArtists === undefined ? undefined : artistNamesToCredits(albumArtists as unknown as string[]), 663 + ...dataRest 664 + }, 665 + meta: metaRest, 666 + // @ts-expect-error 667 + lifecycle: lifecycle.steps 668 + } 669 + if('scrobble' in lifecycle) { 670 + updatedDeadPlay.scrobble = lifecycle.scrobble; 671 + } 672 + if('input' in lifecycle || 'original' in lifecycle) { 673 + updatedDeadPlay.original = removeUndefinedKeys<PlayOriginal>({ 674 + // @ts-expect-error 675 + data: lifecycle.input, 676 + // @ts-expect-error 677 + play: lifecycle.original 678 + }) 679 + } 649 680 try { 650 681 const res = await this.playRepo.createPlays([ 651 682 playToRepositoryCreatePlayOpts({ 652 - play: { 653 - ...play, 654 - data: { 655 - artists: artists === undefined ? undefined : artistNamesToCredits(artists as unknown as string[]), 656 - albumArtists: albumArtists === undefined ? undefined : artistNamesToCredits(albumArtists as unknown as string[]), 657 - ...dataRest 658 - }, 659 - meta: { 660 - ...metaRest, 661 - lifecycle: { 662 - steps: [] 663 - } 664 - } 665 - }, 683 + play: updatedDeadPlay, 666 684 componentId: this.dbComponent.id, 667 685 state: 'failed', 668 686 parentId: play.id ··· 871 889 payload: result.payload, 872 890 warnings: result.warnings, 873 891 response: result.response, 874 - mergedScrobble: result.mergedScrobble !== undefined ? lifecyclelessInvariantTransform(result.mergedScrobble) : undefined 892 + mergedScrobble: result.mergedScrobble !== undefined ? statefulInvariantTransform(result.mergedScrobble) : undefined 875 893 } 876 894 return playObj; 877 895 } finally { ··· 1081 1099 if (!matchResult.match) { 1082 1100 const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare); 1083 1101 signal.throwIfAborted(); 1084 - if (transformedScrobble.meta.lifecycle === undefined) { 1085 - transformedScrobble.meta.lifecycle = { 1086 - //original: transformedScrobble, 1087 - steps: [] 1088 - }; 1089 - } 1090 1102 try { 1091 1103 const scrobbledPlay = await this.scrobble(transformedScrobble, {signal}); 1092 1104 await this.addScrobbledTrack(scrobbledPlay); ··· 1430 1442 } 1431 1443 // not in queue or existing queued check failed for some reason and we don't want to lose scrobble 1432 1444 const { 1433 - meta: { 1434 - lifecycle, 1435 - ...metaRest 1436 - }, 1445 + data, 1446 + meta 1437 1447 } = play 1438 1448 const createPlayData = playToRepositoryCreatePlayOpts({ 1439 1449 play: { 1440 - ...play, 1441 - meta: { 1442 - ...metaRest, 1443 - lifecycle: { 1444 - steps: [] 1445 - } 1446 - } 1450 + data, 1451 + meta 1447 1452 }, 1448 1453 componentId: this.dbComponent.id, 1449 1454 state: 'queued',
-2
src/backend/tests/jellyfin/jellyfin.test.ts
··· 19 19 import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js"; 20 20 import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; 21 21 import { MarkOptional } from "ts-essentials"; 22 - import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 23 22 24 23 const dataAsFixture = (data: any): TestFixture => { 25 24 return data as TestFixture; ··· 54 53 play: { 55 54 ...validPlayerState.play, 56 55 meta: { 57 - lifecycle: defaultLifecycle(), 58 56 ...validPlayerState.play?.meta, 59 57 ...meta 60 58 }
-3
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 24 24 import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTrackName.json' with { type: "json" }; 25 25 import veryWrong from './incorrectlyMapped/veryWrong.json' with { type: "json" }; 26 26 import { generatePlay } from "../../../core/PlayTestUtils.js"; 27 - import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 28 27 import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.js"; 29 28 30 29 interface LZTestFixture { ··· 145 144 } 146 145 }, 147 146 meta: { 148 - lifecycle: defaultLifecycle() 149 147 } 150 148 } 151 149 try { ··· 189 187 } 190 188 }, 191 189 meta: { 192 - lifecycle: defaultLifecycle() 193 190 } 194 191 } 195 192 const res = await client.submitListen(play);
+13 -14
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 14 14 import { http, HttpResponse, delay } from "msw"; 15 15 import { generatePlay, withBrainz } from '../../../core/PlayTestUtils.js'; 16 16 import { intersect, missingMbidTypes } from '../../utils.js'; 17 - import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 18 17 import { CoverArtApiClient, CoverArtApiConfig } from '../../common/vendor/musicbrainz/CoverArtApiClient.js'; 19 18 import { artistCreditToName, artistNamesToCredits, artistNameToCredit } from '../../../core/StringUtils.js'; 20 19 ··· 67 66 album: "The Universe Smiles Upon You ii" 68 67 }, 69 68 meta: { 70 - lifecycle: defaultLifecycle() 69 + 71 70 } 72 71 } 73 72 await mbTransformer.initialize(); ··· 98 97 url: { 99 98 web: "https://www.last.fm/music/Kanon+Oguni/_/Cyber+Space+(CrossWorlds+Remix):+Final+Lap+-+No+Chants", 100 99 }, 101 - lifecycle: defaultLifecycle() 100 + 102 101 } 103 102 }; 104 103 await mbTransformer.initialize(); ··· 128 127 } 129 128 }, 130 129 meta: { 131 - lifecycle: defaultLifecycle() 130 + 132 131 } 133 132 } 134 133 await mbTransformer.initialize(); ··· 157 156 isrc: 'GBAHT1600302' 158 157 }, 159 158 meta: { 160 - lifecycle: defaultLifecycle() 159 + 161 160 } 162 161 } 163 162 await mbTransformer.initialize(); ··· 188 187 } 189 188 }, 190 189 meta: { 191 - lifecycle: defaultLifecycle() 190 + 192 191 } 193 192 } 194 193 await mbTransformer.initialize(); ··· 222 221 } 223 222 }, 224 223 meta: { 225 - lifecycle: defaultLifecycle() 224 + 226 225 } 227 226 } 228 227 await mbTransformer.initialize(); ··· 251 250 album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions" 252 251 }, 253 252 meta: { 254 - lifecycle: defaultLifecycle() 253 + 255 254 } 256 255 } 257 256 await mbTransformer.initialize(); ··· 275 274 artists: artistNamesToCredits(["SEGA Sound Team / Tomoya Ohtani"]), 276 275 }, 277 276 meta: { 278 - lifecycle: defaultLifecycle() 277 + 279 278 } 280 279 } 281 280 await mbTransformer.initialize(); ··· 301 300 album: "25時、ナイトコードで。 SEKAI ALBUM Vol.3" 302 301 }, 303 302 meta: { 304 - lifecycle: defaultLifecycle() 303 + 305 304 } 306 305 } 307 306 await mbTransformer.initialize(); ··· 326 325 album: "Leo / need SEKAI ALBUM Vol.1" 327 326 }, 328 327 meta: { 329 - lifecycle: defaultLifecycle() 328 + 330 329 } 331 330 } 332 331 await mbTransformer.initialize(); ··· 356 355 isrc: 'JPK651601515' 357 356 }, 358 357 meta: { 359 - lifecycle: defaultLifecycle() 358 + 360 359 } 361 360 } 362 361 await mbTransformer.initialize(); ··· 400 399 album: "The Universe Smiles Upon You ii" 401 400 }, 402 401 meta: { 403 - lifecycle: defaultLifecycle() 402 + 404 403 } 405 404 } 406 405 await multiMb.initialize(); ··· 434 433 album: "The Universe Smiles Upon You ii" 435 434 }, 436 435 meta: { 437 - lifecycle: defaultLifecycle() 436 + 438 437 } 439 438 } 440 439 await multiMb.initialize();
-2
src/backend/tests/plex/plex.test.ts
··· 11 11 import PlexApiSource from "../../sources/PlexApiSource.js"; 12 12 import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; 13 13 import { MarkOptional } from "ts-essentials"; 14 - import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 15 14 16 15 const validSession = validSessionResponse.object.mediaContainer.metadata[0]; 17 16 ··· 44 43 play: { 45 44 ...validPlayerState.play, 46 45 meta: { 47 - lifecycle: defaultLifecycle(), 48 46 ...validPlayerState.play?.meta, 49 47 ...meta 50 48 }
+1 -3
src/backend/tests/scrobbler/scrobblers.test.ts
··· 17 17 18 18 import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js"; 19 19 import { PaginatedTimeRangeOptions, PlayPlatformId, REFRESH_STALE_DEFAULT } from '../../common/infrastructure/Atomic.js'; 20 - import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 21 20 import { shuffleArray } from '../../utils/DataUtils.js'; 22 21 import { DEFAULT_CONSOLIDATE_DURATION, DEFAULT_GROUP_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.js'; 23 22 import { asPlay } from '../../../core/PlayMarshalUtils.js'; ··· 425 424 "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27) 426 425 }, 427 426 meta: { 428 - source: 'Spotify', 429 - lifecycle: defaultLifecycle() 427 + source: 'Spotify' 430 428 } 431 429 } 432 430 await using testScrobbler = await generateTestScrobbler();
+3 -3
src/backend/utils/PlayComparisonUtils.ts
··· 10 10 import { PlayTransformRules, TRANSFORM_HOOK, TransformHook } from "../common/infrastructure/Transform.js"; 11 11 import { Logger } from "@foxxmd/logging"; 12 12 import { loggerNoop } from '../common/MaybeLogger.js'; 13 - import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js"; 13 + import { statefulInvariantTransform } from "../../core/PlayUtils.js"; 14 14 import { findAsyncSequential } from "./AsyncUtils.js"; 15 15 import dayjs from "dayjs"; 16 16 ··· 470 470 471 471 // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble 472 472 if (existingExactSubmitted !== undefined) { 473 - result.closestMatchedPlay = lifecyclelessInvariantTransform(existingExactSubmitted.play); 473 + result.closestMatchedPlay = statefulInvariantTransform(existingExactSubmitted.play); 474 474 result.score = 1; 475 475 result.match = true; 476 476 result.reason = 'Exact Match found in previously successfully scrobbled plays'; ··· 562 562 563 563 if (result.score <= score && score > 0) { 564 564 result.reason = confidence; 565 - result.closestMatchedPlay = lifecyclelessInvariantTransform(x); 565 + result.closestMatchedPlay = statefulInvariantTransform(x); 566 566 result.match = score >= DUP_SCORE_THRESHOLD; 567 567 result.breakdowns = scoreBreakdowns; 568 568 result.score = score;
+1 -15
src/backend/utils/PlayTransformUtils.ts
··· 1 1 import { Logger } from "@foxxmd/logging"; 2 2 import { searchAndReplace as searchAndReplaceFunc, testMaybeRegex as testMaybeRegexFunc } from "@foxxmd/regex-buddy-core"; 3 - import { PlayLifecycle, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 3 + import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 4 4 5 5 import { 6 6 ConditionalSearchAndReplaceRegExp, ··· 181 181 meta: { 182 182 seenAt: dayjs(), 183 183 ...play.meta, 184 - lifecycle: { 185 - steps: [] 186 - } 187 184 }, 188 185 original: { 189 186 input: data, ··· 192 189 } 193 190 basePlay.original.play.meta.seenAt = basePlay.meta.seenAt; 194 191 return basePlay; 195 - } 196 - 197 - export const defaultLifecycle = (extra?: PlayLifecycle): PlayLifecycle => { 198 - const { 199 - steps = [], 200 - ...rest 201 - } = extra ?? {}; 202 - return { 203 - steps, 204 - ...rest, 205 - } 206 192 }
+9 -13
src/client/components/ActivityTimeline.tsx
··· 41 41 }, 42 42 meta: { 43 43 source, 44 - lifecycle: { 45 - // input, 46 - // original, 47 - steps = [], 48 - scrobble: { 49 - match, 50 - payload, 51 - error, 52 - warnings = [] 53 - } = {}, 54 - scrobble 55 - }, 56 - } = {} 44 + } = {}, 45 + lifecycle: steps = [], 46 + scrobble: { 47 + match, 48 + payload, 49 + error, 50 + warnings = [] 51 + } = {}, 52 + scrobble 57 53 } = play; 58 54 const { 59 55 play: original,
-1
src/client/components/ScrobbleActionResult.tsx
··· 1 1 import { ComponentProps, Fragment } from "react" 2 2 import { Timeline, Icon, Span, Stack, Heading, Tabs, DataList, Alert, List } from '@chakra-ui/react'; 3 - import { JsonPlayObject, LifecycleStep, PlayLifecycle, PlayMatchResult, ScrobbleResult } from "../../core/Atomic"; 4 3 import { PlayData } from "./PlayData"; 5 4 import { ErrorAlert } from "./ErrorAlert"; 6 5 import { BiWrench } from "react-icons/bi";
+9 -8
src/core/Atomic.ts
··· 282 282 283 283 comment?: string 284 284 285 - lifecycle: PlayLifecycle<D> 285 + //lifecycle: PlayLifecycle<D> 286 286 lifecycleInputs?: LifecycleInput[] 287 287 288 288 [key: string]: any ··· 311 311 mergedScrobble?: PlayObjectLifecycleless<D> 312 312 } 313 313 314 - export interface PlayLifecycle<D extends DateLike = Dayjs> { 315 - //input?: object 316 - //original?: PlayObjectLifecycleless<D> 317 - steps: LifecycleStep[] 318 - //scrobble?: ScrobbleResult<D> 319 - } 314 + // export interface PlayLifecycle<D extends DateLike = Dayjs> { 315 + // //input?: object 316 + // //original?: PlayObjectLifecycleless<D> 317 + // //steps: LifecycleStep[] 318 + // //scrobble?: ScrobbleResult<D> 319 + // } 320 320 321 321 export interface LifecycleStep { 322 322 stageName: string ··· 375 375 meta: PlayMetaLifecycleless<D> 376 376 original?: PlayOriginal<D> 377 377 scrobble?: ScrobbleResult<D> 378 + lifecycle?: LifecycleStep[] 378 379 } 379 380 380 381 export const isPlayObject = (obj: object): obj is PlayObject => { ··· 387 388 status: string 388 389 error?: ErrorLike 389 390 } 390 - export type JsonPlayObject = PlayObjectLifecycleless<string>; 391 + export type JsonPlayObject = AmbPlayObject<string>; 391 392 392 393 export interface ObjectPlayData extends PlayData { 393 394 playDate?: Dayjs
-9
src/core/PlayTestUtils.ts
··· 15 15 import { nanoid } from 'nanoid'; 16 16 import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js'; 17 17 import { MarkOptional } from 'ts-essentials'; 18 - import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js'; 19 18 import clone from 'clone'; 20 19 import { removeUndefinedKeys } from '../backend/utils.js'; 21 20 import { FmTealAlphaFeedPlay } from '../backend/common/vendor/teal/lexicons/index.js'; ··· 73 72 ...defaultData 74 73 } 75 74 cleanPlay.meta = { 76 - lifecycle: defaultLifecycle(), 77 75 ...cleanPlay.meta, 78 76 ...defaultMeta 79 77 } ··· 188 186 source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})], 189 187 seenAt: dayjs(), 190 188 ...meta, 191 - lifecycle: { 192 - // original: { 193 - // data: {}, 194 - // meta: {} 195 - // }, 196 - steps: [] 197 - }, 198 189 url: { 199 190 web: 'https://example.com', 200 191 origin: 'https://example.com'
+5 -8
src/core/PlayUtils.ts
··· 36 36 return `${id[0]}-${id[1]}`; 37 37 }; 38 38 39 - export const lifecyclelessInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 39 + export const statefulInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 40 40 const { 41 - meta: { 42 - lifecycle, ...rest 43 - } = {}, 41 + meta, 42 + data 44 43 } = play; 45 44 return { 46 - ...play, 47 - meta: { 48 - ...rest 49 - } 45 + data, 46 + meta 50 47 }; 51 48 }; 52 49
+3 -3
src/core/tests/utils/fixtures.ts
··· 3 3 import { AmbPlayObject, DateLike, LifecycleInput, LifecycleStep, ObjectPlayData, PLAY_STATES, PlayMeta, PlayObject, PlayOriginal, PlayState, ScrobbleResult } from '../../Atomic.js'; 4 4 import { MarkOptional } from 'ts-essentials'; 5 5 import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js'; 6 - import { lifecyclelessInvariantTransform } from '../../PlayUtils.js'; 6 + import { statefulInvariantTransform } from '../../PlayUtils.js'; 7 7 import clone from 'clone'; 8 8 import { diffObjects } from '../../DataUtils.js'; 9 9 import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.js'; ··· 94 94 } 95 95 } 96 96 97 - lplay.meta.lifecycle.steps = steps; 97 + lplay.lifecycle = steps; 98 98 lplay.data = transformedPlay.data; 99 99 100 100 return lplay; ··· 141 141 } 142 142 143 143 scrobbleRes.response = generateRandomObj(2); 144 - scrobbleRes.mergedScrobble = lifecyclelessInvariantTransform(play); 144 + scrobbleRes.mergedScrobble = statefulInvariantTransform(play); 145 145 play.scrobble = scrobbleRes; 146 146 147 147 return play;
-2
src/stories/ActivityTimeline.stories.tsx
··· 6 6 import { ActivityTimeline } from "../client/components/ActivityTimeline"; 7 7 import {Provider} from "../client/components/Provider"; 8 8 import { generateJsonPlays } from "../core/PlayTestUtils.js"; 9 - import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; 10 9 import { generatePlayApiCommonDetailed } from "../core/tests/utils/apiFixtures.js"; 11 - import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; 12 10 import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; 13 11 import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; 14 12
-1
src/stories/List.stories.tsx
··· 7 7 import {Provider} from "../client/components/Provider"; 8 8 import { generateJsonPlays, normalizePlays } from "../core/PlayTestUtils.js"; 9 9 import { ErrorLike, JsonPlayObject } from "../core/Atomic.js"; 10 - import {examplePlay, lastfmErrorExample} from './storyUtils.js'; 11 10 import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures' 12 11 import { generateArray } from "../core/DataUtils.js"; 13 12 import dayjs from "dayjs";
+1 -3
src/stories/TransformSteps.stories.tsx
··· 6 6 import { TransformSteps } from "../client/components/TransformSteps.js"; 7 7 import {Provider} from "../client/components/Provider"; 8 8 import { generateJsonPlays, generatePlay } from "../core/PlayTestUtils.js"; 9 - import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; 10 - import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; 11 9 import {generatePlayWithLifecycle} from '../core/tests/utils/fixtures' 12 10 import { asJsonPlayObject } from "../core/PlayMarshalUtils.js"; 13 11 ··· 33 31 // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 34 32 export const Multiple = meta.story({ 35 33 args: { 36 - steps: multiPlay.meta!.lifecycle!.steps, 34 + steps: multiPlay.lifecycle!, 37 35 original: asJsonPlayObject(original), 38 36 collapsibleOpen: true 39 37 }
-961
src/stories/storyUtils.ts
··· 1 - import { JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; 2 - 3 - const exampleLifecycle = (): PlayLifecycle<string> => ({ 4 - "steps": [ 5 - { 6 - "name": "preCompare", 7 - "source": "Spotify - default", 8 - "patch": { 9 - "data": { 10 - "albumArtists": [ 11 - [ 12 - "Tyler, The Creator" 13 - ], 14 - 0, 15 - 0 16 - ], 17 - "album": [ 18 - "Flower Boy", 19 - "Scum Fuck Flower Boy" 20 - ], 21 - "track": [ 22 - "Boredom (feat. Rex Orange County & Anna of the North)", 23 - "Boredom" 24 - ], 25 - "duration": [ 26 - 320.72, 27 - 320 28 - ], 29 - "meta": { 30 - "brainz": { 31 - "recording": [ 32 - "379db622-cc58-4bbf-9a3e-a7fa50b25fd1" 33 - ], 34 - "artist": [ 35 - [ 36 - "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 37 - "883cbf1f-dcaa-4d17-b9ed-394e1fdabc87", 38 - "4d7db1c4-3c00-4140-8c83-c6d99cfdecf4" 39 - ] 40 - ], 41 - "album": [ 42 - "eb340386-6815-4933-9a28-940f7140f009" 43 - ], 44 - "releaseGroup": [ 45 - "e248e931-c26c-4c94-aba2-2f89ce583901" 46 - ] 47 - } 48 - } 49 - } 50 - }, 51 - "inputs": [ 52 - { 53 - "type": "mbQuery", 54 - "input": "isrc:USQX91701279" 55 - }, 56 - { 57 - "type": "mbRecording", 58 - "input": { 59 - "id": "379db622-cc58-4bbf-9a3e-a7fa50b25fd1", 60 - "score": 100, 61 - "artist-credit-id": "c5abb112-bd0f-4155-9c78-9e3baf1b8ea5", 62 - "title": "Boredom", 63 - "length": 320000, 64 - "video": null, 65 - "artist-credit": [ 66 - { 67 - "joinphrase": " ft. ", 68 - "name": "Tyler, The Creator", 69 - "artist": { 70 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 71 - "name": "Tyler, The Creator", 72 - "sort-name": "Tyler, The Creator", 73 - "aliases": [ 74 - { 75 - "sort-name": "Okonma, Tyler", 76 - "name": "Tyler Okonma", 77 - "locale": null, 78 - "type": null, 79 - "primary": null, 80 - "begin-date": null, 81 - "end-date": null 82 - }, 83 - { 84 - "sort-name": "Okonma, T.", 85 - "name": "T. Okonma", 86 - "locale": null, 87 - "type": null, 88 - "primary": null, 89 - "begin-date": null, 90 - "end-date": null 91 - }, 92 - { 93 - "sort-name": "Okonma, Tyler Gregory", 94 - "type-id": "d4dcd0c0-b341-3612-a332-c0ce797b25cf", 95 - "name": "Tyler Gregory Okonma", 96 - "locale": null, 97 - "type": "Legal name", 98 - "primary": null, 99 - "begin-date": null, 100 - "end-date": null 101 - }, 102 - { 103 - "sort-name": "Haley, Wolf", 104 - "name": "Wolf Haley", 105 - "locale": null, 106 - "type": null, 107 - "primary": null, 108 - "begin-date": null, 109 - "end-date": null 110 - } 111 - ] 112 - } 113 - }, 114 - { 115 - "joinphrase": " & ", 116 - "name": "Rex Orange County", 117 - "artist": { 118 - "id": "883cbf1f-dcaa-4d17-b9ed-394e1fdabc87", 119 - "name": "Rex Orange County", 120 - "sort-name": "Rex Orange County", 121 - "aliases": [ 122 - { 123 - "sort-name": "O'Connor, Alexander James", 124 - "name": "Alexander James O'Connor", 125 - "locale": null, 126 - "type": null, 127 - "primary": null, 128 - "begin-date": null, 129 - "end-date": null 130 - }, 131 - { 132 - "sort-name": "O'Connor, Alexander", 133 - "type-id": "d4dcd0c0-b341-3612-a332-c0ce797b25cf", 134 - "name": "Alexander O'Connor", 135 - "locale": null, 136 - "type": "Legal name", 137 - "primary": null, 138 - "begin-date": null, 139 - "end-date": null 140 - } 141 - ] 142 - } 143 - }, 144 - { 145 - "name": "Anna of the North", 146 - "artist": { 147 - "id": "4d7db1c4-3c00-4140-8c83-c6d99cfdecf4", 148 - "name": "Anna of the North", 149 - "sort-name": "Anna of the North", 150 - "aliases": [ 151 - { 152 - "sort-name": "Lotterud, Anna", 153 - "type-id": "d4dcd0c0-b341-3612-a332-c0ce797b25cf", 154 - "name": "Anna Lotterud", 155 - "locale": null, 156 - "type": "Legal name", 157 - "primary": null, 158 - "begin-date": null, 159 - "end-date": null 160 - } 161 - ] 162 - } 163 - } 164 - ], 165 - "first-release-date": "2017-07-21", 166 - "releases": [ 167 - { 168 - "id": "eb340386-6815-4933-9a28-940f7140f009", 169 - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", 170 - "artist-credit-id": "3289218e-1edd-4ace-91c6-e7af96ce7144", 171 - "count": 1, 172 - "title": "Scum Fuck Flower Boy", 173 - "status": "Official", 174 - "disambiguation": "Explicit Cover", 175 - "artist-credit": [ 176 - { 177 - "name": "Tyler, The Creator", 178 - "artist": { 179 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 180 - "name": "Tyler, The Creator", 181 - "sort-name": "Tyler, The Creator" 182 - } 183 - } 184 - ], 185 - "release-group": { 186 - "id": "e248e931-c26c-4c94-aba2-2f89ce583901", 187 - "type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 188 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 189 - "title": "Flower Boy", 190 - "primary-type": "Album" 191 - }, 192 - "date": "2017-07-21", 193 - "country": "US", 194 - "release-events": [ 195 - { 196 - "date": "2017-07-21", 197 - "area": { 198 - "id": "489ce91b-6658-3307-9877-795b68554c98", 199 - "name": "United States", 200 - "sort-name": "United States", 201 - "iso-3166-1-codes": [ 202 - "US" 203 - ] 204 - } 205 - } 206 - ], 207 - "track-count": 14, 208 - "media": [ 209 - { 210 - "id": "f7525bfa-5263-3c90-926b-4f53cfc28021", 211 - "position": 1, 212 - "format": "CD", 213 - "track": [ 214 - { 215 - "id": "931cb165-a25b-4e54-b606-1e3c31c06348", 216 - "number": "8", 217 - "title": "Boredom", 218 - "length": 321000 219 - } 220 - ], 221 - "track-count": 14, 222 - "track-offset": 7 223 - } 224 - ], 225 - "albumScore": 3, 226 - "albumCompareScore": 0 227 - }, 228 - { 229 - "id": "88aba80a-e240-4464-8dad-a32654ba1348", 230 - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", 231 - "artist-credit-id": "3289218e-1edd-4ace-91c6-e7af96ce7144", 232 - "count": 1, 233 - "title": "Scum Fuck Flower Boy", 234 - "status": "Official", 235 - "artist-credit": [ 236 - { 237 - "name": "Tyler, The Creator", 238 - "artist": { 239 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 240 - "name": "Tyler, The Creator", 241 - "sort-name": "Tyler, The Creator" 242 - } 243 - } 244 - ], 245 - "release-group": { 246 - "id": "e248e931-c26c-4c94-aba2-2f89ce583901", 247 - "type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 248 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 249 - "title": "Flower Boy", 250 - "primary-type": "Album" 251 - }, 252 - "date": "2017-07-21", 253 - "country": "US", 254 - "release-events": [ 255 - { 256 - "date": "2017-07-21", 257 - "area": { 258 - "id": "489ce91b-6658-3307-9877-795b68554c98", 259 - "name": "United States", 260 - "sort-name": "United States", 261 - "iso-3166-1-codes": [ 262 - "US" 263 - ] 264 - } 265 - } 266 - ], 267 - "track-count": 14, 268 - "media": [ 269 - { 270 - "id": "0be9089c-cfa8-36b8-9036-ab3347e48da6", 271 - "position": 1, 272 - "format": "CD", 273 - "track": [ 274 - { 275 - "id": "aeff9385-9073-4c2e-915d-2136dc23be82", 276 - "number": "8", 277 - "title": "Boredom", 278 - "length": 320000 279 - } 280 - ], 281 - "track-count": 14, 282 - "track-offset": 7 283 - } 284 - ], 285 - "albumScore": 3, 286 - "albumCompareScore": 0 287 - }, 288 - { 289 - "id": "dd09e440-879d-447b-9dfa-8547b369548e", 290 - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", 291 - "artist-credit-id": "3289218e-1edd-4ace-91c6-e7af96ce7144", 292 - "count": 1, 293 - "title": "Flower Boy", 294 - "status": "Official", 295 - "disambiguation": "Apple Digital Master", 296 - "artist-credit": [ 297 - { 298 - "name": "Tyler, The Creator", 299 - "artist": { 300 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 301 - "name": "Tyler, The Creator", 302 - "sort-name": "Tyler, The Creator" 303 - } 304 - } 305 - ], 306 - "release-group": { 307 - "id": "e248e931-c26c-4c94-aba2-2f89ce583901", 308 - "type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 309 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 310 - "title": "Flower Boy", 311 - "primary-type": "Album" 312 - }, 313 - "date": "2017-07-21", 314 - "country": "XW", 315 - "release-events": [ 316 - { 317 - "date": "2017-07-21", 318 - "area": { 319 - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", 320 - "name": "[Worldwide]", 321 - "sort-name": "[Worldwide]", 322 - "iso-3166-1-codes": [ 323 - "XW" 324 - ] 325 - } 326 - } 327 - ], 328 - "track-count": 14, 329 - "media": [ 330 - { 331 - "id": "76a1bbb7-74c4-3953-a224-682a51c011c9", 332 - "position": 1, 333 - "format": "Digital Media", 334 - "track": [ 335 - { 336 - "id": "77f18908-29e2-4bc4-a170-67789188a506", 337 - "number": "8", 338 - "title": "Boredom", 339 - "length": 320000 340 - } 341 - ], 342 - "track-count": 14, 343 - "track-offset": 7 344 - } 345 - ], 346 - "albumScore": 2, 347 - "albumCompareScore": 0 348 - }, 349 - { 350 - "id": "c4d321ac-f21d-45e8-aebc-31986fc8233b", 351 - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", 352 - "artist-credit-id": "3289218e-1edd-4ace-91c6-e7af96ce7144", 353 - "count": 1, 354 - "title": "Flower Boy", 355 - "status": "Official", 356 - "artist-credit": [ 357 - { 358 - "name": "Tyler, The Creator", 359 - "artist": { 360 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 361 - "name": "Tyler, The Creator", 362 - "sort-name": "Tyler, The Creator" 363 - } 364 - } 365 - ], 366 - "release-group": { 367 - "id": "e248e931-c26c-4c94-aba2-2f89ce583901", 368 - "type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 369 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 370 - "title": "Flower Boy", 371 - "primary-type": "Album" 372 - }, 373 - "date": "2017-07-21", 374 - "country": "XW", 375 - "release-events": [ 376 - { 377 - "date": "2017-07-21", 378 - "area": { 379 - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", 380 - "name": "[Worldwide]", 381 - "sort-name": "[Worldwide]", 382 - "iso-3166-1-codes": [ 383 - "XW" 384 - ] 385 - } 386 - } 387 - ], 388 - "track-count": 14, 389 - "media": [ 390 - { 391 - "id": "359440fa-d653-3ba0-bbb4-57c41c2bdf78", 392 - "position": 1, 393 - "format": "Digital Media", 394 - "track": [ 395 - { 396 - "id": "3496999b-31a1-489d-a395-7f977e0eb00f", 397 - "number": "8", 398 - "title": "Boredom", 399 - "length": 320000 400 - } 401 - ], 402 - "track-count": 14, 403 - "track-offset": 7 404 - } 405 - ], 406 - "albumScore": 2, 407 - "albumCompareScore": 0 408 - }, 409 - { 410 - "id": "523f5e88-9988-436d-ab60-6d514c1f0e15", 411 - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", 412 - "artist-credit-id": "3289218e-1edd-4ace-91c6-e7af96ce7144", 413 - "count": 1, 414 - "title": "Flower Boy", 415 - "status": "Official", 416 - "artist-credit": [ 417 - { 418 - "name": "Tyler, The Creator", 419 - "artist": { 420 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 421 - "name": "Tyler, The Creator", 422 - "sort-name": "Tyler, The Creator" 423 - } 424 - } 425 - ], 426 - "release-group": { 427 - "id": "e248e931-c26c-4c94-aba2-2f89ce583901", 428 - "type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 429 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 430 - "title": "Flower Boy", 431 - "primary-type": "Album" 432 - }, 433 - "date": "2017-07-21", 434 - "country": "XW", 435 - "release-events": [ 436 - { 437 - "date": "2017-07-21", 438 - "area": { 439 - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", 440 - "name": "[Worldwide]", 441 - "sort-name": "[Worldwide]", 442 - "iso-3166-1-codes": [ 443 - "XW" 444 - ] 445 - } 446 - } 447 - ], 448 - "track-count": 14, 449 - "media": [ 450 - { 451 - "id": "401a7220-e021-3794-b52d-b7a75fdd8fc0", 452 - "position": 1, 453 - "format": "CD", 454 - "track": [ 455 - { 456 - "id": "106952b2-4331-4132-a69f-e926b11b1536", 457 - "number": "8", 458 - "title": "Boredom", 459 - "length": 320720 460 - } 461 - ], 462 - "track-count": 14, 463 - "track-offset": 7 464 - } 465 - ], 466 - "albumScore": 2, 467 - "albumCompareScore": 0 468 - }, 469 - { 470 - "id": "dacce9af-884c-4b60-a814-12e7d65f01be", 471 - "status-id": "1156806e-d06a-38bd-83f0-cf2284a808b9", 472 - "artist-credit-id": "949a7fd5-fe73-3e8f-922e-01ff4ca958f7", 473 - "count": 1, 474 - "title": "Insecure Music (Complete Season 02)", 475 - "status": "Bootleg", 476 - "artist-credit": [ 477 - { 478 - "name": "Various Artists", 479 - "artist": { 480 - "id": "89ad4ac3-39f7-470e-963a-56509c546377", 481 - "name": "Various Artists", 482 - "sort-name": "Various Artists", 483 - "disambiguation": "add compilations to this artist" 484 - } 485 - } 486 - ], 487 - "release-group": { 488 - "id": "8daa4b76-fb5d-4ece-98c4-beb0aaf09bcb", 489 - "type-id": "dd2a21e1-0c00-3729-a7a0-de60b84eb5d1", 490 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 491 - "title": "Insecure Music (Complete Season 02)", 492 - "primary-type": "Album", 493 - "secondary-types": [ 494 - "Compilation", 495 - "Soundtrack" 496 - ], 497 - "secondary-type-ids": [ 498 - "dd2a21e1-0c00-3729-a7a0-de60b84eb5d1", 499 - "22a628ad-c082-3c4f-b1b6-d41665107b88" 500 - ] 501 - }, 502 - "track-count": 74, 503 - "media": [ 504 - { 505 - "id": "d78e3f46-874b-4acb-968f-826896a81fd7", 506 - "position": 1, 507 - "format": "Digital Media", 508 - "track": [ 509 - { 510 - "id": "db7f1d1f-71a9-4e59-9add-137e265af169", 511 - "number": "63", 512 - "title": "Boredom" 513 - } 514 - ], 515 - "track-count": 74, 516 - "track-offset": 62 517 - } 518 - ], 519 - "albumScore": 1, 520 - "albumCompareScore": 0 521 - }, 522 - { 523 - "id": "7b19feb7-2c94-4f49-a887-868cde3c5941", 524 - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", 525 - "artist-credit-id": "3289218e-1edd-4ace-91c6-e7af96ce7144", 526 - "count": 2, 527 - "title": "Flower Boy", 528 - "status": "Official", 529 - "disambiguation": "limited edition translucent yellow", 530 - "artist-credit": [ 531 - { 532 - "name": "Tyler, The Creator", 533 - "artist": { 534 - "id": "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 535 - "name": "Tyler, The Creator", 536 - "sort-name": "Tyler, The Creator" 537 - } 538 - } 539 - ], 540 - "release-group": { 541 - "id": "e248e931-c26c-4c94-aba2-2f89ce583901", 542 - "type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 543 - "primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc", 544 - "title": "Flower Boy", 545 - "primary-type": "Album" 546 - }, 547 - "date": "2017-11-27", 548 - "country": "GB", 549 - "release-events": [ 550 - { 551 - "date": "2017-11-27", 552 - "area": { 553 - "id": "8a754a16-0027-3a29-b6d7-2b40ea0481ed", 554 - "name": "United Kingdom", 555 - "sort-name": "United Kingdom", 556 - "iso-3166-1-codes": [ 557 - "GB" 558 - ] 559 - } 560 - }, 561 - { 562 - "date": "2017-11-27", 563 - "area": { 564 - "id": "489ce91b-6658-3307-9877-795b68554c98", 565 - "name": "United States", 566 - "sort-name": "United States", 567 - "iso-3166-1-codes": [ 568 - "US" 569 - ] 570 - } 571 - }, 572 - { 573 - "date": "2017-11-27", 574 - "area": { 575 - "id": "89a675c2-3e37-3518-b83c-418bad59a85a", 576 - "name": "Europe", 577 - "sort-name": "Europe", 578 - "iso-3166-1-codes": [ 579 - "XE" 580 - ] 581 - } 582 - } 583 - ], 584 - "track-count": 14, 585 - "media": [ 586 - { 587 - "id": "8051dbab-8499-33b7-a6ad-ebae0991fe35", 588 - "position": 2, 589 - "format": "Vinyl", 590 - "track": [ 591 - { 592 - "id": "3de6c108-2ee1-4a5f-8fc5-446d91168a95", 593 - "number": "C1", 594 - "title": "Boredom", 595 - "length": 324000 596 - } 597 - ], 598 - "track-count": 7, 599 - "track-offset": 0 600 - } 601 - ], 602 - "albumScore": 1, 603 - "albumCompareScore": 0 604 - } 605 - ], 606 - "isrcs": [ 607 - "USQX91701279" 608 - ], 609 - "titleScore": 0, 610 - "artistScore": 0, 611 - "albumScore": 0, 612 - "rankScore": 0 613 - } 614 - } 615 - ] 616 - } 617 - ], 618 - "scrobble": { 619 - "match": { 620 - "match": false, 621 - "score": 0.09299683367479979, 622 - "breakdowns": [ 623 - "Artist: 0.02 * 0.3 = 0.01", 624 - "Title: 0.22 * 0.4 = 0.09", 625 - "Time: (No correlation) 0 * 0.5 = 0.00", 626 - "Time Detail => Existing: 2026-03-14T20:35:17.000Z - Candidate: 2026-03-15T15:27:14.682Z | Temporal Sameness: No correlation | Play Diff: 68,221s (Needed <10s) | Fuzzy Duration Diff: 67,901s (Needed <= 10s) | Range Comparison N/A", 627 - "Score 0.09 => No Match" 628 - ], 629 - "reason": "Score 0.09 => No Match", 630 - "closestMatchedPlay": { 631 - "data": { 632 - "artists": [ 633 - {"name": "Gorillaz"} 634 - ], 635 - "track": "Tormenta", 636 - "album": "Cracker Island", 637 - // @ts-ignore 638 - "playDate": "2026-03-14T20:35:17.000Z", 639 - "meta": { 640 - "brainz": { 641 - "album": "52518f4a-9543-4f9c-9a86-89052f4bf458" 642 - } 643 - } 644 - }, 645 - "meta": { 646 - "nowPlaying": false, 647 - "mbid": "", 648 - "source": "Lastfm", 649 - "url": { 650 - "web": "https://www.last.fm/music/Gorillaz/_/Tormenta" 651 - }, 652 - "lifecycle": { 653 - "steps": [] 654 - } 655 - }, 656 - } 657 - }, 658 - "payload": { 659 - "artist": "Tyler, The Creator", 660 - "track": "Boredom", 661 - "album": "Scum Fuck Flower Boy", 662 - "timestamp": 1773588738, 663 - "mbid": "379db622-cc58-4bbf-9a3e-a7fa50b25fd1", 664 - "duration": 320 665 - }, 666 - "warnings": [ 667 - "test warning", 668 - "missing data in response" 669 - ], 670 - "response": { 671 - "scrobbles": { 672 - "scrobble": { 673 - "artist": { 674 - "corrected": "0", 675 - "#text": "Tyler, The Creator" 676 - }, 677 - "album": { 678 - "corrected": "0", 679 - "#text": "Scum Fuck Flower Boy" 680 - }, 681 - "track": { 682 - "corrected": "0", 683 - "#text": "Boredom" 684 - }, 685 - "ignoredMessage": { 686 - "code": "0", 687 - "#text": "" 688 - }, 689 - "albumArtist": { 690 - "corrected": "0", 691 - "#text": "" 692 - }, 693 - "timestamp": "1773588738" 694 - }, 695 - "@attr": { 696 - "ignored": 0, 697 - "accepted": 1 698 - } 699 - } 700 - }, 701 - "mergedScrobble": { 702 - "data": { 703 - "artists": [ 704 - {"name": "Tyler, The Creator"}, 705 - {"name": "Rex Orange County"}, 706 - {"name": "Anna of the North"} 707 - ], 708 - "album": "Scum Fuck Flower Boy", 709 - "track": "Boredom", 710 - "duration": 320, 711 - // @ts-ignore 712 - "playDate": "2026-03-15T15:32:18.000Z", 713 - "isrc": "USQX91701279", 714 - "meta": { 715 - "spotify": { 716 - "track": "5WNYg3usc6H8N3MBEp4zVk", 717 - "artist": [ 718 - "4V8LLVI7PbaPR0K2TGSxFF", 719 - "7pbDxGE6nQSZVfiFdq9lOL", 720 - "1mSJCvDX0W7Dn7S9C6vmvI" 721 - ], 722 - "albumArtist": [ 723 - "4V8LLVI7PbaPR0K2TGSxFF" 724 - ], 725 - "album": "2nkto6YNI4rUYTLqEwWJ3o" 726 - }, 727 - "brainz": { 728 - "trackNumber": 8, 729 - "recording": "379db622-cc58-4bbf-9a3e-a7fa50b25fd1", 730 - "artist": [ 731 - "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 732 - "883cbf1f-dcaa-4d17-b9ed-394e1fdabc87", 733 - "4d7db1c4-3c00-4140-8c83-c6d99cfdecf4" 734 - ], 735 - "album": "eb340386-6815-4933-9a28-940f7140f009", 736 - "releaseGroup": "e248e931-c26c-4c94-aba2-2f89ce583901" 737 - } 738 - }, 739 - "listenedFor": 293.56100000000004, 740 - 741 - "listenRanges": [ 742 - // @ts-ignore 743 - [ 744 - { 745 - "timestamp": "2026-03-15T15:27:14.682Z", 746 - "position": 5.455 747 - }, 748 - { 749 - "timestamp": "2026-03-15T15:32:08.223Z", 750 - "position": 299.016 751 - } 752 - ] 753 - ], 754 - "repeat": false 755 - }, 756 - "meta": { 757 - "deviceId": "3cc6dc47a8-Pixel 9a", 758 - "source": "Spotify", 759 - "musicService": "Spotify", 760 - "trackId": "5WNYg3usc6H8N3MBEp4zVk", 761 - "trackProgressPosition": 5.455, 762 - "scrobbleTsSOC": 2, 763 - "newFromSource": true, 764 - "url": { 765 - "web": "https://open.spotify.com/track/5WNYg3usc6H8N3MBEp4zVk" 766 - }, 767 - "art": { 768 - "album": "https://i.scdn.co/image/ab67616d00001e028940ac99f49e44f59e6f7fb3" 769 - } 770 - } 771 - } 772 - } 773 - }); 774 - 775 - export const examplePlay = (): JsonPlayObject => ({ 776 - "data": { 777 - "artists": [ 778 - {"name": "Tyler, The Creator"}, 779 - {"name": "Rex Orange County"}, 780 - {"name": "Anna of the North"} 781 - ], 782 - "album": "Scum Fuck Flower Boy", 783 - "track": "Boredom", 784 - "duration": 320, 785 - // @ts-ignore 786 - "playDate": "2026-03-15T15:32:18.000Z", 787 - "isrc": "USQX91701279", 788 - "meta": { 789 - "spotify": { 790 - "track": "5WNYg3usc6H8N3MBEp4zVk", 791 - "artist": [ 792 - "4V8LLVI7PbaPR0K2TGSxFF", 793 - "7pbDxGE6nQSZVfiFdq9lOL", 794 - "1mSJCvDX0W7Dn7S9C6vmvI" 795 - ], 796 - "albumArtist": [ 797 - "4V8LLVI7PbaPR0K2TGSxFF" 798 - ], 799 - "album": "2nkto6YNI4rUYTLqEwWJ3o" 800 - }, 801 - "brainz": { 802 - "trackNumber": 8, 803 - "recording": "379db622-cc58-4bbf-9a3e-a7fa50b25fd1", 804 - "artist": [ 805 - "f6beac20-5dfe-4d1f-ae02-0b0a740aafd6", 806 - "883cbf1f-dcaa-4d17-b9ed-394e1fdabc87", 807 - "4d7db1c4-3c00-4140-8c83-c6d99cfdecf4" 808 - ], 809 - "album": "eb340386-6815-4933-9a28-940f7140f009", 810 - "releaseGroup": "e248e931-c26c-4c94-aba2-2f89ce583901" 811 - } 812 - }, 813 - "listenedFor": 293.56100000000004, 814 - "listenRanges": [ 815 - // @ts-ignore 816 - [ 817 - { 818 - "timestamp": "2026-03-15T15:27:14.682Z", 819 - "position": 5.455 820 - }, 821 - { 822 - "timestamp": "2026-03-15T15:32:08.223Z", 823 - "position": 299.016 824 - } 825 - ] 826 - ], 827 - "repeat": false 828 - }, 829 - "meta": { 830 - "deviceId": "3cc6dc47a8-Pixel 9a", 831 - "source": "Spotify", 832 - "musicService": "Spotify", 833 - "trackId": "5WNYg3usc6H8N3MBEp4zVk", 834 - "trackProgressPosition": 5.455, 835 - "scrobbleTsSOC": 2, 836 - "newFromSource": true, 837 - "url": { 838 - "web": "https://open.spotify.com/track/5WNYg3usc6H8N3MBEp4zVk" 839 - }, 840 - "art": { 841 - "album": "https://i.scdn.co/image/ab67616d00001e028940ac99f49e44f59e6f7fb3" 842 - }, 843 - lifecycle: exampleLifecycle() 844 - } 845 - }); 846 - 847 - const lastfmErrorLifcycle: PlayLifecycle<string> = { 848 - "steps": [ 849 - { 850 - "name": "preCompare", 851 - "source": "Spotify - default" 852 - } 853 - ], 854 - "scrobble": { 855 - "payload": { 856 - "artist": "Mpho.Wav", 857 - "track": "Bab'omdala", 858 - "album": "Into The Deep", 859 - "timestamp": 1773166834, 860 - "duration": 318.875 861 - }, 862 - "error": { 863 - "showStopper": false, 864 - "name": "Scrobble Submit Error", 865 - "payload": { 866 - "artist": "Mpho.Wav", 867 - "track": "Bab'omdala", 868 - "album": "Into The Deep", 869 - "timestamp": 1773166834, 870 - "duration": 318.875 871 - }, 872 - "message": "Failed to submit scrobble to Last.fm", 873 - "stack": "Scrobble Submit Error: Failed to submit scrobble to Last.fm\n at LastfmApiClient.scrobble (/app/src/backend/common/vendor/LastfmApiClient.ts:447:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async LastfmScrobbler.doScrobble (/app/src/backend/scrobblers/LastfmScrobbler.ts:88:28)\n at async LastfmScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:623:28)\n at async LastfmScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:937:39)\n at async LastfmScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:882:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)", 874 - "cause": { 875 - "showStopper": false, 876 - "name": "Error", 877 - "message": "Last.fm ignored scrobble => (Code 1) (No error message returned) -- See https://www.last.fm/api/errorcodes for more information", 878 - "stack": "Error: Last.fm ignored scrobble => (Code 1) (No error message returned) -- See https://www.last.fm/api/errorcodes for more information\n at LastfmApiClient.scrobble (/app/src/backend/common/vendor/LastfmApiClient.ts:424:23)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async LastfmScrobbler.doScrobble (/app/src/backend/scrobblers/LastfmScrobbler.ts:88:28)\n at async LastfmScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:623:28)\n at async LastfmScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:937:39)\n at async LastfmScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:882:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)" 879 - } 880 - }, 881 - "match": { 882 - "match": false, 883 - "score": 0.32285714285714284, 884 - "breakdowns": [ 885 - "Artist: 0.00 * 0.3 = 0.00", 886 - "Title: 0.06 * 0.4 = 0.02", 887 - "Time: (Fuzzy) 0.6 * 0.5 = 0.30", 888 - "Time Detail => Existing: 14:15:15-04:00 - Candidate: 14:20:34-04:00 | Temporal Sameness: Fuzzy | Play Diff: 319s (Needed <10s) | Fuzzy Duration Diff: 0s (Needed <= 10s) | Range Comparison N/A", 889 - "Score 0.32 => No Match" 890 - ], 891 - "reason": "Score 0.32 => No Match", 892 - "closestMatchedPlay": { 893 - "data": { 894 - "artists": [ 895 - {"name": "Monique Bingham"} 896 - ], 897 - "track": "Deep In The Bottom (of Africa) (feat. Black Coffee) - JazzWRLD Remix Edit (Radio Edit)", 898 - "album": "Deep In The Bottom (Of Africa) The Remixes [Pt. 1]", 899 - // @ts-ignore 900 - "playDate": "2026-03-10T18:15:15.000Z" 901 - }, 902 - "meta": { 903 - "nowPlaying": false, 904 - "mbid": "", 905 - "source": "Lastfm", 906 - "url": { 907 - "web": "https://www.last.fm/music/Monique+Bingham/_/Deep+In+The+Bottom+(of+Africa)+(feat.+Black+Coffee)+-+JazzWRLD+Remix+Edit+(Radio+Edit)" 908 - }, 909 - "lifecycle": { 910 - "steps": [] 911 - } 912 - } 913 - } 914 - } 915 - } 916 - } 917 - 918 - export const lastfmErrorExample = (): JsonPlayObject => ({ 919 - "data": { 920 - "album": "Into The Deep", 921 - "track": "Bab'omdala", 922 - "duration": 318.875, 923 - "isrc": "US23A9495906", 924 - "artists": [ 925 - {"name": "Mpho.Wav"} 926 - ], 927 - "albumArtists": [], 928 - // @ts-ignore 929 - "playDate": "2026-03-10T18:20:34.606Z", 930 - // @ts-ignore 931 - "playDateCompleted": "2026-03-10T18:20:34.606Z", 932 - "meta": { 933 - "spotify": { 934 - "track": "3Uar9flTUlakmFt9nkJdNZ", 935 - "artist": [ 936 - "4l8MDydHy2RGwcGscG0uCB" 937 - ], 938 - "albumArtist": [], 939 - "album": "1TCD8jrQdFclnpYVQxjon2" 940 - }, 941 - "brainz": { 942 - "trackNumber": 1 943 - } 944 - } 945 - }, 946 - "meta": { 947 - "deviceId": "NoDevice-SingleUser", 948 - "source": "Spotify", 949 - "musicService": "Spotify", 950 - "trackId": "3Uar9flTUlakmFt9nkJdNZ", 951 - "scrobbleTsSOC": 2, 952 - "newFromSource": false, 953 - "url": { 954 - "web": "https://open.spotify.com/track/3Uar9flTUlakmFt9nkJdNZ" 955 - }, 956 - "art": { 957 - "album": "https://i.scdn.co/image/ab67616d00001e02b0e9752208d1c426fcda8820" 958 - }, 959 - lifecycle: lastfmErrorLifcycle 960 - } 961 - });