diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index f3f7d405..920b016b 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -19,8 +19,10 @@ export const ActivityDetails = (props: ActivityDetailProps) => { play: { meta: { lifecycle: { + original, scrobble } = {}, + lifecycle } = {}, } = {} } @@ -29,6 +31,7 @@ export const ActivityDetails = (props: ActivityDetailProps) => { const [collapsibleOpen, setCollapsibleOpen] = useState(undefined); return ( + @@ -37,7 +40,7 @@ export const ActivityDetails = (props: ActivityDetailProps) => { - + @@ -58,11 +61,12 @@ export const ActivityDetails = (props: ActivityDetailProps) => { - {error !== undefined ? : null} {scrobble !== undefined ? : null} + {error !== undefined ? : null} + ) } \ No newline at end of file diff --git a/src/client/components/ActivityTimeline.tsx b/src/client/components/ActivityTimeline.tsx index 258118bc..e5f3257e 100644 --- a/src/client/components/ActivityTimeline.tsx +++ b/src/client/components/ActivityTimeline.tsx @@ -6,6 +6,7 @@ import { ErrorAlert } from "./ErrorAlert"; import { IoMdCodeDownload } from "react-icons/io"; import { BiWrench } from "react-icons/bi"; import { IoMusicalNoteOutline } from "react-icons/io5"; +import { PiMagnifyingGlassDuotone } from "react-icons/pi"; import { TbDatabaseEdit } from "react-icons/tb"; import { MdFiberNew } from "react-icons/md"; import { capitalize } from "../../core/StringUtils"; @@ -17,6 +18,7 @@ import { ScrobbleMatchResult } from "./ScrobbleMatchResult"; import { ScrobbleActionResult } from "./ScrobbleActionResult"; import { ExpandCollapse } from "./ExpandCollapse"; import { MSCollapsible } from "./MSCollapsible"; +import { TimelineErrorIcon } from "./timeline/TimelineIcon"; export interface ActivityDetailProps { @@ -41,7 +43,8 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { steps = [], scrobble: { match, - payload + payload, + error } = {}, scrobble }, @@ -51,12 +54,12 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { const [scrobbleCollapsibleOpen, setScrobbleCollapsibleOpen] = useState(false); return ( - + - + @@ -90,7 +93,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { - + @@ -112,8 +115,8 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { - - + + @@ -136,9 +139,11 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { - - - + {error !== undefined ? : ( + + + + )} diff --git a/src/client/components/ScrobbleActionResult.tsx b/src/client/components/ScrobbleActionResult.tsx index 51889312..d1dac7ed 100644 --- a/src/client/components/ScrobbleActionResult.tsx +++ b/src/client/components/ScrobbleActionResult.tsx @@ -12,6 +12,7 @@ import { JsonDiffPatch } from "./JsonDiff"; import { formatNumber, jdiff } from "../../core/DataUtils"; import { capitalize } from "../../core/StringUtils"; import { MSCollapsible, MSCollapsibleExternalProps } from "./MSCollapsible"; +import { TimelineErrorIcon } from "./timeline/TimelineIcon"; export interface ScrobbleActionResultProps extends MSCollapsibleExternalProps { result: ScrobbleResult, @@ -32,13 +33,30 @@ export const ScrobbleActionResult = (props: ScrobbleActionResultProps) => { collapsibleOpen } = props; + let responseSuffix: JSX.Element, + warningsElm: JSX.Element, + errorElm: JSX.Element | null; + + if (warnings.length > 0) { + warningsElm = warnings + } + if (error !== undefined) { + errorElm = an error + } + + if (warningsElm !== undefined && errorElm !== undefined) { + responseSuffix = with {warningsElm} and {errorElm}; + } else if (warningsElm !== undefined || errorElm !== undefined) { + responseSuffix = with {warningsElm ?? errorElm}; + } + return ( - + - + @@ -52,35 +70,40 @@ export const ScrobbleActionResult = (props: ScrobbleActionResultProps) => { - {response !== undefined ? ( + {response !== undefined || error !== undefined ? ( - - - + {error !== undefined ? : ( + + + + )} - Received Response{scrobbler !== undefined ? from {capitalize(scrobbler)} : null}{warnings.length > 0 ? with warnings : null} + Received Response{scrobbler !== undefined ? from {capitalize(scrobbler)} : null}{responseSuffix !== undefined ? {responseSuffix} : null} - - {warnings.length > 0 ? ( - - - - Warnings in Response - - - {warnings.map((x) => {x})} - - - - - ) : null} + + {error !== undefined ? : null} + {response !== undefined ? : null} + {warnings.length > 0 ? ( + + + + Warnings in Response + + + {warnings.map((x) => {x})} + + + + + ) : null} + diff --git a/src/client/components/TransformSteps.tsx b/src/client/components/TransformSteps.tsx index c913e2aa..c34d4402 100644 --- a/src/client/components/TransformSteps.tsx +++ b/src/client/components/TransformSteps.tsx @@ -1,10 +1,10 @@ -import { ComponentProps } from "react" -import { Timeline, Icon, Span, Stack, Heading } from '@chakra-ui/react'; +import { ComponentProps, Fragment } from "react" +import { Timeline, Icon, Span, Stack, Heading, Box } from '@chakra-ui/react'; import { JsonPlayObject, LifecycleStep } from "../../core/Atomic"; import { PlayData } from "./PlayData"; import { ErrorAlert } from "./ErrorAlert"; import { BiWrench } from "react-icons/bi"; -import { IoMusicalNoteOutline } from "react-icons/io5"; +import { MdMusicNote } from "react-icons/md"; import { ChakraCodeBlockShort, ChakraPlainBlockShort } from "./CodeBlock"; import { JsonDiffPatch } from "./JsonDiff"; import { jdiff } from "../../core/DataUtils"; @@ -25,47 +25,63 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { let currentPlay: JsonPlayObject | false = JSON.parse(JSON.stringify(original)); return ( - + {steps.map((x, index) => { + const { + patch, + inputs, + source, + name + } = x; let err: Error; - const left = JSON.parse(JSON.stringify(currentPlay)); - if (currentPlay !== false) { + const left = currentPlay !== false ? JSON.parse(JSON.stringify(currentPlay)) : false; + if (currentPlay !== false && patch !== undefined) { try { - currentPlay = jdiff.patch(currentPlay, x.patch) as JsonPlayObject; + currentPlay = jdiff.patch(currentPlay, patch) as JsonPlayObject; } catch (e) { err = new Error('Could not patch Play object', { cause: e }); currentPlay = false; } + } else { + currentPlay = false; } + let diffElm: JSX.Element; + if(left !== false && currentPlay !== false) { + diffElm = + + ; + } else if(patch !== undefined) { + diffElm = ; + } + const showAnyDetails = inputs !== undefined || diffElm !== undefined || err !== undefined; return - + - {x.name} with {x.source} + {name} with {source} - - Diff - {err !== undefined ? : null} - {left !== false && currentPlay !== false ? ( - - - - ) : } - Inputs - - {x.inputs.map((y) => { - return - })} + {showAnyDetails ? + + {err !== undefined ? : null} + {diffElm !== undefined ? Diff{diffElm} : null } + {inputs !== undefined ? ( + + Inputs + + {x.inputs.map((y) => { + return + })} + ) : null} - + : null } })} @@ -74,8 +90,8 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { - - + + diff --git a/src/client/components/timeline/TimelineIcon.tsx b/src/client/components/timeline/TimelineIcon.tsx new file mode 100644 index 00000000..0a7470dc --- /dev/null +++ b/src/client/components/timeline/TimelineIcon.tsx @@ -0,0 +1,8 @@ +import { AiOutlineExclamationCircle } from "react-icons/ai"; +import { Icon } from '@chakra-ui/react'; + +export const TimelineErrorIcon = () => ( + + + + ) \ No newline at end of file diff --git a/src/stories/ActivityTimeline.stories.tsx b/src/stories/ActivityTimeline.stories.tsx index b2b0c2ab..b58468a6 100644 --- a/src/stories/ActivityTimeline.stories.tsx +++ b/src/stories/ActivityTimeline.stories.tsx @@ -7,7 +7,7 @@ import { ActivityTimeline } from "../client/components/ActivityTimeline"; import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../backend/tests/utils/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; -import { examplePlay } from "./storyUtils.js"; +import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ @@ -32,4 +32,11 @@ decorators: [ // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const ActivityTimelineStory = meta.story({ //render: function Render(args) { return () } -}); \ No newline at end of file +}); + +export const ScrobbleError = meta.story({ + args: { + play: lastfmErrorExample() + } +}); + diff --git a/src/stories/List.stories.tsx b/src/stories/List.stories.tsx index f3a64d4a..f77bfcf7 100644 --- a/src/stories/List.stories.tsx +++ b/src/stories/List.stories.tsx @@ -7,7 +7,7 @@ import { CList } from "../client/components/List"; import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../backend/tests/utils/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject } from "../core/Atomic.js"; -import {examplePlay} from './storyUtils.js'; +import {examplePlay, lastfmErrorExample} from './storyUtils.js'; const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)"; @@ -28,7 +28,7 @@ const errorExample: ErrorLike = { // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ - title: 'List', + title: 'Examples/ActivityLog', component: CList, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout @@ -38,17 +38,11 @@ const meta = preview.meta({ tags: ['autodocs'], // More on argTypes: https://storybook.js.org/docs/api/argtypes args: { - data: (generateJsonPlays(3, undefined, {source: 'Spotify'})).map((x, index) => { - const mod = (index + 1) % 3; - switch(mod) { - case 1: - return {play: x, status: 'queued'} - case 2: - return {play: examplePlay(), status: 'scrobbled'} - case 0: - return {play: x, status: 'error', error: errorExample}; - } - }), + data:[ + ...generateJsonPlays(2).map((x) => ({play: x, status: 'queued'})), + {play: examplePlay(), status: 'scrobbled'}, + {play: lastfmErrorExample(), status: 'error'} + ] , }, decorators: [ (Story) => (), diff --git a/src/stories/storyUtils.ts b/src/stories/storyUtils.ts index 6b4d1080..a6c780ae 100644 --- a/src/stories/storyUtils.ts +++ b/src/stories/storyUtils.ts @@ -1099,4 +1099,324 @@ export const examplePlay = (): JsonPlayObject => ({ }, lifecycle: exampleLifecycle() } -}); \ No newline at end of file +}); + +const lastfmErrorLifcycle: PlayLifecycle = { + "input": { + "track": { + "album": { + "album_type": "single", + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4l8MDydHy2RGwcGscG0uCB" + }, + "href": "https://api.spotify.com/v1/artists/4l8MDydHy2RGwcGscG0uCB", + "id": "4l8MDydHy2RGwcGscG0uCB", + "name": "Mpho.Wav", + "type": "artist", + "uri": "spotify:artist:4l8MDydHy2RGwcGscG0uCB" + } + ], + "external_urls": { + "spotify": "https://open.spotify.com/album/1TCD8jrQdFclnpYVQxjon2" + }, + "href": "https://api.spotify.com/v1/albums/1TCD8jrQdFclnpYVQxjon2", + "id": "1TCD8jrQdFclnpYVQxjon2", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab67616d0000b273b0e9752208d1c426fcda8820", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67616d00001e02b0e9752208d1c426fcda8820", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab67616d00004851b0e9752208d1c426fcda8820", + "width": 64 + } + ], + "name": "Into The Deep", + "release_date": "2026-02-27", + "release_date_precision": "day", + "total_tracks": 3, + "type": "album", + "uri": "spotify:album:1TCD8jrQdFclnpYVQxjon2" + }, + "artists": [ + { + "external_urls": { + "spotify": "https://open.spotify.com/artist/4l8MDydHy2RGwcGscG0uCB" + }, + "href": "https://api.spotify.com/v1/artists/4l8MDydHy2RGwcGscG0uCB", + "id": "4l8MDydHy2RGwcGscG0uCB", + "name": "Mpho.Wav", + "type": "artist", + "uri": "spotify:artist:4l8MDydHy2RGwcGscG0uCB" + } + ], + "disc_number": 1, + "duration_ms": 318875, + "explicit": false, + "external_ids": { + "isrc": "US23A9495906" + }, + "external_urls": { + "spotify": "https://open.spotify.com/track/3Uar9flTUlakmFt9nkJdNZ" + }, + "href": "https://api.spotify.com/v1/tracks/3Uar9flTUlakmFt9nkJdNZ", + "id": "3Uar9flTUlakmFt9nkJdNZ", + "is_local": false, + "name": "Bab'omdala", + "popularity": 26, + "preview_url": "https://p.scdn.co/mp3-preview/ec6e58ff47870192adfa02cd02b963e269ff330e?cid=f85f5aa4211f4ea78aa364547798b340", + "track_number": 1, + "type": "track", + "uri": "spotify:track:3Uar9flTUlakmFt9nkJdNZ" + }, + "played_at": "2026-03-10T18:20:34.606Z", + "context": { + "type": "album", + "external_urls": { + "spotify": "https://open.spotify.com/album/4JgbPlF06nOTO03mSBSgtq" + }, + "href": "https://api.spotify.com/v1/albums/4JgbPlF06nOTO03mSBSgtq", + "uri": "spotify:album:4JgbPlF06nOTO03mSBSgtq" + } + }, + "original": { + "data": { + "album": "Into The Deep", + "track": "Bab'omdala", + "duration": 318.875, + "isrc": "US23A9495906", + "artists": [ + "Mpho.Wav" + ], + "albumArtists": [], + // @ts-ignore + "playDate": "2026-03-10T18:20:34.606Z", + // @ts-ignore + "playDateCompleted": "2026-03-10T18:20:34.606Z", + "meta": { + "spotify": { + "track": "3Uar9flTUlakmFt9nkJdNZ", + "artist": [ + "4l8MDydHy2RGwcGscG0uCB" + ], + "albumArtist": [], + "album": "1TCD8jrQdFclnpYVQxjon2" + }, + "brainz": { + "trackNumber": 1 + } + } + }, + "meta": { + "deviceId": "NoDevice-SingleUser", + "source": "Spotify", + "musicService": "Spotify", + "trackId": "3Uar9flTUlakmFt9nkJdNZ", + "scrobbleTsSOC": 2, + "newFromSource": false, + "url": { + "web": "https://open.spotify.com/track/3Uar9flTUlakmFt9nkJdNZ" + }, + "art": { + "album": "https://i.scdn.co/image/ab67616d00001e02b0e9752208d1c426fcda8820" + } + } + }, + "steps": [ + { + "name": "preCompare", + "source": "Spotify - default" + } + ], + "scrobble": { + "payload": { + "artist": "Mpho.Wav", + "track": "Bab'omdala", + "album": "Into The Deep", + "timestamp": 1773166834, + "duration": 318.875 + }, + "error": { + "showStopper": false, + "name": "Scrobble Submit Error", + "payload": { + "artist": "Mpho.Wav", + "track": "Bab'omdala", + "album": "Into The Deep", + "timestamp": 1773166834, + "duration": 318.875 + }, + "message": "Failed to submit scrobble to Last.fm", + "stack": "Scrobble Submit Error: Failed to submit scrobble to Last.fm\n at LastfmApiClient.scrobble (/app/src/backend/common/vendor/LastfmApiClient.ts:447:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async LastfmScrobbler.doScrobble (/app/src/backend/scrobblers/LastfmScrobbler.ts:88:28)\n at async LastfmScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:623:28)\n at async LastfmScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:937:39)\n at async LastfmScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:882:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)", + "cause": { + "showStopper": false, + "name": "Error", + "message": "Last.fm ignored scrobble => (Code 1) (No error message returned) -- See https://www.last.fm/api/errorcodes for more information", + "stack": "Error: Last.fm ignored scrobble => (Code 1) (No error message returned) -- See https://www.last.fm/api/errorcodes for more information\n at LastfmApiClient.scrobble (/app/src/backend/common/vendor/LastfmApiClient.ts:424:23)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async LastfmScrobbler.doScrobble (/app/src/backend/scrobblers/LastfmScrobbler.ts:88:28)\n at async LastfmScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:623:28)\n at async LastfmScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:937:39)\n at async LastfmScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:882:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)" + } + }, + "match": { + "match": false, + "score": 0.32285714285714284, + "breakdowns": [ + "Artist: 0.00 * 0.3 = 0.00", + "Title: 0.06 * 0.4 = 0.02", + "Time: (Fuzzy) 0.6 * 0.5 = 0.30", + "Time Detail => Existing: 14:15:15-04:00 - Candidate: 14:20:34-04:00 | Temporal Sameness: Fuzzy | Play Diff: 319s (Needed <10s) | Fuzzy Duration Diff: 0s (Needed <= 10s) | Range Comparison N/A", + "Score 0.32 => No Match" + ], + "reason": "Score 0.32 => No Match", + "closestMatchedPlay": { + "data": { + "artists": [ + "Monique Bingham" + ], + "track": "Deep In The Bottom (of Africa) (feat. Black Coffee) - JazzWRLD Remix Edit (Radio Edit)", + "album": "Deep In The Bottom (Of Africa) The Remixes [Pt. 1]", + // @ts-ignore + "playDate": "2026-03-10T18:15:15.000Z" + }, + "meta": { + "nowPlaying": false, + "mbid": "", + "source": "Lastfm", + "url": { + "web": "https://www.last.fm/music/Monique+Bingham/_/Deep+In+The+Bottom+(of+Africa)+(feat.+Black+Coffee)+-+JazzWRLD+Remix+Edit+(Radio+Edit)" + }, + "lifecycle": { + "input": { + "artist": { + "url": "https://www.last.fm/music/Monique+Bingham", + "name": "Monique Bingham", + "image": [ + { + "size": "small", + "#text": "https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png" + }, + { + "size": "medium", + "#text": "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png" + }, + { + "size": "large", + "#text": "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png" + }, + { + "size": "extralarge", + "#text": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" + } + ], + "mbid": "" + }, + "date": { + "uts": "1773166515", + "#text": "10 Mar 2026, 18:15" + }, + "mbid": "", + "name": "Deep In The Bottom (of Africa) (feat. Black Coffee) - JazzWRLD Remix Edit (Radio Edit)", + "image": [ + { + "size": "small", + "#text": "https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png" + }, + { + "size": "medium", + "#text": "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png" + }, + { + "size": "large", + "#text": "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png" + }, + { + "size": "extralarge", + "#text": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" + } + ], + "url": "https://www.last.fm/music/Monique+Bingham/_/Deep+In+The+Bottom+(of+Africa)+(feat.+Black+Coffee)+-+JazzWRLD+Remix+Edit+(Radio+Edit)", + "streamable": "0", + "album": { + "mbid": "", + "#text": "Deep In The Bottom (Of Africa) The Remixes [Pt. 1]" + }, + "loved": "0" + }, + "original": { + "data": { + "track": "Deep In The Bottom (of Africa) (feat. Black Coffee) - JazzWRLD Remix Edit (Radio Edit)", + "album": "Deep In The Bottom (Of Africa) The Remixes [Pt. 1]", + "artists": [ + "Monique Bingham" + ], + // @ts-ignore + "playDate": "2026-03-10T18:15:15.000Z" + }, + "meta": { + "nowPlaying": false, + "mbid": "", + "source": "Lastfm", + "url": { + "web": "https://www.last.fm/music/Monique+Bingham/_/Deep+In+The+Bottom+(of+Africa)+(feat.+Black+Coffee)+-+JazzWRLD+Remix+Edit+(Radio+Edit)" + } + } + }, + "steps": [] + } + } + } + } + } +} + +export const lastfmErrorExample = (): JsonPlayObject => ({ + "data": { + "album": "Into The Deep", + "track": "Bab'omdala", + "duration": 318.875, + "isrc": "US23A9495906", + "artists": [ + "Mpho.Wav" + ], + "albumArtists": [], + // @ts-ignore + "playDate": "2026-03-10T18:20:34.606Z", + // @ts-ignore + "playDateCompleted": "2026-03-10T18:20:34.606Z", + "meta": { + "spotify": { + "track": "3Uar9flTUlakmFt9nkJdNZ", + "artist": [ + "4l8MDydHy2RGwcGscG0uCB" + ], + "albumArtist": [], + "album": "1TCD8jrQdFclnpYVQxjon2" + }, + "brainz": { + "trackNumber": 1 + } + } + }, + "meta": { + "deviceId": "NoDevice-SingleUser", + "source": "Spotify", + "musicService": "Spotify", + "trackId": "3Uar9flTUlakmFt9nkJdNZ", + "scrobbleTsSOC": 2, + "newFromSource": false, + "url": { + "web": "https://open.spotify.com/track/3Uar9flTUlakmFt9nkJdNZ" + }, + "art": { + "album": "https://i.scdn.co/image/ab67616d00001e02b0e9752208d1c426fcda8820" + }, + lifecycle: lastfmErrorLifcycle + } + }); \ No newline at end of file