import { Traverse, TraverseContext } from 'neotraverse/modern'; import { faker } from '@faker-js/faker'; import dayjs from 'dayjs'; import { AmbPlayObject, JsonPlayObject, LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE, ScrobbleResult } from '../../Atomic.js'; import { ListenRange } from '../../../backend/sources/PlayerState/ListenRange.js'; import { ListenProgressPositional, ListenProgressTS } from '../../../backend/sources/PlayerState/ListenProgress.js'; import { MarkOptional } from 'ts-essentials'; import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js'; import { lifecyclelessInvariantTransform } from '../../PlayUtils.js'; import clone from 'clone'; import { diffObjects } from '../../DataUtils.js'; 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'; interface BlockPath { key: string, parent: string }; type BlockPaths = BlockPath[]; /** We know some nodes will never have data that needs to be transformed * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them */ const blockedKeys: PropertyKey[] = ['patch', 'inputs', 'payload', 'response', 'error']; /** We know some paths/nodes will never have data that needs to be transformed * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them */ const blockedPaths: BlockPaths = [ { parent: 'data', key: 'meta' }, { parent: 'lifecycle', key: 'input' } ] const shouldBlock = (ctx: TraverseContext): boolean => { if (blockedKeys.includes(ctx.key)) { return true; } return blockedPaths.some((x) => { let blocked = x.key === ctx.key; if (blocked && x.parent !== undefined) { blocked = ctx.parent !== undefined && ctx.parent.key === x.parent; } return blocked; }) } export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { const cloned = clone(play); new Traverse(cloned).forEach((ctx, x) => { if (shouldBlock(ctx)) { ctx.block(); return; } if (dayjs.isDayjs(x)) { ctx.update(x.toISOString()); } else if (x instanceof ListenRange) { ctx.update(x.toJSON(), true); } }); return cloned as unknown as JsonPlayObject; } export const asPlay = (data: JsonPlayObject): PlayObject => { const cloned = clone(data); new Traverse(cloned).forEach((ctx, x) => { if (shouldBlock(ctx)) { ctx.block(); return; } if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) { ctx.update(dayjs(x), true); } else if (ctx.key === 'listenRanges') { const ranges = x[0].map((y: PlayProgressAmb) => { if (y.positionPercent === undefined) { return new ListenProgressPositional({ timestamp: dayjs(y.timestamp), position: y.position }); } else { return new ListenProgressTS({ timestamp: dayjs(y.timestamp), positionPercent: y.positionPercent }); } }) ctx.update(ranges, true); } }); return cloned as unknown as PlayObject; } export interface ScrobbleMatchOptions { match?: boolean warnings?: boolean error?: boolean } export interface GeneratePlayWithLifecycleOptions { original?: { data?: ObjectPlayData, meta?: MarkOptional opts?: GeneratePlayOpts }, lifecycleSteps?: { preCompare?: number | (boolean | 'prereq' | 'skipped' | 'stop' | 'continuewitherror')[] postCompare?: number | (boolean | 'prereq' | 'skipped'| 'stop' | 'continuewitherror')[] } } export const generatePlayWithLifecycle = (opts: GeneratePlayWithLifecycleOptions = {}) => { const { original: originalOpts = {}, lifecycleSteps: { preCompare, postCompare, } = {} } = opts; const original = generatePlay(originalOpts.data, originalOpts.meta, originalOpts.opts); const lplay: PlayObject = { data: {}, meta: { ...original.meta } }; lplay.meta.lifecycle.original = lifecyclelessInvariantTransform(original); lplay.meta.lifecycle.input = generateRandomObj(); let steps: LifecycleStep[] = []; let transformedPlay = clone(original); if(preCompare !== undefined) { if(typeof preCompare === 'number') { for(let i = 0; i < preCompare; i++) { const step = generateLifecycleStep(transformedPlay, {name: 'preCompare'}); steps.push(step[0]); transformedPlay = step[1]; } } else { for (const prec of preCompare) { const [step, modified] = generateLifecycleStep(transformedPlay, { name: 'preCompare', error: prec === true ? undefined : prec === false ? true : prec }) steps.push(step); if (step.flowResult === 'continue') { transformedPlay = modified; } else { break; } } } } if(postCompare !== undefined) { if (typeof postCompare === 'number') { for (let i = 0; i < postCompare; i++) { const step = generateLifecycleStep(transformedPlay, { name: 'postCompare' }); steps.push(step[0]); transformedPlay = step[1]; } } else { for (const postc of postCompare) { const [step, modified] = generateLifecycleStep(transformedPlay, { name: 'preCompare', error: postc === true ? undefined : postc === false ? true : postc }) steps.push(step); if (step.flowResult === 'continue') { transformedPlay = modified; } else { break; } } } } lplay.meta.lifecycle.steps = steps; lplay.data = transformedPlay.data; return lplay; } export const playWithLifecycleScrobble = async (play: PlayObject, opts: ScrobbleMatchOptions = {}): Promise => { const { match = false, warnings = false, error = false, } = opts; const scrobbleRes: ScrobbleResult = {}; const existingPlays = generatePlays(2); if(match) { existingPlays.push(play); } const res = await existingScrobble(play, existingPlays); scrobbleRes.match = res; if(res.match) { play.meta.lifecycle.scrobble = scrobbleRes; return play; } scrobbleRes.payload = playToListenPayload(play); if(error) { scrobbleRes.error = new Error('Failed to scrobble to client', {cause: new UpstreamError('Client returned a 400 or something')}); play.meta.lifecycle.scrobble = scrobbleRes; return play; } if(warnings) { scrobbleRes.warnings = faker.helpers.multiple(faker.lorem.sentence, {count: {min: 1, max: 3}}); } scrobbleRes.response = generateRandomObj(2); scrobbleRes.mergedScrobble = lifecyclelessInvariantTransform(play); play.meta.lifecycle.scrobble = scrobbleRes; return play; } export const generateLifecycleInput = (typeName?: string): LifecycleInput => { return { type: typeName ?? `${faker.string.alpha(2)}-${faker.hacker.noun()}`, input: generateRandomObj(2) }; } export interface GenerateLifecycleOptions { name?: string source?: string equal?: boolean error?: boolean | 'prereq' | 'skipped' | 'stop' | 'continuewitherror' inputCount?: number } const modifiableKeys: PropertyKey[] = ['track', 'album', 'albumArtists', 'artists', 'duration', 'meta','brainz']; export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleOptions = {}): [LifecycleStep, PlayObject] => { const { name = ['preCompare', 'postCompare'][faker.number.int({ min: 0, max: 1 })], source = `${play.meta?.source}-${faker.word.noun()}`, equal = faker.datatype.boolean(0.1), error = false, inputCount } = opts; const inputs = faker.helpers.multiple(() => generateLifecycleInput(), { count: inputCount ?? { min: 0, max: 2 } }); const step: LifecycleStep = { name, source, flowResult: 'continue', inputs } if (equal) { return [step, play]; } if(error !== false) { if(error === true) { step.flowResult = 'stop'; step.flowReason = 'Error encountered while transforming'; step.error = new Error('Failed to do something', {cause: new Error('Oops it borked.')}); } else if(error === 'prereq') { step.flowResult = 'stop'; step.flowKnownState = 'prereq'; step.flowReason = 'Transform could not be completed due to prerequisite failure'; step.error = mergeSimpleError(new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, cause: new SimpleError('Results were empty')})); } else if(error === 'stop') { step.flowResult = 'stop'; } else if(error === 'continuewitherror') { step.flowResult = 'continue'; step.flowReason = 'Transform encountered an error but continuing due to onFailure: continue'; step.error = mergeSimpleError(new SkipTransformStageError('An error that was an okay to continue with')); } else { step.flowResult = 'continue'; step.flowKnownState = 'skip'; step.flowReason = `Stage ${name} was skipped`; step.error = mergeSimpleError(new SkipTransformStageError('No desired MBIDs were missing', {shortStack: true})); } return [step, play]; } play.data.meta = { ...(play.data?.meta ?? {}), brainz: { ...(play.data?.meta?.brainz ?? {}) } } const modifiedPlay = clone(play); const randomPlay = generatePlay(); let somethingModified = false; while (!somethingModified) { new Traverse(modifiedPlay).forEach((ctx, x) => { if (modifiableKeys.includes(ctx.key)) { if (faker.datatype.boolean(0.3)) { if(ctx.key === 'meta' && (ctx.parent === undefined || ctx.parent.key !== 'data')) { return; } somethingModified = true; if(ctx.key === 'brainz' && Object.keys(x ?? {}).length === 0) { ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track']}), true); } else if (ctx.parent !== undefined && ctx.parent.key === 'brainz') { if (Array.isArray(x)) { ctx.update(faker.helpers.multiple(generateMbid, { count: { min: 1, max: 3 } })); } else { ctx.update(generateMbid()); } } else { ctx.update(randomPlay.data[ctx.key]); } } } }); } step.patch = diffObjects(play.data, modifiedPlay.data);// jdiff.diff(play, modifiedPlay); return [step, modifiedPlay]; } export interface RandomObjOptions { maxObjSize?: number maxKeyLength?: number keyCount?: number maxDepth?: number } const generateRandomVal = (depth: number = 0, opt: RandomObjOptions = {}, typeId?: number) => { const i = typeId ?? faker.number.int({ min: 1, max: depth > (opt.maxDepth ?? 3) ? 6 : 8 }); switch (i) { case 1: return undefined; case 2: return faker.datatype.boolean(); case 3: return faker.date.recent().toISOString(); case 4: return faker.lorem.word(); case 5: return faker.lorem.sentences({ min: 0, max: 5 }); case 6: return faker.helpers.arrayElement([faker.number.int({ min: 1, max: 1000 }), faker.number.float()]); case 7: return generateRandomObj(depth + 1, opt); case 8: const typeId = faker.number.int({ min: 4, max: depth > (opt.maxDepth ?? 3) ? 6 : 7 }); return faker.helpers.multiple(() => generateRandomVal(depth + 1, opt, typeId), { count: { min: 1, max: opt.maxObjSize ?? 7 } }) } } export const generateRandomObj = (depth: number = 0, opt: RandomObjOptions = {}) => { const tgrt: any = {} const keyCount = opt.keyCount ?? faker.number.int({ min: 1, max: 13 }); for (let i = 0; i < keyCount; i++) { const key = faker.lorem.slug({ min: 1, max: 3 }) if (tgrt[key] === undefined) { tgrt[key] = generateRandomVal(depth + 1, opt) } } return tgrt }