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

fix: Serialize errors at api boundary and fix recursive cause bug in error alert render

FoxxMD (Jun 24, 2026, 6:27 PM UTC) 2ac89c5d b0db1dd8

+50 -5
+5 -1
src/backend/server/api.ts
··· 44 44 import { ComponentClientApi, ComponentSourceApi, ComponentSourceApiJson } from "../../core/Api.js"; 45 45 import { asDayjsHydratedObject } from "../../core/DataUtils.js"; 46 46 import { Dayjs } from "dayjs"; 47 + import { asSerializablePlaySelect } from "../../core/PlayMarshalUtils.js"; 47 48 48 49 const maxBufferSize = 300; 49 50 const output: Record<number, FixedSizeList<LogDataPretty>> = {}; ··· 293 294 294 295 const hydratedQuery = asDayjsHydratedObject<QueryPlaysOptsJson, QueryPlaysOpts<Dayjs>>(query); 295 296 const playRes = await component.getPlaysPaginated(hydratedQuery); 297 + 298 + // @ts-expect-error 299 + playRes.data = playRes.data.map(x => asSerializablePlaySelect(x)) 296 300 //PlayApiCommonDetailed 297 301 // plus paginatioon 298 302 return res.json(playRes); ··· 310 314 const playRes = await component.getPlayApiResponse(playUid as string); 311 315 //PlayApiCommonDetailed 312 316 // plus paginatioon 313 - return res.json(playRes); 317 + return res.json(asSerializablePlaySelect(playRes)); 314 318 }); 315 319 316 320 /**
+2 -2
src/client/components/ErrorAlert.tsx
··· 60 60 stack: err.stack 61 61 }; 62 62 errors.push(thisErr); 63 - if(err.cause !== undefined && typeof err.cause === 'object' && err.cause !== null) { 64 - return walkError(err, errors); 63 + if(isErrorIsh(err.cause)) { 64 + return walkError(err.cause, errors); 65 65 } 66 66 return errors; 67 67 }
+12
src/core/DataUtils.ts
··· 10 10 import assert from "node:assert"; 11 11 import dayjs, {Dayjs} from "dayjs"; 12 12 import { Traverse } from "neotraverse/modern"; 13 + import { serializeError } from "serialize-error"; 13 14 14 15 const console = new ConsoleFormatter(); 15 16 ··· 107 108 108 109 if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) { 109 110 ctx.update(dayjs(x), true); 111 + } 112 + }); 113 + return cloned as unknown as U; 114 + }; 115 + 116 + export const asErrorSerializedObject = <T, U>(obj: T): U => { 117 + const cloned = clone(obj); 118 + new Traverse(cloned).forEach((ctx, x) => { 119 + 120 + if (x !== null && typeof x === 'object' && x instanceof Error) { 121 + ctx.update(serializeError(x), true); 110 122 } 111 123 }); 112 124 return cloned as unknown as U;
+31 -2
src/core/PlayMarshalUtils.ts
··· 2 2 import dayjs, { Dayjs } from 'dayjs'; 3 3 import { Traverse, TraverseContext } from 'neotraverse/modern'; 4 4 import { ListenRange } from '../backend/sources/PlayerState/ListenRange.js'; 5 - import { AmbPlayObject, DateLike, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js'; 5 + import { AmbPlayObject, DateLike, ErrorLike, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE, Replace } from './Atomic.js'; 6 6 import { ListenProgressPositional, ListenProgressTS } from '../backend/sources/PlayerState/ListenProgress.js'; 7 - import { DeepPick, PickKeys } from 'ts-essentials'; 7 + import { DeepPick, ElementOf, MarkOptional, PickKeys } from 'ts-essentials'; 8 + import { PlaySelectWithQueueStates } from '../backend/common/database/drizzle/drizzleTypes.js'; 9 + import { ErrorObject, serializeError } from 'serialize-error'; 10 + import { PlayApiCommonDetailed } from './Api.js'; 8 11 9 12 interface BlockPath { key: string, parent: string }; 10 13 type BlockPaths = BlockPath[]; ··· 56 59 }); 57 60 return cloned as unknown as JsonPlayObject; 58 61 }; 62 + 63 + export type SerializablePlaySelect = Replace<MarkOptional<PlayApiCommonDetailed, 'queueStates'>, 'error', ErrorObject> & {queueStates?: Replace<ElementOf<PlayApiCommonDetailed['queueStates']>, 'error', ErrorObject>[]}; 64 + export const asSerializablePlaySelect = (data: MarkOptional<PlayApiCommonDetailed, 'queueStates'>): SerializablePlaySelect => { 65 + const { 66 + error, 67 + queueStates = [], 68 + ...rest 69 + } = data; 70 + 71 + const qMapped = queueStates.map((x) => { 72 + const { 73 + error: e, 74 + ...restQ 75 + } = x; 76 + return { 77 + ...restQ, 78 + error: e instanceof Error ? serializeError(e) : e 79 + } 80 + }); 81 + 82 + return { 83 + ...rest, 84 + error: error instanceof Error ? serializeError(error) : error, 85 + queueStates: qMapped 86 + } 87 + } 59 88 60 89 export const asPlay = (data: JsonPlayObject | PlayObject): PlayObject => { 61 90 const cloned = clone(data);