···2020import { defaultLifecycle } from '../../utils/PlayTransformUtils.js';
2121import { shuffleArray } from '../../utils/DataUtils.js';
2222import { DEFAULT_CONSOLIDATE_DURATION, DEFAULT_GROUP_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.js';
2323-import { asPlay } from '../../../core/tests/utils/fixtures.js';
2323+import { asPlay } from '../../../core/PlayMarshalUtils.js';
2424import { nanoid } from 'nanoid';
2525import { getRoot } from '../../ioc.js';
2626import { transientCache } from '../utils/CacheTestUtils.js';
+18
src/backend/utils/FSUtils.ts
···3333 // });
3434 // });
3535}
3636+3637export const fileOrDirectoryIsWriteable = (location: string) => {
3738 const pathInfo = pathUtil.parse(location);
3839 const isDir = pathInfo.ext === '';
···6364 }
6465};
65666767+export const fileExists = (location: string) => {
6868+ const pathInfo = pathUtil.parse(location);
6969+ const isDir = pathInfo.ext === '';
7070+ try {
7171+ accessSync(location, constants.R_OK);
7272+ return true;
7373+ } catch (err: any) {
7474+ const { code } = err;
7575+ if (code === 'ENOENT') {
7676+ return false;
7777+ } else if (code === 'EACCES') {
7878+ throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application does not have permission to write to it.`);
7979+ } else {
8080+ throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application is unable to access it due to a system error`, { cause: err });
8181+ }
8282+ }
8383+};
+82
src/core/PlayMarshalUtils.ts
···11+import clone from 'clone';
22+import dayjs from 'dayjs';
33+import { Traverse, TraverseContext } from 'neotraverse/modern';
44+import { ListenRange } from '../backend/sources/PlayerState/ListenRange.js';
55+import { AmbPlayObject, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js';
66+import { ListenProgressPositional, ListenProgressTS } from '../backend/sources/PlayerState/ListenProgress.js';
77+88+interface BlockPath { key: string, parent: string };
99+type BlockPaths = BlockPath[];
1010+1111+/** We know some nodes will never have data that needs to be transformed
1212+ * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them
1313+ */
1414+const blockedKeys: PropertyKey[] = ['patch', 'inputs', 'payload', 'response', 'error'];
1515+/** We know some paths/nodes will never have data that needs to be transformed
1616+ * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them
1717+ */
1818+const blockedPaths: BlockPaths = [
1919+ {
2020+ parent: 'data',
2121+ key: 'meta'
2222+ },
2323+ {
2424+ parent: 'lifecycle',
2525+ key: 'input'
2626+ }
2727+];
2828+2929+export const shouldBlock = (ctx: TraverseContext): boolean => {
3030+ if (blockedKeys.includes(ctx.key)) {
3131+ return true;
3232+ }
3333+ return blockedPaths.some((x) => {
3434+ let blocked = x.key === ctx.key;
3535+ if (blocked && x.parent !== undefined) {
3636+ blocked = ctx.parent !== undefined && ctx.parent.key === x.parent;
3737+ }
3838+ return blocked;
3939+ });
4040+};
4141+4242+export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => {
4343+ const cloned = clone(play);
4444+ new Traverse(cloned).forEach((ctx, x) => {
4545+ if (shouldBlock(ctx)) {
4646+ ctx.block();
4747+ return;
4848+ }
4949+5050+ if (dayjs.isDayjs(x)) {
5151+ ctx.update(x.toISOString());
5252+ } else if (x instanceof ListenRange) {
5353+ ctx.update(x.toJSON(), true);
5454+ }
5555+ });
5656+ return cloned as unknown as JsonPlayObject;
5757+};
5858+5959+export const asPlay = (data: JsonPlayObject): PlayObject => {
6060+ const cloned = clone(data);
6161+ new Traverse(cloned).forEach((ctx, x) => {
6262+ if (shouldBlock(ctx)) {
6363+ ctx.block();
6464+ return;
6565+ }
6666+6767+ if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) {
6868+ ctx.update(dayjs(x), true);
6969+ } else if (ctx.key === 'listenRanges') {
7070+ const ranges = x[0].map((y: PlayProgressAmb<string>) => {
7171+ if (y.positionPercent === undefined) {
7272+ return new ListenProgressPositional({ timestamp: dayjs(y.timestamp), position: y.position });
7373+ } else {
7474+ return new ListenProgressTS({ timestamp: dayjs(y.timestamp), positionPercent: y.positionPercent });
7575+ }
7676+ });
7777+ ctx.update(ranges, true);
7878+ }
7979+ });
8080+ return cloned as unknown as PlayObject;
8181+};
8282+
+1-79
src/core/tests/utils/fixtures.ts
···11import { Traverse, TraverseContext } from 'neotraverse/modern';
22import { faker } from '@faker-js/faker';
33-import dayjs from 'dayjs';
44-import { AmbPlayObject, JsonPlayObject, LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE, ScrobbleResult } from '../../Atomic.js';
55-import { ListenRange } from '../../../backend/sources/PlayerState/ListenRange.js';
66-import { ListenProgressPositional, ListenProgressTS } from '../../../backend/sources/PlayerState/ListenProgress.js';
33+import { LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, ScrobbleResult } from '../../Atomic.js';
74import { MarkOptional } from 'ts-essentials';
85import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js';
96import { lifecyclelessInvariantTransform } from '../../PlayUtils.js';
···1310import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js';
1411import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js';
1512import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError } from '../../../backend/common/errors/MSErrors.js';
1616-1717-interface BlockPath { key: string, parent: string };
1818-type BlockPaths = BlockPath[];
1919-2020-/** We know some nodes will never have data that needs to be transformed
2121- * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them
2222- */
2323-const blockedKeys: PropertyKey[] = ['patch', 'inputs', 'payload', 'response', 'error'];
2424-/** We know some paths/nodes will never have data that needs to be transformed
2525- * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them
2626- */
2727-const blockedPaths: BlockPaths = [
2828- {
2929- parent: 'data',
3030- key: 'meta'
3131- },
3232- {
3333- parent: 'lifecycle',
3434- key: 'input'
3535- }
3636-]
3737-3838-const shouldBlock = (ctx: TraverseContext): boolean => {
3939- if (blockedKeys.includes(ctx.key)) {
4040- return true;
4141- }
4242- return blockedPaths.some((x) => {
4343- let blocked = x.key === ctx.key;
4444- if (blocked && x.parent !== undefined) {
4545- blocked = ctx.parent !== undefined && ctx.parent.key === x.parent;
4646- }
4747- return blocked;
4848- })
4949-}
5050-5151-export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => {
5252- const cloned = clone(play);
5353- new Traverse(cloned).forEach((ctx, x) => {
5454- if (shouldBlock(ctx)) {
5555- ctx.block();
5656- return;
5757- }
5858-5959- if (dayjs.isDayjs(x)) {
6060- ctx.update(x.toISOString());
6161- } else if (x instanceof ListenRange) {
6262- ctx.update(x.toJSON(), true);
6363- }
6464- });
6565- return cloned as unknown as JsonPlayObject;
6666-}
6767-6868-export const asPlay = (data: JsonPlayObject): PlayObject => {
6969- const cloned = clone(data);
7070- new Traverse(cloned).forEach((ctx, x) => {
7171- if (shouldBlock(ctx)) {
7272- ctx.block();
7373- return;
7474- }
7575-7676- if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) {
7777- ctx.update(dayjs(x), true);
7878- } else if (ctx.key === 'listenRanges') {
7979- const ranges = x[0].map((y: PlayProgressAmb<string>) => {
8080- if (y.positionPercent === undefined) {
8181- return new ListenProgressPositional({ timestamp: dayjs(y.timestamp), position: y.position });
8282- } else {
8383- return new ListenProgressTS({ timestamp: dayjs(y.timestamp), positionPercent: y.positionPercent });
8484- }
8585- })
8686- ctx.update(ranges, true);
8787- }
8888- });
8989- return cloned as unknown as PlayObject;
9090-}
91139214export interface ScrobbleMatchOptions {
9315 match?: boolean
+2-1
src/stories/ActivityTimeline.stories.tsx
···88import { generateJsonPlays } from "../core/PlayTestUtils.js";
99import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js";
1010import { examplePlay, lastfmErrorExample } from "./storyUtils.js";
1111-import { asJsonPlayObject, generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js";
1111+import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js";
1212+import { asJsonPlayObject } from '../core/PlayMarshalUtils.js';
12131314// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
1415const meta = preview.meta({
+2-1
src/stories/List.stories.tsx
···88import { generateJsonPlays, normalizePlays } from "../core/PlayTestUtils.js";
99import { ErrorLike, JsonPlayObject } from "../core/Atomic.js";
1010import {examplePlay, lastfmErrorExample} from './storyUtils.js';
1111-import {playWithLifecycleScrobble, generatePlayWithLifecycle, asJsonPlayObject} from '../core/tests/utils/fixtures'
1111+import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
1212import { generateArray } from "../core/DataUtils.js";
1313import dayjs from "dayjs";
1414+import { asJsonPlayObject } from "../core/PlayMarshalUtils.js";
14151516const 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)";
1617
+1-1
src/stories/PlayInfo.stories.tsx
···77import { Container } from '@chakra-ui/react';
88import { generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js"
99import clone from "clone";
1010-import { asJsonPlayObject } from "../core/tests/utils/fixtures.js";
1010+import { asJsonPlayObject } from '../core/PlayMarshalUtils.js';
11111212type PropsAndCustomArgs = React.ComponentProps<typeof PlayData> & {
1313 includeAlbumArtists?: boolean;
+2-1
src/stories/TransformSteps.stories.tsx
···88import { generateJsonPlays } from "../core/PlayTestUtils.js";
99import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js";
1010import { examplePlay, lastfmErrorExample } from "./storyUtils.js";
1111-import {asJsonPlayObject, generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
1111+import {generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
1212+import { asJsonPlayObject } from "../core/PlayMarshalUtils.js";
12131314// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
1415const meta = preview.meta({