[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 error boundaries and better error detection for rendering

FoxxMD (Jun 24, 2026, 5:18 PM UTC) b0db1dd8 a603be6e

+60 -25
+3 -2
src/client/AppNext.tsx
··· 18 18 import { LogsFetchable } from './components/LogsNext'; 19 19 import { SplitLayout } from './components/layouts/SplitLayout'; 20 20 import { ComponentDetailedRoutable } from './components/msComponent/MSComponentDetailed'; 21 + import { MSErrorBoundary } from './components/ErrorBoundary'; 21 22 22 23 function NoMatch() { 23 24 const location = useLocation(); ··· 73 74 Component: Layout, 74 75 children: [ { 75 76 index: true, 76 - element: <Container boxSize="full" p="0" maxWidth="4xl"><MSComponentListFetchable/></Container>, 77 + element: <Container boxSize="full" p="0" maxWidth="4xl"><MSErrorBoundary><MSComponentListFetchable/></MSErrorBoundary></Container>, 77 78 }, 78 79 { 79 80 path: "components/:componentId", 80 - element: <Container boxSize="full" p="0" maxWidth="8xl"><ComponentDetailedRoutable/></Container> 81 + element: <Container boxSize="full" p="0" maxWidth="8xl"><MSErrorBoundary><ComponentDetailedRoutable/></MSErrorBoundary></Container> 81 82 }, 82 83 { 83 84 path: "*",
+3
src/client/components/ActivityTimeline.tsx
··· 20 20 import { TimelineErrorIcon } from "./timeline/TimelineIcon"; 21 21 import { Muted } from "./Typography"; 22 22 import { PlayApiCommonDetailed } from "../../core/Api"; 23 + import { MSErrorBoundary } from "./ErrorBoundary"; 23 24 24 25 25 26 export interface ActivityDetailProps { ··· 103 104 } 104 105 105 106 return ( 107 + <MSErrorBoundary> 106 108 <Timeline.Root variant="subtle" size="lg"> 107 109 <Timeline.Item> 108 110 <Timeline.Connector> ··· 241 243 </Timeline.Item> 242 244 ) : null} 243 245 </Timeline.Root> 246 + </MSErrorBoundary> 244 247 ) 245 248 }
+2 -1
src/client/components/AppHeader.tsx
··· 16 16 import { ToggleTip } from "./ToggleTip"; 17 17 import { ErrorLike } from "../../core/Atomic"; 18 18 import { ErrorAlert } from "./ErrorAlert"; 19 + import { MSErrorBoundary } from "./ErrorBoundary"; 19 20 20 21 export const AppTitle = (props: { fetchable?: boolean } = {}) => { 21 22 const { ··· 152 153 </FloatingPanel.Control> 153 154 </FloatingPanel.Header> 154 155 <FloatingPanel.Body> 155 - <LogsFetchable streamable={props.streamable} /> 156 + <MSErrorBoundary><LogsFetchable streamable={props.streamable} /></MSErrorBoundary> 156 157 </FloatingPanel.Body> 157 158 <FloatingPanel.ResizeTriggers /> 158 159 </FloatingPanel.Content>
+6 -5
src/client/components/ErrorAlert.tsx
··· 3 3 import { ErrorLike } from '../../core/Atomic'; 4 4 import { ChakraCodeBlock } from './CodeBlock'; 5 5 import { ChakraClip } from './ChakraClipboard'; 6 + import { ErrorIsh, isErrorIsh } from '../../core/ErrorUtils'; 6 7 7 8 export interface ErrorAlertProps { 8 - error: ErrorLike 9 + error: ErrorIsh 9 10 status?: "error" | "info" | "warning" | "success" | "neutral" 10 11 } 11 12 12 13 export const ErrorAlert = (props: ErrorAlertProps) => { 13 14 14 - if(props.error === undefined || props.error === null) { 15 + if(!isErrorIsh(props.error)) { 15 16 return null; 16 17 } 17 18 let causes: ErrorData[] = []; 18 - if(props.error.cause !== undefined && typeof props.error.cause === 'object' && props.error.cause !== null) { 19 - causes = walkError(props.error.cause as ErrorLike); 19 + if(isErrorIsh(props.error.cause)) { 20 + causes = walkError(props.error.cause); 20 21 } 21 22 22 23 return ( ··· 51 52 stack?: string 52 53 } 53 54 54 - const walkError = (err: ErrorLike, errors: ErrorData[] = []): ErrorData[] => { 55 + const walkError = (err: ErrorIsh, errors: ErrorData[] = []): ErrorData[] => { 55 56 const thisErr: ErrorData = { 56 57 name: err.name, 57 58 code: 'code' in err ? err.code : undefined,
+1 -1
src/client/components/ErrorBoundary.tsx
··· 13 13 <Alert.Content> 14 14 <Alert.Title>Error while render</Alert.Title> 15 15 <Alert.Description> 16 - <ChakraCodeBlock code={getErrorMessage(error)} language="plaintext" /> 16 + <ChakraCodeBlock code={getErrorMessage(error)} maxLines={6} collapsedMaxHeight="10em" language="plaintext" /> 17 17 </Alert.Description> 18 18 </Alert.Content> 19 19 </Alert.Root>
+3
src/client/components/MSCollapsible.tsx
··· 1 1 import { Collapsible, Stack, Box, useBreakpointValue } from "@chakra-ui/react" 2 2 import { ComponentProps, PropsWithChildren, useState, useEffect, useMemo } from "react"; 3 3 import { LuChevronRight } from "react-icons/lu" 4 + import { MSErrorBoundary } from "./ErrorBoundary"; 4 5 5 6 //padding="0" borderWidth="0px" 6 7 ··· 127 128 </Collapsible.Trigger> 128 129 <Collapsible.Content> 129 130 <Box {...boxProps}> 131 + <MSErrorBoundary> 130 132 {props.children} 133 + </MSErrorBoundary> 131 134 </Box> 132 135 </Collapsible.Content> 133 136 </Collapsible.Root>
+16 -13
src/client/components/PlayData.tsx
··· 9 9 import { formatNumber } from '../../core/DataUtils.js'; 10 10 import { Muted } from './Typography.js'; 11 11 import { ArtistCreditTags } from './ArtistCreditDisplay.js'; 12 + import { MSErrorBoundary } from './ErrorBoundary.js'; 12 13 13 14 const EmptyPlayData = () => { 14 15 return ( ··· 71 72 72 73 return ( 73 74 <Box position="relative"> 74 - <Float placement="top-end" offsetX="4" offsetY="2" hideBelow="sm" zIndex={100}>{code}</Float> 75 - <Tabs.Root size="sm" variant="outline" defaultValue={compareDefault}> 76 - <Tabs.List> 77 - <Tabs.Trigger value="Initial">Initial</Tabs.Trigger> 78 - <Tabs.Trigger value="Final">Final</Tabs.Trigger> 79 - </Tabs.List> 80 - <Tabs.Content value="Initial"> 81 - {codeMode ? <ChakraCodeBlock code={play} /> : <PlayDataDataList play={play} dates={dates} />} 82 - </Tabs.Content> 83 - <Tabs.Content value="Final"> 84 - {codeMode ? <ChakraCodeBlock code={final} /> : <PlayDataDataList play={final} dates={dates} />} 85 - </Tabs.Content> 86 - </Tabs.Root> 75 + <MSErrorBoundary> 76 + <Float placement="top-end" offsetX="4" offsetY="2" hideBelow="sm" zIndex={100}>{code}</Float> 77 + <Tabs.Root size="sm" variant="outline" defaultValue={compareDefault}> 78 + <Tabs.List> 79 + <Tabs.Trigger value="Initial">Initial</Tabs.Trigger> 80 + <Tabs.Trigger value="Final">Final</Tabs.Trigger> 81 + </Tabs.List> 82 + <Tabs.Content value="Initial"> 83 + {codeMode ? <ChakraCodeBlock code={play} /> : <PlayDataDataList play={play} dates={dates} />} 84 + </Tabs.Content> 85 + <Tabs.Content value="Final"> 86 + {codeMode ? <ChakraCodeBlock code={final} /> : <PlayDataDataList play={final} dates={dates} />} 87 + </Tabs.Content> 88 + </Tabs.Root> 89 + </MSErrorBoundary> 87 90 </Box> 88 91 ); 89 92 }
+3
src/client/components/msComponent/MSComponentDetailed.tsx
··· 25 25 import { shortTodayAwareFormat } from "../../../core/TimeUtils.js"; 26 26 import { durationToHuman } from "../../../backend/utils.js"; 27 27 import { tanQueries } from "../../queries/index.js"; 28 + import { MSErrorBoundary } from "../ErrorBoundary.js"; 28 29 29 30 export const MSComponentHeading = (props: { data?: Pick<ComponentCommonApiJson, 'name' | 'mode' | 'type'>, fetchable?: boolean }) => { 30 31 if (props.data === undefined) { ··· 111 112 } 112 113 } 113 114 return ( 115 + <MSErrorBoundary> 114 116 <Flex direction="column" width="100%" truncate rowGap="1"> 115 117 <Flex width="100%" truncate> 116 118 <Box marginEnd="auto" truncate><MSComponentName data={props.data}/></Box> ··· 135 137 <Heading size="3xl">{isComponentTypeSource(props.data.mode) ? 'Plays' : 'Scrobbles'}</Heading> 136 138 <ListContainerFilterable render="virtDynamic" componentType={props.data.mode} componentId={props.data.id}/> 137 139 </Flex> 140 + </MSErrorBoundary> 138 141 ) 139 142 } 140 143
+2 -1
src/client/components/msComponent/MSComponentList.tsx
··· 7 7 import { baseUrl } from "../../utils"; 8 8 import { ErrorAlert } from "../ErrorAlert"; 9 9 import { tanQueries } from "../../queries"; 10 + import { MSErrorBoundary } from "../ErrorBoundary"; 10 11 11 12 export interface ComponentListProps { 12 13 components: ComponentsApiJson[] ··· 33 34 return x.mode === 'source'; 34 35 } 35 36 return x.mode === 'client'; 36 - }).map(x => props.fetchable ? <MSComponentSummaryFetchable key={x.id} componentId={x.id} data={x}/> : <MSComponentSummary data={x} key={x.uid} />)} 37 + }).map(x => props.fetchable ? <MSErrorBoundary><MSComponentSummaryFetchable key={x.id} componentId={x.id} data={x}/></MSErrorBoundary> : <MSComponentSummary data={x} key={x.uid} />)} 37 38 </Stack> 38 39 </Stack> 39 40 )
+6 -2
src/client/components/msComponent/MSComponentSummary.tsx
··· 16 16 } from "@flamefrontend/sse-runtime-react"; 17 17 import { CountLiveIndicator, DeadLetterIndicator, QueuedIndicator } from "./Stats.js"; 18 18 import { ComponentStateBadge } from "../Badges.js"; 19 + import { MSErrorBoundary } from "../ErrorBoundary.js"; 19 20 20 21 const presentPlayersContainerProps: ComponentProps<typeof Stack> = { 21 22 paddingTop: '2', ··· 43 44 </Card.Body>); 44 45 } 45 46 46 - return (<Card.Root variant="subtle"> 47 + return ( 48 + <MSErrorBoundary> 49 + <Card.Root variant="subtle"> 47 50 <Card.Header {...cardHeaderProps}> 48 51 <LinkBox> 49 52 <Flex justify="space-between"> ··· 67 70 <QuickStatsSource data={data} streamable={props.fetchable} /> 68 71 </Card.Header> 69 72 {body} 70 - </Card.Root>) 73 + </Card.Root> 74 + </MSErrorBoundary>) 71 75 } 72 76 73 77 const QuickStatsSource = (props: { data: ComponentCommonApiJson, streamable?: boolean }) => {
+15
src/core/ErrorUtils.ts
··· 4 4 5 5 export type ErrorIsh = Error | MarkOptional<ErrorLike, 'stack'>; 6 6 7 + export const isErrorIsh = (val: unknown): val is ErrorIsh => { 8 + if(val === undefined || val === null || typeof val !== 'object') { 9 + return false; 10 + } 11 + if(!('message' in val)) { 12 + return false; 13 + } 14 + if('cause' in val) { 15 + if(!isErrorIsh(val.cause)) { 16 + return false; 17 + } 18 + } 19 + return true; 20 + } 21 + 7 22 /** 8 23 * Adapted from https://github.com/voxpelli/pony-cause 9 24 * */