[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): Better error/warning indicators for timeline

FoxxMD (Jul 7, 2026, 2:23 PM UTC) e6130eb5 97b60427

+102 -26
+19 -16
src/client/components/ActivityDetail.tsx
··· 1 - import React, { ComponentProps, useState, Fragment, useEffect } from "react" 2 - import { Accordion, For, Span, Code, Alert, Stack, Text, Box, AbsoluteCenter, Button, Clipboard, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton, SkeletonText, Collapsible, BadgeProps } from '@chakra-ui/react'; 3 - import { CLIENT_DEAD_QUEUE, ComponentType, QUEUE_NAMES, Second } from "../../core/Atomic"; 1 + import React, { useState, Fragment, useEffect } from "react" 2 + import { Accordion, Span, Code, Alert, Stack, Box, Separator, HStack, Flex, useAccordionItemContext, Skeleton, SkeletonText, Collapsible, BadgeProps } from '@chakra-ui/react'; 3 + import { CLIENT_DEAD_QUEUE, ComponentType, Second } from "../../core/Atomic"; 4 4 import { PlayData } from "./PlayData"; 5 5 import { ErrorAlert } from "./ErrorAlert"; 6 - import { AiOutlineExclamationCircle } from "react-icons/ai"; 7 6 import { ActivityTimeline } from "./ActivityTimeline"; 8 7 import { ExpandCollapse } from "./ExpandCollapse"; 9 - import { MsSseEvent, PlayApiCommon, PlayApiCommonDetailed, SortPlaysBy, SortPlaysByProps } from "../../core/Api"; 10 - import { InfiniteData, QueryFunctionContext, QueryOptions, queryOptions, SuspenseQueriesOptions, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; 11 - import ky from 'ky'; 12 - import { baseUrl } from "../utils"; 8 + import { MsSseEvent, PlayApiCommonDetailed, SortPlaysByProps } from "../../core/Api"; 9 + import { InfiniteData, useQuery, useQueryClient } from '@tanstack/react-query'; 13 10 import { ShortDateDisplay } from "./DateDisplay"; 14 11 import { TextMuted } from "./TextMuted"; 15 - import { VscDebugRestart } from "react-icons/vsc"; 16 12 import { EphemeralElement, PlayStateBadge } from "./Badges"; 17 13 import { MarkOptional } from "ts-essentials"; 18 - import { QueryPlaysOpts, QueryPlaysOptsJson } from "../../backend/common/database/drizzle/repositories/PlayRepository"; 14 + import { QueryPlaysOptsJson } from "../../backend/common/database/drizzle/repositories/PlayRepository"; 19 15 import { tanQueries } from "../queries"; 20 16 import { PaginatedResponse } from "../../backend/common/database/drizzle/repositories/BaseRepository"; 21 17 import { LuChevronRight } from "react-icons/lu"; 22 18 import { useSSEContext, useSSEEvent } from "@flamefrontend/sse-runtime-react"; 23 - import { DebugCopy, InsertedIcon, RetryButton, UpdatedIcon } from "./icons/ChakraIcons"; 24 - import dayjs from "dayjs"; 19 + import { DebugCopy, ExclamationCircleIcon, ExclamationTriangleIcon, InsertedIcon, RetryButton, UpdatedIcon } from "./icons/ChakraIcons"; 20 + import { activityTimelineHasIssue } from "../utils/ComponentUtils"; 25 21 26 22 type UseActivityQueryOptions = { 27 23 msQuery?: QueryPlaysOptsJson ··· 238 234 239 235 const [collapsibleOpen, setCollapsibleOpen] = useState(undefined); 240 236 237 + let timelineStatusIcon: React.JSX.Element | undefined; 238 + const timelineIssue = activityTimelineHasIssue(activity); 239 + if(timelineIssue === 'error') { 240 + timelineStatusIcon = <ExclamationCircleIcon size="sm" color="red.focusRing"/>; 241 + } else if(timelineIssue === 'warn') { 242 + timelineStatusIcon = <ExclamationTriangleIcon size="sm" color="yellow.focusRing"/>; 243 + } 244 + 241 245 return ( 242 246 <Stack gap="2"> 243 - <ActivityErrorSummary activity={props.activity}/> 247 + {/* <ActivityErrorSummary activity={props.activity}/> */} 248 + {error !== undefined && error !== null ? <ErrorAlert error={error}/> : null} 244 249 <Accordion.Root width="full" variant="enclosed" collapsible multiple> 245 250 <Accordion.Item value="info"> 246 251 <Accordion.ItemTrigger> ··· 257 262 <Flex justify="flex-start"> 258 263 <Accordion.ItemTrigger> 259 264 <Accordion.ItemIndicator /> 260 - Timeline {error !== undefined && error !== null ? (<Icon size="sm" color="red.focusRing"> 261 - <AiOutlineExclamationCircle /> 262 - </Icon>) : null} 265 + Timeline {timelineStatusIcon} 263 266 </Accordion.ItemTrigger> 264 267 <Stack style={{ 265 268 paddingBlock: "var(--accordion-padding-y)",
+15 -3
src/client/components/ActivityTimeline.tsx
··· 1 - import { ComponentProps, useState, Fragment } from "react" 1 + import React, { ComponentProps, useState, Fragment } from "react" 2 2 import { Accordion, Timeline, Icon, Span, Stack, Heading, Card, Box, Tabs, Skeleton, SkeletonCircle, SkeletonText, HTMLChakraProps } from '@chakra-ui/react'; 3 3 import { ComponentType, ErrorLike, JsonPlayObject, PlayActivity } from "../../core/Atomic"; 4 4 import { PlayData } from "./PlayData"; ··· 21 21 import { Muted } from "./Typography"; 22 22 import { PlayApiCommonDetailed } from "../../core/Api"; 23 23 import { MSErrorBoundary } from "./ErrorBoundary"; 24 - import { timelineTextFormatting } from "../utils/ComponentUtils"; 24 + import { activityTransformHasIssue, timelineTextFormatting } from "../utils/ComponentUtils"; 25 25 26 26 27 27 export interface ActivityDetailProps { ··· 104 104 } 105 105 } 106 106 107 + let transformVerb: string = 'Transformed Play'; 108 + 109 + const transformIssue = activityTransformHasIssue(props.activity); 110 + let transformResult: React.JSX.Element | undefined; 111 + if(transformIssue === 'error') { 112 + transformVerb = 'Transforming Play'; 113 + transformResult = <Span> resulted in <Span color="red.solid">an error</Span></Span>; 114 + } else if(transformIssue === 'warn') { 115 + transformVerb = 'Transforming Play'; 116 + transformResult = <Span> resulted in <Span color="orange.solid">warnings</Span></Span>; 117 + } 118 + 107 119 return ( 108 120 <MSErrorBoundary> 109 121 <Timeline.Root variant="subtle" size="lg"> ··· 158 170 <Timeline.Content gap="4"> 159 171 <Timeline.Title> 160 172 <MSCollapsible 161 - indicator={<Span {...timelineTextFormatting}>Transformed Play <Span color="fg.muted">using configured Rules</Span></Span>} 173 + indicator={<Span {...timelineTextFormatting}>{transformVerb} <Span color="fg.muted">using configured Rules</Span>{transformResult}</Span>} 162 174 defaultOpen={collapsibleOpen} 163 175 timeline> 164 176 <Card.Root bgColor="bg.muted" size="sm">
+1 -1
src/client/components/TransformSteps.tsx
··· 1 - import { ComponentProps, Fragment, useMemo } from "react" 1 + import React, { ComponentProps, Fragment, useMemo } from "react" 2 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";
+14 -2
src/client/components/icons/ChakraIcons.tsx
··· 1 1 import { 2 2 LuChevronRight, 3 3 LuChevronLeft, 4 + LuCircleAlert, 5 + LuTriangleAlert, 4 6 LuActivity, 5 7 LuGithub, 6 8 LuTerminal, ··· 45 47 </IconButton> 46 48 ); 47 49 } 48 - export const makeChakraIcon = (IconComponent: IconType) => (props: ComponentProps<typeof Icon> & { iconProps?: IconBaseProps }) => <Icon {...props}><IconComponent {...props.iconProps}/></Icon> 50 + export const makeChakraIcon = (IconComponent: IconType) => (props: ComponentProps<typeof Icon> & { iconProps?: IconBaseProps }) => { 51 + const { 52 + iconProps, 53 + ...rest 54 + } = props; 55 + return <Icon {...rest}><IconComponent {...iconProps}/></Icon> 56 + } 49 57 50 58 export const ChevronRight = LuChevronRight; 51 59 export const ChevronRightButton = (props: ComponentProps<typeof IconButton>) => ( ··· 163 171 export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp); 164 172 165 173 export const CopyIcon = makeChakraIcon(LuCopy); 166 - export const CopyIconButton = makeIconButton(LuCopy); 174 + export const CopyIconButton = makeIconButton(LuCopy); 175 + 176 + export const ExclamationCircleIcon = makeChakraIcon(LuCircleAlert); 177 + 178 + export const ExclamationTriangleIcon = makeChakraIcon(LuTriangleAlert);
+49
src/client/utils/ComponentUtils.tsx
··· 1 1 import { Card, HTMLChakraProps } from '@chakra-ui/react'; 2 + import { PlayApiCommon, PlayApiCommonDetailed } from '../../core/Api'; 3 + import { QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED } from '../../core/Atomic'; 2 4 3 5 export const cardHeaderSeparator: Card.HeaderProps = { 4 6 borderBottomWidth: "1px", ··· 8 10 export const timelineTextFormatting: HTMLChakraProps<"span"> = { 9 11 textAlign: "left", 10 12 textWrap: "balance" 13 + } 14 + 15 + export const activityTransformHasIssue = (activity: PlayApiCommon): 'warn' | 'error' | undefined => { 16 + const { 17 + play: { 18 + lifecycle = [], 19 + } = {}, 20 + } = activity; 21 + for(const step of lifecycle) { 22 + if(step.flowKnownState === 'prereq') { 23 + return 'warn'; 24 + } 25 + if(step.flowKnownState === 'skip') { 26 + continue; 27 + } 28 + if(step.error !== undefined && step.error !== null && Object.keys(step.error).length > 0) { 29 + return 'error'; 30 + } 31 + } 32 + return undefined; 33 + } 34 + 35 + export const activityTimelineHasIssue = (activity: PlayApiCommonDetailed): 'warn' | 'error' | undefined => { 36 + const { 37 + queueStates = [], 38 + play: { 39 + scrobble: { 40 + error: scrobbleError, 41 + warnings: scrobbleWarnings = [] 42 + } = {}, 43 + } = {}, 44 + } = activity; 45 + const transformIssue = activityTransformHasIssue(activity); 46 + if(transformIssue !== undefined) { 47 + return transformIssue; 48 + } 49 + if (scrobbleError !== undefined) { 50 + return 'error'; 51 + } 52 + if(scrobbleWarnings.length > 0) { 53 + return 'warn'; 54 + } 55 + if (queueStates.some(x => x.queueStatus === QUEUE_STATUS_FAILED) && !queueStates.some(x => x.queueStatus === QUEUE_STATUS_COMPLETED)) { 56 + return 'error'; 57 + } 58 + 59 + return undefined; 11 60 }
+4 -4
src/core/tests/utils/fixtures.ts
··· 191 191 if(error === true) { 192 192 step.flowResult = 'stop'; 193 193 step.flowReason = 'Error encountered while transforming'; 194 - step.error = new Error('Failed to do something', {cause: new Error('Oops it borked.')}); 194 + step.error = serializeError(new Error('Failed to do something', {cause: new Error('Oops it borked.')})); 195 195 } else if(error === 'prereq') { 196 196 step.flowResult = 'stop'; 197 197 step.flowKnownState = 'prereq'; 198 198 step.flowReason = 'Transform could not be completed due to prerequisite failure'; 199 - step.error = mergeSimpleError(new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, cause: new SimpleError('Results were empty')})); 199 + step.error = serializeError(mergeSimpleError(new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, cause: new SimpleError('Results were empty')}))); 200 200 } else if(error === 'stop') { 201 201 step.flowResult = 'stop'; 202 202 } else if(error === 'continuewitherror') { 203 203 step.flowResult = 'continue'; 204 204 step.flowReason = 'Transform encountered an error but continuing due to onFailure: continue'; 205 - step.error = mergeSimpleError(new SkipTransformStageError('An error that was an okay to continue with')); 205 + step.error = serializeError(mergeSimpleError(new SkipTransformStageError('An error that was an okay to continue with'))); 206 206 } else { 207 207 step.flowResult = 'continue'; 208 208 step.flowKnownState = 'skip'; 209 209 step.flowReason = `Stage ${name} was skipped`; 210 - step.error = mergeSimpleError(new SkipTransformStageError('No desired MBIDs were missing', {shortStack: true})); 210 + step.error = serializeError(mergeSimpleError(new SkipTransformStageError('No desired MBIDs were missing', {shortStack: true}))); 211 211 } 212 212 213 213 return [step, play];