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

Implement proper play conversion for frontend/backend testing

Convert to json-equivalent object, and back, without needing json stringify/parse

FoxxMD (Mar 20, 2026, 3:22 PM UTC) 7d69e77a 03973d21

+120 -35
+11
package-lock.json
··· 170 170 "mocha": "^10.3.0", 171 171 "mockdate": "^3.0.5", 172 172 "msw": "^2.12.10", 173 + "neotraverse": "^0.6.18", 173 174 "nodemon": "^3.0.3", 174 175 "playwright": "^1.58.2", 175 176 "sinon": "^21.0.2", ··· 15125 15126 "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 15126 15127 "engines": { 15127 15128 "node": ">= 0.6" 15129 + } 15130 + }, 15131 + "node_modules/neotraverse": { 15132 + "version": "0.6.18", 15133 + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", 15134 + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", 15135 + "dev": true, 15136 + "license": "MIT", 15137 + "engines": { 15138 + "node": ">= 10" 15128 15139 } 15129 15140 }, 15130 15141 "node_modules/next-themes": {
+1
package.json
··· 208 208 "mocha": "^10.3.0", 209 209 "mockdate": "^3.0.5", 210 210 "msw": "^2.12.10", 211 + "neotraverse": "^0.6.18", 211 212 "nodemon": "^3.0.3", 212 213 "playwright": "^1.58.2", 213 214 "sinon": "^21.0.2",
+1 -1
src/backend/tests/component/transformers.test.ts
··· 7 7 import { ConditionalSearchAndReplaceRegExp, STAGE_TYPES, STAGE_TYPES_METADATA, STAGE_TYPES_USER, TRANSFORM_HOOK } from "../../common/infrastructure/Transform.js"; 8 8 9 9 import { isConditionalSearchAndReplace } from "../../utils/PlayTransformUtils.js"; 10 - import { asPlays, generateArtistsStr, generatePlay, normalizePlays } from "../../../core/PlayTestUtils.js"; 10 + import { generateArtistsStr, generatePlay } from "../../../core/PlayTestUtils.js"; 11 11 import { WebhookPayload } from "../../common/infrastructure/config/health/webhooks.js"; 12 12 import { findCauseByMessage } from "../../utils/ErrorUtils.js"; 13 13 import NativeTransformer from "../../common/transforms/NativeTransformer.js";
+3 -3
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 460 460 461 461 it('Finds none missing when all mbids are defined', function() { 462 462 463 - const play = withBrainz(generatePlay(), ['album', 'artist', 'track']); 463 + const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'track']}); 464 464 const missing = missingMbidTypes(play); 465 465 expect(missing.length).eq(0); 466 466 }); ··· 474 474 475 475 it('Finds duration missing', function() { 476 476 477 - const play = withBrainz(generatePlay(), ['album', 'artist', 'track']); 477 + const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'track']}); 478 478 delete play.data.duration; 479 479 const missing = missingMbidTypes(play); 480 480 expect(missing.length).eq(1); ··· 491 491 492 492 it('intersect is not empty when missing any desired types', function() { 493 493 494 - const play = withBrainz(generatePlay(), ['album', 'track']); 494 + const play = withBrainz(generatePlay(), {include: ['album', 'track']}); 495 495 const missing = missingMbidTypes(play); 496 496 expect(missing).to.have.members(['artists']); 497 497 expect(intersect(DEFAULT_MISSING_TYPES, missing)).length.is.greaterThan(0);
+2 -3
src/backend/tests/plays/playParsing.test.ts
··· 1 - import { loggerTest, loggerDebug, childLogger } from "@foxxmd/logging"; 2 1 import chai, { assert, expect } from 'chai'; 3 2 import asPromised from 'chai-as-promised'; 4 3 import { after, before, describe, it } from 'mocha'; 5 4 6 - import { asPlays, generateArtistsStr, generatePlay, normalizePlays } from "../../../core/PlayTestUtils.js"; 7 - import { parseArtistCredits, parseContextAwareStringList, parseCredits, parseTrackCredits, uniqueNormalizedStrArr } from "../../utils/StringUtils.js"; 5 + import { generateArtistsStr, } from "../../../core/PlayTestUtils.js"; 6 + import { parseArtistCredits, parseCredits, parseTrackCredits, uniqueNormalizedStrArr } from "../../utils/StringUtils.js"; 8 7 import testData from '../utils/playTestData.json' with { type: "json" }; 9 8 import { intersect } from "../../utils.js"; 10 9 import { ExpectedResults } from "../utils/interfaces.js";
+5 -3
src/backend/tests/scrobbler/scrobblers.test.ts
··· 12 12 import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' }; 13 13 import withDuration from '../plays/withDuration.json' with { type: 'json' }; 14 14 import { MockNetworkError, withRequestInterception } from "../utils/networking.js"; 15 - import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, generateSourcePlayerObj, normalizePlays } from "../../../core/PlayTestUtils.js"; 15 + import { generatePlay, generatePlayPlatformId, generatePlays, generateSourcePlayerObj, normalizePlays } from "../../../core/PlayTestUtils.js"; 16 16 import MockDate from 'mockdate'; 17 17 18 18 import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js"; ··· 20 20 import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 21 21 import { shuffleArray } from '../../utils/DataUtils.js'; 22 22 import { DEFAULT_CONSOLIDATE_DURATION, DEFAULT_GROUP_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.js'; 23 + import { asPlay } from '../../../core/tests/utils/fixtures.js'; 23 24 24 25 chai.use(asPromised); 25 26 26 27 const firstPlayDate = dayjs().subtract(1, 'hour'); 27 28 const olderFirstPlayDate = dayjs().subtract(4, 'hour'); 28 29 29 - const withDurPlays = asPlays(withDuration); 30 - const mixedDurPlays = asPlays(mixedDuration); 30 + const withDurPlays = withDuration.map(asPlay); 31 + // @ts-expect-error mixed duration is missing meta but not used for tests 32 + const mixedDurPlays = mixedDuration.map(asPlay); 31 33 const normalizedWithDur = normalizePlays(withDurPlays, {initialDate: firstPlayDate}); 32 34 const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: firstPlayDate}); 33 35
+11 -6
src/core/Atomic.ts
··· 84 84 } 85 85 } 86 86 87 - export interface PlayProgressAmb { 88 - timestamp: string | Dayjs 87 + export interface PlayProgressAmb<D extends DateLike = Dayjs> { 88 + timestamp: D 89 89 position?: number 90 90 positionPercent?: number 91 91 } ··· 299 299 300 300 export interface PlayLifecycle<D extends DateLike = Dayjs> { 301 301 input?: object 302 - original: PlayObjectLifecycleless 302 + original: PlayObjectLifecycleless<D> 303 303 steps: LifecycleStep[] 304 304 scrobble?: ScrobbleResult 305 305 } ··· 321 321 warnings?: string[] 322 322 } 323 323 324 - export interface PlayMatchResult { 324 + export interface PlayMatchResult<D extends DateLike = Dayjs> { 325 325 match: boolean 326 326 score: number 327 327 breakdowns: string[] 328 328 reason?: string 329 - closestMatchedPlay?: PlayObjectLifecycleless 330 - transformedPlay?: PlayObjectLifecycleless 329 + closestMatchedPlay?: PlayObjectLifecycleless<D> 330 + transformedPlay?: PlayObjectLifecycleless<D> 331 331 summary?: String 332 332 } 333 333 ··· 575 575 indicate?: boolean; 576 576 }; 577 577 } 578 + /** Only checks for DateT since we can reasonbly sure if this exists its a date we can parse with dayjs 579 + * 580 + * It needs to be cheap since we mostly use this when walking play objects to transform strings back to dayjs and there may be many strings to check 581 + */ 582 + export const REGEX_ISO8601_LOOSE = new RegExp(/\d{4}-[01]\d-[0-3]\dT/);
+5 -15
src/core/PlayTestUtils.ts
··· 23 23 dayjs.extend(duration); 24 24 dayjs.extend(timezone); 25 25 26 - export const asPlays = (data: object[]): PlayObject[] => { 27 - return data.map(x => { 28 - const y = x as JsonPlayObject; 29 - return { 30 - ...y, 31 - data: { 32 - ...y.data, 33 - playDate: dayjs(y.data.playDate), 34 - playDateCompleted: y.data.playDateCompleted !== undefined ? dayjs(y.data.playDateCompleted) : undefined 35 - } 36 - } 37 - }); 38 - } 39 - 40 26 export const normalizePlays = (plays: PlayObject[], 41 27 options?: { 42 28 //sortFunc?: (a: PlayObject, b: PlayObject) => 0 | 1 | -1 ··· 194 180 return JSON.parse(JSON.stringify(plays)); 195 181 } 196 182 197 - export const withBrainz = (play: PlayObject, include: ('track' | 'artist' | 'album')[]): PlayObject => { 183 + export interface WithBrainzOptions { 184 + include: ('track' | 'artist' | 'album')[] 185 + } 186 + export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => { 187 + const {include} = opts; 198 188 for(const i of include) { 199 189 switch(i) { 200 190 case 'track':
+79 -2
src/core/tests/utils/fixtures.ts
··· 1 + import { Traverse, TraverseContext } from 'neotraverse/modern'; 2 + import dayjs from 'dayjs'; 3 + import { AmbPlayObject, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from '../../Atomic.js'; 4 + import { ListenRange } from '../../../backend/sources/PlayerState/ListenRange.js'; 5 + import { ListenProgressPositional, ListenProgressTS } from '../../../backend/sources/PlayerState/ListenProgress.js'; 6 + import { clone } from 'jsondiffpatch'; 1 7 2 - export const generatePlayWithLifecycle = () => { 3 - 8 + interface BlockPath { key: string, parent: string }; 9 + type BlockPaths = BlockPath[]; 10 + 11 + /** We know some nodes will never have data that needs to be transformed 12 + * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them 13 + */ 14 + const blockedKeys: PropertyKey[] = ['patch', 'inputs', 'payload', 'response', 'error']; 15 + /** We know some paths/nodes will never have data that needs to be transformed 16 + * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them 17 + */ 18 + const blockedPaths: BlockPaths = [ 19 + { 20 + parent: 'data', 21 + key: 'meta' 22 + }, 23 + { 24 + parent: 'lifecycle', 25 + key: 'input' 26 + } 27 + ] 28 + 29 + const shouldBlock = (ctx: TraverseContext): boolean => { 30 + if (blockedKeys.includes(ctx.key)) { 31 + return true; 32 + } 33 + return blockedPaths.some((x) => { 34 + let blocked = x.key === ctx.key; 35 + if (blocked && x.parent !== undefined) { 36 + blocked = ctx.parent !== undefined && ctx.parent.key === x.parent; 37 + } 38 + return blocked; 39 + }) 40 + } 41 + 42 + export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { 43 + const cloned = clone(play); 44 + new Traverse(cloned).forEach((ctx, x) => { 45 + if (shouldBlock(ctx)) { 46 + ctx.block(); 47 + return; 48 + } 49 + 50 + if (dayjs.isDayjs(x)) { 51 + ctx.update(x.toISOString()); 52 + } else if (x instanceof ListenRange) { 53 + ctx.update(x.toJSON(), true); 54 + } 55 + }); 56 + return cloned as unknown as JsonPlayObject; 57 + } 58 + 59 + export const asPlay = (data: JsonPlayObject): PlayObject => { 60 + const cloned = clone(data); 61 + new Traverse(cloned).forEach((ctx, x) => { 62 + if (shouldBlock(ctx)) { 63 + ctx.block(); 64 + return; 65 + } 66 + 67 + if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) { 68 + ctx.update(dayjs(x), true); 69 + } else if (ctx.key === 'listenRanges') { 70 + const ranges = x[0].map((y: PlayProgressAmb<string>) => { 71 + if (y.positionPercent === undefined) { 72 + return new ListenProgressPositional({ timestamp: dayjs(y.timestamp), position: y.position }); 73 + } else { 74 + return new ListenProgressTS({ timestamp: dayjs(y.timestamp), positionPercent: y.positionPercent }); 75 + } 76 + }) 77 + ctx.update(ranges, true); 78 + } 79 + }); 80 + return cloned as unknown as PlayObject; 4 81 }
+2 -2
src/stories/storyUtils.ts
··· 1 1 import { JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; 2 2 3 - const exampleLifecycle = (): PlayLifecycle => ({ 3 + const exampleLifecycle = (): PlayLifecycle<string> => ({ 4 4 "input": { 5 5 "device": { 6 6 "id": "3cc6dc47a8551498a70e519ee09cd8423ff9d48c", ··· 1101 1101 } 1102 1102 }); 1103 1103 1104 - const lastfmErrorLifcycle: PlayLifecycle = { 1104 + const lastfmErrorLifcycle: PlayLifecycle<string> = { 1105 1105 "input": { 1106 1106 "track": { 1107 1107 "album": {