From d474ae24d3439f1ce850d80d93e569fdd91a14ae Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 26 Jan 2026 19:13:03 +0000 Subject: [PATCH] feat: Use json diff to track play changes * Store Source input and original play * Store transformer hook results as diff patches alongside stage-specific arbitrary input data * Use jsondiffpatch console format to log stage diffs * Store scrobble payload data --- package-lock.json | 22 ++++++ package.json | 1 + src/backend/common/AbstractComponent.ts | 71 +++++++++++++------ .../transforms/AtomicPartsTransformer.ts | 11 ++- .../transforms/MusicbrainzTransformer.ts | 5 +- src/backend/common/vendor/KodiApiClient.ts | 6 +- src/backend/common/vendor/LastfmApiClient.ts | 11 +-- .../common/vendor/ListenbrainzApiClient.ts | 9 +-- src/backend/common/vendor/RockSkyApiClient.ts | 7 +- .../bluesky/AbstractBlueSkyApiClient.ts | 7 +- .../common/vendor/koito/KoitoApiClient.ts | 7 +- .../common/vendor/maloja/MalojaApiClient.ts | 6 +- .../musicbrainz/MusicbrainzApiClient.ts | 17 +++-- .../scrobblers/AbstractScrobbleClient.ts | 2 + src/backend/sources/AzuracastSource.ts | 6 +- src/backend/sources/ChromecastSource.ts | 8 ++- src/backend/sources/DeezerInternalSource.ts | 7 +- src/backend/sources/DeezerSource.ts | 6 +- src/backend/sources/IcecastSource.ts | 6 +- src/backend/sources/JRiverSource.ts | 6 +- src/backend/sources/JellyfinApiSource.ts | 7 +- src/backend/sources/MPDSource.ts | 6 +- src/backend/sources/MPRISSource.ts | 6 +- src/backend/sources/MopidySource.ts | 6 +- src/backend/sources/MusicCastSource.ts | 6 +- src/backend/sources/MusikcubeSource.ts | 6 +- src/backend/sources/PlexApiSource.ts | 6 +- src/backend/sources/SonosSource.ts | 6 +- src/backend/sources/SpotifySource.ts | 15 ++-- src/backend/sources/SubsonicSource.ts | 6 +- src/backend/sources/VLCSource.ts | 6 +- src/backend/sources/WebScrobblerSource.ts | 6 +- src/backend/sources/YTMusicSource.ts | 6 +- src/backend/tests/jellyfin/jellyfin.test.ts | 5 +- .../tests/listenbrainz/listenbrainz.test.ts | 5 +- .../tests/musicbrainz/musicbrainz.test.ts | 50 +++++++++---- src/backend/tests/plex/plex.test.ts | 5 +- .../tests/scrobbler/scrobblers.test.ts | 4 +- src/backend/tests/utils/PlayTestUtils.ts | 16 ++++- src/backend/utils/DataUtils.ts | 8 +++ src/backend/utils/PlayComparisonUtils.ts | 8 +-- src/backend/utils/PlayTransformUtils.ts | 31 +++++++- src/core/Atomic.ts | 36 ++++++++++ 43 files changed, 357 insertions(+), 120 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71bd1b26..df9aa8f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "iti": "^0.7.0", "jest-diff": "^30.2.0", "json5": "^2.2.3", + "jsondiffpatch": "^0.7.3", "keyv": "^5.5.0", "kodi-api": "^0.2.1", "lastfm-ts-api": "^2.5.0", @@ -1070,6 +1071,12 @@ "@dbus-types/dbus": "^0.0.4" } }, + "node_modules/@dmsnell/diff-match-patch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dmsnell/diff-match-patch/-/diff-match-patch-1.1.0.tgz", + "integrity": "sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==", + "license": "Apache-2.0" + }, "node_modules/@donedeal0/superdiff": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@donedeal0/superdiff/-/superdiff-1.1.3.tgz", @@ -8277,6 +8284,21 @@ "node": ">=6" } }, + "node_modules/jsondiffpatch": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.7.3.tgz", + "integrity": "sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==", + "license": "MIT", + "dependencies": { + "@dmsnell/diff-match-patch": "^1.1.0" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", diff --git a/package.json b/package.json index ca9612c5..1aba8b6a 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "iti": "^0.7.0", "jest-diff": "^30.2.0", "json5": "^2.2.3", + "jsondiffpatch": "^0.7.3", "keyv": "^5.5.0", "kodi-api": "^0.2.1", "lastfm-ts-api": "^2.5.0", diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 229640fd..4fdc7731 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -27,6 +27,10 @@ import { findCauseByReference } from "../utils/ErrorUtils.js"; import { hashObject } from "../utils/StringUtils.js"; import { metaInvariantTransform, playContentInvariantTransform } from "../utils/PlayComparisonUtils.js"; import { MSCache } from "./Cache.js"; +import { jdiff } from "../utils/DataUtils.js"; +import ConsoleFormatter from "jsondiffpatch/formatters/console"; + +const console = new ConsoleFormatter(); export default abstract class AbstractComponent extends AbstractInitializable { @@ -240,7 +244,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { break; } } else if(onFailure === 'continue') { - logger.warn(merged, 'A transform encountered an error but continuing due to onFailure: continue'); + logger.warn(merged, 'A transform encounte[red an error but continuing due to onFailure: continue'); } else { const reqError = findCauseByReference(err, StagePrerequisiteError); if(reqError !== undefined) { @@ -272,17 +276,24 @@ export default abstract class AbstractComponent extends AbstractInitializable { } } - if(transformedPlay.meta.transforms === undefined) { - transformedPlay.meta.transforms = { - original: play.data - }; - } + const orig = play.data; + + // if(transformedPlay.meta.transforms === undefined) { + // transformedPlay.meta.transforms = { + // original: play.data + // }; + // } + + const o = JSON.parse(JSON.stringify(play)); + const t = JSON.parse(JSON.stringify(transformedPlay)); + const patch = jdiff.diff(o,t); + if(shouldLog !== false) { if(transformHistory.length === 0) { logger.debug('Transform Diff: No Change'); } else { const historyToDiff: {name: string, data: PlayData}[] = [ - {name: 'Original', data: transformedPlay.meta.transforms.original} + {name: 'Original', data: play.data} ]; if(shouldLog === true) { const last = transformHistory[transformHistory.length - 1]; @@ -301,26 +312,44 @@ export default abstract class AbstractComponent extends AbstractInitializable { if(deepEqual(last.data, curr.data)) { diffs.push(`${last.name} => ${curr.name} -- No Change`); } else { - diffs.push(diffStringsUnified( - buildPlayHumanDiffable(last.data, {expandMeta: true}), - buildPlayHumanDiffable(curr.data, {expandMeta: true}), - { - aAnnotation: last.name, - aColor: chalk.red, - bAnnotation: curr.name, - bColor: chalk.green - } - )) + const unchanged = index - 1 === 0 ? last.data : undefined; + + const formattedDiff = console.format(jdiff.diff(last.data, curr.data), unchanged); + diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`); } }); - logger.debug(`Transform Diff\n${diffs}`); + logger.debug(`Transform Diff\n${diffs.join('\n')}`); } + } + const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier()); + if(existingStepIndex !== -1) { + transformedPlay.meta.lifecycle.steps[existingStepIndex].patch = patch; + transformedPlay.meta.lifecycle.steps[existingStepIndex].inputs = transformedPlay.meta.lifecycleInputs; + } else { + transformedPlay.meta.lifecycle.steps.push({ + name: hookType, + source: this.getIdentifier(), + patch: patch, + inputs: transformedPlay.meta.lifecycleInputs + }); } - const previousHistory = transformedPlay.meta.transforms[hookType] ?? []; - transformedPlay.meta.transforms[hookType] = [...previousHistory, ...transformHistory]; + delete transformedPlay.meta.lifecycleInputs; + + // transformedPlay.meta.lifecycle = { + // ...(transformedPlay.meta.lifecycle ?? { + // original: play + // }), + // steps: [ + // ...(transformedPlay.meta.lifecycle?.steps ?? []), + // { + // name: hookType, + // source: this.getIdentifier(), + // patch: patch + // } + // ] + // } await this.cache.cacheTransform.set(transformHash, transformedPlay, '5m'); - //logger.debug(`Cache set ${transformHash}`) return transformedPlay; } catch (e) { logger.warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e})); diff --git a/src/backend/common/transforms/AtomicPartsTransformer.ts b/src/backend/common/transforms/AtomicPartsTransformer.ts index 1615d987..14a298c4 100644 --- a/src/backend/common/transforms/AtomicPartsTransformer.ts +++ b/src/backend/common/transforms/AtomicPartsTransformer.ts @@ -1,4 +1,4 @@ -import { ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; +import { isPlayObject, ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; import { AtomicStageConfig, StageConfig } from "../infrastructure/Transform.js"; import AbstractTransformer from "./AbstractTransformer.js"; @@ -122,6 +122,15 @@ export default abstract class AtomicPartsTransformer { @@ -636,7 +637,9 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { diff --git a/src/backend/common/vendor/KodiApiClient.ts b/src/backend/common/vendor/KodiApiClient.ts index 5676f09c..a8bac833 100644 --- a/src/backend/common/vendor/KodiApiClient.ts +++ b/src/backend/common/vendor/KodiApiClient.ts @@ -2,11 +2,12 @@ import dayjs from "dayjs"; import { KodiClient } from 'kodi-api' import normalizeUrl from "normalize-url"; import { URL } from "url"; -import { PlayObject } from "../../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../../core/Atomic.js"; import { RecentlyPlayedOptions } from "../../sources/AbstractSource.js"; import { AbstractApiOptions, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; import { KodiData } from "../infrastructure/config/source/kodi.js"; import AbstractApiClient from "./AbstractApiClient.js"; +import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; interface KodiDuration { hours: number @@ -104,7 +105,7 @@ export class KodiApiClient extends AbstractApiClient { const album = albumVal === null || albumVal === '' ? undefined : albumVal; const trackProgressPosition = time !== undefined ? Math.round(dayjs.duration(time).asSeconds()) : undefined; - return { + const play: PlayObjectLifecycleless = { data: { track: title, album: album, @@ -123,6 +124,7 @@ export class KodiApiClient extends AbstractApiClient { deviceId: playerid !== undefined ? `Player${playerid}` : undefined, } } + return baseFormatPlayObj(obj, play); } testConnection = async () => { diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index 8e251de5..ef097598 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -1,5 +1,5 @@ import dayjs, { Dayjs } from "dayjs"; -import { BrainzMeta, PlayObject, URLData } from "../../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless, URLData } from "../../../core/Atomic.js"; import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; import { removeUndefinedKeys, sleep, writeFile } from "../../utils.js"; import { objectIsEmpty, readJson } from '../../utils/DataUtils.js'; @@ -14,6 +14,7 @@ import { parseArtistCredits } from "../../utils/StringUtils.js"; import { LastFMUser, LastFMAuth, LastFMTrack, LastFMUserGetRecentTracksResponse, LastFMBooleanNumber, LastFMUpdateNowPlayingResponse, LastFMUserGetInfoResponse } from 'lastfm-ts-api'; import clone from 'clone'; import { IncomingMessage } from "http"; +import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; const badErrors = [ 'api key suspended', @@ -438,7 +439,7 @@ export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayOb artists = [artist]; } - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { track, album: nonEmptyStringOrDefault(album), @@ -461,7 +462,7 @@ export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayOb }; } - return play; + return baseFormatPlayObj(obj, play); } export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload => { @@ -564,7 +565,7 @@ export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectO recording: nonEmptyStringOrDefault(mbid) }); - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { artists: [...new Set(artistStrings)] as string[], track: title, @@ -587,7 +588,7 @@ export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectO brainz } } - return play; + return baseFormatPlayObj(obj, play); } type LastFMTrackScrobbleResponse = Readonly<{ diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 7b076173..10ab201c 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -1,7 +1,7 @@ import { stringSameness } from '@foxxmd/string-sameness'; import dayjs from "dayjs"; import request, { Request, Response } from 'superagent'; -import { BrainzMeta, PlayObject, URLData } from "../../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless, URLData } from "../../../core/Atomic.js"; import { combinePartsToString, slice } from "../../../core/StringUtils.js"; import { findDelimiters, @@ -23,6 +23,7 @@ import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/ import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js'; import { version } from '../../ioc.js'; import { ListenPayload, ListenResponse, ListenType, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from './listenbrainz/interfaces.js'; +import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.js'; interface SubmitOptions { log?: boolean @@ -555,7 +556,7 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { } } - return play; + return baseFormatPlayObj(listen, play); } /** @@ -636,7 +637,7 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { } - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { playDate: dayjs.unix(listened_at), track: normalTrackName, @@ -681,7 +682,7 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { } } - return play; + return baseFormatPlayObj(listen, play); } diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 5cf06fcd..e8072797 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import request, { Request, Response } from 'superagent'; -import { PlayObject, URLData } from "../../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../core/Atomic.js"; import { nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; import { UpstreamError } from "../errors/UpstreamError.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; @@ -13,6 +13,7 @@ import { playToListenPayload } from './ListenbrainzApiClient.js'; import { RockskyScrobble } from './rocksky/interfaces.js'; import { Handle } from "@atcute/lexicons"; import { identifierToAtProtoHandle } from './bluesky/bsUtils.js'; +import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; interface SubmitOptions { log?: boolean @@ -208,7 +209,7 @@ interface UserScrobbleResponse { } export const rockskyScrobbleToPlay = (obj: RockskyScrobble): PlayObject => { - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { track: obj.title, artists: nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : [], @@ -222,5 +223,5 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble): PlayObject => { } }; - return play; + return baseFormatPlayObj(obj, play); } \ No newline at end of file diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index b46bb86b..aa4dcae9 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -4,13 +4,14 @@ import { ListRecord, ScrobbleRecord, TealClientData } from "../../infrastructure import AbstractApiClient from "../AbstractApiClient.js"; import { Agent } from "@atproto/api"; import { MSCache } from "../../Cache.js"; -import { BrainzMeta, PlayObject } from "../../../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../../../core/Atomic.js"; import { musicServiceToCononical } from "../ListenbrainzApiClient.js"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; import dayjs from "dayjs"; import { getScrobbleTsSOCDateWithContext } from "../../../utils/TimeUtils.js"; import { removeUndefinedKeys } from "../../../utils.js"; +import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; export abstract class AbstractBlueSkyApiClient extends AbstractApiClient { @@ -89,7 +90,7 @@ export const listRecordToPlay = (listRecord: ListRecord): PlayOb export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {}): PlayObject => { - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { track: record.trackName, artists: record.artists.filter(x => x.artistName !== undefined).map(x => x.artistName), @@ -120,7 +121,7 @@ export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {} play.data.meta = {brainz}; } - return play; + return baseFormatPlayObj(record, play); }; export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?(?did.*?)\/fm.teal.alpha.feed.play\/(?.*))/); diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts index 5b0c8ce8..903fc1eb 100644 --- a/src/backend/common/vendor/koito/KoitoApiClient.ts +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { PlayObject, URLData } from "../../../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../../infrastructure/Atomic.js"; import { KoitoData, ListenObjectResponse, ListensResponse } from "../../infrastructure/config/client/koito.js"; import AbstractApiClient from "../AbstractApiClient.js"; @@ -10,6 +10,7 @@ import { playToListenPayload } from "../ListenbrainzApiClient.js"; import { SubmitPayload } from '../listenbrainz/interfaces.js'; import { ListenType } from '../listenbrainz/interfaces.js'; import { parseRegexSingleOrFail } from "../../../utils.js"; +import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; interface SubmitOptions { log?: boolean @@ -181,7 +182,7 @@ export class KoitoApiClient extends AbstractApiClient { } export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => { - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { track: obj.track.title, artists: (obj.track.artists ?? []).map(x => x.name), @@ -204,5 +205,5 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { } } } - return play; + return baseFormatPlayObj(obj, play); } \ No newline at end of file diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts index 87aa27bc..29e72f31 100644 --- a/src/backend/common/vendor/maloja/MalojaApiClient.ts +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -4,7 +4,7 @@ import compareVersions from "compare-versions"; import AbstractApiClient from "../AbstractApiClient.js"; import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; import { MalojaData } from "../../infrastructure/config/client/maloja.js"; -import { PlayObject, URLData } from "../../../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../../infrastructure/Atomic.js"; import { isNodeNetworkException } from "../../errors/NodeErrors.js"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js"; @@ -13,6 +13,7 @@ import { UpstreamError } from "../../errors/UpstreamError.js"; import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; import { buildTrackString } from '../../../../core/StringUtils.js'; +import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; @@ -368,7 +369,7 @@ export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObject return [...acc, ...aStrings]; }, []); const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); - return { + const play: PlayObjectLifecycleless = { data: removeUndefinedKeys({ artists: [...new Set(artistStrings)] as string[], track: title, @@ -384,6 +385,7 @@ export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObject } } } + return baseFormatPlayObj(obj, play); } export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): MalojaScrobbleV3RequestData => { diff --git a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts index dba98514..133ef999 100644 --- a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts +++ b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts @@ -1,5 +1,5 @@ import { Response } from 'superagent'; -import { PlayObject, URLData } from "../../../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; import { UpstreamError } from "../../errors/UpstreamError.js"; import { AbstractApiOptions, FormatPlayObjectOptions, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../../infrastructure/Atomic.js"; import AbstractApiClient from "../AbstractApiClient.js"; @@ -18,6 +18,8 @@ import { stripIndents } from "common-tags"; import { getNodeNetworkException, hasNodeNetworkException, isNodeNetworkException } from '../../errors/NodeErrors.js'; import { SimpleError } from '../../errors/MSErrors.js'; import { resourceLimits } from 'worker_threads'; +import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; +import { IRecordingMSList } from '../../transforms/MusicbrainzTransformer.js'; export interface SubmitResponse { payload?: { @@ -193,7 +195,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { } } - searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise => { + searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise => { const { escapeCharacters = true, @@ -206,6 +208,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { const cacheKey = `mb-recSearch-${hashObject({...playContentInvariantTransform(play), using})}`; this.logger.debug(`Starting search`); + let q = ''; // https://github.com/Borewit/musicbrainz-api?tab=readme-ov-file#search-function // https://wiki.musicbrainz.org/MusicBrainz_API/Search#Recording // https://beta.musicbrainz.org/doc/MusicBrainz_API/Search @@ -248,8 +251,6 @@ export class MusicbrainzApiClient extends AbstractApiClient { } } - let q = ''; - if(freetext) { q += `${query.recording ?? ''} `; @@ -328,7 +329,9 @@ export class MusicbrainzApiClient extends AbstractApiClient { throw new Error('results returned but no recordings list in response data, something handled incorrectly?'); } - return res; + (res as IRecordingMSList).requestQuery = q; + + return res as IRecordingMSList; } testConnection = async () => { @@ -373,7 +376,7 @@ export const recordingToPlay = (data: IRecording, options?: {ignoreVA?: boolean} } } - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { track: data.title, artists, @@ -398,7 +401,7 @@ export const recordingToPlay = (data: IRecording, options?: {ignoreVA?: boolean} } } - return play; + return baseFormatPlayObj(data, play); } diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 9a697961..590a1207 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -796,11 +796,13 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']}); const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play); if (timeFrameValid && !(await this.alreadyScrobbled(currQueuedPlay.play))) { const transformedScrobble = await this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare); + transformedScrobble.meta.lifecycle.scrobble = this.playToClientPayload(transformedScrobble); try { const scrobbledPlay = await this.scrobble(transformedScrobble); this.emitEvent('scrobble', {play: transformedScrobble}); this.addScrobbledTrack(transformedScrobble, scrobbledPlay); } catch (e) { + currQueuedPlay.play.meta.lifecycle.scrobble = this.playToClientPayload(transformedScrobble); if (e instanceof UpstreamError && e.showStopper === false) { this.addDeadLetterScrobble(currQueuedPlay, e); this.logger.warn(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e})); diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index 7b058079..554ae4fd 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -4,7 +4,7 @@ import { childLogger, Logger } from "@foxxmd/logging"; import { EventEmitter } from "events"; import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket' import pEvent from 'p-event'; -import { PlayObject, URLData } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; import { FormatPlayObjectOptions, @@ -16,6 +16,7 @@ import { } from "../common/infrastructure/Atomic.js"; import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js"; import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export class AzuracastSource extends MemorySource { @@ -247,7 +248,7 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp const track: string = title ?? text; - return { + const play: PlayObjectLifecycleless = { data: { artists: artist !== undefined && artist !== '' ? [artist] : [], album: album !== '' ? album : undefined, @@ -260,6 +261,7 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp mediaPlayerName: 'Azuracast' } } + return baseFormatPlayObj(obj, play); } const getMessageData = (e: any): T => { diff --git a/src/backend/sources/ChromecastSource.ts b/src/backend/sources/ChromecastSource.ts index 359d0124..8722eac3 100644 --- a/src/backend/sources/ChromecastSource.ts +++ b/src/backend/sources/ChromecastSource.ts @@ -5,7 +5,7 @@ import { Client as CastClient } from 'castv2'; import dayjs from "dayjs"; import { EventEmitter } from "events"; import e from "express"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { buildTrackString } from "../../core/StringUtils.js"; import { NETWORK_ERROR_FAILURE_CODES } from "../common/errors/NodeErrors.js"; import { @@ -35,6 +35,7 @@ import { findCauseByReference } from "../utils/ErrorUtils.js"; import { discoveryAvahi, discoveryNative } from "../utils/MDNSUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; interface ChromecastDeviceInfo { mdns: MdnsDeviceInfo @@ -716,7 +717,7 @@ export class ChromecastSource extends MemoryPositionalSource { trackProgressPosition = currentTime; } - return { + const play: PlayObjectLifecycleless = { data: { track, album, @@ -731,6 +732,7 @@ export class ChromecastSource extends MemoryPositionalSource { deviceId, source } - } + }; + return baseFormatPlayObj(obj, play); } } diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index 8215aaaa..4ae2d32b 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import EventEmitter from "events"; import request, { Request, Response, SuperAgent } from 'superagent'; -import { PlayObject, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js"; import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; import { TRANSFORM_HOOK } from "../common/infrastructure/Transform.js"; @@ -13,6 +13,7 @@ import MemorySource from "./MemorySource.js"; import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js"; import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js"; import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; interface DeezerHistoryResponse { errors: [] @@ -68,7 +69,7 @@ export default class DeezerInternalSource extends MemorySource { static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject { const {newFromSource = false} = options; - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { artists: [obj.ART_NAME], album: obj.ALB_TITLE, @@ -91,7 +92,7 @@ export default class DeezerInternalSource extends MemorySource { album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg` } } - return play; + return baseFormatPlayObj(obj, play); } protected async doBuildInitData(): Promise { diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index c9197072..1cd98e40 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -3,13 +3,14 @@ import EventEmitter from "events"; import passport from "passport"; import { Strategy as DeezerStrategy } from 'passport-deezer'; import request from 'superagent'; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js"; import { readJson } from '../utils/DataUtils.js'; import { joinedUrl } from "../utils/NetworkUtils.js"; import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export default class DeezerSource extends AbstractSource { workingCredsPath; @@ -72,7 +73,7 @@ export default class DeezerSource extends AbstractSource { title: albumName, } = {}, } = obj; - return { + const play: PlayObjectLifecycleless = { data: { artists: [artistName], album: albumName, @@ -90,6 +91,7 @@ export default class DeezerSource extends AbstractSource { } } } + return baseFormatPlayObj(obj, play); } protected async doBuildInitData(): Promise { diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index 7855ec84..1ccad5c3 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -1,6 +1,6 @@ import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { EventEmitter } from "events"; -import { PlayObject, URLData } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -14,6 +14,7 @@ import { IcecastMetadata, IcecastSourceConfig } from "../common/infrastructure/c import IcecastMetadataStats from "icecast-metadata-stats"; import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js"; import { isDebugMode, sleep } from "../utils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export class IcecastSource extends MemorySource { @@ -207,7 +208,7 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = } } - return { + const play: PlayObjectLifecycleless = { data: { track, artists @@ -217,4 +218,5 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = mediaPlayerName: 'Icecast' } } + return baseFormatPlayObj(obj, play); } \ No newline at end of file diff --git a/src/backend/sources/JRiverSource.ts b/src/backend/sources/JRiverSource.ts index fb09ce8d..d5a94c11 100644 --- a/src/backend/sources/JRiverSource.ts +++ b/src/backend/sources/JRiverSource.ts @@ -2,12 +2,13 @@ import dayjs from "dayjs"; import { EventEmitter } from "events"; import normalizeUrl from 'normalize-url'; import { URL } from "url"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { JRiverSourceConfig } from "../common/infrastructure/config/source/jriver.js"; import { Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiClient.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export class JRiverSource extends MemoryPositionalSource { declare config: JRiverSourceConfig; @@ -99,7 +100,7 @@ export class JRiverSource extends MemoryPositionalSource { const album = Album === null || Album === '' ? undefined : Album; const length = Number.parseInt(DurationMS.toString()) / 1000; - return { + const play: PlayObjectLifecycleless = { data: { track: Name, album: album, @@ -117,6 +118,7 @@ export class JRiverSource extends MemoryPositionalSource { deviceId: `Zone${ZoneID}${ZoneName !== undefined ? `-${ZoneName}` : ''}`, } } + return baseFormatPlayObj(obj, play); } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index 86ea77c4..d5525d23 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -45,7 +45,7 @@ import EventEmitter from "events"; import { nanoid } from "nanoid"; import pEvent from "p-event"; import { Simulate } from "react-dom/test-utils"; -import { BrainzMeta, PlayObject } from "../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, @@ -60,6 +60,7 @@ import { joinedUrl } from "../utils/NetworkUtils.js"; import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { FixedSizeList } from "fixed-size-list"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; const shortDeviceId = truncateStringToLength(10, ''); @@ -438,7 +439,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource { meta.albumArtist = [ProviderIds.MusicBrainzAlbumArtist]; } - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { artists: Artists, album: Album, @@ -457,7 +458,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource { if(Object.keys(meta).length > 0) { play.data.meta = { brainz: meta }; } - return play; + return baseFormatPlayObj(obj,play); } getRecentlyPlayed = async (options = {}) => { diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 6dc16c67..732b121b 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import { EventEmitter } from "events"; import mpdapiNS, { MPDApi } from 'mpd-api'; import mpd2 from 'mpd2'; -import { BrainzMeta, PlayObject } from "../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -20,6 +20,7 @@ import { import { isPortReachable } from "../utils/NetworkUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; const mpdClient = mpdapiNS.default; @@ -187,7 +188,7 @@ export class MPDSource extends MemoryPositionalSource { brainz.artist = [musicbrainz_artistid]; } - return { + const play: PlayObjectLifecycleless = { data: { artists: artists, albumArtists, @@ -201,6 +202,7 @@ export class MPDSource extends MemoryPositionalSource { mediaPlayerName: 'mpd' } } + return baseFormatPlayObj({...obj, trackProgressPosition: options.trackProgressPosition}, play); } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { diff --git a/src/backend/sources/MPRISSource.ts b/src/backend/sources/MPRISSource.ts index c0c140ff..57473236 100644 --- a/src/backend/sources/MPRISSource.ts +++ b/src/backend/sources/MPRISSource.ts @@ -2,7 +2,7 @@ import { Interfaces as Notifications } from '@dbus-types/notifications' import dayjs from "dayjs"; import { DBusInterface, MessageBus, sessionBus, Connection, ConnectOpts } from 'dbus-ts'; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { MPRIS_IFACE, @@ -20,6 +20,7 @@ import MemorySource from "./MemorySource.js"; import { Readable, Writable } from 'stream'; import net from 'net'; import pEvent from 'p-event'; +import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js'; export class MPRISSource extends MemorySource { @@ -71,7 +72,7 @@ export class MPRISSource extends MemorySource { actualAlbumArtists = albumArtist; } - return { + const play: PlayObjectLifecycleless = { data: { track: title, album, @@ -91,6 +92,7 @@ export class MPRISSource extends MemorySource { deviceId: name, } } + return baseFormatPlayObj(obj, play); } protected async doCheckConnection(): Promise { diff --git a/src/backend/sources/MopidySource.ts b/src/backend/sources/MopidySource.ts index 48fd6b0a..e5641bad 100644 --- a/src/backend/sources/MopidySource.ts +++ b/src/backend/sources/MopidySource.ts @@ -5,7 +5,7 @@ import Mopidy, { models } from "mopidy"; import normalizeUrl from 'normalize-url'; import pEvent from 'p-event'; import { URL } from "url"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { buildTrackString } from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, @@ -16,6 +16,7 @@ import { import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export class MopidySource extends MemoryPositionalSource { declare config: MopidySourceConfig; @@ -159,7 +160,7 @@ export class MopidySource extends MemoryPositionalSource { artists = performers; } - return { + const play: PlayObjectLifecycleless = { data: { track: name, album: albumName, @@ -177,6 +178,7 @@ export class MopidySource extends MemoryPositionalSource { //deviceId: name, } } + return baseFormatPlayObj({...obj, trackProgressPosition}, play); } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index 04b7756a..44cbb7a2 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -1,7 +1,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { EventEmitter } from "events"; -import { PlayObject, URLData } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -11,6 +11,7 @@ import { import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; import request, { Request, Response } from 'superagent'; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export class MusicCastSource extends MemoryPositionalSource { @@ -146,7 +147,7 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F playback } = obj; - return { + const play: PlayObjectLifecycleless = { data: { artists: artist !== undefined && artist !== '' ? [artist] : [], album: album !== '' ? album : undefined, @@ -161,4 +162,5 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F mediaPlayerVersion: options.version } } + return baseFormatPlayObj(obj, play); } \ No newline at end of file diff --git a/src/backend/sources/MusikcubeSource.ts b/src/backend/sources/MusikcubeSource.ts index 0c19c74b..a51d499e 100644 --- a/src/backend/sources/MusikcubeSource.ts +++ b/src/backend/sources/MusikcubeSource.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import normalizeUrl from 'normalize-url'; import pEvent from 'p-event'; import { URL } from "url"; -import { PlayObject, URLData } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; import { FormatPlayObjectOptions, @@ -24,6 +24,7 @@ import { sleep } from "../utils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { normalizeWSAddress } from "../utils/NetworkUtils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; const CLIENT_STATE = { 0: 'connecting', @@ -188,7 +189,7 @@ export class MusikcubeSource extends MemoryPositionalSource { if(album_artist !== undefined && album_artist !== artist) { albumArtists.push(album_artist); } - return { + const play: PlayObjectLifecycleless = { data: { artists: artists, albumArtists, @@ -204,6 +205,7 @@ export class MusikcubeSource extends MemoryPositionalSource { mediaPlayerVersion: this.version } } + return baseFormatPlayObj(obj, play); } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 35e56d99..771eed72 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -1,5 +1,5 @@ import EventEmitter from "events"; -import { PlayObject, URLData } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { asPlayerStateDataMaybePlay, @@ -28,6 +28,7 @@ import { FixedSizeList } from 'fixed-size-list'; import { SDKValidationError } from '@lukehagar/plexjs/sdk/models/errors/sdkvalidationerror.js'; import { Keyv } from 'cacheable'; import { initMemoryCache } from "../common/Cache.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; const shortDeviceId = truncateStringToLength(10, ''); @@ -368,7 +369,7 @@ export default class PlexApiSource extends MemoryPositionalSource { realArtists.push(artist); } - return { + const play: PlayObjectLifecycleless = { data: { artists: realArtists, albumArtists, @@ -391,6 +392,7 @@ export default class PlexApiSource extends MemoryPositionalSource { trackProgressPosition: viewOffset / 1000, } } + return baseFormatPlayObj(obj, play); } getRecentlyPlayed = async (options = {}) => { diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 4030b04a..7ddf830a 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -23,6 +23,7 @@ import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybe import { isDebugMode, playObjDataMatch, sleep } from "../utils.js"; import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.js"; import dayjs, { Dayjs } from "dayjs"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export interface DeviceState { device: SonosDevice @@ -374,7 +375,7 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions titleStr = Title; } - return { + const play: PlayObjectLifecycleless = { data: { track: titleStr, album: Album, @@ -392,6 +393,7 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions source: 'Sonos' } } + return baseFormatPlayObj({...obj, device: options.device}, play); } export const getInvariantDeviceData = (data: DeviceState): SimpleDeviceState => { diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 529889e1..9562004a 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -2,7 +2,7 @@ import dayjs, { Dayjs } from "dayjs"; import EventEmitter from "events"; import SpotifyWebApi from "spotify-web-api-node"; import request from 'superagent'; -import { BrainzMeta, PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; @@ -34,6 +34,7 @@ import PlayHistoryObject = SpotifyApi.PlayHistoryObject; import TrackObjectFull = SpotifyApi.TrackObjectFull; import UserDevice = SpotifyApi.UserDevice; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; const state = 'random'; @@ -110,9 +111,11 @@ export default class SpotifySource extends MemoryPositionalSource { external_ids: { isrc }, - track_number + track_number, } = track; + delete obj.track.available_markets; + scrobbleTsSOC = SCROBBLE_TS_SOC_END; played_at = dayjs(pa); playDateCompleted = played_at; @@ -151,6 +154,8 @@ export default class SpotifySource extends MemoryPositionalSource { track_number } = item as TrackObjectFull; + delete (obj.item as TrackObjectFull).available_markets; + scrobbleTsSOC = SCROBBLE_TS_SOC_START; played_at = dayjs(timestamp); artists = art; @@ -190,7 +195,7 @@ export default class SpotifySource extends MemoryPositionalSource { } } - const play: PlayObject = { + const play: PlayObjectLifecycleless = { data: { artists: artists.map(x => x.name), albumArtists: actualAlbumArtists.map(x => x.name), @@ -236,7 +241,7 @@ export default class SpotifySource extends MemoryPositionalSource { play.meta.art = {album: imageData.url}; } - return play; + return baseFormatPlayObj(obj, play); } buildSpotifyApi = async () => { @@ -392,7 +397,7 @@ export default class SpotifySource extends MemoryPositionalSource { getPlayHistory = async (options: RecentlyPlayedOptions = {}) => { const {limit = 20} = options; const func = (api: SpotifyWebApi) => api.getMyRecentlyPlayedTracks({ - limit + limit: 3 }); const result = await this.callApi>(func); return result.body.items.map((x: PlayHistoryObject) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index e18308ee..5b50bd28 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -3,7 +3,7 @@ import dayjs from "dayjs"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; import EventEmitter from "events"; import request, { Request } from 'superagent'; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; @@ -16,6 +16,7 @@ import MemorySource from "./MemorySource.js"; import { SubsonicPlayerState } from './PlayerState/SubsonicPlayerState.js'; import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; import { Logger } from '@foxxmd/logging'; +import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js'; dayjs.extend(isSameOrAfter); @@ -74,7 +75,7 @@ export class SubsonicSource extends MemorySource { username, } = obj; - return { + const play: PlayObjectLifecycleless = { data: { artists: [artist], album, @@ -94,6 +95,7 @@ export class SubsonicSource extends MemorySource { mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version } } + return baseFormatPlayObj(obj, play); } callApi = async (req: Request, retries = 0): Promise => { diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 84c206b1..005e9497 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -2,7 +2,7 @@ import { parseRegexSingle, parseToRegex } from "@foxxmd/regex-buddy-core"; import { EventEmitter } from "events"; import * as VLC from "vlc-client" import { VlcMeta, VlcStatus } from "vlc-client/dist/Types.js"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -17,6 +17,7 @@ import { firstNonEmptyStr } from "../utils/StringUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { isDebugMode } from "../utils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; const CLIENT_PLAYER_STATE: Record = { 'playing': REPORTED_PLAYER_STATUSES.playing, @@ -214,7 +215,7 @@ export class VLCSource extends MemoryPositionalSource { state } = vlcState || {}; - return { + const play: PlayObjectLifecycleless = { data: { artists: artists, albumArtists, @@ -229,6 +230,7 @@ export class VLCSource extends MemoryPositionalSource { mediaPlayerVersion: this.vlcVersion } } + return baseFormatPlayObj({...obj, vlcState}, play); } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index b42ce426..e8698cca 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import EventEmitter from "events"; -import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, SOURCE_SOT } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -22,6 +22,7 @@ import MemorySource from "./MemorySource.js"; import { Logger } from "@foxxmd/logging"; import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export class WebScrobblerSource extends MemorySource { @@ -122,7 +123,7 @@ export class WebScrobblerSource extends MemorySource { const albumArtist = processed.albumArtist ?? parsed.albumArtist; const duration = parsed.duration ?? processed.duration; - return { + const play: PlayObjectLifecycleless = { data: { track, artists: [artist], @@ -149,6 +150,7 @@ export class WebScrobblerSource extends MemorySource { nowPlaying: options.nowPlaying ?? false } } + return baseFormatPlayObj(obj, play); } getRecentlyPlayed = async (options = {}) => this.getFlatRecentlyDiscoveredPlays() diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index e771b409..4c0987ff 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -1,6 +1,6 @@ import dayjs, { Dayjs } from "dayjs"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; import { Innertube, UniversalCache, Parser, YTNodes, ApiResponse, IBrowseResponse, Log, SessionOptions } from 'youtubei.js'; @@ -21,6 +21,7 @@ import { buildTrackString, truncateStringToLength } from "../../core/StringUtils import { joinedUrl } from "../utils/NetworkUtils.js"; import { todayAwareFormat } from "../utils/TimeUtils.js"; import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; export interface HistoryIngressResult { plays: PlayObject[], @@ -411,7 +412,7 @@ Redirect URI : ${this.redirectUri}`); const durObj = dayjs.duration(dur.seconds, 's') duration = durObj.asSeconds(); } - return { + const play: PlayObjectLifecycleless = { data: { artists, albumArtists, @@ -429,6 +430,7 @@ Redirect URI : ${this.redirectUri}`); comment: shelf } } + return baseFormatPlayObj(obj, play); } recentlyPlayedTrackIsValid = (playObj: PlayObject) => playObj.meta.newFromSource diff --git a/src/backend/tests/jellyfin/jellyfin.test.ts b/src/backend/tests/jellyfin/jellyfin.test.ts index cdc23520..cb01abac 100644 --- a/src/backend/tests/jellyfin/jellyfin.test.ts +++ b/src/backend/tests/jellyfin/jellyfin.test.ts @@ -16,6 +16,8 @@ import { // @ts-expect-error weird typings? import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js"; import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; +import { MarkOptional } from "ts-essentials"; +import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; const dataAsFixture = (data: any): TestFixture => { return data as TestFixture; @@ -41,7 +43,7 @@ const validPlayerState: PlayerStateDataMaybePlay = { platformId: ['1234', 'MyUser'], play: generatePlay({}, {mediaType: 'Audio', user: 'MyUser', deviceId: '1234'}) } -const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { +const playWithMeta = (meta: MarkOptional): PlayerStateDataMaybePlay => { const {user, deviceId} = meta; const platformId = validPlayerState.platformId; return { @@ -50,6 +52,7 @@ const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { play: { ...validPlayerState.play, meta: { + lifecycle: defaultLifecycle(), ...validPlayerState.play?.meta, ...meta } diff --git a/src/backend/tests/listenbrainz/listenbrainz.test.ts b/src/backend/tests/listenbrainz/listenbrainz.test.ts index 2f27506b..83a8fb49 100644 --- a/src/backend/tests/listenbrainz/listenbrainz.test.ts +++ b/src/backend/tests/listenbrainz/listenbrainz.test.ts @@ -23,6 +23,7 @@ import slightlyDifferentNames from './correctlyMapped/trackNameSlightlyDifferent import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTrackName.json' with { type: "json" }; import veryWrong from './incorrectlyMapped/veryWrong.json' with { type: "json" }; import { generatePlay } from "../utils/PlayTestUtils.js"; +import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; interface LZTestFixture { data: ListenResponse @@ -141,7 +142,9 @@ describe('Listenbrainz Response Behavior', function() { } } }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } try { await client.submitListen(play); diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index 8f00e0e7..c3754065 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -14,6 +14,7 @@ import { MockNetworkError, withRequestInterception } from '../utils/networking.j import { http, HttpResponse, delay } from "msw"; import { generatePlay, withBrainz } from '../utils/PlayTestUtils.js'; import { intersect, missingMbidTypes } from '../../utils.js'; +import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; const envPath = path.join(projectDir, '.env'); dotenv.config({ path: envPath }); @@ -63,7 +64,9 @@ describe('Musicbrainz API', function () { artists: ["Khruangbin"], album: "The Universe Smiles Upon You ii" }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -93,6 +96,7 @@ describe('Musicbrainz API', function () { url: { web: "https://www.last.fm/music/Kanon+Oguni/_/Cyber+Space+(CrossWorlds+Remix):+Final+Lap+-+No+Chants", }, + lifecycle: defaultLifecycle() } }; await mbTransformer.tryInitialize(); @@ -121,7 +125,9 @@ describe('Musicbrainz API', function () { } } }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -148,7 +154,9 @@ describe('Musicbrainz API', function () { album: "Fake", isrc: 'GBAHT1600302' }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -177,7 +185,9 @@ describe('Musicbrainz API', function () { } } }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -209,7 +219,9 @@ describe('Musicbrainz API', function () { } } }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -236,7 +248,9 @@ describe('Musicbrainz API', function () { artists: ["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"], album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions" }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -258,7 +272,9 @@ describe('Musicbrainz API', function () { track: "Undefeatable (feat. Kellin Quinn)", artists: ["SEGA Sound Team / Tomoya Ohtani"], }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -282,7 +298,9 @@ describe('Musicbrainz API', function () { artists: ["、ナイトコードで。"], album: "25時、ナイトコードで。 SEKAI ALBUM Vol.3" }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -305,7 +323,9 @@ describe('Musicbrainz API', function () { artists: ["Leo/need"], album: "Leo / need SEKAI ALBUM Vol.1" }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -333,7 +353,9 @@ describe('Musicbrainz API', function () { album: "PERSONA5 ORIGINAL SOUNDTRACK", isrc: 'JPK651601515' }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await mbTransformer.tryInitialize(); @@ -375,7 +397,9 @@ describe('Musicbrainz API', function () { artists: ["Khruangbin"], album: "The Universe Smiles Upon You ii" }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await multiMb.tryInitialize(); @@ -407,7 +431,9 @@ describe('Musicbrainz API', function () { artists: ["Khruangbin"], album: "The Universe Smiles Upon You ii" }, - meta: {} + meta: { + lifecycle: defaultLifecycle() + } } await multiMb.tryInitialize(); diff --git a/src/backend/tests/plex/plex.test.ts b/src/backend/tests/plex/plex.test.ts index c26e70ce..f2ddb38d 100644 --- a/src/backend/tests/plex/plex.test.ts +++ b/src/backend/tests/plex/plex.test.ts @@ -10,6 +10,8 @@ import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js" import { PlexApiData } from "../../common/infrastructure/config/source/plex.js"; import PlexApiSource from "../../sources/PlexApiSource.js"; import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; +import { MarkOptional } from "ts-essentials"; +import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; const validSession = validSessionResponse.object.mediaContainer.metadata[0]; @@ -33,7 +35,7 @@ const validPlayerState: PlayerStateDataMaybePlay = { platformId: ['1234', 'MyUser'], play: generatePlay({}, {mediaType: 'track', user: 'MyUser', deviceId: '1234', library: 'Music'}) } -const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { +const playWithMeta = (meta: MarkOptional): PlayerStateDataMaybePlay => { const {user, deviceId} = meta; const platformId = validPlayerState.platformId; return { @@ -42,6 +44,7 @@ const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { play: { ...validPlayerState.play, meta: { + lifecycle: defaultLifecycle(), ...validPlayerState.play?.meta, ...meta } diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index f6b4f47b..5dbab82f 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -16,6 +16,7 @@ import MockDate from 'mockdate'; import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js"; import { PlayPlatformId } from '../../common/infrastructure/Atomic.js'; +import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; chai.use(asPromised); @@ -396,7 +397,8 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27) }, meta: { - source: 'Spotify' + source: 'Spotify', + lifecycle: defaultLifecycle() } } diff --git a/src/backend/tests/utils/PlayTestUtils.ts b/src/backend/tests/utils/PlayTestUtils.ts index 54f1682e..ffc0e221 100644 --- a/src/backend/tests/utils/PlayTestUtils.ts +++ b/src/backend/tests/utils/PlayTestUtils.ts @@ -13,6 +13,8 @@ import { findDelimiters } from '../../utils/StringUtils.js'; import { ListRecord, ScrobbleRecord } from '../../common/infrastructure/config/client/tealfm.js'; import { nanoid } from 'nanoid'; import { LastFMTrackObject } from '../../common/vendor/LastfmApiClient.js'; +import { MarkOptional } from 'ts-essentials'; +import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -40,7 +42,7 @@ export const normalizePlays = (plays: PlayObject[], endDate?: Dayjs defaultDuration?: number, defaultData?: ObjectPlayData, - defaultMeta?: PlayMeta + defaultMeta?: MarkOptional } ): PlayObject[] => { const { @@ -80,6 +82,7 @@ export const normalizePlays = (plays: PlayObject[], ...defaultData } cleanPlay.meta = { + lifecycle: defaultLifecycle(), ...cleanPlay.meta, ...defaultMeta } @@ -155,7 +158,7 @@ export const generatePlayerStateData = (options: Omit { +export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional = {}): PlayObject => { return { data: { track: faker.music.songName(), @@ -168,6 +171,13 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): Pl meta: { source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})], ...meta, + lifecycle: { + original: { + data: {}, + meta: {} + }, + steps: [] + } } } } @@ -221,7 +231,7 @@ export const generatePlayPlatformId = (deviceId?: string, userId?: string): Play return [did, uid]; } -export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: PlayMeta = {}): PlayObject[] => { +export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional = {}): PlayObject[] => { return Array.from(Array(numberOfPlays), () => generatePlay(data, meta)); } diff --git a/src/backend/utils/DataUtils.ts b/src/backend/utils/DataUtils.ts index 3c352bc5..487fc382 100644 --- a/src/backend/utils/DataUtils.ts +++ b/src/backend/utils/DataUtils.ts @@ -1,6 +1,14 @@ import JSON5 from "json5"; import { constants, promises } from "fs"; import { MaybeLogger } from "../common/logging.js"; +import { create as diffCreate } from 'jsondiffpatch'; + +export const jdiff = diffCreate({ + propertyFilter(name, context) { + return name !== 'lifecycle'; + }, + //omitRemovedValues: true +}); export const asArray = (data: T | T[]): T[] => { if (Array.isArray(data)) { diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index af733a74..b97baf60 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -1,5 +1,5 @@ import { getListDiff, ListDiff } from "@donedeal0/superdiff"; -import { PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; +import { PlayObject, PlayObjectLifecycleless, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js"; import { buildTrackString } from "../../core/StringUtils.js"; import { playObjDataMatch, setIntersection } from "../utils.js"; import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js"; @@ -8,7 +8,7 @@ import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js" import { StringSamenessResult } from "@foxxmd/string-sameness"; -export const metaInvariantTransform = (play: PlayObject): PlayObject => { +export const metaInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { const { meta: { trackId @@ -37,7 +37,7 @@ export const playDateInvariantTransform = (play: PlayObject): PlayObject => { } } -export const playContentInvariantTransform = (play: PlayObject): PlayObject => { +export const playContentInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => { const { data: { playDate, @@ -54,7 +54,7 @@ export const playContentInvariantTransform = (play: PlayObject): PlayObject => { } -export type PlayTransformer = (play: PlayObject) => PlayObject; +export type PlayTransformer = (play: PlayObject) => PlayObjectLifecycleless; export type ListTransformers = PlayTransformer[]; export const defaultListTransformers: ListTransformers = [metaInvariantTransform, playDateInvariantTransform]; diff --git a/src/backend/utils/PlayTransformUtils.ts b/src/backend/utils/PlayTransformUtils.ts index 1d01c01b..a9c22960 100644 --- a/src/backend/utils/PlayTransformUtils.ts +++ b/src/backend/utils/PlayTransformUtils.ts @@ -1,6 +1,6 @@ import { Logger, loggerTest } from "@foxxmd/logging"; import { searchAndReplace as searchAndReplaceFunc, testMaybeRegex as testMaybeRegexFunc } from "@foxxmd/regex-buddy-core"; -import { ObjectPlayData, PlayObject } from "../../core/Atomic.js"; +import { ObjectPlayData, PlayLifecycle, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { buildTrackString } from "../../core/StringUtils.js"; import { @@ -171,4 +171,33 @@ export interface SuppliedRegex { export interface TransformPlayPartsOptions { logger?: () => Logger, regex?: SuppliedRegex +} + +export const baseFormatPlayObj = (data: any, play: PlayObjectLifecycleless): PlayObject => { + return { + data: { + ...play.data + }, + meta: { + ...play.meta, + lifecycle: { + input: data, + original: play, + steps: [] + } + } + } +} + +export const defaultLifecycle = (extra?: PlayLifecycle): PlayLifecycle => { + const { + original = {data: {}, meta: {}}, + steps = [], + ...rest + } = extra ?? {}; + return { + original, + steps, + ...rest, + } } \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 9ced3ccc..511ab119 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -2,6 +2,8 @@ import { LogDataPretty, LogLevel } from "@foxxmd/logging"; import { Dayjs } from "dayjs"; import { ListenProgress } from "../backend/sources/PlayerState/ListenProgress.js"; import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; +import { Delta } from 'jsondiffpatch'; +import { MarkOptional } from "ts-essentials"; export interface SourceStatusData { status: string; @@ -262,9 +264,39 @@ export interface PlayMeta { comment?: string + lifecycle: PlayLifecycle + lifecycleInputs?: LifecycleInput[] + [key: string]: any } +export interface LifecycleInput { + type: string, input: (object | string) +} + +export interface PlayObjectLifecycleless { + data: ObjectPlayData, + meta: MarkOptional +} + +// export interface PlayMetaLifecycled extends PlayMeta { +// lifecycle: PlayLifecycle +// } + +export interface PlayLifecycle { + input?: object + original: PlayObjectLifecycleless + steps: LifecycleStep[] + scrobble?: object +} + +export interface LifecycleStep { + name: string + source: string + patch?: Delta + inputs?: LifecycleInput[] +} + export type ScrobbleTsSOC = 1 | 2; export const SCROBBLE_TS_SOC_START: ScrobbleTsSOC = 1; @@ -283,6 +315,10 @@ export interface PlayObject extends AmbPlayObject { data: ObjectPlayData, } +// export interface PlayObjectLifecycled extends PlayObject { +// meta: PlayMetaLifecycled +// } + export interface JsonPlayObject extends AmbPlayObject { data: JsonPlayData } -- 2.51.2