Something went wrong. Try again.
[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
Something went wrong. Try again.
10 kB · 307 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307import { Traverse } from 'neotraverse/modern';import { faker } from '@faker-js/faker';import { type AmbPlayObject, type DateLike, type LifecycleInput, type LifecycleStep, type ObjectPlayData, PLAY_STATES, type PlayMatchResult, type PlayMeta, type PlayObject, type PlayOriginal, type PlayState, type ScrobbleResult } from '../../Atomic.ts';import { generateBrainz, generateMbid, generatePlay, type GeneratePlayOpts, generatePlays } from './PlayTestUtils.ts';import { statefulInvariantTransform } from '../../PlayUtils.ts';import clone from 'clone';import { diffObjects } from '../../DataUtils.ts';//import { existingScrobble } from '../../../backend/utils/PlayComparisonUtils.ts';import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError } from '../../../backend/common/errors/MSErrors.ts';import dayjs, { type Dayjs } from 'dayjs';import type {TransformHook} from '../../Transform.ts';import { serializeError } from 'serialize-error';
export interface ScrobbleMatchOptions { match?: boolean warnings?: boolean error?: boolean}export interface GeneratePlayWithLifecycleOptions { original?: { data?: ObjectPlayData, meta?: PlayMeta 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();
const 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.lifecycle = steps; lplay.data = transformedPlay.data;
return lplay;}
export const generatePlayInput = <D extends DateLike = Dayjs>(play?: AmbPlayObject<D>, data?: object | false): PlayOriginal<D> => { return { play, data: data ?? generateRandomObj(2) }}
export const playWithLifecycleScrobble = async (play: PlayObject, opts: ScrobbleMatchOptions = {}): Promise<PlayObject> => { const { match = false, warnings = false, error = false, } = opts;
const scrobbleRes: ScrobbleResult = { createdAt: dayjs() };
const existingPlays = generatePlays(2);
if(match) { existingPlays.push(play); }
const res: PlayMatchResult<Dayjs> = { match, score: 0.9, breakdowns: ['fsdf','fsdf','fsdf'], reason: 'fsdfd', createdAt: dayjs().toISOString() } scrobbleRes.match = res; if(res.match) { play.scrobble = scrobbleRes; return play; }
scrobbleRes.payload = generateRandomObj(1); if(error) { scrobbleRes.error = serializeError(new Error('Failed to scrobble to client', {cause: new Error('Client returned a 400 or something')})); play.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 = statefulInvariantTransform(play); play.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 = { hook: name as TransformHook, stageType: faker.helpers.arrayElement(['musicbrainz','native','user']), stageName: faker.word.noun({length: {min: 1, max: 3}}), source, flowResult: 'continue', inputs, createdAt: dayjs().toISOString() }
if (equal) { return [step, play]; }
if(error !== false) { if(error === true) { step.flowResult = 'stop'; step.flowReason = 'Error encountered while transforming'; step.error = new StageTransformError('foo', '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 = serializeError(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 = serializeError(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 = serializeError(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','recording']}), 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 allowUndefined?: boolean}
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 (opt.allowUndefined ?? true) ? undefined : null; 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}
export const randomPlayState = () => faker.helpers.arrayElement(PLAY_STATES) as PlayState;