[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 api fixtures

FoxxMD (Jun 10, 2026, 1:54 AM UTC) d9d37c68 3bb88776

+189 -51
+2 -1
src/backend/common/database/drizzle/schema/schema.ts
··· 7 7 import { ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm } from "../../../infrastructure/Transform.js"; 8 8 import { JobRangeCount, JobRangeTime } from "../../../infrastructure/Job.js"; 9 9 import { serializeError, deserializeError } from "serialize-error"; 10 + import { generatePlayUid } from "../../../../../core/StringUtils.js"; 10 11 11 12 const DayjsTimestamp = customType< 12 13 { ··· 67 68 68 69 export const plays = sqliteTable("plays", { 69 70 id: integer().primaryKey(), 70 - uid: text({ length: 30 }).notNull().unique().$defaultFn(() => nanoid(20)), 71 + uid: text({ length: 30 }).notNull().unique().$defaultFn(() => generatePlayUid()), 71 72 componentId: integer().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}), 72 73 error: ErrorLikeJson('error'), 73 74 playedAt: DayjsTimestamp('playedAt'),
+6 -11
src/client/components/ActivityDetail.tsx
··· 6 6 import { AiOutlineExclamationCircle } from "react-icons/ai"; 7 7 import { ActivityTimeline } from "./ActivityTimeline"; 8 8 import { ExpandCollapse } from "./ExpandCollapse"; 9 + import { PlayApiCommon, PlayApiCommonDetailed } from "../../core/Api"; 9 10 10 11 export interface ActivityDetailProps { 11 - activity: PlayActivity 12 + activity: PlayApiCommonDetailed 12 13 } 13 14 14 15 export const ActivityDetails = (props: ActivityDetailProps) => { ··· 16 17 activity, 17 18 activity: { 18 19 error, 19 - play: { 20 - meta: { 21 - lifecycle: { 22 - original, 23 - scrobble 24 - } = {}, 25 - lifecycle 26 - } = {}, 27 - } = {} 20 + input: { 21 + play: original, 22 + } 28 23 } 29 24 } = props; 30 25 ··· 69 64 </Flex> 70 65 <Accordion.ItemContent> 71 66 <Accordion.ItemBody> 72 - <ActivityTimeline play={activity.play} collapsibleOpen={collapsibleOpen} /> 67 + <ActivityTimeline activity={activity} collapsibleOpen={collapsibleOpen} /> 73 68 </Accordion.ItemBody> 74 69 </Accordion.ItemContent> 75 70 </Accordion.Item>
+13 -5
src/client/components/ActivityTimeline.tsx
··· 19 19 import { MSCollapsible } from "./MSCollapsible"; 20 20 import { TimelineErrorIcon } from "./timeline/TimelineIcon"; 21 21 import { Muted } from "./Typography"; 22 + import { PlayApiCommonDetailed } from "../../core/Api"; 22 23 23 24 24 25 export interface ActivityDetailProps { 25 - play: JsonPlayObject 26 + activity: PlayApiCommonDetailed 26 27 collapsibleOpen?: boolean 27 28 } 28 29 29 30 export const ActivityTimeline = (props: ActivityDetailProps) => { 30 31 const { 31 - play, 32 + activity:{ 33 + play, 34 + input 35 + } = {}, 32 36 collapsibleOpen 33 37 } = props; 34 38 const { ··· 38 42 meta: { 39 43 source, 40 44 lifecycle: { 41 - input, 42 - original, 45 + // input, 46 + // original, 43 47 steps = [], 44 48 scrobble: { 45 49 match, ··· 51 55 }, 52 56 } = {} 53 57 } = play; 58 + const { 59 + play: original, 60 + data: ogInput 61 + } = input || {}; 54 62 55 63 let scrobbleSummary: React.JSX.Element, 56 64 scrobbleIconProps: Record<string, any> = { ··· 98 106 <PlayData play={original} /> 99 107 </Tabs.Content> 100 108 <Tabs.Content value="source"> 101 - <ChakraCodeBlockShort code={input} /> 109 + <ChakraCodeBlockShort code={ogInput} /> 102 110 </Tabs.Content> 103 111 </Tabs.Root> 104 112 </Card.Body>
+21 -13
src/client/components/playActivity/PlayList.tsx
··· 12 12 import { ActivityDetails } from '../ActivityDetail.js'; 13 13 import { sortByNewestPlayDate, sortByNewestSeenDate } from '../../../core/PlayUtils.js'; 14 14 import "./PlayList.scss"; 15 + import { PlayApiCommon } from '../../../core/Api.js'; 15 16 16 17 dayjs.extend(doy); 17 18 18 19 export interface ActivityLogProps { 19 - data: PlayActivity[] 20 + data: PlayApiCommon[] 20 21 sortBy?: 'played' | 'seen' 21 22 render?: 'virtCollapse' | 'virtAccordian' | 'accordian' 22 23 } ··· 27 28 } 28 29 29 30 interface GroupData { 30 - plays: PlayActivity[] 31 + plays: PlayApiCommon[] 31 32 date: Dayjs 32 33 } 33 34 34 - const generateGroupInfo = (data: PlayActivity[]): GroupInfo[] => { 35 + const generateGroupInfo = (data: PlayApiCommon[]): GroupInfo[] => { 35 36 36 37 const groupsReduced = data.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => { 37 38 const date = dayjs(curr.play.data.playDate); ··· 48 49 return groupsReduced.groups.concat(groupsReduced.active); 49 50 } 50 51 51 - const generateGroupPlays = (data: PlayActivity[]): GroupData[] => { 52 + const generateGroupPlays = (data: PlayApiCommon[]): GroupData[] => { 52 53 53 54 const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { 54 55 const date = dayjs(curr.play.data.playDate); ··· 91 92 } 92 93 } 93 94 94 - const VirtualizedCollapse = (props: { data: PlayActivity[] }) => { 95 + const VirtualizedCollapse = (props: { data: PlayApiCommon[] }) => { 95 96 const { 96 97 data, 97 98 } = props; ··· 180 181 paddingInline: "var(--chakra-spacing-4)" 181 182 }} justify="flex-start" alignItems="flex-end"> 182 183 <StatusBadge maxWidth="fit-content" data={activity} /> 183 - {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 184 + {activity.state === 'failed' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 184 185 <VscDebugRestart /> 185 186 </IconButton> : null} 186 187 </Stack> ··· 200 201 ); 201 202 } 202 203 203 - const VirtualizedAccordian = (props: { data: PlayActivity[] }) => { 204 + const VirtualizedAccordian = (props: { data: PlayApiCommon[] }) => { 204 205 const { 205 206 data, 206 207 } = props; ··· 290 291 ); 291 292 } 292 293 293 - const PlainAccordian = (props: { data: PlayActivity[], sortBy: 'played' | 'seen' }) => { 294 + const PlainAccordian = (props: { data: PlayApiCommon[], sortBy: 'played' | 'seen' }) => { 294 295 const { 295 296 data = [], 296 297 sortBy ··· 343 344 paddingInline: "var(--accordion-padding-x)" 344 345 }} justify="flex-start" alignItems="flex-end"> 345 346 <StatusBadge maxWidth="fit-content" data={activity} /> 346 - {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 347 + {activity.state === 'failed' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 347 348 <VscDebugRestart /> 348 349 </IconButton> : null} 349 350 </Stack> ··· 365 366 ); 366 367 } 367 368 368 - const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayActivity }) => { 369 + const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayApiCommon }) => { 369 370 370 371 const { data, ...rest } = props; 371 372 372 373 let badgeColor = undefined, 373 - badgeText = capitalize(data.status); 374 + badgeText = capitalize(data.state); 374 375 375 - switch (data.status) { 376 + switch (data.state) { 376 377 case 'queued': 377 378 badgeColor = 'gray'; 378 379 break; 379 380 case 'scrobbled': 381 + case 'discovered': 380 382 badgeColor = 'green'; 381 383 break; 382 - case 'error': 384 + case 'failed': 383 385 badgeColor = 'red'; 386 + break; 387 + case 'discarded': 388 + badgeColor = 'grey'; 389 + break; 390 + case 'duped': 391 + badgeColor = 'orange'; 384 392 break; 385 393 } 386 394
+9 -9
src/core/Api.ts
··· 1 1 import { ErrorLike, JsonPlayObject, PlayState } from "./Atomic.js" 2 2 3 - export type PlayApiCommon = { 3 + export interface PlayApiCommon { 4 4 uid: string 5 5 componentId: number 6 6 state: PlayState 7 7 play: JsonPlayObject 8 8 compacted: boolean 9 9 playedAt: string 10 - seentAt: string 10 + seenAt: string 11 11 updatedAt: string 12 12 parentUid?: string 13 13 // TODO add parent source type/name? 14 14 } 15 15 16 - export type PlayInputApi = { 16 + export interface PlayInputApi { 17 17 id: number 18 - data: object 19 - play: JsonPlayObject 18 + data?: object 19 + play?: JsonPlayObject 20 20 createdAt: string 21 21 } 22 22 23 - export type QueueStateApi = { 23 + export interface QueueStateApi { 24 24 id: number 25 25 queueName: string 26 26 queueState: string ··· 30 30 updatedAt: string 31 31 } 32 32 33 - export type PlayApiCommonDetailed = PlayApiCommon & { 34 - error: ErrorLike 35 - input: PlayInputApi 33 + export interface PlayApiCommonDetailed extends PlayApiCommon { 34 + error?: ErrorLike 35 + input?: PlayInputApi 36 36 queueStates: QueueStateApi[] 37 37 }
+12 -3
src/core/Atomic.ts
··· 366 366 id?: number 367 367 uid?: string 368 368 data: PlayData<D>, 369 - meta: PlayMetaLifecycleless 369 + meta: PlayMetaLifecycleless<D> 370 370 original?: PlayOriginal<D> 371 371 } 372 372 ··· 614 614 */ 615 615 export const REGEX_ISO8601_WELLKNOWN = new RegExp(/dayjs-(\d{4}-[01]\d-[0-3]\dT.*)/); 616 616 617 - export const CLIENT_INGRESS_QUEUE = 'ingress'; 618 - export const CLIENT_DEAD_QUEUE = 'dead'; 617 + export const CLIENT_INGRESS_QUEUE: QueueName = 'ingress'; 618 + export const CLIENT_DEAD_QUEUE: QueueName = 'dead'; 619 + export type QueueName = 'ingress' | 'dead'; 620 + export const QUEUE_NAMES = [CLIENT_INGRESS_QUEUE, CLIENT_DEAD_QUEUE]; 619 621 620 622 /** 621 623 * Useful TS type-only utility for testing type equality ··· 639 641 export const PLAY_CLIENT_STATE = [...PLAY_STATE_COMMON, 'duped', 'scrobbled']; 640 642 export type PlayState = PlaySourceState | PlayClientState; 641 643 export const PLAY_STATES = Array.from(new Set(...PLAY_CLIENT_STATE, ...PLAY_SOURCE_STATE)); 644 + 645 + 646 + export type QueueStatus = 'queued' | 'completed' | 'failed'; 647 + export const QUEUE_STATUS_QUEUED: QueueStatus = 'queued'; 648 + export const QUEUE_STATUS_COMPLETED: QueueStatus = 'completed'; 649 + export const QUEUE_STATUS_FAILED: QueueStatus = 'failed'; 650 + export const QUEUE_STATUSES: QueueStatus[] = [QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED, QUEUE_STATUS_QUEUED];
+2 -2
src/core/PlayMarshalUtils.ts
··· 2 2 import dayjs from 'dayjs'; 3 3 import { Traverse, TraverseContext } from 'neotraverse/modern'; 4 4 import { ListenRange } from '../backend/sources/PlayerState/ListenRange.js'; 5 - import { AmbPlayObject, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js'; 5 + import { AmbPlayObject, DateLike, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js'; 6 6 import { ListenProgressPositional, ListenProgressTS } from '../backend/sources/PlayerState/ListenProgress.js'; 7 7 8 8 interface BlockPath { key: string, parent: string }; ··· 39 39 }); 40 40 }; 41 41 42 - export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { 42 + export const asJsonPlayObject = (play: AmbPlayObject<DateLike>): JsonPlayObject => { 43 43 const cloned = clone(play); 44 44 new Traverse(cloned).forEach((ctx, x) => { 45 45 if (shouldBlock(ctx)) {
+4 -1
src/core/StringUtils.ts
··· 17 17 import { DELIMETERS_REGEX, DELIMITERS } from "../backend/common/infrastructure/Atomic.js"; 18 18 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 19 19 import { removeUndefinedKeys } from "../backend/utils.js"; 20 + import { nanoid } from "nanoid"; 20 21 21 22 dayjs.extend(utc) 22 23 dayjs.extend(isBetween); ··· 331 332 return removeUndefinedKeys({name, mbid, ...rest}); 332 333 } 333 334 export const artistCreditToName = (a: ArtistCredit): string => a.name; 334 - export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name); 335 + export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name); 336 + 337 + export const generatePlayUid = () => nanoid(20);
+99
src/core/tests/utils/apiFixtures.ts
··· 1 + import { faker } from "@faker-js/faker"; 2 + import { PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi } from "../../Api.js"; 3 + import { CLIENT_INGRESS_QUEUE, JsonPlayObject, PlayObject, PlayState, QUEUE_STATUSES } from "../../Atomic.js"; 4 + import { generatePlay } from "../../PlayTestUtils.js"; 5 + import { generatePlayInput, randomPlayState } from "./fixtures.js"; 6 + import { asJsonPlayObject } from "../../PlayMarshalUtils.js"; 7 + import { generatePlayUid } from "../../StringUtils.js"; 8 + import dayjs from "dayjs"; 9 + import { ErrorLike } from "serialize-error"; 10 + 11 + export const generatePlayApiCommon = (commonData: Partial<PlayApiCommon> & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters<typeof generatePlay>): PlayApiCommon => { 12 + let play: JsonPlayObject | PlayObject; 13 + const { 14 + play: cPlay, 15 + ...rest 16 + } = commonData; 17 + if(cPlay !== undefined) { 18 + play = cPlay 19 + } else { 20 + play = generatePlay(...playOpts); 21 + } 22 + 23 + const { 24 + playedAt = typeof play.data.playDate === 'string' ? play.data.playDate : play.data.playDate.toISOString(), 25 + seenAt = playedAt, 26 + updatedAt = seenAt, 27 + compacted = false, 28 + state = randomPlayState(), 29 + componentId = faker.number.int({min: 1, max: 10}), 30 + uid = generatePlayUid() 31 + } = commonData; 32 + 33 + return { 34 + play: asJsonPlayObject(play), 35 + ...rest, 36 + playedAt, 37 + seenAt, 38 + updatedAt, 39 + compacted, 40 + state, 41 + componentId, 42 + uid 43 + } 44 + } 45 + 46 + export const generatePlayInputApi = (inputData: Partial<PlayInputApi> = {}, ...args: Parameters<typeof generatePlayInput>): PlayInputApi => { 47 + const res = generatePlayInput(...args); 48 + let createdAt: string = dayjs().toISOString(); 49 + if(res.play?.data?.playDate !== undefined) { 50 + if(typeof res.play?.data?.playDate === 'string') { 51 + createdAt = res.play?.data?.playDate; 52 + } else { 53 + createdAt = res.play?.data?.playDate.toISOString(); 54 + } 55 + } 56 + return { 57 + id: faker.number.int({min: 1, max: 100}), 58 + createdAt, 59 + data: res.data, 60 + play: res.play !== undefined ? asJsonPlayObject(res.play) : undefined, 61 + ...inputData, 62 + } 63 + } 64 + 65 + export const generateQueueStateApi = (data: Partial<QueueStateApi>): QueueStateApi => { 66 + const cAt = faker.date.recent().toISOString(); 67 + return { 68 + id: faker.number.int({min: 1, max: 100}), 69 + queueName: CLIENT_INGRESS_QUEUE, 70 + queueState: faker.helpers.arrayElement(QUEUE_STATUSES), 71 + createdAt: cAt, 72 + updatedAt: cAt, 73 + retries: 0, 74 + ...data 75 + } 76 + } 77 + 78 + export const generatePlayApiCommonDetailed = (opts: { 79 + playOpts?: Parameters<typeof generatePlayApiCommon>, 80 + inputOpts?: Parameters<typeof generatePlayInputApi>, 81 + queueOpts?: Parameters<typeof generateQueueStateApi> 82 + } = {}, error?: ErrorLike): PlayApiCommonDetailed => { 83 + const { 84 + playOpts = [], 85 + inputOpts = [], 86 + queueOpts = [], 87 + } = opts; 88 + 89 + const playCommon = generatePlayApiCommon(...playOpts); 90 + const inputRes = generatePlayInputApi(...inputOpts); 91 + const queueRes = generateQueueStateApi(queueOpts[0]); 92 + 93 + return { 94 + ...playCommon, 95 + input: inputRes, 96 + queueStates: [queueRes], 97 + error 98 + } 99 + }
+12 -2
src/core/tests/utils/fixtures.ts
··· 1 1 import { Traverse, TraverseContext } from 'neotraverse/modern'; 2 2 import { faker } from '@faker-js/faker'; 3 - import { LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, ScrobbleResult } from '../../Atomic.js'; 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 6 import { lifecyclelessInvariantTransform } from '../../PlayUtils.js'; ··· 10 10 import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js'; 11 11 import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js'; 12 12 import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError } from '../../../backend/common/errors/MSErrors.js'; 13 + import { Dayjs } from 'dayjs'; 13 14 14 15 export interface ScrobbleMatchOptions { 15 16 match?: boolean ··· 96 97 lplay.data = transformedPlay.data; 97 98 98 99 return lplay; 100 + } 101 + 102 + export const generatePlayInput = <D extends DateLike = Dayjs>(play?: AmbPlayObject<D>, data?: object | false): PlayOriginal<D> => { 103 + return { 104 + play, 105 + data: data ?? generateRandomObj(2) 106 + } 99 107 } 100 108 101 109 export const playWithLifecycleScrobble = async (play: PlayObject, opts: ScrobbleMatchOptions = {}): Promise<PlayObject> => { ··· 282 290 } 283 291 } 284 292 return tgrt 285 - } 293 + } 294 + 295 + export const randomPlayState = () => faker.helpers.arrayElement(PLAY_STATES) as PlayState;
+9 -4
src/stories/ActivityTimeline.stories.tsx
··· 7 7 import {Provider} from "../client/components/Provider"; 8 8 import { generateJsonPlays } from "../core/PlayTestUtils.js"; 9 9 import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; 10 + import { generatePlayApiCommonDetailed } from "../core/tests/utils/apiFixtures.js"; 10 11 import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; 11 12 import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; 12 13 import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; ··· 23 24 tags: ['autodocs'], 24 25 // More on argTypes: https://storybook.js.org/docs/api/argtypes 25 26 args: { 26 - play: examplePlay() 27 + activity: generatePlayApiCommonDetailed() 27 28 }, 28 - render: function Render(args, { loaded: { play } }) { return (<ActivityTimeline {...args} play={play}/>) }, 29 + render: function Render(args, { loaded: { activity } }) { return (<ActivityTimeline {...args} activity={activity}/>) }, 29 30 decorators: [ 30 31 (Story) => (<Provider><Container maxWidth="4xl"><Story/></Container></Provider>), 31 32 ] ··· 43 44 postCompare: 1, 44 45 } 45 46 } 46 - ))) 47 - return {play: scrobbleError}; 47 + ))); 48 + 49 + return {activity: generatePlayApiCommonDetailed({ 50 + playOpts: [{play: scrobbleError}], 51 + inputOpts: [{play: scrobbleError}] 52 + })}; 48 53 } 49 54 ], 50 55 });