diff --git a/src/backend/common/vendor/teal/TealApiClient.ts b/src/backend/common/vendor/teal/TealApiClient.ts index 319ed5fa..f0265f8f 100644 --- a/src/backend/common/vendor/teal/TealApiClient.ts +++ b/src/backend/common/vendor/teal/TealApiClient.ts @@ -1,5 +1,5 @@ import dayjs, { Dayjs, ManipulateType } from "dayjs"; -import { PlayObject, PlayObjectMinimal, BrainzMeta, SourcePlayerObj, MBID, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; +import { PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; import { getRoot } from "../../../ioc.js"; import { removeUndefinedKeys } from "../../../utils.js"; import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; @@ -8,7 +8,6 @@ import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeL import { ListRecord, RecordOptions, TealClientData } from "../../infrastructure/config/client/tealfm.js"; import AbstractApiClient from "../AbstractApiClient.js"; import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.js"; -import { Duration } from "dayjs/plugin/duration.js"; import { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.js"; import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.js"; @@ -18,6 +17,7 @@ import { decodeTid, generateTID } from "@ewanc26/tid"; import { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthenticatedApiClient.js"; import { UpstreamError } from "../../errors/UpstreamError.js"; import { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto'; +import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.js"; export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens { @@ -150,24 +150,8 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp } return baseFormatPlayObj(record, play); -};export const nowPlayingExpirationDuration = (data: Pick): Duration => { - let expiry: Dayjs = dayjs().add(10, 'minute'); - - const { - position, play - } = data; - - // if we have position and duration then expiration is set as calculated end of listening session - if (position !== undefined && play?.data.duration !== undefined) { - expiry = dayjs().add(play.data.duration - position, 'second'); - } else if (play?.data.duration !== undefined) { - // else if we have duration but not position then use track duration - expiry = dayjs().add(play.data.duration, 'second'); - } +} - // otherwise use 10 minutes - return dayjs.duration(expiry.diff(dayjs(), 'ms')); -}; export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => { const { $type, ...item } = notPlaying ? { trackName: "", artists: [] } diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index a2dea688..c994011e 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -1,7 +1,7 @@ import { childLogger, Logger, LogLevel } from "@foxxmd/logging"; import dayjs, { Dayjs } from "dayjs"; +import { Duration } from "dayjs/plugin/duration.js"; import EventEmitter from "events"; -import { FixedSizeList } from 'fixed-size-list'; import { nanoid } from "nanoid"; import { MarkOptional, MarkRequired } from "ts-essentials"; import { @@ -18,7 +18,8 @@ import { CLIENT_INGRESS_QUEUE, CLIENT_DEAD_QUEUE, PlayOriginal, - PlayLifecycle + PlayLifecycle, + SourcePlayerJson } from "../../core/Atomic.js"; import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; import AbstractComponent from "../common/AbstractComponent.js"; @@ -133,6 +134,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i nowPlayingMinThreshold: NowPlayingUpdateThreshold = (_) => 10; nowPlayingMaxThreshold: NowPlayingUpdateThreshold = (_) => 30; nowPlayingLastUpdated?: Dayjs; + nowPlayingExpirationDate?: Dayjs; nowPlayingLastPlay?: SourcePlayerObj; nowPlayingQueue: NowPlayingQueue = new Map(); nowPlayingTaskInterval: number = 5000; @@ -434,6 +436,17 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i countLive: this.tracksScrobbledTotal, deadLetterScrobbles: this.deadLetterQueued, deadLetterScrobblesTotal: this.deadLetterLength, + supportsNowPlaying: this.supportsNowPlaying, + players: {...this.getNowPlayingPlayers()} + } + } + + public getNowPlayingPlayers(): Record { + if(this.nowPlayingLastPlay === undefined) { + return {}; + } + return { + [this.nowPlayingLastPlay.platformId]: {...(this.nowPlayingLastPlay as unknown as SourcePlayerJson), expiration: !this.nowPlayingIsRealtime ? this.nowPlayingExpirationDate?.toISOString() : undefined } } } @@ -1601,15 +1614,27 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i // finally, do the update if(shouldUpdate) { this.npLogger.verbose(`Updating because ${npUpdateTopReason}${clientReason !== undefined ? ` --AND-- ${clientReason}` : ''}`); + const isClearing = this.nowPlayingIsRealtime && shouldClearNPStatus(sourcePlayerData); try { await this.doPlayingNow(sourcePlayerData); this.npLogger.trace(`Now Playing updated.`); this.setStatus('Now Playing updated'); + if(!isClearing) { + this.nowPlayingExpirationDate = dayjs().add(nowPlayingExpirationDuration(sourcePlayerData)); + this.emitEvent('playerUpdate', {...sourcePlayerData, expiration: this.nowPlayingExpirationDate}); + } else { + this.nowPlayingExpirationDate = undefined; + this.emitEvent('playerDelete', {platformId: sourcePlayerData.platformId}); + } this.emitEvent('nowPlayingUpdated', sourcePlayerData); } catch (e) { this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e})); } - this.nowPlayingLastPlay = sourcePlayerData; + if(isClearing) { + this.nowPlayingLastPlay = undefined; + } else { + this.nowPlayingLastPlay = sourcePlayerData; + } this.nowPlayingLastUpdated = dayjs(); } this.nowPlayingQueue = new Map(); @@ -1743,6 +1768,20 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i protected doPlayingNow = (data: SourcePlayerObj): Promise => Promise.resolve(undefined) + protected statusExpiresSoon = () => { + if(this.nowPlayingExpirationDate === undefined) { + return false; + } + // may want to make this configurable in the future? + return Math.abs(dayjs().diff(this.nowPlayingExpirationDate, 's')) < 15; + } + protected statusAlreadyExpired = () => { + if(this.nowPlayingExpirationDate === undefined) { + return false; + } + return dayjs().isAfter(this.nowPlayingExpirationDate); + } + public getQueued = (queueName: string, statuses: string[], offset?: number) => { return this.playRepo.getQueued(queueName, {offset}); } @@ -1826,4 +1865,23 @@ export const npPlayerInValidNPUpdateState = (data: SourcePlayerObj): [boolean, s return [true, `NP player in valid update state: '${data.status.calculated }'`]; } return [false, `NP player is is invalid update state: stopped`]; -} \ No newline at end of file +} + +export const nowPlayingExpirationDuration = (data: Pick): Duration => { + let expiry: Dayjs = dayjs().add(10, 'minute'); + + const { + position, play + } = data; + + // if we have position and duration then expiration is set as calculated end of listening session + if (position !== undefined && play?.data.duration !== undefined) { + expiry = dayjs().add(play.data.duration - position, 'second'); + } else if (play?.data.duration !== undefined) { + // else if we have duration but not position then use track duration + expiry = dayjs().add(play.data.duration, 'second'); + } + + // otherwise use 10 minutes + return dayjs.duration(expiry.diff(dayjs(), 'ms')); +}; diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index 1d4fc143..9c49b06a 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -17,7 +17,7 @@ import { TealClientConfig } from "../common/infrastructure/config/client/tealfm. import { ATProtoAppApiClient } from "../common/vendor/atproto/ATProtoAppApiClient.js"; import { playToRecord, TealApiClient } from "../common/vendor/teal/TealApiClient.js"; import { playToStatusRecord } from "../common/vendor/teal/TealApiClient.js"; -import { nowPlayingExpirationDuration } from "../common/vendor/teal/TealApiClient.js"; +import { nowPlayingExpirationDuration } from "./AbstractScrobbleClient.js"; import { recordToPlay } from "../common/vendor/teal/TealApiClient.js"; import dayjs, { Dayjs } from "dayjs"; import { durationToHuman, isDebugMode } from "../utils.js"; @@ -32,7 +32,6 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { requiresAuth = true; requiresAuthInteraction = false; override nowPlayingIsRealtime: boolean = true; - protected lastExpirationDate: Dayjs; declare config: TealClientConfig; @@ -145,34 +144,17 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { // this will usually happen if a player stops playing the last track in a queue // -- worth doing since PDS calls have a daily rate limit if(isClearing && (this.statusExpiresSoon() || this.statusAlreadyExpired())) { - this.npLogger.debug(`Not calling status record update because status is about to expire (or has already), expiring ${durationToHuman(dayjs.duration(dayjs().diff(this.lastExpirationDate)))}`); + this.npLogger.debug(`Not calling status record update because status is about to expire (or has already), expiring ${durationToHuman(dayjs.duration(dayjs().diff(this.nowPlayingExpirationDate)))}`); return; } try { await this.client.updateStatusRecord(playToStatusRecord(data.play, isClearing, data.position)); - if(!isClearing) { - this.lastExpirationDate = dayjs().add(nowPlayingExpirationDuration(data)); - } } catch (e) { throw e; } } - protected statusExpiresSoon = () => { - if(this.lastExpirationDate === undefined) { - return false; - } - // may want to make this configurable in the future? - return Math.abs(dayjs().diff(this.lastExpirationDate, 's')) < 15; - } - protected statusAlreadyExpired = () => { - if(this.lastExpirationDate === undefined) { - return false; - } - return dayjs().isAfter(this.lastExpirationDate); - } - protected async doHydrateHistoricalScrobbles(opts: {allowFailures?: boolean, signal?: AbortSignal } = {}) { const logger = childLogger(this.logger, ['Historical Plays']); const { diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 8aba6f84..6bca2b15 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -262,6 +262,14 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } return res.json({}); }); + app.get('/api/components/:componentVal/players', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + if(req.component instanceof MemorySource) { + return res.json(req.component.playersToObject()); + } else if(req.component instanceof AbstractScrobbleClient && req.component.nowPlayingEnabled) { + return res.json(req.component.getNowPlayingPlayers()); + } + return res.json({}); + }); app.get('/api/sources/:componentVal/players/:platformId', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => { if(req.component instanceof MemorySource) { @@ -278,6 +286,28 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } return res.json({}); }); + app.get('/api/components/:componentVal/players/:platformId', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + const { + params: { + platformId + } + } = req; + if(req.component instanceof MemorySource) { + + const player = req.component.players.get(platformId as string); + if(player === undefined) { + return res.status(400).json({error: `No player with platform id ${platformId} exists`}); + } + return res.json(player); + } else if(req.component instanceof AbstractScrobbleClient && req.component.nowPlayingEnabled) { + const players = req.component.getNowPlayingPlayers(); + if(players[platformId as string] === undefined) { + return res.status(400).json({error: `No player with platform id ${platformId} exists`}); + } + return res.json(players[platformId as string]); + } + return res.status(400).json({error: `Component does not support players`}); + }); app.get('/api/components/:componentVal', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { const { diff --git a/src/client/components/chakraPlayer/Player.tsx b/src/client/components/chakraPlayer/Player.tsx index 9fc31920..d6fddaf0 100644 --- a/src/client/components/chakraPlayer/Player.tsx +++ b/src/client/components/chakraPlayer/Player.tsx @@ -13,13 +13,15 @@ import { useSSEContext, useSSEEvent, } from "@flamefrontend/sse-runtime-react"; -import { ComponentCommonApiJson, isComponentSourceApiJson, MsSseEvent, MsSseEventPayload } from "../../../core/Api"; +import { ComponentCommonApiJson, ComponentSourceApiJson, isComponentClientApiJson, isComponentSourceApiJson, MsSseEvent, MsSseEventPayload } from "../../../core/Api"; import LinearProgress from '@mui/material/LinearProgress'; import { InfoTip, ToggleTip } from "../ToggleTip"; import { tanQueries } from "../../queries"; +import dayjs from "dayjs"; export interface PlayerProps { - data: SourcePlayerJson + data: SourcePlayerJson & {expiration?: string} + nowPlaying?: boolean sot?: SOURCE_SOT_TYPES } @@ -36,6 +38,7 @@ export const ChakraPlayer = (props: PlayerProps) => { const { data, + nowPlaying = false, sot = SOURCE_SOT.PLAYER } = props; @@ -58,13 +61,20 @@ export const ChakraPlayer = (props: PlayerProps) => { reported, stale, orphaned - } = {} + } = {}, + expiration } = data; + if(expiration !== undefined && dayjs().isAfter(dayjs(expiration))) { + return null; + } + const playArt = art.track ?? art.album ?? art.artist ?? undefined; + const isNowPlaying = nowPlaying || nowPlayingMode; + let durPer = null; - if (!nowPlayingMode) { + if (!isNowPlaying) { if (duration !== undefined && duration !== null && duration !== 0) { if (listenedDuration === 0 || listenedDuration === null) { durPer = ' (0%)'; @@ -89,7 +99,7 @@ export const ChakraPlayer = (props: PlayerProps) => { // but cannot use interval id in useEffect or it causes circular dependencies since we set intervalId here too // so clear inside the set state function (bad) using the previous data argument, before returning new value let interval; - if(data.status?.calculated === 'playing' && data.position !== undefined && !data.status?.stale && !data.status?.orphaned) { + if(!isNowPlaying && data.status?.calculated === 'playing' && data.position !== undefined && !data.status?.stale && !data.status?.orphaned) { setProgressBuffer(data.position); interval = setInterval(() => { setProgressBuffer((oldPosition) => { @@ -105,6 +115,15 @@ export const ChakraPlayer = (props: PlayerProps) => { } return interval; }); + } else if(isNowPlaying) { + interval = setInterval(() => { + }, 1000); + setIntervalId((old) => { + if(old !== undefined) { + clearInterval(old); + } + return interval; + }); } else { setProgressBuffer(undefined); if(intervalId !== undefined) { @@ -117,9 +136,9 @@ export const ChakraPlayer = (props: PlayerProps) => { } } return () => clearInterval(interval); - },[setProgressBuffer, data, setIntervalId]); + },[setProgressBuffer, data, setIntervalId, isNowPlaying]); - const indeterminate = nowPlayingMode || (calculated === 'playing' && data.position === undefined); + const indeterminate = isNowPlaying || (calculated === 'playing' && data.position === undefined); const positionProgress = indeterminate || data.position === undefined || duration === undefined ? undefined : Math.trunc((data.position/duration) * 100); const bufferProgress = indeterminate || data.position === undefined || duration === undefined || positionBuffer === undefined ? undefined : Math.trunc((positionBuffer/duration) * 100); const positionTimestamp = indeterminate || data.position === undefined ? '-' : timeToHumanTimestamp((positionBuffer ?? data.position) * 1000); @@ -127,7 +146,8 @@ export const ChakraPlayer = (props: PlayerProps) => { const bufferTip = positionBuffer !== undefined ? : null; - return + return + {playArt !== undefined ? : null}
@@ -159,16 +179,18 @@ export const ChakraPlayer = (props: PlayerProps) => { */} - {['unknown', 'playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)}{bufferTip} + {['unknown', 'playing'].includes(calculated) && isNowPlaying ? 'Now Playing' : capitalize(calculated)}{!isNowPlaying ? bufferTip : null} - Listened: {nowPlayingMode !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer} + Listened: {isNowPlaying !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer} + } export interface ChakraPlayerFetchableProps { componentId: number + nowPlaying?: boolean platformId: string data?: SourcePlayerJson sot?: SOURCE_SOT_TYPES @@ -177,6 +199,7 @@ export interface ChakraPlayerFetchableProps { export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { const { componentId, + nowPlaying, platformId, data: initData, sot @@ -205,47 +228,59 @@ export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { } if (!isPending) { - return + return } } -export const PlayersContainer = (props: { data: ComponentCommonApiJson, live?: boolean, stack?: ComponentProps, container?: ComponentProps }) => { +export const PlayersContainer = (props: { data: ComponentCommonApiJson, live?: boolean, nowPlaying?: boolean, stack?: ComponentProps, container?: ComponentProps }) => { const { data, + nowPlaying, live, container = {}, stack = {} } = props; - if (isComponentSourceApiJson(data)) { const { players = {} } = data; + let playerContainers: React.JSX.Element[] = []; + // const isSource = isComponentSourceApiJson(data); + // const now = dayjs(); if (Object.keys(players).length > 0) { - return - { - Object.entries(players).map(([key, x]) => ( - - {live ? : } - - )) - } - ; + for(const [key, x] of Object.entries(players)) { + // if(!isSource && 'expiration' in x) { + // const expiresAt = dayjs(x.expiration as string); + // if(now.isAfter(expiresAt)) { + // continue; + // } + // } + playerContainers.push( + live ? : + ); + }; } - return null; - } - return null; + // if(playerContainers.length > 0) { + // return + // {playerContainers} + // ; + // } + // return null; + + return + {playerContainers} + ; } -export const PlayersContainerFetchable = (props: { data: ComponentCommonApiJson, live?: boolean, stack?: ComponentProps, container?: ComponentProps }) => { +export const PlayersContainerFetchable = (props: { data: ComponentCommonApiJson, live?: boolean, nowPlaying?: boolean, stack?: ComponentProps, container?: ComponentProps }) => { const { data: initData, + nowPlaying, live = true, container = {}, stack = {} } = props; - if (isComponentSourceApiJson(initData)) { const queryClient = useQueryClient(); @@ -262,7 +297,7 @@ export const PlayersContainerFetchable = (props: { data: ComponentCommonApiJson, case 'playerUpdate': { const playerPayload = payload.data as MsSseEventPayload; queryClient.setQueryData(tanQueries.players.list(initData.id).queryKey, (old: Record) => { - if(old[playerPayload.data.platformId] === undefined) { + if(old[playerPayload.data.platformId] === undefined || 'expiration' in playerPayload.data) { let newData: Record = {...old}; newData[playerPayload.data.platformId] = playerPayload.data; return newData; @@ -292,7 +327,5 @@ export const PlayersContainerFetchable = (props: { data: ComponentCommonApiJson, const mergedData = useMemo(() => ({...initData, players: data}),[initData,data]); - return - } - return null; + return } \ No newline at end of file diff --git a/src/client/components/msComponent/MSComponentSummary.tsx b/src/client/components/msComponent/MSComponentSummary.tsx index d7d1da6b..32ae0021 100644 --- a/src/client/components/msComponent/MSComponentSummary.tsx +++ b/src/client/components/msComponent/MSComponentSummary.tsx @@ -19,8 +19,8 @@ import { ComponentStateBadge } from "../Badges.js"; import { MSErrorBoundary } from "../ErrorBoundary.js"; const presentPlayersContainerProps: ComponentProps = { -paddingTop: '2', -borderTopWidth: '1px' +//paddingTop: '2', +//borderTopWidth: '1px' }; export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetchable?: boolean }) => { @@ -32,6 +32,7 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetcha let body = ; let cardHeaderProps: Card.HeaderProps = {}; + const isClient = isComponentClientApiJson(data); if(isComponentSourceApiJson(data)) { const { sleeping @@ -39,10 +40,10 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetcha if(sleeping) { sleepingRender = ; } - body = ( - {fetchable ? : } - ); } + body = ( + {fetchable ? : } + ); return ( diff --git a/src/core/Api.ts b/src/core/Api.ts index 4278162d..3064ec0b 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -79,6 +79,7 @@ export type ComponentCommonApi = { state: ComponentState /** More specific, live activity state like "sleeping", "hydrating historical scrobbles", "processing dead scrobbles", etc... */ status?: string + players: Record } & Omit export type ComponentCommonApiJson = Replace, string>; @@ -95,6 +96,8 @@ export type ComponentCientApiBase = { tracksScrobbled: number deadLetterScrobbles: number deadLetterScrobblesTotal: number + supportsNowPlaying: boolean + players: Record } export type ComponentClientApi = ComponentCommonApi & ComponentCientApiBase; @@ -107,7 +110,6 @@ export type ComponentSourceApiBase = { manualListening?: boolean systemListeningBehavior?: boolean tracksDiscovered: number; - players: Record wakeAt?: string sleeping: boolean } diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 479948ac..788d5743 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -192,7 +192,8 @@ export const generateClientApiJson = (data: Partial = {}): C const { queued = faker.number.int({min: 1, max: 2000}), deadLetterScrobbles = faker.number.int({min: 1, max: 2000}), - deadLetterScrobblesTotal = faker.number.int({min: deadLetterScrobbles, max: 2000}) + deadLetterScrobblesTotal = faker.number.int({min: deadLetterScrobbles, max: 2000}), + players = (data.players ?? {}), } = data; return { ...common, @@ -200,6 +201,8 @@ export const generateClientApiJson = (data: Partial = {}): C tracksScrobbled: common.countLive, deadLetterScrobbles, deadLetterScrobblesTotal, + players, + supportsNowPlaying: Object.keys(players).length > 0 } } diff --git a/src/stories/component/ComponentSummary.stories.tsx b/src/stories/component/ComponentSummary.stories.tsx index 9230e4cf..2a854df3 100644 --- a/src/stories/component/ComponentSummary.stories.tsx +++ b/src/stories/component/ComponentSummary.stories.tsx @@ -94,6 +94,12 @@ export const ClientSummary = meta.story({ } }); +export const ClientSummaryWithNowPlaying = meta.story({ + args: { + data: generateClientApiJson({players: {test: generateSourcePlayerJson(undefined, {art: true})}}) + } +}); + const randomQueue = () => faker.helpers.arrayElement(['scrobbleQueued', 'scrobbleDequeued']); export const ClientSummaryFetchable = meta.story({