[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): Clean up scrobble timeline items

* Use timeline summary formatting to fix spacing
* Provide client name from fetched component data, if present, so scrobble summary is explicit where it sent/response scrobble from

FoxxMD (Jul 15, 2026, 4:22 PM UTC) 67c18231 2014e78a

+64 -44
+8 -4
src/client/components/ActivityDetail.tsx
··· 4 4 import React, { Fragment, useEffect, useState } from "react"; 5 5 import { LuChevronRight } from "react-icons/lu"; 6 6 import type { MarkOptional } from "ts-essentials"; 7 - import type { MsSseEvent, PaginatedResponse, PlayApiCommonDetailed, QueryPlaysOptsJson, SortPlaysByProps } from "../../core/Api"; 7 + import type { ComponentsApiJson, MsSseEvent, PaginatedResponse, PlayApiCommonDetailed, QueryPlaysOptsJson, SortPlaysByProps } from "../../core/Api"; 8 8 import { CLIENT_DEAD_QUEUE, type ComponentType, type Second } from "../../core/Atomic"; 9 - import { tanQueries } from "../queries"; 9 + import { tanQueries, useQueryWatcher } from "../queries"; 10 10 import { activityTimelineHasIssue } from "../utils/ComponentUtils"; 11 11 import { ActivityTimeline } from "./ActivityTimeline"; 12 12 import { EphemeralElement, PlayStateBadge } from "./Badges"; ··· 84 84 export interface ActivityDetailProps { 85 85 activity: PlayApiCommonDetailed 86 86 componentType: ComponentType 87 + componentName?: string 87 88 } 88 89 89 90 export interface ActivitySummaryProps extends SortPlaysByProps { ··· 215 216 const { 216 217 activity, 217 218 componentType, 219 + componentName, 218 220 activity: { 219 221 error, 220 222 input: { ··· 271 273 </Flex> 272 274 <Accordion.ItemContent> 273 275 <Accordion.ItemBody> 274 - <ActivityTimeline activity={activity} collapsibleOpen={collapsibleOpen} componentType={componentType} /> 276 + <ActivityTimeline activity={activity} collapsibleOpen={collapsibleOpen} componentType={componentType} componentName={componentName} /> 275 277 </Accordion.ItemBody> 276 278 </Accordion.ItemContent> 277 279 </Accordion.Item> ··· 290 292 export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => { 291 293 const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity, refetchOnMount: 'always'}); 292 294 295 + const { data } = useQueryWatcher<ComponentsApiJson>(tanQueries.components.single(props.componentId).queryKey); 296 + 293 297 if(isPending) { 294 298 return <Fragment><Skeleton height="100px"/><Skeleton height="100px"/></Fragment> 295 299 } ··· 298 302 return <ErrorAlert error={error}/> 299 303 } 300 304 301 - return <ActivityDetails componentType={props.componentType} key={props.uid} activity={activity}/> 305 + return <ActivityDetails componentType={props.componentType} componentName={data?.type} key={props.uid} activity={activity}/> 302 306 } 303 307 304 308 export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed}) => {
+29 -29
src/client/components/ActivityTimeline.tsx
··· 1 - import type { Collapsible, IconProps} from '@chakra-ui/react'; 2 - import { Card, Icon, SkeletonCircle, SkeletonText, Span, Tabs, Timeline, type HTMLChakraProps } from '@chakra-ui/react'; 1 + import type { Collapsible } from '@chakra-ui/react'; 2 + import { Card, Icon, SkeletonCircle, SkeletonText, Span, Tabs, Timeline} from '@chakra-ui/react'; 3 3 import type { Dayjs } from "dayjs"; 4 4 import dayjs from "dayjs"; 5 5 import React from "react"; ··· 12 12 import { sortByNewestDate } from "../../core/PlayUtils"; 13 13 import { capitalizeWords } from "../../core/StringUtils"; 14 14 import { shortTodayAwareFormat } from "../../core/TimeUtils"; 15 - import { activityTransformHasIssue, timelineTextFormatting, timelineIconProps } from "../utils/ComponentUtils"; 15 + import { activityTransformHasIssue, timelineIconProps, TimelineItemSummaryText } from "../utils/ComponentUtils"; 16 16 import { ChakraCodeBlockShort } from "./CodeBlock"; 17 17 import { ErrorAlert } from "./ErrorAlert"; 18 18 import { MSErrorBoundary } from "./ErrorBoundary"; ··· 26 26 import { Muted } from "./Typography"; 27 27 28 28 29 - export interface ActivityDetailProps { 29 + interface ActivityTimelineProps { 30 30 activity?: PlayApiCommonDetailed 31 31 collapsibleOpen?: boolean, 32 32 componentType?: ComponentType 33 + componentName?: string 33 34 } 34 - 35 - const ItemSummaryText = (props: HTMLChakraProps<"span"> & {children: React.ReactNode}) => <Span {...timelineTextFormatting}>{props.children}</Span> 36 35 37 36 const timelineCollapsibleProps: Collapsible.TriggerProps = {alignItems: "flex-end"}; 38 37 ··· 67 66 </Timeline.Connector> 68 67 <Timeline.Content> 69 68 <Timeline.Title> 70 - <ItemSummaryText>{props.dead ? 'Dead ' : ''}Queued <Muted>at</Muted> {shortTodayAwareFormat(dayjs(props.datetime))}</ItemSummaryText> 69 + <TimelineItemSummaryText>{props.dead ? 'Dead ' : ''}Queued <Muted>at</Muted> {shortTodayAwareFormat(dayjs(props.datetime))}</TimelineItemSummaryText> 71 70 </Timeline.Title> 72 71 </Timeline.Content> 73 72 </Timeline.Item> 74 73 ) 75 74 76 - const NewItem = (props: Pick<ActivityDetailProps, 'collapsibleOpen' | 'activity' | 'componentType'>) => { 75 + const NewItem = (props: Pick<ActivityTimelineProps, 'collapsibleOpen' | 'activity' | 'componentType'>) => { 77 76 const { 78 77 activity: { 79 78 play, ··· 107 106 <Timeline.Title> 108 107 <MSCollapsible 109 108 triggerProps={timelineCollapsibleProps} 110 - indicator={<ItemSummaryText> 109 + indicator={<TimelineItemSummaryText> 111 110 {componentType === 'source' ? 'Discovered' : 'Recieved'} <Muted>new Play from</Muted> <Span fontWeight="medium">{capitalizeWords(source)}</Span> <Muted>at {shortTodayAwareFormat(dayjs(seenAt))}</Muted> 112 - </ItemSummaryText>} 111 + </TimelineItemSummaryText>} 113 112 defaultOpen={collapsibleOpen} 114 113 timeline 115 114 disableUntil="md"> ··· 136 135 ) 137 136 } 138 137 139 - const TransformsItem = (props: Pick<ActivityDetailProps, 'activity' | 'collapsibleOpen'> & { steps: LifecycleStep[], original: JsonPlayObject }) => { 138 + const TransformsItem = (props: Pick<ActivityTimelineProps, 'activity' | 'collapsibleOpen'> & { steps: LifecycleStep[], original: JsonPlayObject }) => { 140 139 const { 141 140 steps = [], 142 141 collapsibleOpen, ··· 166 165 <Timeline.Title> 167 166 <MSCollapsible 168 167 triggerProps={timelineCollapsibleProps} 169 - indicator={<ItemSummaryText>{transformVerb} <Muted>using configured Rules</Muted> <Muted>for</Muted> {steps[0].hook} {transformResult}</ItemSummaryText>} 168 + indicator={<TimelineItemSummaryText>{transformVerb} <Muted>using configured Rules</Muted> <Muted>for</Muted> {steps[0].hook} {transformResult}</TimelineItemSummaryText>} 170 169 defaultOpen={collapsibleOpen} 171 170 timeline> 172 171 <Card.Root bgColor="bg.muted" size="sm"> ··· 193 192 </Timeline.Connector> 194 193 <Timeline.Content> 195 194 <Timeline.Title > 196 - <ItemSummaryText>Play <Muted>was</Muted> not transformed <Muted>because no</Muted> Transform Rules <Muted> were used/configured.</Muted></ItemSummaryText> 195 + <TimelineItemSummaryText>Play <Muted>was</Muted> not transformed <Muted>because no</Muted> Transform Rules <Muted> were used/configured.</Muted></TimelineItemSummaryText> 197 196 </Timeline.Title> 198 197 </Timeline.Content> 199 198 </Timeline.Item> 200 199 ) 201 200 202 - const ScrobbleMatchItem = (props: Pick<ActivityDetailProps, 'collapsibleOpen'> & { match: PlayMatchResult<string> }) => { 201 + const ScrobbleMatchItem = (props: Pick<ActivityTimelineProps, 'collapsibleOpen'> & { match: PlayMatchResult<string> }) => { 203 202 const { 204 203 match, 205 204 collapsibleOpen ··· 219 218 <Timeline.Title> 220 219 <MSCollapsible 221 220 triggerProps={timelineCollapsibleProps} 222 - indicator={<ItemSummaryText><Muted>Found </Muted>{match.match ? <Span color="orange.solid"> a duplicate Scrobble</Span> : 'no duplicate Scrobbles'}</ItemSummaryText>} 221 + indicator={<TimelineItemSummaryText><Muted>Found </Muted>{match.match ? <Span color="orange.solid"> a duplicate Scrobble</Span> : 'no duplicate Scrobbles'}</TimelineItemSummaryText>} 223 222 defaultOpen={collapsibleOpen} 224 223 disableUntil="md" 225 224 timeline> ··· 236 235 237 236 } 238 237 239 - const ScrobbleResponseItem = (props: Pick<ActivityDetailProps, 'collapsibleOpen'> & { scrobble: ScrobbleResult<string> }) => { 238 + const ScrobbleResponseItem = (props: Pick<ActivityTimelineProps, 'collapsibleOpen'> & { scrobble: ScrobbleResult<string>, componentName?: string }) => { 240 239 const { 241 240 scrobble: { 242 241 payload, ··· 244 243 warnings = [] 245 244 } = {}, 246 245 scrobble, 247 - collapsibleOpen 246 + collapsibleOpen, 247 + componentName = 'downstream service' 248 248 } = props; 249 249 250 250 let scrobbleSummary: React.JSX.Element; ··· 253 253 }; 254 254 if (payload !== undefined) { 255 255 if (error !== undefined) { 256 - scrobbleSummary = <ItemSummaryText>Scrobble attempt <Muted>to Client resulted in</Muted> <Span color="red.solid">an error.</Span></ItemSummaryText> 256 + scrobbleSummary = <TimelineItemSummaryText>Scrobble attempt <Muted>to {capitalizeWords(componentName)} resulted in</Muted> <Span color="red.solid">an error.</Span></TimelineItemSummaryText> 257 257 } else if (warnings.length > 0) { 258 - scrobbleSummary = <ItemSummaryText>Scrobbled <Muted>to Client but response </Muted> <Span color="orange.solid">has warnings.</Span></ItemSummaryText>; 259 - scrobbleIconProps.color = 'orange.focusRing'; 258 + scrobbleSummary = <TimelineItemSummaryText>Scrobbled <Muted>to {capitalizeWords(componentName)} but response </Muted> <Span color="orange.solid">has warnings.</Span></TimelineItemSummaryText> 260 259 } else { 261 - scrobbleSummary = <ItemSummaryText>Scrobbled <Muted>to Client</Muted> successfully.</ItemSummaryText>; 260 + scrobbleSummary = <TimelineItemSummaryText>Scrobbled <Muted>to {capitalizeWords(componentName)}</Muted> successfully.</TimelineItemSummaryText>; 262 261 } 263 262 } 264 263 ··· 284 283 disableUntil="md"> 285 284 <Card.Root bgColor="bg.muted" size="sm"> 286 285 <Card.Body textStyle="sm"> 287 - <ScrobbleActionResult result={scrobble} scrobbler="Koito" collapsibleOpen={collapsibleOpen} /> 286 + <ScrobbleActionResult componentName={componentName} result={scrobble} collapsibleOpen={collapsibleOpen} /> 288 287 </Card.Body> 289 288 </Card.Root> 290 289 </MSCollapsible> ··· 310 309 </Timeline.Connector> 311 310 <Timeline.Content gap="4"> 312 311 <Timeline.Title> 313 - <ItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queued <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</ItemSummaryText> 312 + <TimelineItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queued <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</TimelineItemSummaryText> 314 313 </Timeline.Title> 315 314 </Timeline.Content> 316 315 </Timeline.Item> ··· 328 327 </Timeline.Connector> 329 328 <Timeline.Content gap="4"> 330 329 <Timeline.Title> 331 - <ItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queue finished processing <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</ItemSummaryText> 330 + <TimelineItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queue finished processing <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</TimelineItemSummaryText> 332 331 </Timeline.Title> 333 332 </Timeline.Content> 334 333 </Timeline.Item> ··· 338 337 if(queueState.queueStatus === QUEUE_STATUS_FAILED) { 339 338 let titleContent: React.JSX.Element; 340 339 if(queueState.error === undefined) { 341 - titleContent = <ItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queue failed <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</ItemSummaryText>; 340 + titleContent = <TimelineItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queue failed <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</TimelineItemSummaryText>; 342 341 } else { 343 342 titleContent = ( 344 343 <MSCollapsible 345 344 triggerProps={timelineCollapsibleProps} 346 - indicator={<ItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queue failed <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</ItemSummaryText>} 345 + indicator={<TimelineItemSummaryText>{queueState.queueName === CLIENT_DEAD_QUEUE ? 'Dead ' : ''}Queue failed <Muted>at</Muted> {shortTodayAwareFormat(dayjs(queueState.updatedAt))}</TimelineItemSummaryText>} 347 346 defaultOpen={collapsibleOpen} 348 347 disableUntil="md" 349 348 timeline> ··· 373 372 type TimelineDataTypes = 'new' | 'queue-created-ingress' | 'queue-created-dead' | 'queue-updated-ingress' | 'queue-updated-dead' | 'scrobble-match' | 'scrobble-response' | 'transform-steps'; 374 373 type TimelineData = {id: TimelineDataTypes, dt: Dayjs}; 375 374 376 - export const ActivityTimeline = (props: ActivityDetailProps) => { 375 + export const ActivityTimeline = (props: ActivityTimelineProps) => { 377 376 378 377 if(props.activity === undefined) { 379 378 return <TimelineLoading/>; ··· 387 386 queueStates, 388 387 } = {}, 389 388 collapsibleOpen, 390 - componentType 389 + componentType, 390 + componentName 391 391 } = props; 392 392 const { 393 393 lifecycle: steps = [], ··· 539 539 case 'scrobble-match': 540 540 return <ScrobbleMatchItem key={timelineKey} match={match} collapsibleOpen={collapsibleOpen}/>; 541 541 case 'scrobble-response': 542 - return <ScrobbleResponseItem key={timelineKey} scrobble={scrobble} collapsibleOpen={collapsibleOpen}/>; 542 + return <ScrobbleResponseItem key={timelineKey} scrobble={scrobble} componentName={componentName} collapsibleOpen={collapsibleOpen}/>; 543 543 case 'transform-steps': 544 544 { 545 545 const val = x as TransformStepsTimelineData;
+12 -8
src/client/components/ScrobbleActionResult.tsx
··· 1 1 import React, { Fragment } from "react" 2 - import { Timeline, Icon, Span, Stack, Alert, List, HStack } from '@chakra-ui/react'; 2 + import { Timeline, Icon, Span, Stack, Alert, List } from '@chakra-ui/react'; 3 3 import { ErrorAlert } from "./ErrorAlert"; 4 4 import { HiOutlineCloudUpload, HiOutlineCloudDownload } from "react-icons/hi"; 5 5 import { ChakraCodeBlockShort } from "./CodeBlock"; 6 - import { capitalize } from "../../core/StringUtils"; 6 + import { capitalizeWords } from "../../core/StringUtils"; 7 7 import { MSCollapsible, type MSCollapsibleExternalProps } from "./MSCollapsible"; 8 8 import { TimelineErrorIcon } from "./timeline/TimelineIcon"; 9 9 import type {ScrobbleResult} from "../../core/Atomic"; 10 + import { TimelineItemSummaryText } from "../utils/ComponentUtils"; 11 + import { Muted } from "./Typography"; 10 12 11 13 export interface ScrobbleActionResultProps extends MSCollapsibleExternalProps { 12 14 result: ScrobbleResult<string>, 13 15 scrobbler?: string, 16 + componentName?: string 14 17 } 15 18 16 19 export const ScrobbleActionResult = (props: ScrobbleActionResultProps) => { ··· 22 25 payload, 23 26 response, 24 27 } = {}, 25 - scrobbler, 26 - collapsibleOpen 28 + collapsibleOpen, 29 + componentName = 'downstream service' 27 30 } = props; 28 31 29 32 let responseSuffix: React.JSX.Element, ··· 38 41 } 39 42 40 43 if (warningsElm !== undefined && errorElm !== undefined) { 41 - responseSuffix = <Fragment>with {warningsElm} and {errorElm}</Fragment>; 44 + responseSuffix = <Fragment><Muted>with</Muted> {warningsElm} and {errorElm}</Fragment>; 42 45 } else if (warningsElm !== undefined || errorElm !== undefined) { 43 - responseSuffix = <Fragment>with {warningsElm ?? errorElm}</Fragment>; 46 + responseSuffix = <Fragment><Muted>with</Muted> {warningsElm ?? errorElm}</Fragment>; 44 47 } 45 48 49 + //{capitalizeWords(componentName)} 46 50 return ( 47 51 <Timeline.Root variant="subtle" css={{ "--timeline-separator-display": 'block' }}> 48 52 <Timeline.Item> ··· 56 60 </Timeline.Connector> 57 61 <Timeline.Content> 58 62 <Timeline.Title> 59 - <MSCollapsible indicator={<Fragment><Span color="fg.muted">Sent</Span> Scrobble Payload{scrobbler !== undefined ? <Fragment><Span color="fg.muted">to</Span> {capitalize(scrobbler)}</Fragment> : null}</Fragment>} 63 + <MSCollapsible indicator={<TimelineItemSummaryText><Span color="fg.muted">Sent</Span> Scrobble Payload <Span color="fg.muted"> to {capitalizeWords(componentName)}</Span></TimelineItemSummaryText>} 60 64 defaultOpen={collapsibleOpen} 61 65 disableUntil="md" 62 66 timeline> ··· 80 84 <Timeline.Content> 81 85 <Timeline.Title> 82 86 <MSCollapsible 83 - indicator={<HStack gap="1"><Span color="fg.muted">Received</Span> Response{scrobbler !== undefined ? <Fragment><Span color="fg.muted">from</Span> {capitalize(scrobbler)}</Fragment> : null}{responseSuffix !== undefined ? <Span> {responseSuffix}</Span> : null}</HStack>} 87 + indicator={<TimelineItemSummaryText><Span color="fg.muted">Received</Span> Response <Span color="fg.muted"> from {capitalizeWords(componentName)}</Span>{responseSuffix !== undefined ? <Span> {responseSuffix}</Span> : null}</TimelineItemSummaryText>} 84 88 timeline 85 89 defaultOpen={collapsibleOpen} 86 90 disableUntil="md">
+11 -1
src/client/components/playActivity/ActivityList.tsx
··· 139 139 } else if(!isFetching && allPlays.length === 0) { 140 140 rendered = <NoPlayResults type="empty"/> 141 141 } else { 142 - rendered = <ActivityList total={data?.pages.length > 0 ? data.pages[0].meta.total : undefined} hasNextPage={hasNextPage} fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} render="virtDynamic" data={allPlays} live {...props} sortBy="played" query={query} />; 142 + rendered = <ActivityList 143 + total={data?.pages.length > 0 ? data.pages[0].meta.total : undefined} 144 + hasNextPage={hasNextPage} 145 + fetchNextPage={fetchNextPage} 146 + isFetchingNextPage={isFetchingNextPage} 147 + render="virtDynamic" 148 + data={allPlays} 149 + live 150 + {...props} 151 + sortBy="played" 152 + query={query} />; 143 153 } 144 154 145 155 return rendered;
+4 -2
src/client/utils/ComponentUtils.tsx
··· 1 - import type { Card, IconProps} from '@chakra-ui/react'; 2 - import type {HTMLChakraProps} from '@chakra-ui/react'; 1 + import type { Card, IconProps, HTMLChakraProps} from '@chakra-ui/react'; 2 + import { Span } from '@chakra-ui/react'; 3 3 import type {PlayApiCommonDetailed} from '../../core/Api'; 4 4 import { type LifecycleStep, QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED } from '../../core/Atomic'; 5 5 ··· 16 16 export const timelineIconProps: IconProps = { 17 17 fontSize: 'md' 18 18 } 19 + 20 + export const TimelineItemSummaryText = (props: HTMLChakraProps<"span"> & {children: React.ReactNode}) => <Span {...timelineTextFormatting}>{props.children}</Span> 19 21 20 22 export const activityTransformHasIssue = (steps: LifecycleStep[]): 'warn' | 'error' | undefined => { 21 23 for(const step of steps) {