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

virtualized activity log

FoxxMD (Mar 24, 2026, 5:47 PM UTC) 2735c283 0daaec81

+329 -120
+11
package-lock.json
··· 101 101 "react-icons": "^5.6.0", 102 102 "react-redux": "^8.1.2", 103 103 "react-router-dom": "^6.15.0", 104 + "react-virtuoso": "^4.17.0", 104 105 "round-robin-js": "^3.0.10", 105 106 "sass": "^1.93.3", 106 107 "serialize-error": "^13.0.1", ··· 16971 16972 "peerDependencies": { 16972 16973 "react": ">=16.8", 16973 16974 "react-dom": ">=16.8" 16975 + } 16976 + }, 16977 + "node_modules/react-virtuoso": { 16978 + "version": "4.17.0", 16979 + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.17.0.tgz", 16980 + "integrity": "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w==", 16981 + "license": "MIT", 16982 + "peerDependencies": { 16983 + "react": ">=16 || >=17 || >= 18 || >= 19", 16984 + "react-dom": ">=16 || >=17 || >= 18 || >=19" 16974 16985 } 16975 16986 }, 16976 16987 "node_modules/readable-stream": {
+1
package.json
··· 139 139 "react-icons": "^5.6.0", 140 140 "react-redux": "^8.1.2", 141 141 "react-router-dom": "^6.15.0", 142 + "react-virtuoso": "^4.17.0", 142 143 "round-robin-js": "^3.0.10", 143 144 "sass": "^1.93.3", 144 145 "serialize-error": "^13.0.1",
+14 -13
src/client/components/CodeBlock.tsx
··· 4 4 import { ComponentProps, PropsWithChildren, useMemo } from "react"; 5 5 import { safeStringify } from "../../core/StringUtils"; 6 6 import { MarkOptional } from "ts-essentials"; 7 + import { shikiAdapter } from "./shikiAdapter"; 7 8 8 - const shikiAdapter = createShikiAdapter<HighlighterGeneric<any, any>>({ 9 - async load() { 10 - const { createHighlighter } = await import("shiki") 11 - return createHighlighter({ 12 - langs: ["json", "plaintext"], 13 - themes: ["github-dark", "github-light"], 14 - }) 15 - }, 16 - theme: { 17 - light: "github-light", 18 - dark: "github-dark", 19 - }, 20 - }); 9 + // const shikiAdapter = createShikiAdapter<HighlighterGeneric<any, any>>({ 10 + // async load() { 11 + // const { createHighlighter } = await import("shiki") 12 + // return createHighlighter({ 13 + // langs: ["json", "plaintext"], 14 + // themes: ["github-dark", "github-light"], 15 + // }) 16 + // }, 17 + // theme: { 18 + // light: "github-light", 19 + // dark: "github-dark", 20 + // }, 21 + // }); 21 22 22 23 export interface ChakraCodeBaseProps { 23 24 language?: string
-93
src/client/components/List.tsx
··· 1 - import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container } from '@chakra-ui/react'; 2 - import { JsonPlayObject, PlayActivity } from '../../core/Atomic'; 3 - import { ShortDateDisplay } from './DateDisplay'; 4 - import { TextMuted } from './TextMuted'; 5 - import { capitalize } from '../../core/StringUtils'; 6 - import { ComponentProps } from "react" 7 - import { VscDebugRestart } from "react-icons/vsc"; 8 - import { PlayData, PlayInfoContainer } from './PlayData'; 9 - import { ActivityDetails } from './ActivityDetail'; 10 - export interface ActivityLogProps { 11 - data: PlayActivity[] 12 - } 13 - 14 - export const CList = (props: ActivityLogProps) => { 15 - return ( 16 - <Stack gap="2"> 17 - <Box> 18 - <Flex direction="row" justify="space-between"> 19 - 20 - <Text fontWeight="semibold">Today</Text> 21 - 22 - <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 23 - <VscDebugRestart /> 24 - </IconButton> 25 - </Flex> 26 - <Separator orientation="horizontal" height="4" /> 27 - </Box> 28 - <Accordion.Root variant="enclosed" collapsible multiple> 29 - {props.data.map((activity, index) => { 30 - const { play } = activity; 31 - return ( 32 - <Accordion.Item key={index} value={index.toString()}> 33 - <Flex justify="space-between"> 34 - <Accordion.ItemTrigger truncate cursor="pointer"> 35 - <Accordion.ItemIndicator /> 36 - <Stack gap="1" truncate> 37 - <Span>{play.data.track}</Span> 38 - <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 39 - <HStack gap="1"> 40 - <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 41 - <TextMuted>{play.meta?.source}</TextMuted> 42 - </HStack> 43 - </Stack> 44 - </Accordion.ItemTrigger> 45 - <Stack style={{ 46 - paddingBlock: "var(--accordion-padding-y)", 47 - paddingInline: "var(--accordion-padding-x)" 48 - }} justify="flex-start" alignItems="flex-end"> 49 - <StatusBadge maxWidth="fit-content" data={activity} /> 50 - {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 51 - <VscDebugRestart /> 52 - </IconButton> : null} 53 - </Stack> 54 - 55 - </Flex> 56 - <Accordion.ItemContent> 57 - <Accordion.ItemBody borderTopColor="gray.border" > 58 - <ActivityDetails activity={activity}/> 59 - </Accordion.ItemBody> 60 - </Accordion.ItemContent> 61 - </Accordion.Item> 62 - ) 63 - })} 64 - </Accordion.Root> 65 - </Stack> 66 - ); 67 - } 68 - 69 - const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayActivity }) => { 70 - 71 - const { data, ...rest } = props; 72 - 73 - let badgeColor = undefined, 74 - badgeText = capitalize(data.status); 75 - 76 - switch (data.status) { 77 - case 'queued': 78 - badgeColor = 'gray'; 79 - break; 80 - case 'scrobbled': 81 - badgeColor = 'green'; 82 - break; 83 - case 'error': 84 - badgeColor = 'red'; 85 - break; 86 - } 87 - 88 - return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}</Badge> 89 - } 90 - 91 - export const ListContainer = (props?: ComponentProps<typeof CList>) => { 92 - return <Container maxWidth="3xl"><CList {...props}/></Container> 93 - }
+9
src/client/components/playActivity/PlayList.scss
··· 1 + [data-testid="virtuoso-item-list"] div[data-index]:first-child div.chakra-collapsible__root { 2 + border-top-left-radius: 0.25rem; 3 + border-top-right-radius: 0.25rem; 4 + } 5 + 6 + [data-testid="virtuoso-item-list"] div[data-index]:last-child div.chakra-collapsible__root { 7 + border-bottom-left-radius: 0.25rem; 8 + border-bottom-right-radius: 0.25rem; 9 + }
+238
src/client/components/playActivity/PlayList.tsx
··· 1 + import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Collapsible } from '@chakra-ui/react'; 2 + import { JsonPlayObject, PlayActivity } from '../../../core/Atomic.js'; 3 + import { ShortDateDisplay } from '../DateDisplay.js'; 4 + import { TextMuted } from '../TextMuted.js'; 5 + import { LuChevronRight } from "react-icons/lu" 6 + import { capitalize } from '../../../core/StringUtils.js'; 7 + import { ComponentProps, useMemo } from "react" 8 + import dayjs, { Dayjs } from 'dayjs'; 9 + import doy from 'dayjs/plugin/dayOfYear.js'; 10 + import { VscDebugRestart } from "react-icons/vsc"; 11 + import { PlayData, PlayInfoContainer } from '../PlayData.js'; 12 + import { GroupedVirtuoso } from 'react-virtuoso' 13 + import { ActivityDetails } from '../ActivityDetail.js'; 14 + import { sortByNewestPlayDate } from '../../../core/PlayUtils.js'; 15 + import "./PlayList.scss"; 16 + 17 + dayjs.extend(doy); 18 + 19 + export interface ActivityLogProps { 20 + data: PlayActivity[] 21 + sortBy?: 'played' | 'seen' 22 + virtual?: boolean 23 + } 24 + 25 + interface GroupInfo { 26 + count: number 27 + date: Dayjs 28 + } 29 + 30 + export const PlayList = (props: ActivityLogProps) => { 31 + 32 + const { 33 + data = [], 34 + sortBy = 'played', 35 + virtual = false, 36 + } = props; 37 + 38 + if(virtual) { 39 + 40 + 41 + 42 + const sorted = useMemo(() => props.data.toSorted((a, b) => sortByNewestPlayDate(a.play, b.play)), [data, sortBy]); 43 + const groupsReduced = useMemo(() => { 44 + return sorted.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => { 45 + const date = dayjs(curr.play.data.playDate); 46 + if (acc.active === undefined) { 47 + return { ...acc, active: { count: 1, date } }; 48 + } 49 + if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 50 + return { groups: [...acc.groups, acc.active], active: { count: 1, date } } 51 + } 52 + 53 + return { groups: acc.groups, active: { ...acc.active, count: acc.active.count + 1 } }; 54 + }, { groups: [] }); 55 + }, [sorted, sortBy]); 56 + 57 + const allGroups = groupsReduced.groups.concat(groupsReduced.active); 58 + 59 + return ( 60 + <GroupedVirtuoso 61 + style={{ height: '700px' }} 62 + fixedItemHeight={80} 63 + fixedGroupHeight={50} 64 + groupCounts={allGroups.map(x => x.count)} 65 + groupContent={(index) => { 66 + const gData = allGroups[index]; 67 + let headerText: string; 68 + if (gData.date.isToday()) { 69 + headerText = 'Today'; 70 + } else { 71 + headerText = gData.date.format('MMM DD'); 72 + if (gData.date.year() !== dayjs().year()) { 73 + headerText += `, ${gData.date.year()}`; 74 + } 75 + } 76 + return ( 77 + <Box paddingY="2"> 78 + <Flex direction="row" justify="space-between"> 79 + 80 + <Text fontWeight="semibold">{headerText}</Text> 81 + 82 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 83 + <VscDebugRestart /> 84 + </IconButton> 85 + </Flex> 86 + <Separator orientation="horizontal" height="4" /> 87 + </Box> 88 + ) 89 + }} 90 + itemContent={(index, groupIndex) => { 91 + const activity = sorted[index]; 92 + const { play } = activity; 93 + return ( 94 + <Collapsible.Root key={index} 95 + 96 + lazyMount 97 + _open={{ 98 + background: "var(--chakra-colors-bg-subtle)" 99 + }} 100 + style={{ 101 + borderColor: "var(--chakra-colors-border)", 102 + borderWidth: '1px', 103 + }} 104 + > 105 + <Flex justify="space-between"> 106 + <Collapsible.Trigger 107 + userSelect="text" 108 + 109 + paddingY="3" 110 + display="flex" 111 + gap="2" 112 + alignItems="center" 113 + truncate cursor="pointer" 114 + style={{ 115 + paddingBlock: "var(--chakra-spacing-2)", 116 + paddingInline: "var(--chakra-spacing-4)" 117 + }} 118 + > 119 + <Collapsible.Indicator 120 + transition="transform 0.2s" 121 + _open={{ transform: "rotate(90deg)" }} 122 + > 123 + <LuChevronRight /> 124 + </Collapsible.Indicator> 125 + <Stack gap="1" truncate alignItems="flex-start"> 126 + <Span>{play.data.track}</Span> 127 + <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 128 + <HStack gap="1"> 129 + <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 130 + <TextMuted>{play.meta?.source}</TextMuted> 131 + </HStack> 132 + </Stack> 133 + </Collapsible.Trigger> 134 + <Stack style={{ 135 + paddingBlock: "var(--chakra-spacing-2)", 136 + paddingInline: "var(--chakra-spacing-4)" 137 + }} justify="flex-start" alignItems="flex-end"> 138 + <StatusBadge maxWidth="fit-content" data={activity} /> 139 + {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 140 + <VscDebugRestart /> 141 + </IconButton> : null} 142 + </Stack> 143 + 144 + </Flex> 145 + <Collapsible.Content borderTopColor="gray.border" 146 + style={{ 147 + paddingBlock: "var(--chakra-spacing-4)", 148 + paddingInline: "var(--chakra-spacing-4)" 149 + }}> 150 + <ActivityDetails activity={activity} /> 151 + </Collapsible.Content> 152 + </Collapsible.Root> 153 + ) 154 + }} 155 + /> 156 + ); 157 + 158 + } 159 + 160 + return ( 161 + <Stack gap="2"> 162 + <Box> 163 + <Flex direction="row" justify="space-between"> 164 + 165 + <Text fontWeight="semibold">Today</Text> 166 + 167 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 168 + <VscDebugRestart /> 169 + </IconButton> 170 + </Flex> 171 + <Separator orientation="horizontal" height="4" /> 172 + </Box> 173 + <Accordion.Root variant="enclosed" collapsible multiple> 174 + {props.data.map((activity, index) => { 175 + const { play } = activity; 176 + return ( 177 + <Accordion.Item key={index} value={index.toString()}> 178 + <Flex justify="space-between"> 179 + <Accordion.ItemTrigger truncate cursor="pointer"> 180 + <Accordion.ItemIndicator /> 181 + <Stack gap="1" truncate> 182 + <Span>{play.data.track}</Span> 183 + <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 184 + <HStack gap="1"> 185 + <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 186 + <TextMuted>{play.meta?.source}</TextMuted> 187 + </HStack> 188 + </Stack> 189 + </Accordion.ItemTrigger> 190 + <Stack style={{ 191 + paddingBlock: "var(--accordion-padding-y)", 192 + paddingInline: "var(--accordion-padding-x)" 193 + }} justify="flex-start" alignItems="flex-end"> 194 + <StatusBadge maxWidth="fit-content" data={activity} /> 195 + {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 196 + <VscDebugRestart /> 197 + </IconButton> : null} 198 + </Stack> 199 + 200 + </Flex> 201 + <Accordion.ItemContent> 202 + <Accordion.ItemBody borderTopColor="gray.border" > 203 + <ActivityDetails activity={activity}/> 204 + </Accordion.ItemBody> 205 + </Accordion.ItemContent> 206 + </Accordion.Item> 207 + ) 208 + })} 209 + </Accordion.Root> 210 + </Stack> 211 + ); 212 + } 213 + 214 + const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayActivity }) => { 215 + 216 + const { data, ...rest } = props; 217 + 218 + let badgeColor = undefined, 219 + badgeText = capitalize(data.status); 220 + 221 + switch (data.status) { 222 + case 'queued': 223 + badgeColor = 'gray'; 224 + break; 225 + case 'scrobbled': 226 + badgeColor = 'green'; 227 + break; 228 + case 'error': 229 + badgeColor = 'red'; 230 + break; 231 + } 232 + 233 + return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}</Badge> 234 + } 235 + 236 + export const ListContainer = (props?: ComponentProps<typeof PlayList>) => { 237 + return <Container maxWidth="3xl"><PlayList {...props} /></Container> 238 + }
+16
src/client/components/shikiAdapter.ts
··· 1 + import { createShikiAdapter } from "@chakra-ui/react" 2 + import type { HighlighterGeneric } from "shiki" 3 + 4 + export const shikiAdapter = createShikiAdapter<HighlighterGeneric<any, any>>({ 5 + async load() { 6 + const { createHighlighter } = await import("shiki") 7 + return createHighlighter({ 8 + langs: ["json", "plaintext"], 9 + themes: ["github-dark", "github-light"], 10 + }) 11 + }, 12 + theme: { 13 + light: "github-light", 14 + dark: "github-dark", 15 + }, 16 + });
+14
src/core/PlayTestUtils.ts
··· 75 75 ...defaultMeta 76 76 } 77 77 78 + if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) { 79 + cleanPlay.meta.lifecycle.original.data.playDate = lastDate; 80 + } 81 + if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) { 82 + cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's'); 83 + } 84 + 78 85 if(index + 1 <= plays.length - 1) { 79 86 const listenTime = (plays[index+1].data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2}); 80 87 lastDate = cleanPlay.data.playDate.subtract(listenTime, 'seconds'); ··· 109 116 cleanPlay.meta = { 110 117 ...cleanPlay.meta, 111 118 ...defaultMeta 119 + } 120 + 121 + if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) { 122 + cleanPlay.meta.lifecycle.original.data.playDate = lastDate; 123 + } 124 + if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) { 125 + cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's'); 112 126 } 113 127 114 128 if(progressDirection === 'newer') {
+11 -4
src/core/PlayUtils.ts
··· 1 1 import { PlayPlatformId } from "../backend/common/infrastructure/Atomic.js"; 2 - import { PlayObject, PlayObjectLifecycleless } from "./Atomic.js"; 2 + import { AmbPlayObject, DateLike, PlayObject, PlayObjectLifecycleless } from "./Atomic.js"; 3 + import dayjs from "dayjs"; 3 4 4 5 5 6 /** sorts playObj formatted objects by playDate in descending (newest first) order */ 6 - export const sortByNewestPlayDate = (a: PlayObject, b: PlayObject) => { 7 + export const sortByNewestPlayDate = (a: AmbPlayObject<DateLike>, b: AmbPlayObject<DateLike>) => { 7 8 const { 8 9 data: { 9 10 playDate: aPlayDate ··· 23 24 if (bPlayDate === undefined) { 24 25 return -1; 25 26 } 26 - return aPlayDate.isBefore(bPlayDate) ? 1 : -1; 27 - };export const genGroupIdStr = (id: PlayPlatformId) => { 27 + 28 + const realA = typeof aPlayDate === 'string' ? dayjs(aPlayDate) : aPlayDate; 29 + const realB = typeof bPlayDate === 'string' ? dayjs(bPlayDate) : bPlayDate; 30 + return realA.isBefore(realB) ? 1 : -1; 31 + }; 32 + 33 + export const genGroupIdStr = (id: PlayPlatformId) => { 28 34 return `${id[0]}-${id[1]}`; 29 35 }; 36 + 30 37 export const lifecyclelessInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { 31 38 const { 32 39 meta: {
+15 -10
src/stories/List.stories.tsx
··· 3 3 4 4 import { fn } from 'storybook/test'; 5 5 import { Container } from '@chakra-ui/react'; 6 - import { CList } from "../client/components/List"; 6 + import { PlayList } from "../client/components/playActivity/PlayList.js"; 7 7 import {Provider} from "../client/components/Provider"; 8 - import { generateJsonPlays } from "../core/PlayTestUtils.js"; 8 + import { generateJsonPlays, normalizePlays } from "../core/PlayTestUtils.js"; 9 9 import { ErrorLike, JsonPlayObject } from "../core/Atomic.js"; 10 10 import {examplePlay, lastfmErrorExample} from './storyUtils.js'; 11 11 import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures' 12 + import { generateArray } from "../core/DataUtils.js"; 13 + import dayjs from "dayjs"; 12 14 13 15 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)"; 14 16 ··· 30 32 // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 31 33 const meta = preview.meta({ 32 34 title: 'Examples/ActivityLog', 33 - component: CList, 35 + component: PlayList, 34 36 parameters: { 35 37 // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 36 38 layout: 'padded', ··· 40 42 // More on argTypes: https://storybook.js.org/docs/api/argtypes 41 43 args: { 42 44 data:[ 43 - // ...generateJsonPlays(2).map((x) => ({play: x, status: 'queued'})), 44 - // {play: examplePlay(), status: 'scrobbled'}, 45 - // {play: lastfmErrorExample(), status: 'error'} 46 45 ] , 47 46 }, 48 - render: function Render(args, { loaded: { data } }) { return (<CList {...args} data={data ?? []}/>) }, 47 + render: function Render(args, { loaded: { data } }) { return (<PlayList {...args} data={data ?? []}/>) }, 49 48 decorators: [ 50 49 (Story) => (<Provider><Container maxWidth="4xl"><Story/></Container></Provider>), 51 50 ] ··· 56 55 export const List = meta.story({ 57 56 loaders: [ 58 57 async () => { 59 - const queued = await generatePlayWithLifecycle(); 58 + const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => ({play: x, status: 'queued'})); 59 + 60 60 const scrobbled = await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}})); 61 61 const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true}); 62 + 63 + const promisedScrobbled = generateArray(10,() => playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); 64 + const promised = await Promise.all(promisedScrobbled); 65 + const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => ({play: x, status: 'scrobbled'})); 62 66 return {data: [ 63 - {play: queued, status: 'queued'}, 67 + ...queued, 64 68 {play: scrobbled, status: 'scrobbled'}, 65 - {play: scrobbleError, status: 'error'} 69 + {play: scrobbleError, status: 'error'}, 70 + ...yesterdayScrobbled 66 71 ]}; 67 72 } 68 73 ],