[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(scrobblers): Serialize error correctly and add debug info to dead letter ui

FoxxMD (Jan 29, 2026, 5:13 PM UTC) 2aac56c9 67f6880d

+91 -8
+56
package-lock.json
··· 93 93 "react-router-dom": "^6.15.0", 94 94 "round-robin-js": "^3.0.10", 95 95 "sass": "^1.93.3", 96 + "serialize-error": "^13.0.1", 96 97 "spotify-web-api-node": "^5.0.2", 97 98 "superagent": "^8.0.9", 98 99 "tailwindcss": "^4.1.16", ··· 9477 9478 "node": ">=4" 9478 9479 } 9479 9480 }, 9481 + "node_modules/non-error": { 9482 + "version": "0.1.0", 9483 + "resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz", 9484 + "integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==", 9485 + "license": "MIT", 9486 + "engines": { 9487 + "node": ">=20" 9488 + }, 9489 + "funding": { 9490 + "url": "https://github.com/sponsors/sindresorhus" 9491 + } 9492 + }, 9480 9493 "node_modules/normalize-exception": { 9481 9494 "version": "4.0.1", 9482 9495 "resolved": "https://registry.npmjs.org/normalize-exception/-/normalize-exception-4.0.1.tgz", ··· 11481 11494 "node": ">= 0.8" 11482 11495 } 11483 11496 }, 11497 + "node_modules/serialize-error": { 11498 + "version": "13.0.1", 11499 + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz", 11500 + "integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==", 11501 + "license": "MIT", 11502 + "dependencies": { 11503 + "non-error": "^0.1.0", 11504 + "type-fest": "^5.4.1" 11505 + }, 11506 + "engines": { 11507 + "node": ">=20" 11508 + }, 11509 + "funding": { 11510 + "url": "https://github.com/sponsors/sindresorhus" 11511 + } 11512 + }, 11513 + "node_modules/serialize-error/node_modules/type-fest": { 11514 + "version": "5.4.2", 11515 + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.2.tgz", 11516 + "integrity": "sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg==", 11517 + "license": "(MIT OR CC0-1.0)", 11518 + "dependencies": { 11519 + "tagged-tag": "^1.0.0" 11520 + }, 11521 + "engines": { 11522 + "node": ">=20" 11523 + }, 11524 + "funding": { 11525 + "url": "https://github.com/sponsors/sindresorhus" 11526 + } 11527 + }, 11484 11528 "node_modules/serialize-javascript": { 11485 11529 "version": "6.0.2", 11486 11530 "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", ··· 12020 12064 }, 12021 12065 "engines": { 12022 12066 "node": ">=8" 12067 + } 12068 + }, 12069 + "node_modules/tagged-tag": { 12070 + "version": "1.0.0", 12071 + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", 12072 + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", 12073 + "license": "MIT", 12074 + "engines": { 12075 + "node": ">=20" 12076 + }, 12077 + "funding": { 12078 + "url": "https://github.com/sponsors/sindresorhus" 12023 12079 } 12024 12080 }, 12025 12081 "node_modules/tailwindcss": {
+1
package.json
··· 127 127 "react-router-dom": "^6.15.0", 128 128 "round-robin-js": "^3.0.10", 129 129 "sass": "^1.93.3", 130 + "serialize-error": "^13.0.1", 130 131 "spotify-web-api-node": "^5.0.2", 131 132 "superagent": "^8.0.9", 132 133 "tailwindcss": "^4.1.16",
+5 -3
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 62 62 import { normalizeStr } from "../utils/StringUtils.js"; 63 63 import prom, { Counter, Gauge } from 'prom-client'; 64 64 import { ScrobbleSubmitError } from "../common/errors/MSErrors.js"; 65 + import {serializeError} from 'serialize-error'; 65 66 66 67 type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>; 67 68 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 812 813 this.addScrobbledTrack(scrobbledPlay, scrobbledPlay.meta.lifecycle.scrobble.mergedScrobble ?? scrobbledPlay); 813 814 } catch (e) { 814 815 currQueuedPlay.play.meta.lifecycle.scrobble = { 815 - error: e, 816 - payload: this.playToClientPayload(transformedScrobble) 817 816 }; 818 817 819 818 const submitError = findCauseByReference(e, ScrobbleSubmitError); 820 819 if(submitError !== undefined) { 821 - currQueuedPlay.play.meta.lifecycle.scrobble.error = submitError; 822 820 currQueuedPlay.play.meta.lifecycle.scrobble.payload = submitError.payload; 823 821 currQueuedPlay.play.meta.lifecycle.scrobble.response = submitError.responseBody; 822 + currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(submitError); 823 + } else { 824 + currQueuedPlay.play.meta.lifecycle.scrobble.payload = this.playToClientPayload(transformedScrobble); 825 + currQueuedPlay.play.meta.lifecycle.scrobble.error = serializeError(e); 824 826 } 825 827 826 828 if (hasUpstreamError(e, false)) {
+1 -1
src/backend/sources/SpotifySource.ts
··· 405 405 getPlayHistory = async (options: RecentlyPlayedOptions = {}) => { 406 406 const {limit = 20} = options; 407 407 const func = (api: SpotifyWebApi) => api.getMyRecentlyPlayedTracks({ 408 - limit: 1 408 + limit 409 409 }); 410 410 const result = await this.callApi<ReturnType<typeof this.spotifyApi.getMyRecentlyPlayedTracks>>(func); 411 411 return result.body.items.map((x: PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate);
+26 -3
src/client/deadLetter/DeadPage.tsx
··· 14 14 import dayjs from "dayjs"; 15 15 import {RootState} from "../store"; 16 16 import {connect, ConnectedProps} from "react-redux"; 17 + import { faBug } from "@fortawesome/free-solid-svg-icons"; 18 + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 19 + import clsx from "clsx"; 20 + import { useCopyToClipboard } from '../components/copyToClipboardHook'; 17 21 18 22 const displayOpts = { 19 23 include: recentIncludes, ··· 30 34 isLoading, 31 35 isSuccess 32 36 } = useGetDeadQuery({name: searchParams.get('name'), type: searchParams.get('type')}); 37 + 38 + const { copy, isCopied } = useCopyToClipboard(); 39 + 40 + const [copiedIndex, setIndex] = useState(null); 41 + 42 + const copyActionCB = useCallback((obj, index) => { 43 + copy(JSON.stringify(obj, null, 2)); 44 + setIndex(index); 45 + },[copy, setIndex]); 46 + 47 + const baseClass = ['mr-3']; 33 48 34 49 const [removeDeadFetch, removeResult] = useRemoveDeadSingleMutation(); 35 50 const [retryDeadFetch, processResult] = useProcessDeadSingleMutation(); ··· 51 66 </div> 52 67 <div className="p-5"> 53 68 {isSuccess && !isLoading && data.length === 0 ? 'No failed scrobbles!' : null} 54 - <ul>{data.map(x => (<li className="my-2.5" key={x.id}> 55 - <div className="text-lg"><PlayDisplay data={x.play} buildOptions={displayOpts}/></div> 69 + <ul>{data.map(x => 70 + { 71 + const classes = [...baseClass].concat(copiedIndex !== x.id ? ['underline','cursor-pointer'] : []); 72 + return (<li className="my-2.5" key={x.id}> 73 + <div className="text-lg"> 74 + <button className={clsx(classes)} onClick={() => copyActionCB(x.play.meta.lifecycle, x.id)}>{copiedIndex === x.id ? 'Copied!' : <FontAwesomeIcon 75 + color="white" icon={faBug}/>}</button> 76 + <PlayDisplay data={x.play} buildOptions={displayOpts}/></div> 56 77 <div><span className="font-semibold">Source</span>:{x.source.replace('Source -', '')}</div> 57 78 <div><span className="font-semibold">Retries</span>: {x.retries}</div> 58 79 <div><span className="font-semibold">Last Retried</span>: {x.lastRetry === undefined ? 'Never' : dayjs.duration(dayjs(x.lastRetry).diff(dayjs())).humanize(true)}</div> 59 80 <div><span className="font-semibold">Error</span>: <span className="font-mono text-sm">{x.error}</span></div> 60 81 <div onClick={() => retryDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Retry</div> 61 82 <div onClick={() => removeDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Remove</div> 62 - </li>))}</ul> 83 + </li>) 84 + } 85 + )}</ul> 63 86 </div> 64 87 </div> 65 88 </div>
+2 -1
src/core/Atomic.ts
··· 4 4 import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; 5 5 import { Delta } from 'jsondiffpatch'; 6 6 import { MarkOptional } from "ts-essentials"; 7 + import { ErrorObject } from "serialize-error"; 7 8 8 9 export interface SourceStatusData { 9 10 status: string; ··· 290 291 scrobble?: { 291 292 payload?: ScrobblePayload 292 293 warnings?: string[] 293 - error?: Error 294 + error?: Error | ErrorObject 294 295 response?: ScrobbleResponse 295 296 mergedScrobble?: PlayObjectLifecycleless 296 297 }