diff --git a/src/backend/common/database/drizzle/schema/schema.ts b/src/backend/common/database/drizzle/schema/schema.ts index 240bc270..4e8a1509 100644 --- a/src/backend/common/database/drizzle/schema/schema.ts +++ b/src/backend/common/database/drizzle/schema/schema.ts @@ -7,6 +7,7 @@ import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.js"; import { ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm } from "../../../infrastructure/Transform.js"; import { JobRangeCount, JobRangeTime } from "../../../infrastructure/Job.js"; import { serializeError, deserializeError } from "serialize-error"; +import { generatePlayUid } from "../../../../../core/StringUtils.js"; const DayjsTimestamp = customType< { @@ -67,7 +68,7 @@ const ErrorLikeJson = customType< export const plays = sqliteTable("plays", { id: integer().primaryKey(), - uid: text({ length: 30 }).notNull().unique().$defaultFn(() => nanoid(20)), + uid: text({ length: 30 }).notNull().unique().$defaultFn(() => generatePlayUid()), componentId: integer().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}), error: ErrorLikeJson('error'), playedAt: DayjsTimestamp('playedAt'), diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index 88ecb7ca..4df590e5 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -6,9 +6,10 @@ import { ErrorAlert } from "./ErrorAlert"; import { AiOutlineExclamationCircle } from "react-icons/ai"; import { ActivityTimeline } from "./ActivityTimeline"; import { ExpandCollapse } from "./ExpandCollapse"; +import { PlayApiCommon, PlayApiCommonDetailed } from "../../core/Api"; export interface ActivityDetailProps { - activity: PlayActivity + activity: PlayApiCommonDetailed } export const ActivityDetails = (props: ActivityDetailProps) => { @@ -16,15 +17,9 @@ export const ActivityDetails = (props: ActivityDetailProps) => { activity, activity: { error, - play: { - meta: { - lifecycle: { - original, - scrobble - } = {}, - lifecycle - } = {}, - } = {} + input: { + play: original, + } } } = props; @@ -69,7 +64,7 @@ export const ActivityDetails = (props: ActivityDetailProps) => { - + diff --git a/src/client/components/ActivityTimeline.tsx b/src/client/components/ActivityTimeline.tsx index 5e64f56a..f59bb638 100644 --- a/src/client/components/ActivityTimeline.tsx +++ b/src/client/components/ActivityTimeline.tsx @@ -19,16 +19,20 @@ import { ExpandCollapse } from "./ExpandCollapse"; import { MSCollapsible } from "./MSCollapsible"; import { TimelineErrorIcon } from "./timeline/TimelineIcon"; import { Muted } from "./Typography"; +import { PlayApiCommonDetailed } from "../../core/Api"; export interface ActivityDetailProps { - play: JsonPlayObject + activity: PlayApiCommonDetailed collapsibleOpen?: boolean } export const ActivityTimeline = (props: ActivityDetailProps) => { const { - play, + activity:{ + play, + input + } = {}, collapsibleOpen } = props; const { @@ -38,8 +42,8 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { meta: { source, lifecycle: { - input, - original, + // input, + // original, steps = [], scrobble: { match, @@ -51,6 +55,10 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { }, } = {} } = play; + const { + play: original, + data: ogInput + } = input || {}; let scrobbleSummary: React.JSX.Element, scrobbleIconProps: Record = { @@ -98,7 +106,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { - + diff --git a/src/client/components/playActivity/PlayList.tsx b/src/client/components/playActivity/PlayList.tsx index 7b7a2c14..1f23bcd5 100644 --- a/src/client/components/playActivity/PlayList.tsx +++ b/src/client/components/playActivity/PlayList.tsx @@ -12,11 +12,12 @@ import { GroupedVirtuoso } from 'react-virtuoso' import { ActivityDetails } from '../ActivityDetail.js'; import { sortByNewestPlayDate, sortByNewestSeenDate } from '../../../core/PlayUtils.js'; import "./PlayList.scss"; +import { PlayApiCommon } from '../../../core/Api.js'; dayjs.extend(doy); export interface ActivityLogProps { - data: PlayActivity[] + data: PlayApiCommon[] sortBy?: 'played' | 'seen' render?: 'virtCollapse' | 'virtAccordian' | 'accordian' } @@ -27,11 +28,11 @@ interface GroupInfo { } interface GroupData { - plays: PlayActivity[] + plays: PlayApiCommon[] date: Dayjs } -const generateGroupInfo = (data: PlayActivity[]): GroupInfo[] => { +const generateGroupInfo = (data: PlayApiCommon[]): GroupInfo[] => { const groupsReduced = data.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => { const date = dayjs(curr.play.data.playDate); @@ -48,7 +49,7 @@ const generateGroupInfo = (data: PlayActivity[]): GroupInfo[] => { return groupsReduced.groups.concat(groupsReduced.active); } -const generateGroupPlays = (data: PlayActivity[]): GroupData[] => { +const generateGroupPlays = (data: PlayApiCommon[]): GroupData[] => { const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { const date = dayjs(curr.play.data.playDate); @@ -91,7 +92,7 @@ export const PlayList = (props: ActivityLogProps) => { } } -const VirtualizedCollapse = (props: { data: PlayActivity[] }) => { +const VirtualizedCollapse = (props: { data: PlayApiCommon[] }) => { const { data, } = props; @@ -180,7 +181,7 @@ const VirtualizedCollapse = (props: { data: PlayActivity[] }) => { paddingInline: "var(--chakra-spacing-4)" }} justify="flex-start" alignItems="flex-end"> - {activity.status === 'error' ? + {activity.state === 'failed' ? : null} @@ -200,7 +201,7 @@ const VirtualizedCollapse = (props: { data: PlayActivity[] }) => { ); } -const VirtualizedAccordian = (props: { data: PlayActivity[] }) => { +const VirtualizedAccordian = (props: { data: PlayApiCommon[] }) => { const { data, } = props; @@ -290,7 +291,7 @@ const VirtualizedAccordian = (props: { data: PlayActivity[] }) => { ); } -const PlainAccordian = (props: { data: PlayActivity[], sortBy: 'played' | 'seen' }) => { +const PlainAccordian = (props: { data: PlayApiCommon[], sortBy: 'played' | 'seen' }) => { const { data = [], sortBy @@ -343,7 +344,7 @@ const PlainAccordian = (props: { data: PlayActivity[], sortBy: 'played' | 'seen' paddingInline: "var(--accordion-padding-x)" }} justify="flex-start" alignItems="flex-end"> - {activity.status === 'error' ? + {activity.state === 'failed' ? : null} @@ -365,23 +366,30 @@ const PlainAccordian = (props: { data: PlayActivity[], sortBy: 'played' | 'seen' ); } -const StatusBadge = (props: ComponentProps & { data: PlayActivity }) => { +const StatusBadge = (props: ComponentProps & { data: PlayApiCommon }) => { const { data, ...rest } = props; let badgeColor = undefined, - badgeText = capitalize(data.status); + badgeText = capitalize(data.state); - switch (data.status) { + switch (data.state) { case 'queued': badgeColor = 'gray'; break; case 'scrobbled': + case 'discovered': badgeColor = 'green'; break; - case 'error': + case 'failed': badgeColor = 'red'; break; + case 'discarded': + badgeColor = 'grey'; + break; + case 'duped': + badgeColor = 'orange'; + break; } return {badgeText} diff --git a/src/core/Api.ts b/src/core/Api.ts index b7353789..6f395d45 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -1,26 +1,26 @@ import { ErrorLike, JsonPlayObject, PlayState } from "./Atomic.js" -export type PlayApiCommon = { +export interface PlayApiCommon { uid: string componentId: number state: PlayState play: JsonPlayObject compacted: boolean playedAt: string - seentAt: string + seenAt: string updatedAt: string parentUid?: string // TODO add parent source type/name? } -export type PlayInputApi = { +export interface PlayInputApi { id: number - data: object - play: JsonPlayObject + data?: object + play?: JsonPlayObject createdAt: string } -export type QueueStateApi = { +export interface QueueStateApi { id: number queueName: string queueState: string @@ -30,8 +30,8 @@ export type QueueStateApi = { updatedAt: string } -export type PlayApiCommonDetailed = PlayApiCommon & { - error: ErrorLike - input: PlayInputApi +export interface PlayApiCommonDetailed extends PlayApiCommon { + error?: ErrorLike + input?: PlayInputApi queueStates: QueueStateApi[] } \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 0e8f96d6..67aef9c3 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -366,7 +366,7 @@ export interface AmbPlayObject { id?: number uid?: string data: PlayData, - meta: PlayMetaLifecycleless + meta: PlayMetaLifecycleless original?: PlayOriginal } @@ -614,8 +614,10 @@ export const REGEX_ISO8601_LOOSE = new RegExp(/\d{4}-[01]\d-[0-3]\dT/); */ export const REGEX_ISO8601_WELLKNOWN = new RegExp(/dayjs-(\d{4}-[01]\d-[0-3]\dT.*)/); -export const CLIENT_INGRESS_QUEUE = 'ingress'; -export const CLIENT_DEAD_QUEUE = 'dead'; +export const CLIENT_INGRESS_QUEUE: QueueName = 'ingress'; +export const CLIENT_DEAD_QUEUE: QueueName = 'dead'; +export type QueueName = 'ingress' | 'dead'; +export const QUEUE_NAMES = [CLIENT_INGRESS_QUEUE, CLIENT_DEAD_QUEUE]; /** * Useful TS type-only utility for testing type equality @@ -639,3 +641,10 @@ export type PlayClientState = PlayStateCommon | 'duped' | 'scrobbled'; export const PLAY_CLIENT_STATE = [...PLAY_STATE_COMMON, 'duped', 'scrobbled']; export type PlayState = PlaySourceState | PlayClientState; export const PLAY_STATES = Array.from(new Set(...PLAY_CLIENT_STATE, ...PLAY_SOURCE_STATE)); + + +export type QueueStatus = 'queued' | 'completed' | 'failed'; +export const QUEUE_STATUS_QUEUED: QueueStatus = 'queued'; +export const QUEUE_STATUS_COMPLETED: QueueStatus = 'completed'; +export const QUEUE_STATUS_FAILED: QueueStatus = 'failed'; +export const QUEUE_STATUSES: QueueStatus[] = [QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED, QUEUE_STATUS_QUEUED]; \ No newline at end of file diff --git a/src/core/PlayMarshalUtils.ts b/src/core/PlayMarshalUtils.ts index e7f5a3fe..271a2503 100644 --- a/src/core/PlayMarshalUtils.ts +++ b/src/core/PlayMarshalUtils.ts @@ -2,7 +2,7 @@ import clone from 'clone'; import dayjs from 'dayjs'; import { Traverse, TraverseContext } from 'neotraverse/modern'; import { ListenRange } from '../backend/sources/PlayerState/ListenRange.js'; -import { AmbPlayObject, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js'; +import { AmbPlayObject, DateLike, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js'; import { ListenProgressPositional, ListenProgressTS } from '../backend/sources/PlayerState/ListenProgress.js'; interface BlockPath { key: string, parent: string }; @@ -39,7 +39,7 @@ export const shouldBlock = (ctx: TraverseContext): boolean => { }); }; -export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { +export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { const cloned = clone(play); new Traverse(cloned).forEach((ctx, x) => { if (shouldBlock(ctx)) { diff --git a/src/core/StringUtils.ts b/src/core/StringUtils.ts index 9ad8593e..b07bb4fe 100644 --- a/src/core/StringUtils.ts +++ b/src/core/StringUtils.ts @@ -17,6 +17,7 @@ import { import { DELIMETERS_REGEX, DELIMITERS } from "../backend/common/infrastructure/Atomic.js"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { removeUndefinedKeys } from "../backend/utils.js"; +import { nanoid } from "nanoid"; dayjs.extend(utc) dayjs.extend(isBetween); @@ -331,4 +332,6 @@ export const artistNameToCredit = (val: string | undefined | Partial a.name; -export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name); \ No newline at end of file +export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name); + +export const generatePlayUid = () => nanoid(20); \ No newline at end of file diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts new file mode 100644 index 00000000..57cc12ea --- /dev/null +++ b/src/core/tests/utils/apiFixtures.ts @@ -0,0 +1,99 @@ +import { faker } from "@faker-js/faker"; +import { PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi } from "../../Api.js"; +import { CLIENT_INGRESS_QUEUE, JsonPlayObject, PlayObject, PlayState, QUEUE_STATUSES } from "../../Atomic.js"; +import { generatePlay } from "../../PlayTestUtils.js"; +import { generatePlayInput, randomPlayState } from "./fixtures.js"; +import { asJsonPlayObject } from "../../PlayMarshalUtils.js"; +import { generatePlayUid } from "../../StringUtils.js"; +import dayjs from "dayjs"; +import { ErrorLike } from "serialize-error"; + +export const generatePlayApiCommon = (commonData: Partial & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters): PlayApiCommon => { + let play: JsonPlayObject | PlayObject; + const { + play: cPlay, + ...rest + } = commonData; + if(cPlay !== undefined) { + play = cPlay + } else { + play = generatePlay(...playOpts); + } + + const { + playedAt = typeof play.data.playDate === 'string' ? play.data.playDate : play.data.playDate.toISOString(), + seenAt = playedAt, + updatedAt = seenAt, + compacted = false, + state = randomPlayState(), + componentId = faker.number.int({min: 1, max: 10}), + uid = generatePlayUid() + } = commonData; + + return { + play: asJsonPlayObject(play), + ...rest, + playedAt, + seenAt, + updatedAt, + compacted, + state, + componentId, + uid + } +} + +export const generatePlayInputApi = (inputData: Partial = {}, ...args: Parameters): PlayInputApi => { + const res = generatePlayInput(...args); + let createdAt: string = dayjs().toISOString(); + if(res.play?.data?.playDate !== undefined) { + if(typeof res.play?.data?.playDate === 'string') { + createdAt = res.play?.data?.playDate; + } else { + createdAt = res.play?.data?.playDate.toISOString(); + } + } + return { + id: faker.number.int({min: 1, max: 100}), + createdAt, + data: res.data, + play: res.play !== undefined ? asJsonPlayObject(res.play) : undefined, + ...inputData, + } +} + +export const generateQueueStateApi = (data: Partial): QueueStateApi => { + const cAt = faker.date.recent().toISOString(); + return { + id: faker.number.int({min: 1, max: 100}), + queueName: CLIENT_INGRESS_QUEUE, + queueState: faker.helpers.arrayElement(QUEUE_STATUSES), + createdAt: cAt, + updatedAt: cAt, + retries: 0, + ...data + } +} + +export const generatePlayApiCommonDetailed = (opts: { + playOpts?: Parameters, + inputOpts?: Parameters, + queueOpts?: Parameters +} = {}, error?: ErrorLike): PlayApiCommonDetailed => { + const { + playOpts = [], + inputOpts = [], + queueOpts = [], + } = opts; + + const playCommon = generatePlayApiCommon(...playOpts); + const inputRes = generatePlayInputApi(...inputOpts); + const queueRes = generateQueueStateApi(queueOpts[0]); + + return { + ...playCommon, + input: inputRes, + queueStates: [queueRes], + error + } +} \ No newline at end of file diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index 139042a9..50b2bf57 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -1,6 +1,6 @@ import { Traverse, TraverseContext } from 'neotraverse/modern'; import { faker } from '@faker-js/faker'; -import { LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, ScrobbleResult } from '../../Atomic.js'; +import { AmbPlayObject, DateLike, LifecycleInput, LifecycleStep, ObjectPlayData, PLAY_STATES, PlayMeta, PlayObject, PlayOriginal, PlayState, ScrobbleResult } from '../../Atomic.js'; import { MarkOptional } from 'ts-essentials'; import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js'; import { lifecyclelessInvariantTransform } from '../../PlayUtils.js'; @@ -10,6 +10,7 @@ import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.js' import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js'; import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js'; import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError } from '../../../backend/common/errors/MSErrors.js'; +import { Dayjs } from 'dayjs'; export interface ScrobbleMatchOptions { match?: boolean @@ -98,6 +99,13 @@ export const generatePlayWithLifecycle = (opts: GeneratePlayWithLifecycleOptions return lplay; } +export const generatePlayInput = (play?: AmbPlayObject, data?: object | false): PlayOriginal => { + return { + play, + data: data ?? generateRandomObj(2) + } +} + export const playWithLifecycleScrobble = async (play: PlayObject, opts: ScrobbleMatchOptions = {}): Promise => { const { match = false, @@ -282,4 +290,6 @@ export const generateRandomObj = (depth: number = 0, opt: RandomObjOptions = {}) } } return tgrt -} \ No newline at end of file +} + +export const randomPlayState = () => faker.helpers.arrayElement(PLAY_STATES) as PlayState; \ No newline at end of file diff --git a/src/stories/ActivityTimeline.stories.tsx b/src/stories/ActivityTimeline.stories.tsx index 185b23b8..46e82759 100644 --- a/src/stories/ActivityTimeline.stories.tsx +++ b/src/stories/ActivityTimeline.stories.tsx @@ -7,6 +7,7 @@ import { ActivityTimeline } from "../client/components/ActivityTimeline"; import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../core/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; +import { generatePlayApiCommonDetailed } from "../core/tests/utils/apiFixtures.js"; import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; @@ -23,9 +24,9 @@ const meta = preview.meta({ tags: ['autodocs'], // More on argTypes: https://storybook.js.org/docs/api/argtypes args: { - play: examplePlay() + activity: generatePlayApiCommonDetailed() }, - render: function Render(args, { loaded: { play } }) { return () }, + render: function Render(args, { loaded: { activity } }) { return () }, decorators: [ (Story) => (), ] @@ -43,8 +44,12 @@ export const ActivityTimelineStory = meta.story({ postCompare: 1, } } - ))) - return {play: scrobbleError}; + ))); + + return {activity: generatePlayApiCommonDetailed({ + playOpts: [{play: scrobbleError}], + inputOpts: [{play: scrobbleError}] + })}; } ], });