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

some ui fixes

FoxxMD (Jun 10, 2026, 5:36 PM UTC) 4230674c b0955270

+75 -23
+5 -3
src/client/components/ActivityTimeline.tsx
··· 24 24 25 25 export interface ActivityDetailProps { 26 26 activity: PlayApiCommonDetailed 27 - collapsibleOpen?: boolean 27 + collapsibleOpen?: boolean, 28 + componentType?: 'source' | 'client' 28 29 } 29 30 30 31 export const ActivityTimeline = (props: ActivityDetailProps) => { 31 32 const { 32 33 activity:{ 33 34 play, 34 - input 35 + input, 36 + seenAt 35 37 } = {}, 36 38 collapsibleOpen 37 39 } = props; ··· 86 88 <Timeline.Title> 87 89 <MSCollapsible 88 90 indicator={<Span> 89 - Discovered <Span color="fg.muted">new (Play) activity from</Span> <Span fontWeight="medium">{capitalize(source)}</Span> <Span color="fg.muted">at {shortTodayAwareFormat(dayjs(playDate))}</Span> 91 + Discovered <Span color="fg.muted">new (Play) activity from</Span> <Span fontWeight="medium">{capitalize(source)}</Span> <Span color="fg.muted">at {shortTodayAwareFormat(dayjs(seenAt))}</Span> 90 92 </Span>} 91 93 defaultOpen={collapsibleOpen} 92 94 timeline
+5 -3
src/client/components/TransformSteps.tsx
··· 95 95 flowKnownState, 96 96 flowReason, 97 97 flowResult, 98 - name 98 + stageName, 99 + stageType, 100 + hook 99 101 } = x; 100 102 101 103 let timelineIcon: React.JSX.Element, ··· 107 109 iconProps = flowResult === 'continue' ? {color: "green.focusRing"} : {color: "red.focusRing"}; 108 110 summary = <Fragment><Muted>was</Muted> completed{flowResult === 'stop' ? <Fragment><Muted> and </Muted> stopped <Muted> due to onSuccess condition</Muted></Fragment> : null}</Fragment>; 109 111 if(patch === undefined) { 110 - summary = <Fragment>{summary}<Muted>with</Muted> no change <Muted>to Play</Muted></Fragment>; 112 + summary = <Fragment>{summary}<Muted> with</Muted> no change <Muted>to Play</Muted></Fragment>; 111 113 } 112 114 } else { 113 115 if(flowKnownState === 'skip') { ··· 144 146 <Timeline.Content> 145 147 <Timeline.Title> 146 148 <MSCollapsible 147 - indicator={<Span><Span color="fg.muted">{name} with</Span> {source} {summary}</Span>} 149 + indicator={<Span><Span color="fg.muted">Stage </Span>{stageType}-{stageName}<Span color="fg.muted"> in Hook </Span>{hook} <Span color="fg.muted">from</Span> {source} {summary}</Span>} 148 150 defaultOpen={collapsibleOpen} 149 151 disableUntil="md" 150 152 timeline>
+1 -1
src/client/components/playActivity/PlayList.tsx
··· 273 273 paddingInline: "var(--accordion-padding-x)" 274 274 }} justify="flex-start" alignItems="flex-end"> 275 275 <StatusBadge maxWidth="fit-content" data={activity} /> 276 - {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 276 + {activity.state === 'failed' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 277 277 <VscDebugRestart /> 278 278 </IconButton> : null} 279 279 </Stack>
+49 -10
src/stories/ActivityTimeline.stories.tsx
··· 10 10 import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; 11 11 import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; 12 12 13 + type PropsAndCustomArgs = React.ComponentProps<typeof ActivityTimeline> & { 14 + componentType: 'source' | 'client' 15 + }; 13 16 // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 14 17 const meta = preview.meta({ 15 18 title: 'Examples/ActivityTimline', ··· 22 25 tags: ['autodocs'], 23 26 // More on argTypes: https://storybook.js.org/docs/api/argtypes 24 27 args: { 25 - activity: generatePlayApiCommonDetailed() 28 + activity: generatePlayApiCommonDetailed(), 29 + //componentType: 'source' 30 + }, 31 + argTypes: { 32 + componentType: { 33 + control: { type: 'select' }, 34 + options: ['source', 'client'], 35 + } 26 36 }, 27 - render: function Render(args, { loaded: { activity } }) { return (<ActivityTimeline {...args} activity={activity}/>) }, 37 + render: function Render(args, { loaded: { activity } }) { 38 + return (<ActivityTimeline {...args} activity={activity}/>) 39 + }, 28 40 decorators: [ 29 41 (Story) => (<Provider><Container maxWidth="4xl"><Story/></Container></Provider>), 30 42 ] ··· 34 46 // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 35 47 export const ActivityTimelineStory = meta.story({ 36 48 loaders: [ 37 - async () => { 49 + async (ctx) => { 38 50 const scrobbleError = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle( 39 51 { 40 52 lifecycleSteps: { ··· 44 56 } 45 57 ))); 46 58 47 - return {activity: generatePlayApiCommonDetailed({ 59 + return { 60 + activity: generatePlayApiCommonDetailed({ 48 61 playOpts: [{play: scrobbleError}], 49 62 inputOpts: [{play: scrobbleError}] 50 - })}; 63 + }), 64 + }; 51 65 } 52 66 ], 53 67 }); ··· 56 70 loaders: [ 57 71 async () => { 58 72 const scrobbleError = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})); 59 - return {play: scrobbleError}; 73 + return { 74 + activity: generatePlayApiCommonDetailed({ 75 + playOpts: [{play: scrobbleError}], 76 + inputOpts: [{play: scrobbleError}] 77 + }) 60 78 } 79 + } 61 80 ], 62 81 }); 63 82 ··· 70 89 postCompare: [false], 71 90 } 72 91 })); 73 - return {play: scrobbleError}; 92 + return { 93 + activity: generatePlayApiCommonDetailed({ 94 + playOpts: [{play: scrobbleError}], 95 + inputOpts: [{play: scrobbleError}] 96 + }) 97 + } 74 98 } 75 99 ], 76 100 }); ··· 83 107 preCompare: [true, 'skipped', true], 84 108 } 85 109 })); 86 - return {play}; 110 + return { 111 + activity: generatePlayApiCommonDetailed({ 112 + playOpts: [{play}], 113 + inputOpts: [{play}] 114 + }) 115 + } 87 116 } 88 117 ], 89 118 }); ··· 96 125 preCompare: [true, 'prereq'], 97 126 } 98 127 })); 99 - return {play}; 128 + return { 129 + activity: generatePlayApiCommonDetailed({ 130 + playOpts: [{play}], 131 + inputOpts: [{play}] 132 + }) 133 + } 100 134 } 101 135 ], 102 136 }); ··· 109 143 preCompare: [true, 'stop'], 110 144 } 111 145 })); 112 - return {play}; 146 + return { 147 + activity: generatePlayApiCommonDetailed({ 148 + playOpts: [{play}], 149 + inputOpts: [{play}] 150 + }) 151 + } 113 152 } 114 153 ], 115 154 });
+15 -6
src/stories/List.stories.tsx
··· 11 11 import { generateArray } from "../core/DataUtils.js"; 12 12 import dayjs from "dayjs"; 13 13 import { asJsonPlayObject } from "../core/PlayMarshalUtils.js"; 14 + import { generatePlayApiCommon } from "../core/tests/utils/apiFixtures.js"; 14 15 15 16 const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894: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)"; 16 17 ··· 55 56 export const List = meta.story({ 56 57 loaders: [ 57 58 async () => { 58 - const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => ({play: asJsonPlayObject(x), status: 'queued'})); 59 + const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => generatePlayApiCommon({play: asJsonPlayObject(x), state: 'queued'})); 59 60 60 - const scrobbled = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); 61 - const scrobbleError = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})); 61 + const scrobbled = generatePlayApiCommon({ 62 + play: asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))), 63 + state: 'scrobbled' 64 + }); 65 + const scrobbleError = generatePlayApiCommon({ 66 + play: asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})), 67 + state: 'failed' 68 + }); 62 69 63 70 const promisedScrobbled = generateArray(10,() => playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); 64 71 const promised = await Promise.all(promisedScrobbled); 65 - const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => ({play: asJsonPlayObject(x), status: 'scrobbled'})); 72 + const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => generatePlayApiCommon({play: asJsonPlayObject(x), state: 'scrobbled'})); 66 73 return {data: [ 67 74 ...queued, 68 - {play: scrobbled, status: 'scrobbled'}, 69 - {play: scrobbleError, status: 'error'}, 75 + scrobbled, 76 + scrobbleError, 77 + // {play: scrobbled, status: 'scrobbled'}, 78 + // {play: scrobbleError, status: 'error'}, 70 79 ...yesterdayScrobbled 71 80 ]}; 72 81 }