[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(ui): Add lifecycle step story and fix diff rendering

FoxxMD (Mar 20, 2026, 8:39 PM UTC) 66cdb94d 5ebb0e6a

+134 -54
+1 -2
src/client/components/ActivityDetail.tsx
··· 28 28 } 29 29 } = props; 30 30 31 - debugger; 32 31 const [collapsibleOpen, setCollapsibleOpen] = useState(undefined); 33 32 34 33 return ( ··· 62 61 </Flex> 63 62 <Accordion.ItemContent> 64 63 <Accordion.ItemBody> 65 - {scrobble !== undefined ? <ActivityTimeline play={activity.play} collapsibleOpen={collapsibleOpen} /> : null} 64 + <ActivityTimeline play={activity.play} collapsibleOpen={collapsibleOpen} /> 66 65 </Accordion.ItemBody> 67 66 </Accordion.ItemContent> 68 67 </Accordion.Item>
-2
src/client/components/ActivityTimeline.tsx
··· 51 51 } = {} 52 52 } = play; 53 53 54 - const [scrobbleCollapsibleOpen, setScrobbleCollapsibleOpen] = useState(false); 55 - 56 54 return ( 57 55 <Timeline.Root variant="subtle" size="lg"> 58 56 <Timeline.Item>
+5 -3
src/client/components/JsonDiff.tsx
··· 24 24 export const JsonDiffPatch = (props: JsonDiffPatchProps) => { 25 25 const { 26 26 right, 27 + left, 27 28 diff, 28 29 ...rest 29 30 } = props; 31 + const detachedLeft = JSON.parse(JSON.stringify(left)); 30 32 let realRight: DiffableVal; 31 33 if (right !== undefined) { 32 - realRight = right; 34 + realRight = JSON.parse(JSON.stringify(right)); 33 35 } else if (diff !== undefined) { 34 - realRight = jdiff.patch(props.left, diff as Delta) as DiffableVal; 36 + realRight = jdiff.patch(JSON.parse(JSON.stringify(left)), diff as Delta) as DiffableVal; 35 37 } else { 36 38 throw new Error(`must provide either 'right' or 'diff'`); 37 39 } 38 40 return <MSErrorBoundary> 39 - <JsonDiffReact {...rest} right={realRight} /> 41 + <JsonDiffReact {...rest} left={detachedLeft} right={realRight} /> 40 42 </MSErrorBoundary> 41 43 };
+1 -1
src/client/components/ScrobbleActionResult.tsx
··· 15 15 import { TimelineErrorIcon } from "./timeline/TimelineIcon"; 16 16 17 17 export interface ScrobbleActionResultProps extends MSCollapsibleExternalProps { 18 - result: ScrobbleResult, 18 + result: ScrobbleResult<string>, 19 19 scrobbler?: string, 20 20 } 21 21
+41 -24
src/client/components/TransformSteps.tsx
··· 1 1 import { ComponentProps, Fragment } from "react" 2 - import { Timeline, Icon, Span, Stack, Heading, Box } from '@chakra-ui/react'; 2 + import { Timeline, Icon, Span, Stack, Heading, Box, Text } from '@chakra-ui/react'; 3 3 import { JsonPlayObject, LifecycleStep } from "../../core/Atomic"; 4 4 import { PlayData } from "./PlayData"; 5 5 import { ErrorAlert } from "./ErrorAlert"; ··· 22 22 collapsibleOpen 23 23 } = props; 24 24 25 - let currentPlay: JsonPlayObject | false = JSON.parse(JSON.stringify(original)); 26 - if(currentPlay !== false) { 27 - currentPlay.data.meta = { 28 - ...(currentPlay.data.meta ?? {}), 29 - } 30 - } 25 + let currentPlay: JsonPlayObject = JSON.parse(JSON.stringify(original)), 26 + patchFailed = false; 31 27 32 28 return ( 33 29 <Timeline.Root variant="subtle" css={{ "--timeline-separator-display": 'block' }}> ··· 39 35 name 40 36 } = x; 41 37 let err: Error; 42 - const left = currentPlay !== false ? JSON.parse(JSON.stringify(currentPlay)) : false; 43 - if (currentPlay !== false && patch !== undefined) { 38 + 39 + let left: JsonPlayObject; 40 + if(!patchFailed) { 41 + left = JSON.parse(JSON.stringify(currentPlay)); 42 + left.data.meta = { 43 + ...(left.data.meta ?? {}), 44 + brainz: { 45 + ...(left.data.meta?.brainz ?? {}) 46 + } 47 + } 48 + currentPlay.data.meta = { 49 + ...(currentPlay.data.meta ?? {}), 50 + brainz: { 51 + ...(currentPlay.data.meta?.brainz ?? {}) 52 + } 53 + } 54 + } 55 + 56 + if (!patchFailed && patch !== undefined) { 44 57 try { 45 58 currentPlay = jdiff.patch(currentPlay, patch) as JsonPlayObject; 46 59 } catch (e) { 47 60 err = new Error('Could not patch Play object', { cause: e }); 48 - currentPlay = false; 61 + patchFailed = true; 49 62 } 50 - } else { 51 - currentPlay = false; 52 63 } 53 64 let diffElm: JSX.Element; 54 - if(left !== false && currentPlay !== false) { 55 - diffElm = <ChakraPlainBlockShort code={left}> 56 - <JsonDiffPatch left={left} right={currentPlay} /> 57 - </ChakraPlainBlockShort>; 58 - } else if(patch !== undefined) { 59 - diffElm = <ChakraCodeBlockShort code={patch} />; 65 + 66 + if(err) { 67 + diffElm = <Fragment><ErrorAlert error={err} /><ChakraCodeBlockShort title="Diff Patch" key={`diffblockfallback-${index}`} code={patch} /></Fragment> 68 + } else if(patch === undefined) { 69 + diffElm = <Text>Play was identical after Transform.</Text> 70 + } else if(patchFailed) { 71 + diffElm = <ChakraCodeBlockShort key={`diffblockfallback-${index}`} title="Diff Patch" code={patch} /> 72 + } else { 73 + diffElm = <ChakraPlainBlockShort title="Play Diff" key={`diffblock-${index}`} code={left}> 74 + <JsonDiffPatch key={`diff-${index}`} left={left} right={JSON.parse(JSON.stringify(currentPlay))} /> 75 + </ChakraPlainBlockShort>; 60 76 } 77 + 61 78 const showAnyDetails = inputs !== undefined || diffElm !== undefined || err !== undefined; 62 - return <Timeline.Item> 79 + return <Timeline.Item key={index}> 63 80 <Timeline.Connector> 64 81 <Timeline.Separator /> 65 82 <Timeline.Indicator> ··· 75 92 76 93 {showAnyDetails ? <MSCollapsible indicator="Show Details" defaultOpen={collapsibleOpen} hideBelow="sm"> 77 94 <Stack gap="2"> 78 - {err !== undefined ? <ErrorAlert error={err} /> : null} 79 - {diffElm !== undefined ? <Fragment><Heading size="sm">Diff</Heading>{diffElm}</Fragment> : null } 95 + <Heading size="sm">Diff</Heading> 96 + {diffElm} 80 97 {inputs !== undefined ? ( 81 98 <Fragment> 82 99 <Heading size="sm">Inputs</Heading> 83 100 <Stack gap="1"> 84 - {x.inputs.map((y) => { 85 - return <ChakraCodeBlockShort code={y.input} title={y.type} /> 101 + {x.inputs.map((y, inputsIndex) => { 102 + return <ChakraCodeBlockShort key={`inputs-${inputsIndex}`} code={y.input} title={y.type} /> 86 103 })} 87 104 </Stack></Fragment>) : null} 88 105 </Stack> ··· 91 108 </Timeline.Item> 92 109 })} 93 110 {currentPlay !== false ? ( 94 - <Timeline.Item hideBelow="sm"> 111 + <Timeline.Item key="finalPlay" hideBelow="sm"> 95 112 <Timeline.Connector> 96 113 <Timeline.Separator /> 97 114 <Timeline.Indicator>
+6 -6
src/core/Atomic.ts
··· 288 288 289 289 export type ErrorLike = Error | ErrorObject; 290 290 291 - export interface ScrobbleResult { 292 - match?: PlayMatchResult 291 + export interface ScrobbleResult<D extends DateLike = Dayjs> { 292 + match?: PlayMatchResult<D> 293 293 payload?: ScrobblePayload 294 294 warnings?: string[] 295 295 error?: Error | ErrorObject 296 296 response?: ScrobbleResponse 297 - mergedScrobble?: PlayObjectLifecycleless 297 + mergedScrobble?: PlayObjectLifecycleless<D> 298 298 } 299 299 300 300 export interface PlayLifecycle<D extends DateLike = Dayjs> { 301 301 input?: object 302 302 original: PlayObjectLifecycleless<D> 303 303 steps: LifecycleStep[] 304 - scrobble?: ScrobbleResult 304 + scrobble?: ScrobbleResult<D> 305 305 } 306 306 307 307 export interface LifecycleStep { ··· 314 314 export type ScrobblePayload = object | string; 315 315 export type ScrobbleResponse = object | string; 316 316 317 - export interface ScrobbleActionResult { 317 + export interface ScrobbleActionResult<D extends DateLike = Dayjs> { 318 318 payload: ScrobblePayload, 319 319 response?: ScrobbleResponse, 320 - mergedScrobble?: PlayObject 320 + mergedScrobble?: AmbPlayObject<D> 321 321 warnings?: string[] 322 322 } 323 323
+41 -16
src/core/tests/utils/fixtures.ts
··· 98 98 data?: ObjectPlayData, 99 99 meta?: MarkOptional<PlayMeta, 'lifecycle'> 100 100 opts?: GeneratePlayOpts 101 + }, 102 + lifecycleSteps?: { 103 + preCompare?: boolean | number 104 + postCompare?: boolean | number 101 105 } 102 106 } 103 107 ··· 105 109 106 110 const { 107 111 original: originalOpts = {}, 112 + lifecycleSteps: { 113 + preCompare = true, 114 + postCompare = true 115 + } = {} 108 116 } = opts; 109 117 110 118 const original = generatePlay(originalOpts.data, originalOpts.meta, originalOpts.opts); ··· 120 128 121 129 let steps: LifecycleStep[] = []; 122 130 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 + if(typeof preCompare === 'number') { 133 + for(let i = 0; i < preCompare; i++) { 134 + const step = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); 135 + steps.push(step[0]); 136 + transformedPlay = step[1]; 137 + } 138 + } else if(preCompare === true) { 139 + const firstStep = faker.datatype.boolean(0.7) ? generateLifecycleStep(transformedPlay, {name: 'preCompare'}) : undefined; 140 + if(firstStep !== undefined) { 141 + transformedPlay = firstStep[1]; 142 + steps.push(firstStep[0]); 143 + while(faker.datatype.boolean(0.2)) { 144 + const [pc, modified] = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); 145 + steps.push(pc); 146 + transformedPlay = modified; 147 + } 131 148 } 132 149 } 133 150 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; 151 + if(typeof postCompare === 'number') { 152 + for(let i = 0; i < postCompare; i++) { 153 + const step = generateLifecycleStep(transformedPlay, {name: 'postCompare'}); 154 + steps.push(step[0]); 155 + transformedPlay = step[1]; 156 + } 157 + } else if(postCompare === true) { 158 + const lastStep = faker.datatype.boolean(0.5) ? generateLifecycleStep(transformedPlay, {name: 'postCompare'}) : undefined; 159 + if(lastStep !== undefined) { 160 + transformedPlay = lastStep[1]; 161 + steps.push(lastStep[0]); 162 + while(faker.datatype.boolean(0.1)) { 163 + const [pc, modified] = generateLifecycleStep(transformedPlay, {name: 'postCompare'}); 164 + steps.push(pc); 165 + transformedPlay = modified; 166 + } 142 167 } 143 168 } 144 169
+39
src/stories/TransformSteps.stories.tsx
··· 1 + import preview from "../../.storybook/preview.js"; 2 + import React from 'react'; 3 + 4 + import { fn } from 'storybook/test'; 5 + import { Container } from '@chakra-ui/react'; 6 + import { TransformSteps } from "../client/components/TransformSteps.js"; 7 + import {Provider} from "../client/components/Provider"; 8 + import { generateJsonPlays } from "../core/PlayTestUtils.js"; 9 + import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; 10 + import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; 11 + import {generatePlayWithLifecycle} from '../core/tests/utils/fixtures' 12 + 13 + // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 14 + const meta = preview.meta({ 15 + title: 'Examples/TransformSteps', 16 + component: TransformSteps, 17 + parameters: { 18 + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 19 + layout: 'padded', 20 + }, 21 + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 22 + tags: ['autodocs'], 23 + // More on argTypes: https://storybook.js.org/docs/api/argtypes 24 + decorators: [ 25 + (Story) => (<Provider><Container maxWidth="4xl"><Story/></Container></Provider>), 26 + ] 27 + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args 28 + }); 29 + 30 + const multiPlay = generatePlayWithLifecycle({lifecycleSteps: { preCompare: 2}}); 31 + // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 32 + export const Multiple = meta.story({ 33 + args: { 34 + steps: multiPlay.meta.lifecycle.steps, 35 + original: multiPlay.meta.lifecycle.original, 36 + collapsibleOpen: true 37 + } 38 + //render: function Render(args) { return (<ChakraProvider><MyList></MyList></ChakraProvider>) } 39 + });