From aca07b63f9bcb86c4f5e601ef8fae24e102aeed2 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sun, 4 Oct 2026 02:04:35 +0000 Subject: [PATCH] refactor: TrackData now uses Credits for all relevant properties --- src/backend/common/AbstractComponent.ts | 4 +- .../database/appMigrations/005_creditShape.ts | 366 ++++++++++++++++++ .../drizzle/repositories/PlayRepository.ts | 4 +- .../common/transforms/AbstractTransformer.ts | 4 +- .../transforms/AtomicPartsTransformer.ts | 94 +++-- .../transforms/MusicbrainzTransformer.ts | 66 ++-- .../common/transforms/NativeTransformer.ts | 26 +- .../common/transforms/SpotifyTransformer.ts | 81 ++-- .../common/transforms/UserTransformer.ts | 18 +- .../CoverArtArchiveTransformer.ts | 24 +- .../CoverArtArchiveTransformerUtil.ts | 6 +- .../transforms/rocksky/RockskyTransformer.ts | 112 +++--- src/backend/common/vendor/KodiApiClient.ts | 10 +- src/backend/common/vendor/LastfmApiClient.ts | 85 ++-- .../common/vendor/ListenbrainzApiClient.ts | 69 ++-- src/backend/common/vendor/RockSkyApiClient.ts | 99 ++--- .../chromecast/ChromecastClientUtils.ts | 3 +- .../vendor/discord/DiscordAbstractClient.ts | 13 +- .../common/vendor/discord/DiscordUtils.ts | 15 +- .../common/vendor/koito/KoitoApiClient.ts | 14 +- .../common/vendor/listenbrainz/lzUtils.ts | 64 +-- .../common/vendor/maloja/MalojaApiClient.ts | 20 +- .../musicbrainz/MusicbrainzApiClientPool.ts | 57 ++- .../common/vendor/spotify/SpotifyApiClient.ts | 25 +- .../common/vendor/teal/TealApiClient.ts | 33 +- src/backend/scrobblers/LastfmScrobbler.ts | 2 +- src/backend/sources/AppleMusicSource.ts | 8 +- src/backend/sources/AzuracastSource.ts | 8 +- src/backend/sources/ChromecastSource.ts | 10 +- src/backend/sources/DeezerInternalSource.ts | 13 +- src/backend/sources/DeezerSource.ts | 7 +- src/backend/sources/ExampleSource.ts | 14 +- src/backend/sources/IcecastSource.ts | 6 +- src/backend/sources/JRiverSource.ts | 8 +- src/backend/sources/JellyfinApiSource.ts | 63 +-- src/backend/sources/MPDSource.ts | 34 +- src/backend/sources/MPRISSource.ts | 10 +- src/backend/sources/MopidySource.ts | 10 +- src/backend/sources/MusicCastSource.ts | 8 +- src/backend/sources/MusikcubeSource.ts | 10 +- src/backend/sources/PlexApiSource.ts | 49 +-- src/backend/sources/SonosSource.ts | 16 +- src/backend/sources/SpotifySource.ts | 23 +- src/backend/sources/SubsonicSource.ts | 8 +- src/backend/sources/VLCSource.ts | 10 +- src/backend/sources/WebScrobblerSource.ts | 16 +- src/backend/sources/YTMusicSource.ts | 10 +- .../sources/YandexMusicBridgeSource.ts | 26 +- .../tests/applemusic/applemusic.test.ts | 12 +- .../tests/component/transformers.test.ts | 66 ++-- .../tests/database/creditMigration.test.ts | 212 ++++++++++ src/backend/tests/database/drizzle.test.ts | 2 +- .../tests/database/playRepository.test.ts | 31 +- .../database/testAppMigrations/001_testFoo.ts | 3 +- src/backend/tests/jellyfin/jellyfin.test.ts | 15 +- src/backend/tests/lastfm/lastfm.test.ts | 21 +- .../tests/listenbrainz/listenbrainz.test.ts | 90 ++--- .../tests/musicbrainz/musicbrainz.test.ts | 142 +++---- src/backend/tests/musiccast/musiccast.test.ts | 8 +- src/backend/tests/player/player.test.ts | 3 +- src/backend/tests/plays/mixedDuration.json | 24 +- src/backend/tests/plays/withDuration.json | 20 +- src/backend/tests/rocksky/rocksky.test.ts | 16 +- .../tests/scrobbler/scrobblers.test.ts | 60 +-- src/backend/tests/source/source.test.ts | 38 +- src/backend/tests/spotify/spotify.test.ts | 2 +- .../tests/spotify/spotifyTransformer.test.ts | 47 ++- src/backend/tests/subsonic/subsonic.test.ts | 2 +- src/backend/tests/tealfm/tealfm.test.ts | 36 +- src/backend/tests/utils/invariants.test.ts | 95 +++++ .../tests/utils/playComparisons.test.ts | 20 +- src/backend/tests/ytm/ytm.test.ts | 4 +- src/backend/utils.ts | 55 +-- src/backend/utils/PlayComparisonUtils.ts | 49 +-- src/backend/utils/PlayTransformUtils.ts | 19 +- src/backend/utils/StringUtils.ts | 6 +- src/client/components/ActivityDetail.tsx | 2 +- src/client/components/ArtistCreditDisplay.tsx | 11 +- src/client/components/PlayData.tsx | 11 +- src/client/components/TransformSteps.tsx | 12 - src/client/components/chakraPlayer/Player.tsx | 19 +- .../musicServices/MusicServiceIndicators.tsx | 37 -- src/client/components/play/AlbumSearch.tsx | 73 +--- src/client/components/play/ArtistSearch.tsx | 60 +-- src/client/components/play/PlayEdit.tsx | 28 +- src/client/components/play/TrackSearch.tsx | 118 ++---- .../components/playActivity/ActivityList.tsx | 2 +- src/core/Api.ts | 18 +- src/core/Atomic.ts | 108 +----- src/core/MusicMetadata.ts | 138 ++++++- src/core/StringUtils.ts | 86 ++-- src/core/tests/utils/PlayTestUtils.ts | 58 +-- src/core/tests/utils/apiFixtures.ts | 35 +- src/core/tests/utils/fixtures.ts | 43 +- src/stories/plays/ActivityList.stories.tsx | 2 +- src/stories/plays/PlayInfo.stories.tsx | 2 +- 96 files changed, 1952 insertions(+), 1690 deletions(-) create mode 100644 src/backend/common/database/appMigrations/005_creditShape.ts create mode 100644 src/backend/tests/database/creditMigration.test.ts create mode 100644 src/backend/tests/utils/invariants.test.ts diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 090246e3..63dc9d22 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -22,7 +22,7 @@ import { nanoid } from "nanoid"; import { isDebugMode, sleep } from "../utils.ts"; import { findCauseByFunc, findCauseByReference } from "../utils/ErrorUtils.ts"; import { hashObject, normalizeStr, parseArrayFromMaybeString } from "../utils/StringUtils.ts"; -import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.ts"; +import { playContentCacheHash } from "../utils/PlayComparisonUtils.ts"; import type { MSCache } from "./Cache.ts"; import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.ts"; import clone from "clone"; @@ -368,7 +368,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode(); - const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`; + const transformHash = `playTransform-${hashObject(hook)}-${playContentCacheHash(play)}`; const cachedSteps = useCachedResult ? await this.cache.cacheTransform.get(transformHash) : undefined; if(cachedSteps !== undefined) { logger.trace(`Cache hit for Steps => ${transformHash}`); diff --git a/src/backend/common/database/appMigrations/005_creditShape.ts b/src/backend/common/database/appMigrations/005_creditShape.ts new file mode 100644 index 00000000..e5a74efd --- /dev/null +++ b/src/backend/common/database/appMigrations/005_creditShape.ts @@ -0,0 +1,366 @@ +import type { SqliteDatabase, Migration } from 'sqlite-up'; +import type { MigrateBaseContext } from '../appMigrator.ts'; +import { playEvents, playInputs, plays as drizzlePlays, playsHistorical } from '../drizzle/schema/schema.ts'; +import { asc, eq } from 'drizzle-orm'; +import { PLAY_EVENT_TYPE } from '../../../../core/PlayEvent.ts'; +import type { Credit, LifecycleStep, PlayObject } from '../../../../core/Atomic.ts'; +import { creditsWithIds, mbMeta, spotifyMeta, withAlbumArt, withImage, withMetadata } from '../../../../core/MusicMetadata.ts'; +import { nameToCredit } from '../../../../core/StringUtils.ts'; +import { diffObjects, patchObject } from '../../../../core/DataUtils.ts'; +import { playContentBasicInvariantTransform, playMbidIdentifier } from '../../../utils/PlayComparisonUtils.ts'; +import { hashObject } from '../../../utils/StringUtils.ts'; + +/** + * Converts Plays stored before track/album/artists were Credit objects. + * + * Previously these were plain strings (or name + mbid for artists) with external ids stored separately in data.meta.brainz/spotify + * and art stored in meta.art. This migration folds ids and art into the Credit they belong to. + * + * This file is the only place that still knows about the legacy shape. + */ + +interface LegacyArtist { + name: string + mbid?: string + spotifyId?: string +} + +interface LegacyArt { + album?: string + track?: string + artist?: string +} + +interface LegacyData { + track?: string | Credit + album?: string | Credit + artists?: (string | LegacyArtist | Credit)[] + albumArtists?: (string | LegacyArtist | Credit)[] + meta?: { + brainz?: { + artist?: string[] + albumArtist?: string[] + album?: string + recording?: string + releaseGroup?: string + track?: string + trackNumber?: number + additionalInfo?: object + } + spotify?: { + artist?: string[] + albumArtist?: string[] + album?: string + track?: string + } + } + [key: string]: unknown +} + +const LEGACY_BRAINZ_ID_KEYS = ['artist', 'albumArtist', 'album', 'recording', 'releaseGroup', 'track']; + +const isLegacyArtist = (x: unknown) => typeof x === 'string' || (x !== null && typeof x === 'object' && ('mbid' in x || 'spotifyId' in x)); + +export const isLegacyData = (data: LegacyData = {}): boolean => + typeof data.track === 'string' + || typeof data.album === 'string' + || (data.artists ?? []).some(isLegacyArtist) + || (data.albumArtists ?? []).some(isLegacyArtist) + || data.meta?.spotify !== undefined + || LEGACY_BRAINZ_ID_KEYS.some(x => x in (data.meta?.brainz ?? {})); + +const legacyArtistsToCredits = (artists: LegacyData['artists'], mbids?: string[], spotifyIds?: string[]): Credit[] | undefined => { + if (artists === undefined) { + return undefined; + } + // ids from the parallel id lists are only used if they line up with artists + const named = creditsWithIds( + creditsWithIds(artists.map(x => nameToCredit(typeof x === 'string' ? x : x.name)).filter(x => x !== undefined), mbids, 'musicbrainz', 'artist'), + spotifyIds, 'spotify', 'artist'); + // ids that were stored on the artist itself are always correct + return named.map((x, i) => { + const legacy = artists[i]; + if (typeof legacy === 'string') { + return x; + } + const { metadata = [], image } = legacy as Credit; + const { mbid, spotifyId } = legacy as LegacyArtist; + return withImage(withMetadata(x, ...metadata, mbMeta(mbid, 'artist'), spotifyMeta(spotifyId, 'artist')), image); + }); +} + +const legacyToCredit = (val: string | Credit | undefined): Credit | undefined => typeof val === 'string' ? nameToCredit(val) : val; + +/** Convert legacy play data (and art from play meta) to data using Credits. Data that is already converted is returned as-is. */ +export const legacyDataToCredits = (data: T, art: LegacyArt = {}): PlayObject['data'] => { + if (!isLegacyData(data) && Object.keys(art).length === 0) { + return data as PlayObject['data']; + } + const { + track, + album, + artists, + albumArtists, + meta: { + brainz: { + artist: mbArtists, + albumArtist: mbAlbumArtists, + album: mbRelease, + recording: mbRecording, + releaseGroup: mbReleaseGroup, + track: mbTrack, + ...brainzRest + } = {}, + spotify = {}, + ...metaRest + } = {}, + ...rest + } = data; + + const artistCredits = legacyArtistsToCredits(artists, mbArtists, spotify.artist); + if (artistCredits !== undefined && artistCredits.length > 0) { + artistCredits[0] = withImage(artistCredits[0], art.artist); + } + + const converted: Record = { + ...rest, + ...withAlbumArt({ + track: withImage(withMetadata(legacyToCredit(track), mbMeta(mbTrack, 'track'), mbMeta(mbRecording, 'recording'), spotifyMeta(spotify.track, 'track')), art.track), + album: withMetadata(legacyToCredit(album), mbMeta(mbRelease, 'release'), mbMeta(mbReleaseGroup, 'release-group'), spotifyMeta(spotify.album, 'album')), + }, art.album), + artists: artistCredits, + albumArtists: legacyArtistsToCredits(albumArtists, mbAlbumArtists, spotify.albumArtist), + }; + + const meta: Record = { ...metaRest }; + if (Object.keys(brainzRest).length > 0) { + meta.brainz = brainzRest; + } + if (Object.keys(meta).length > 0) { + converted.meta = meta; + } + + for (const k of Object.keys(converted)) { + if (converted[k] === undefined) { + delete converted[k]; + } + } + return converted as PlayObject['data']; +} + +type LegacyPlay = { data?: LegacyData | PlayObject['data'], meta?: { art?: LegacyArt, [key: string]: unknown }, [key: string]: any }; + +/** Convert a legacy play, and any plays embedded in it, to use Credits. Does not convert lifecycle patches, see `rebuildLifecycle` */ +export const legacyPlayToCredits = (play: T): T => { + if (play === undefined || play === null || play.data === undefined) { + return play; + } + const { art, ...meta } = play.meta ?? {}; + const converted: LegacyPlay = { + ...play, + data: legacyDataToCredits(play.data as LegacyData, art), + meta + }; + if (converted.original?.play !== undefined) { + converted.original = { ...converted.original, play: legacyPlayToCredits(converted.original.play) }; + } + if (converted.scrobble !== undefined) { + converted.scrobble = legacyScrobbleResultToCredits(converted.scrobble); + } + return converted as T; +} + +const legacyMatchResultToCredits = >(match: T): T => { + const converted: Record = { ...match }; + for (const key of ['closestMatchedPlay', 'transformedPlay']) { + if (converted[key] !== undefined && converted[key] !== null) { + converted[key] = legacyPlayToCredits(converted[key]); + } + } + return converted as T; +} + +const legacyScrobbleResultToCredits = >(scrobble: T): T => { + const converted: Record = { ...scrobble }; + if (converted.mergedScrobble !== undefined && converted.mergedScrobble !== null) { + converted.mergedScrobble = legacyPlayToCredits(converted.mergedScrobble); + } + if (converted.match !== undefined && converted.match !== null) { + converted.match = legacyMatchResultToCredits(converted.match); + } + return converted as T; +} + +export const isLegacyPlay = (play: LegacyPlay): boolean => isLegacyData(play.data as LegacyData) || play.meta?.art !== undefined; + +const jsonClone = (val: T): T => JSON.parse(JSON.stringify(val)); + +/** + * Lifecycle steps (stored as transform events) store their changes as a patch against the play data from the previous step, starting with the Play input. + * These patches were generated using the legacy shape so they need to be regenerated. + * + * Replay patches using legacy data, convert each intermediate state, and then diff the converted states to get the new patches. + * + * If patches cannot be replayed (no input data, or a patch fails) they are removed as they cannot be applied to converted data. + */ +export const rebuildLifecycle = (steps: LifecycleStep[], input: LegacyPlay | undefined, finalArt?: LegacyArt): LifecycleStep[] => { + const lastPatchIndex = steps.findLastIndex(x => x.patch !== undefined); + if (lastPatchIndex === -1) { + return steps; + } + let legacyState: LegacyData | undefined = input?.data !== undefined ? jsonClone(input.data as LegacyData) : undefined; + const inputArt = input?.meta?.art; + + return steps.map((step, index) => { + if (step.patch === undefined) { + return step; + } + const { patch, ...rest } = step; + if (legacyState === undefined) { + return rest; + } + try { + const legacyNext: LegacyData = patchObject(legacyState, patch); + // art was not part of patches so we only know what it was for the input and what it ended up as + const newPatch = diffObjects( + jsonClone(legacyDataToCredits(legacyState, inputArt)), + jsonClone(legacyDataToCredits(legacyNext, index === lastPatchIndex ? (finalArt ?? inputArt) : inputArt)) + ); + legacyState = legacyNext; + return newPatch.length > 0 ? { ...rest, patch: newPatch } : rest; + } catch (e) { + // can't replay any patches after this one + legacyState = undefined; + return rest; + } + }); +} + +export const up: Migration['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext | undefined): Promise => { + if(ctx === undefined) { + throw new Error('Context must be defined'); + } + ctx.logger.info('Converting Plays to use Credits for track/album/artists. This may take some time...'); + + let more = true; + let offset = 0, + processed = 0, + updated = 0; + + // Plays must be converted before Inputs because we need legacy Input data to regenerate lifecycle patches + while (more) { + const playsRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset); + for (const row of playsRows) { + processed++; + try { + const legacyPlay = row.play as unknown as LegacyPlay; + const play = legacyPlayToCredits(legacyPlay) as unknown as PlayObject; + if(!isLegacyPlay(legacyPlay)) { + // already converted, patches and events for this play are also already converted + continue; + } + const [input] = await ctx.db.select().from(playInputs).where(eq(playInputs.playId, row.id)).limit(1); + const legacyInput = input?.play as unknown as LegacyPlay | undefined; + if(play.lifecycle !== undefined && play.lifecycle.length > 0) { + play.lifecycle = rebuildLifecycle(play.lifecycle, legacyInput, legacyPlay.meta?.art); + } + + // Lifecycle steps, and results that include plays, are also stored as events + const events = await ctx.db.select().from(playEvents).where(eq(playEvents.playId, row.id)).orderBy(asc(playEvents.id)); + + // steps from all transform events are one continuous chain of patches starting from input + const transformEvents = events.filter(x => x.eventName === PLAY_EVENT_TYPE.transform && Array.isArray(x.data)); + const rebuiltSteps = rebuildLifecycle(transformEvents.flatMap(x => x.data as LifecycleStep[]), legacyInput, legacyPlay.meta?.art); + for(const ev of transformEvents) { + const data = rebuiltSteps.splice(0, (ev.data as LifecycleStep[]).length); + await ctx.db.update(playEvents).set({ data }).where(eq(playEvents.id, ev.id)); + } + for(const ev of events) { + if(ev.data === null || typeof ev.data !== 'object') { + continue; + } + if(ev.eventName === PLAY_EVENT_TYPE.dupeCheck) { + await ctx.db.update(playEvents).set({ data: legacyMatchResultToCredits(ev.data) }).where(eq(playEvents.id, ev.id)); + } else if(ev.eventName === PLAY_EVENT_TYPE.scrobbleResult) { + await ctx.db.update(playEvents).set({ data: legacyScrobbleResultToCredits(ev.data) }).where(eq(playEvents.id, ev.id)); + } + } + await ctx.db.update(drizzlePlays).set({ + play, + playHash: hashObject(playContentBasicInvariantTransform(play).data), + mbidIdentifier: playMbidIdentifier(play) ?? null + }).where(eq(drizzlePlays.id, row.id)); + updated++; + } catch (e) { + ctx.logger.warn(new Error(`Failed to convert Play ${row.id} (${row.uid})`, { cause: e })); + } + } + offset += 100; + ctx.logger.verbose(`Play Conversion Progress: Processed ${processed} | Updated ${updated}`); + if (playsRows.length < 100) { + more = false; + } + } + + ctx.logger.info('Converting Play Inputs...'); + offset = 0; + processed = 0; + updated = 0; + more = true; + while (more) { + const inputRows = await ctx.db.select().from(playInputs).limit(100).offset(offset); + for (const row of inputRows) { + processed++; + try { + const play = legacyPlayToCredits(row.play as unknown as LegacyPlay) as unknown as PlayObject; + await ctx.db.update(playInputs).set({ + play, + playHash: hashObject(playContentBasicInvariantTransform(play).data) + }).where(eq(playInputs.id, row.id)); + updated++; + } catch (e) { + ctx.logger.warn(new Error(`Failed to convert Play Input ${row.id}`, { cause: e })); + } + } + offset += 100; + ctx.logger.verbose(`Play Input Conversion Progress: Processed ${processed} | Updated ${updated}`); + if (inputRows.length < 100) { + more = false; + } + } + + ctx.logger.info('Converting Historical Plays...'); + offset = 0; + processed = 0; + updated = 0; + more = true; + while (more) { + const historicalRows = await ctx.db.select().from(playsHistorical).limit(100).offset(offset); + for (const row of historicalRows) { + processed++; + try { + const play = legacyPlayToCredits(row.play as unknown as LegacyPlay) as unknown as PlayObject; + await ctx.db.update(playsHistorical).set({ + play, + playHash: hashObject(playContentBasicInvariantTransform(play).data), + mbidIdentifier: playMbidIdentifier(play) ?? null + }).where(eq(playsHistorical.id, row.id)); + updated++; + } catch (e) { + ctx.logger.warn(new Error(`Failed to convert Historical Play ${row.id} (${row.uid})`, { cause: e })); + } + } + offset += 100; + ctx.logger.verbose(`Historical Play Conversion Progress: Processed ${processed} | Updated ${updated}`); + if (historicalRows.length < 100) { + more = false; + } + } + + ctx.logger.info('Done.'); +}; + +export const down: Migration['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext | undefined): Promise => { + // Rollback code here + // context is passed as ctx +}; diff --git a/src/backend/common/database/drizzle/repositories/PlayRepository.ts b/src/backend/common/database/drizzle/repositories/PlayRepository.ts index 91f4d46d..7a4ccb9c 100644 --- a/src/backend/common/database/drizzle/repositories/PlayRepository.ts +++ b/src/backend/common/database/drizzle/repositories/PlayRepository.ts @@ -1111,9 +1111,9 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => { ]; const textWhere: typeof where.RAW[] = []; for(const t of args.text) { - textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.track')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`) + textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.track.name')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`) textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.artists')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`) - textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.album')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`) + textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.album.name')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`) } where.AND = [ { diff --git a/src/backend/common/transforms/AbstractTransformer.ts b/src/backend/common/transforms/AbstractTransformer.ts index 0e7f1979..0e50a2e7 100644 --- a/src/backend/common/transforms/AbstractTransformer.ts +++ b/src/backend/common/transforms/AbstractTransformer.ts @@ -7,7 +7,7 @@ import type { cacheFunctions} from "@foxxmd/regex-buddy-core"; import { parseToRegexOrLiteralSearch, testMaybeRegex, searchAndReplace} from "@foxxmd/regex-buddy-core"; import type { Cacheable } from "cacheable"; import { hashObject } from "../../utils/StringUtils.ts"; -import { playContentInvariantTransform } from "../../utils/PlayComparisonUtils.ts"; +import { playContentCacheHash } from "../../utils/PlayComparisonUtils.ts"; import { SkipTransformStageError, StagePrerequisiteError } from "../errors/MSErrors.ts"; import { capitalize } from "../../../core/StringUtils.ts"; import type { StaggerOptions } from "../../utils/AsyncUtils.ts"; @@ -65,7 +65,7 @@ export default abstract class AbstractTransformer(cacheKey) : undefined; if(cachedTransformData !== undefined) { diff --git a/src/backend/common/transforms/AtomicPartsTransformer.ts b/src/backend/common/transforms/AtomicPartsTransformer.ts index 72e81848..461a2859 100644 --- a/src/backend/common/transforms/AtomicPartsTransformer.ts +++ b/src/backend/common/transforms/AtomicPartsTransformer.ts @@ -1,9 +1,32 @@ -import { type ArtistCredit, type ArtMeta, isPlayObject, type ObjectPlayData, type PlayObject, type TrackMetaIsrc } from "../../../core/Atomic.ts"; +import { type Credit, isPlayObject, type ObjectPlayData, type PlayObject, type TrackData, type TrackMeta, type TrackMetaIsrc } from "../../../core/Atomic.ts"; import type {AtomicStageConfig, StageConfig} from "../../../core/Transform.ts"; import AbstractTransformer from "./AbstractTransformer.ts"; +import { mergeCredit, mergeCredits, mergeCreditsMetadata, withImage, withMetadata } from "../../../core/MusicMetadata.ts"; + +/** + * Non-credit data to apply to a play + * + * `credits` are used only for their metadata (service ids) which is added to the corresponding credits of the play, names are not changed + */ +export interface MetaParts extends TrackMetaIsrc { + credits?: Pick +} + +/** Image urls to apply to the corresponding credits of a play */ +export interface ArtParts { + album?: string + track?: string + artist?: string +} //export type GenericAtomicStageConfig = +export const artFromCredits = (data: Pick): ArtParts => ({ + track: data.track?.image, + album: data.album?.image, + artist: data.artists?.[0]?.image +}); + export default abstract class AtomicPartsTransformer = StageConfig> extends AbstractTransformer { protected async doHandle(parts: Z, play: PlayObject, transformData: T): Promise { @@ -17,9 +40,9 @@ export default abstract class AtomicPartsTransformer; for (const [k, v] of Object.entries(metaSources)) { @@ -115,24 +140,12 @@ export default abstract class AtomicPartsTransformer 0) { + transformedPlay.data.artists = [withImage(artists[0], art.artist), ...artists.slice(1)]; + } } const transformInputs = typeof transformData === 'object' && isPlayObject(transformData as object) ? (transformData as PlayObject).meta?.lifecycleInputs : undefined; @@ -169,19 +199,19 @@ export default abstract class AtomicPartsTransformer; - protected abstract handleArtists(play: PlayObject, parts: Y, transformData: T): Promise; - protected abstract handleAlbumArtists(play: PlayObject, parts: Y, transformData: T): Promise; - protected abstract handleAlbum(play: PlayObject, parts: Y, transformData: T): Promise; + protected abstract handleTitle(play: PlayObject, parts: Y, transformData: T): Promise; + protected abstract handleArtists(play: PlayObject, parts: Y, transformData: T): Promise; + protected abstract handleAlbumArtists(play: PlayObject, parts: Y, transformData: T): Promise; + protected abstract handleAlbum(play: PlayObject, parts: Y, transformData: T): Promise; protected async handleDuration(play: PlayObject, parts: Y, transformData: T): Promise { return play.data.duration; } - protected async handleMeta(play: PlayObject, parts: Y, transformData: T): Promise { + protected async handleMeta(play: PlayObject, parts: Y, transformData: T): Promise { return undefined; } - protected async handleArt(play: PlayObject, parts: Y, transformData: T): Promise { + protected async handleArt(play: PlayObject, parts: Y, transformData: T): Promise { return undefined; } diff --git a/src/backend/common/transforms/MusicbrainzTransformer.ts b/src/backend/common/transforms/MusicbrainzTransformer.ts index 5272a197..90fac4f7 100644 --- a/src/backend/common/transforms/MusicbrainzTransformer.ts +++ b/src/backend/common/transforms/MusicbrainzTransformer.ts @@ -1,4 +1,4 @@ -import { type ArtistCredit, DEFAULT_MISSING_TYPES, type LifecycleInput, type MissingMbidType, type OptionalCacheUsage, type PlayObject, type TrackMetaIsrc } from "../../../core/Atomic.ts"; +import { type Credit, DEFAULT_MISSING_TYPES, type LifecycleInput, type MissingMbidType, type OptionalCacheUsage, type PlayObject } from "../../../core/Atomic.ts"; import { MB_RELEASE_GROUP_SECONDARY_TYPES, mBReleaseSecondaryGroupTypesSchema } from "../vendor/musicbrainz/MusicbrainzTypes.ts"; import { type MBReleaseGroupSecondaryType } from "../vendor/musicbrainz/MusicbrainzTypes.ts"; import { type MBReleaseGroupPrimaryType } from "../vendor/musicbrainz/MusicbrainzTypes.ts"; @@ -8,7 +8,8 @@ import { mBReleaseStatusesSchema } from "../vendor/musicbrainz/MusicbrainzTypes. import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts"; import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts"; import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../core/Transform.ts"; -import AtomicPartsTransformer from "./AtomicPartsTransformer.ts"; +import AtomicPartsTransformer, { type MetaParts } from "./AtomicPartsTransformer.ts"; +import { creditIds, creditMbid, stripCredit } from "../../../core/MusicMetadata.ts"; import type {TransformerOptions} from "./AbstractTransformer.ts"; import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../infrastructure/Atomic.ts"; import { DELIMITERS } from '../../../core/Atomic.ts'; @@ -155,8 +156,7 @@ export const parseStageConfig = (data: MusicbrainzTransformerData | undefined = releaseCountryDeny: maybeStringLowerArrayFromString.parse(releaseCountryDeny), releaseCountryPriority: maybeStringLowerArrayFromString.parse(releaseCountryPriority), allowMusicVideo, - ...rest, - }; + ...rest }; if(searchWhenMissing !== undefined) { config.searchWhenMissing = searchWhenMissing.map(asMissingMbid); @@ -319,11 +319,11 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer ${parts.join(' | ')}`); @@ -403,23 +403,17 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { const using: UsingTypes[] = []; - const { - data: { - meta: { - brainz = {} - } = {} - } = {} - } = play; - - if(brainz.recording !== undefined) { + const {track, album, artists} = play.data; + + if(creditMbid(track, 'recording') !== undefined) { using.push('mbidrecording'); - } else if(brainz.track !== undefined) { + } else if(creditMbid(track, 'track') !== undefined) { using.push('mbidtrack'); } else { using.push('title'); } - using.push(brainz.album !== undefined ? 'mbidrelease' : 'album'); - using.push((brainz.artist ?? []).length > 0 ? 'mbidartist' : 'artist'); + using.push(creditMbid(album, 'release') !== undefined ? 'mbidrelease' : 'album'); + using.push(creditIds(artists, 'musicbrainz', 'artist').length > 0 ? 'mbidartist' : 'artist'); this.logger.debug({labels: ['Basic Or MBID Search']}, `Searching using ${using.join(', ')}}`); return await this.api.searchByRecording(play, {using, ...opts}); @@ -434,7 +428,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { - if(play.data.meta?.brainz?.recording !== undefined) { + if(creditMbid(play.data.track, 'recording') !== undefined) { this.logger.debug({labels: ['MBID Search']},'Searching with Recording MBID'); return await this.api.searchByRecording(play, {using: ['mbidrecording'], ...opts}); } @@ -460,8 +454,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { const { - searchArtistMethod = this.defaults.searchArtistMethod, - } = stageConfig; + searchArtistMethod = this.defaults.searchArtistMethod } = stageConfig; if(play.data.artists === undefined) { throw new SearchPrerequisiteError('Play does not have any artists'); } @@ -572,7 +565,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.track; } @@ -585,9 +578,10 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.artists; } @@ -602,7 +596,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.albumArtists; } @@ -616,7 +610,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.album; } @@ -629,7 +623,8 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { if (parts === false || transformData.data.duration === undefined) { @@ -647,7 +642,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.meta; } @@ -659,7 +654,8 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer({...transformData.data.meta, isrc: transformData.data.isrc}); + const {track, album, artists, albumArtists, meta, isrc} = transformData.data; + return removeUndefinedKeys({...meta, isrc, credits: {track, album, artists, albumArtists}}); } public async notify(payload: WebhookPayload): Promise { @@ -768,7 +764,7 @@ export const filterByValidReleaseCountry = (list: IRecordingMatch[], stageConfig } export const filterByExplicitTrackMbid = (list: IRecordingMatch[], play: PlayObject): [IRecordingMatch[], boolean] => { - const trackMbid = play.data.meta?.brainz?.track; + const trackMbid = creditMbid(play.data.track, 'track'); if (trackMbid === undefined) { return [list, false]; } @@ -803,7 +799,7 @@ export const filterByExplicitTrackMbid = (list: IRecordingMatch[], play: PlayObj } export const filterByExplicitReleaseMbid = (list: IRecordingMatch[], play: PlayObject): [IRecordingMatch[], boolean] => { - const albumMbid = play.data.meta?.brainz?.album; + const albumMbid = creditMbid(play.data.album, 'release'); if (albumMbid === undefined) { return [list, false]; } @@ -857,7 +853,7 @@ export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: Mus const grpPAScore = groupPrimaryPriority.findIndex(x => x === a["release-group"]?.["primary-type"]?.toLocaleLowerCase()) + 1; const grpSAScore = (a["release-group"]?.["secondary-types"] ?? []).reduce((acc: number, curr: string) => acc + groupSecPriority.findIndex(x => x === (curr as MBReleaseGroupSecondaryType).toLocaleLowerCase()) + 1,0); const countryAScore = countryPriority.findIndex(x => a.country === undefined ? false : x === a.country.toLocaleLowerCase()) + 1; - const compareScore = scoreNormalizedStringsWeighted(play.data.album, a.title, albumWeight, albumWeight !== 0 ? 0.05 : 0); + const compareScore = scoreNormalizedStringsWeighted(play.data.album?.name, a.title, albumWeight, albumWeight !== 0 ? 0.05 : 0); return { ...a, albumScore: statAScore + grpPAScore + grpSAScore + countryAScore + compareScore, @@ -869,7 +865,7 @@ export const rankReleasesByPriority = (list: IRecordingMatch[], stageConfig: Mus if(releases.length > 0) { albumScore = releases[0].albumCompareScore; } - const titleScore = titleWeight === 0 ? 0 : scoreTrackWeightedAndNormalized(play.data.track ?? '', x.title, titleWeight, {exact: 0.05, naive: 0.03})[0]; + const titleScore = titleWeight === 0 ? 0 : scoreTrackWeightedAndNormalized(play.data.track?.name ?? '', x.title, titleWeight, {exact: 0.05, naive: 0.03})[0]; return { ...x, diff --git a/src/backend/common/transforms/NativeTransformer.ts b/src/backend/common/transforms/NativeTransformer.ts index 182aabb0..51a63a8f 100644 --- a/src/backend/common/transforms/NativeTransformer.ts +++ b/src/backend/common/transforms/NativeTransformer.ts @@ -1,4 +1,4 @@ -import type {ArtistCredit, PlayObject, TransformerCommon} from "../../../core/Atomic.ts"; +import type {Credit, PlayObject, TransformerCommon} from "../../../core/Atomic.ts"; import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts"; import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts"; import type {ExternalMetadataTerm, PlayTransformNativeStage} from "../../../core/Transform.ts"; @@ -10,7 +10,7 @@ import { DELIMITERS_NO_AMP } from '../../../core/Atomic.ts'; import { asArray } from "../../utils/DataUtils.ts"; import { MaybeLogger } from '../MaybeLogger.ts'; import { childLogger } from "@foxxmd/logging"; -import { artistCreditToName, artistNameToCredit } from "../../../core/StringUtils.ts"; +import { creditsToNames, nameToCredit } from "../../../core/StringUtils.ts"; import { SimpleError } from "../errors/MSErrors.ts"; export type ArtistParseSource = 'artists' | 'title' @@ -166,10 +166,10 @@ export default class NativeTransformer extends AtomicPartsTransformer { + protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise { return play.data.track; } - protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.artists; } @@ -184,10 +184,10 @@ export default class NativeTransformer extends AtomicPartsTransformer { + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise { return play.data.albumArtists; } - protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise { + protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise { return play.data.album; } @@ -206,7 +206,7 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr logger = new MaybeLogger() } = options || {}; - let artists: ArtistCredit[] = []; + let artists: Credit[] = []; let track = play.data.track; if(artistsParseFrom.includes('artists')) { @@ -230,7 +230,7 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr artists.push({name: artistCredits.primary}); } if (artistCredits.secondary !== undefined) { - artists = artists.concat(artistCredits.secondary.map((x) => artistNameToCredit(x))); + artists = artists.concat(artistCredits.secondary.map((x) => nameToCredit(x))); } } else { // couldn't parse anything from artist string, use as-is @@ -249,16 +249,18 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr } if(artistsParseFrom.includes('title')) { - const trackArtists = play.data.track !== undefined ? parseTrackCredits(play.data.track, delimiters) : undefined; + const trackArtists = play.data.track !== undefined ? parseTrackCredits(play.data.track.name, delimiters) : undefined; if (trackArtists !== undefined && trackArtists.secondary !== undefined) { - artists = artists.concat(trackArtists.secondary.map((x) => artistNameToCredit(x))); + artists = artists.concat(trackArtists.secondary.map((x) => nameToCredit(x))); if(titleClean) { - track = trackArtists.primary; + track = {...play.data.track!, name: trackArtists.primary}; } } } - artists = (uniqueNormalizedStrArr([...artists.map((x) => artistCreditToName(x))])).map((x) => artistNameToCredit(x)); + // keep the existing credit (and its metadata) for any artist name that survives deduplication + const parsedArtists = artists; + artists = uniqueNormalizedStrArr(creditsToNames(parsedArtists)).map((x) => parsedArtists.find(y => y.name === x) ?? nameToCredit(x)); return { ...play, diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts index d40a5c94..35fd2ced 100644 --- a/src/backend/common/transforms/SpotifyTransformer.ts +++ b/src/backend/common/transforms/SpotifyTransformer.ts @@ -2,13 +2,10 @@ import { childLogger } from "@foxxmd/logging"; import type { Cacheable } from "cacheable"; import type { WebhookPayload } from "../infrastructure/config/health/webhooks.ts"; import { - type ArtistCredit, - type ArtMeta, + type Credit, type LifecycleInput, type OptionalCacheUsage, - type PlayObject, - type TrackMetaIsrc, -} from "../../../core/Atomic.ts"; + type PlayObject } from "../../../core/Atomic.ts"; import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../infrastructure/Atomic.ts"; import { removeUndefinedKeys } from '../../../core/DataUtils.ts'; import type { ExternalMetadataTerm, PlayTransformMetadataStage } from "../../../core/Transform.ts"; @@ -19,7 +16,8 @@ import { intersect } from "../../utils.ts"; import { isCompilation, SpotifyApiClient, trackToPlay } from "../vendor/spotify/SpotifyApiClient.ts"; import { MaybeLogger } from '../MaybeLogger.ts'; import { SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../errors/MSErrors.ts"; -import AtomicPartsTransformer from "./AtomicPartsTransformer.ts"; +import AtomicPartsTransformer, { artFromCredits, type ArtParts, type MetaParts } from "./AtomicPartsTransformer.ts"; +import { creditId, creditIds, stripCredit } from "../../../core/MusicMetadata.ts"; import type { TransformerOptions } from "./AbstractTransformer.ts"; import { SearchPrerequisiteError } from "./MusicbrainzTransformer.ts"; import { @@ -30,8 +28,7 @@ import { type SpotifyMissingType, type SpotifySearchType, type SpotifyTransformerConfig, - type SpotifyTransformerData, -} from "./spotify/SpotifyTransformerUtil.ts"; + type SpotifyTransformerData } from "./spotify/SpotifyTransformerUtil.ts"; /** How much to subtract from a candidate's match score when it belongs to a compilation album and deprioritizeCompilations is enabled */ export const COMPILATION_PENALTY = 0.15; @@ -81,8 +78,7 @@ export const parseStageConfig = (data: SpotifyTransformerData | undefined = {}, const config: SpotifyTransformerDataStrong = { searchWhenMissing: DEFAULT_SPOTIFY_MISSING_TYPES, score: 0.6, - ...rest, - }; + ...rest }; if (searchWhenMissing !== undefined) { config.searchWhenMissing = searchWhenMissing.map((x) => spotifyMissingTypes.parse(x.toLocaleLowerCase().trim())); @@ -113,7 +109,7 @@ export const parseStageConfig = (data: SpotifyTransformerData | undefined = {}, /** Analogous to musicbrainz's missingMbidTypes but checks the presence of Spotify IDs on the Play instead of MBIDs */ export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => { - let missing: SpotifyMissingType[] = []; + const missing: SpotifyMissingType[] = []; const { track, @@ -123,26 +119,14 @@ export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => { isrc } = play.data; - const { - art: { - album: albumArt - } = {} - } = play.meta ?? {}; - - if (play.data.meta?.spotify === undefined) { - missing = missing.concat('ids'); - } else { - const { - track, - album, - artist - } = play.data.meta.spotify; - if (track === undefined || album === undefined || artist === undefined) { - missing.push('ids'); - } - if(artist !== undefined && dataArtists !== undefined && artist.length !== dataArtists.length) { - missing.push('ids'); - } + const albumArt = album?.image; + + const artistIds = creditIds(dataArtists, 'spotify', 'artist'); + if (creditId(track, 'spotify', 'track') === undefined + || creditId(album, 'spotify', 'album') === undefined + || artistIds.length === 0 + || artistIds.length !== (dataArtists ?? []).length) { + missing.push('ids'); } if (track === undefined) { @@ -179,8 +163,7 @@ export const rankTracksBySimilarity = (tracks: SpotifyApi.TrackObjectFull[], pla titleWeight = TITLE_WEIGHT, artistWeight = ARTIST_WEIGHT, albumWeight = 0.3, - deprioritizeCompilations = false, - } = stageConfig; + deprioritizeCompilations = false } = stageConfig; const ranked = tracks.map((track) => { const candidate = trackToPlay(track); @@ -274,8 +257,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { const { searchWhenMissing = this.defaults.searchWhenMissing, - forceSearch = this.defaults.forceSearch ?? false, - } = stageConfig; + forceSearch = this.defaults.forceSearch ?? false } = stageConfig; const missing = missingSpotifyTypes(play); if (intersect(searchWhenMissing, missing).length > 0) { @@ -306,7 +288,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { + protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.track; } @@ -404,10 +386,11 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.artists; } @@ -422,7 +405,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.albumArtists; } @@ -437,7 +420,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { + protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.album; } @@ -449,7 +432,8 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { @@ -467,7 +451,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { + protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.meta; } @@ -479,23 +463,24 @@ export default class SpotifyTransformer extends AtomicPartsTransformer({...transformData.data.meta, isrc: transformData.data.isrc}); + const {track, album, artists, albumArtists, meta, isrc} = transformData.data; + return removeUndefinedKeys({...meta, isrc, credits: {track, album, artists, albumArtists}}); } - protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { + protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { - return play.meta.art; + return undefined; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for duration not met, returning original duration'); - return play.meta.art; + return undefined; } } } - return transformData.meta.art; + return artFromCredits(transformData.data); } public async notify(payload: WebhookPayload): Promise { diff --git a/src/backend/common/transforms/UserTransformer.ts b/src/backend/common/transforms/UserTransformer.ts index 25fcea75..f5085354 100644 --- a/src/backend/common/transforms/UserTransformer.ts +++ b/src/backend/common/transforms/UserTransformer.ts @@ -1,5 +1,5 @@ import { searchAndReplace } from "@foxxmd/regex-buddy-core"; -import type {ArtistCredit, PlayObject} from "../../../core/Atomic.ts"; +import type {Credit, PlayObject} from "../../../core/Atomic.ts"; import { configValToSearchReplace, isSearchAndReplaceTerm, isUserStage, testWhenConditions } from "../../utils/PlayTransformUtils.ts"; import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts"; import type {ConditionalSearchAndReplaceRegExp, PlayTransformUserStage} from "../../../core/Transform.ts"; @@ -66,18 +66,18 @@ export default class UserTransformer extends AtomicPartsTransformer { + protected async handleTitle(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { if (play.data.track === undefined) { return undefined; } const mapper = this.generateMapper(play); - const result = searchAndReplace(play.data.track, parts.map(mapper)); + const result = searchAndReplace(play.data.track.name, parts.map(mapper)); if(result.trim() === '') { return undefined; } - return result.trim(); + return {...play.data.track, name: result.trim()}; } - protected async handleArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { + protected async handleArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { if(play.data.artists === undefined || play.data.artists.length === 0) { return play.data.artists; } @@ -91,7 +91,7 @@ export default class UserTransformer extends AtomicPartsTransformer { + protected async handleAlbumArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { if(play.data.albumArtists === undefined || play.data.albumArtists.length === 0) { return play.data.albumArtists; } @@ -105,16 +105,16 @@ export default class UserTransformer extends AtomicPartsTransformer { + protected async handleAlbum(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { if (play.data.album === undefined) { return undefined; } const mapper = this.generateMapper(play); - const result = searchAndReplace(play.data.album, parts.map(mapper)); + const result = searchAndReplace(play.data.album.name, parts.map(mapper)); if(result.trim() === '') { return undefined; } - return result.trim(); + return {...play.data.album, name: result.trim()}; } public async notify(payload: WebhookPayload): Promise { diff --git a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts index 5d8c6c1c..7c226d32 100644 --- a/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts +++ b/src/backend/common/transforms/coverartarchive/CoverArtArchiveTransformer.ts @@ -1,8 +1,9 @@ -import { type ArtistCredit, type LifecycleInput, type OptionalCacheUsage, type PlayObject, type ArtMeta } from "../../../../core/Atomic.ts"; +import { type Credit, type LifecycleInput, type OptionalCacheUsage, type PlayObject } from "../../../../core/Atomic.ts"; import { isWhenCondition, testWhenConditions } from "../../../utils/PlayTransformUtils.ts"; import type {WebhookPayload} from "../../infrastructure/config/health/webhooks.ts"; import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../../core/Transform.ts"; -import AtomicPartsTransformer from "../AtomicPartsTransformer.ts"; +import AtomicPartsTransformer, { type ArtParts } from "../AtomicPartsTransformer.ts"; +import { creditMbid } from "../../../../core/MusicMetadata.ts"; import type {TransformerOptions} from "../AbstractTransformer.ts"; import { MaybeLogger } from '../../MaybeLogger.ts'; import { childLogger } from "@foxxmd/logging"; @@ -181,7 +182,7 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { - const mbid = play.data.meta?.brainz?.[mbidType]; + const mbid = creditMbid(play.data.album, mbidType === 'album' ? 'release' : 'release-group'); if(mbid === undefined) { throw new SearchPrerequisiteError(`Play does not have ${mbidType} MBID`); } @@ -262,28 +263,28 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer { + protected async handleTitle(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { return play.data.track; } - protected async handleArtists(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + protected async handleArtists(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { return play.data.artists; } - protected async handleAlbumArtists(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + protected async handleAlbumArtists(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { return play.data.albumArtists; } - protected async handleAlbum(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { + protected async handleAlbum(play: PlayObject, parts: boolean | { when?: { title?: string; artists?: string; albumArtists?: string; album?: string; art?: string; }[]; }, transformData: ArtUriData): Promise { return play.data.album; } - protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: ArtUriData): Promise { + protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: ArtUriData): Promise { if (parts === false) { - return play.meta.art; + return undefined; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for art not met, returning original art'); - return play.meta.art; + return undefined; } } } @@ -292,10 +293,7 @@ export default class CoverArtArchiveTransformer extends AtomicPartsTransformer = { export const hasArtFields = (play: PlayObject): CAAMissingType[] => { const t: CAAMissingType[] = []; - if(play.meta.art?.album !== undefined) { + if(play.data.album?.image !== undefined) { t.push('album'); } - // if(play.meta.art?.artist !== undefined) { + // if(play.data.artists?.[0]?.image !== undefined) { // t.push('artist'); // } - // if(play.meta.art?.track !== undefined) { + // if(play.data.track?.image !== undefined) { // t.push('track'); // } return t; diff --git a/src/backend/common/transforms/rocksky/RockskyTransformer.ts b/src/backend/common/transforms/rocksky/RockskyTransformer.ts index e6182711..3b64eefb 100644 --- a/src/backend/common/transforms/rocksky/RockskyTransformer.ts +++ b/src/backend/common/transforms/rocksky/RockskyTransformer.ts @@ -1,8 +1,9 @@ -import { type ArtistCredit, DEFAULT_ROCKSKY_MISSING_TYPES, type LifecycleInput, type OptionalCacheUsage, type PlayObject, type RockskyMissingField, type ArtMeta, type TrackMetaIsrc } from "../../../../core/Atomic.ts"; +import { type Credit, DEFAULT_ROCKSKY_MISSING_TYPES, type LifecycleInput, type OptionalCacheUsage, type PlayObject, type RockskyMissingField } from "../../../../core/Atomic.ts"; import { isWhenCondition, testWhenConditions } from "../../../utils/PlayTransformUtils.ts"; import type {WebhookPayload} from "../../infrastructure/config/health/webhooks.ts"; import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../../core/Transform.ts"; -import AtomicPartsTransformer from "../AtomicPartsTransformer.ts"; +import AtomicPartsTransformer, { artFromCredits, type ArtParts, type MetaParts } from "../AtomicPartsTransformer.ts"; +import { creditMbid, stripCredit } from "../../../../core/MusicMetadata.ts"; import type {TransformerOptions} from "../AbstractTransformer.ts"; import { DELIMITERS } from '../../../../core/Atomic.ts'; import { MaybeLogger } from '../../MaybeLogger.ts'; @@ -11,7 +12,7 @@ import { type UsingTypes } from "../../vendor/musicbrainz/MusicbrainzApiClientPo import { difference } from "../../../utils.ts"; import { SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../../errors/MSErrors.ts"; import type { Cacheable } from "cacheable"; -import { artistCreditsToNames, splitByFirstRegexFound } from "../../../../core/StringUtils.ts"; +import { creditsToNames, creditToName, splitByFirstRegexFound } from "../../../../core/StringUtils.ts"; import { nativeParse } from "../NativeTransformer.ts"; import { hasRequiredScrobbleFields, hasScrobbleConfidenceFields, type SongViewDetailedMS, songViewToPlay } from "../../vendor/RockSkyApiClient.ts"; import { RockskyError, type SongMatchView } from "@rocksky/sdk"; @@ -241,12 +242,12 @@ export default class RockskyTransformer extends AtomicPartsTransformer { this.logger.debug({ labels: ['Basic Search'] }, 'Searching by artist/album/track'); const requestQuery = JSON.stringify({ - title: play.data.track, - artitst: artistCreditsToNames(play.data.artists).join(', '), - album: play.data.album + title: creditToName(play.data.track), + artitst: creditsToNames(play.data.artists).join(', '), + album: creditToName(play.data.album) }); try { - const res = await this.api.rsProxy.matchSong(requireTrack(play), artistCreditsToNames(play.data.artists).join(', '), undefined, undefined, play.data.album); + const res = await this.api.rsProxy.matchSong(requireTrack(play), creditsToNames(play.data.artists).join(', '), undefined, undefined, creditToName(play.data.album)); return { requestQuery, ...res @@ -259,15 +260,9 @@ export default class RockskyTransformer extends AtomicPartsTransformer { const using: UsingTypes[] = []; - const { - data: { - meta: { - brainz = {} - } = {} - } = {} - } = play; - - if(brainz.recording !== undefined) { + const recordingMbid = creditMbid(play.data.track, 'recording'); + + if(recordingMbid !== undefined) { using.push('mbidrecording'); } else { using.push('title'); @@ -280,13 +275,13 @@ export default class RockskyTransformer extends AtomicPartsTransformer { - if(play.data.meta?.brainz?.recording !== undefined) { + const recordingMbid = creditMbid(play.data.track, 'recording'); + if(recordingMbid !== undefined) { this.logger.debug({labels: ['MBID Search']},'Searching with Recording MBID'); const requestQuery = JSON.stringify({ - title: play.data.track, - artitst: artistCreditsToNames(play.data.artists).join(', '), - mbid: play.data.meta?.brainz?.recording + title: creditToName(play.data.track), + artitst: creditsToNames(play.data.artists).join(', '), + mbid: recordingMbid }); try { - const res = await this.api.rsProxy.matchSong(requireTrack(play), artistCreditsToNames(play.data.artists).join(', '), play.data.meta?.brainz?.recording, undefined, undefined); + const res = await this.api.rsProxy.matchSong(requireTrack(play), creditsToNames(play.data.artists).join(', '), recordingMbid, undefined, undefined); return { requestQuery, ...res @@ -364,7 +360,7 @@ export default class RockskyTransformer extends AtomicPartsTransformer 1) { this.logger.debug({labels: ['Parsed Artist Search']},'Searching with track + first value from artist string split'); const requestQuery = JSON.stringify({ - title: play.data.track, + title: creditToName(play.data.track), artitst: naiveSplit[0], }); try { @@ -392,11 +388,11 @@ export default class RockskyTransformer extends AtomicPartsTransformer { + protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.track; } @@ -465,13 +461,14 @@ export default class RockskyTransformer extends AtomicPartsTransformer { + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.artists; } @@ -513,7 +510,7 @@ export default class RockskyTransformer extends AtomicPartsTransformer ({name: x}))); // } @@ -527,7 +524,7 @@ export default class RockskyTransformer extends AtomicPartsTransformer { + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.albumArtists; } @@ -545,7 +542,7 @@ export default class RockskyTransformer extends AtomicPartsTransformer { + protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.album; } @@ -560,8 +557,9 @@ export default class RockskyTransformer extends AtomicPartsTransformer { + protected async handleMeta(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.meta; } @@ -597,36 +595,36 @@ export default class RockskyTransformer extends AtomicPartsTransformer x?.metadata ?? []) + .filter(x => x.name === 'musicbrainz') + .map(x => x.idType)); + + if(play.meta.source === 'lastfm' || existingMbidTypes.size <= 1) { // only one (or none) mbids from original so likely no loss of fidelity by only using // recording mbid from rocksky - return transformData.data.meta; + const {track: rsTrack, album: rsAlbum, artists: rsArtists, albumArtists: rsAlbumArtists} = transformData.data; + return {...transformData.data.meta, credits: {track: rsTrack, album: rsAlbum, artists: rsArtists, albumArtists: rsAlbumArtists}}; } return play.data.meta; } - protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { + protected async handleArt(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { - return play.meta.art; + return undefined; } if (typeof parts === 'object') { if (parts.when !== undefined) { if (!testWhenConditions(parts.when, play, { testMaybeRegex: this.regex.testMaybeRegex })) { this.logger.debug('When condition for duration not met, returning original duration'); - return play.meta.art; + return undefined; } } } - if(transformData.meta?.art !== undefined && Object.keys(transformData.meta?.art).length > 0) { - return transformData.meta?.art; - } - - return play.meta.art + return artFromCredits(transformData.data); } public async notify(payload: WebhookPayload): Promise { @@ -679,5 +677,5 @@ const requireTrack = (play: PlayObject): string => { if(play.data.track === undefined) { throw new SearchPrerequisiteError('Play does not have a track title'); } - return play.data.track; + return play.data.track.name; } diff --git a/src/backend/common/vendor/KodiApiClient.ts b/src/backend/common/vendor/KodiApiClient.ts index 5f3ce380..00f814ce 100644 --- a/src/backend/common/vendor/KodiApiClient.ts +++ b/src/backend/common/vendor/KodiApiClient.ts @@ -8,7 +8,7 @@ import type {AbstractApiOptions, FormatPlayObjectOptions} from "../infrastructur import type {KodiData} from "../infrastructure/config/source/kodi.ts"; import AbstractApiClient from "./AbstractApiClient.ts"; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../../core/StringUtils.ts"; import { AuthError } from "../errors/MSErrors.ts"; interface KodiDuration { @@ -109,10 +109,10 @@ export class KodiApiClient extends AbstractApiClient { const play: PlayObjectMinimal = { data: { - track: title, - album: album, - albumArtists: artistNamesToCredits(albumartist), - artists: artistNamesToCredits(artistVal), + track: nameToCredit(title), + album: nameToCredit(album), + albumArtists: namesToCredits(albumartist), + artists: namesToCredits(artistVal), duration, playDate: dayjs() }, diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index dfd2f1a5..a67e95d1 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -1,10 +1,11 @@ import dayjs, { type Dayjs, type ManipulateType } from "dayjs"; -import type {BrainzMeta, PlayObject, PlayObjectMinimal, ScrobbleActionResult, UnixTimestamp, URLData, Writeable} from "../../../core/Atomic.ts"; -import { artistNamesToCredits, artistNameToCredit, nonEmptyStringOrDefault, splitByFirstFound, truncateStringToLength } from "../../../core/StringUtils.ts"; +import type {PlayObject, PlayObjectMinimal, ScrobbleActionResult, UnixTimestamp, URLData, Writeable} from "../../../core/Atomic.ts"; +import { creditsToNames, namesToCredits, nameToCredit, nonEmptyStringOrDefault, splitByFirstFound, truncateStringToLength } from "../../../core/StringUtils.ts"; import { sleep } from "../../utils.ts"; import { removeUndefinedKeys } from '../../../core/DataUtils.ts'; import { writeFile } from '../../utils/FSUtils.ts'; -import { objectIsEmpty, readJson } from '../../utils/DataUtils.ts'; +import { readJson } from '../../utils/DataUtils.ts'; +import { creditMbid, creditsWithIds, mbMeta } from '../../../core/MusicMetadata.ts'; import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.ts"; import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.ts"; import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.ts"; @@ -569,10 +570,10 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina modifiedPlay.data.playDate = dayjs.unix(timestamp); } if(trackName !== undefined) { - modifiedPlay.data.track = trackName; + modifiedPlay.data.track = {...modifiedPlay.data.track, name: trackName}; } if(albumName !== undefined) { - modifiedPlay.data.album = albumName; + modifiedPlay.data.album = {...modifiedPlay.data.album, name: albumName}; } return {payload: scrobblePayload, response, mergedScrobble: modifiedPlay, createdAt: dayjs().toISOString()}; @@ -663,12 +664,12 @@ export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObjec const play: PlayObjectMinimal = { data: { - track, - album: nonEmptyStringOrDefault(album), - albumArtists: typeof albumArtist === 'string' && nonEmptyStringOrDefault(albumArtist) !== undefined ? [artistNameToCredit(albumArtist)] : undefined, + track: nameToCredit(track, mbMeta(nonEmptyStringOrDefault(mbid), 'recording')), + album: nameToCredit(nonEmptyStringOrDefault(album)), + albumArtists: typeof albumArtist === 'string' && nonEmptyStringOrDefault(albumArtist) !== undefined ? [nameToCredit(albumArtist)] : undefined, duration: typeof duration === 'string' ? parseInt(duration, 10) : duration, playDate: ts, - artists: artistNamesToCredits(artists) + artists: namesToCredits(artists) }, meta: { source: 'lastfm', @@ -676,14 +677,6 @@ export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObjec } }; - if(nonEmptyStringOrDefault(mbid) !== undefined) { - play.data.meta = { - brainz: { - recording: mbid - } - }; - } - return baseFormatPlayObj(obj, play); } @@ -696,13 +689,9 @@ export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload track, duration, playDate, - meta: { - brainz: { - recording: mbid - } = {}, - } = {} } = {} } = playObj; + const mbid = creditMbid(track, 'recording'); // LFM does not support multiple artists in scrobble payload // https://www.last.fm/api/show/track.scrobble @@ -722,8 +711,8 @@ export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload artist: artist, // track is required by LFM, an empty value will be rejected upstream // but we don't throw here since this is also used to build payloads for logging failed scrobbles - track: track ?? '', - album, + track: track?.name ?? '', + album: album?.name, timestamp: getScrobbleTsSOCDate(playObj).unix(), mbid, ...additionalRichPayload @@ -783,17 +772,12 @@ export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectO al = undefined; } } - const brainz = removeUndefinedKeys({ - album: nonEmptyStringOrDefault(albumMbid), - artist: splitByFirstFound(artistMbid, [',',';'], undefined), - recording: nonEmptyStringOrDefault(mbid) - }); const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits([...new Set(artistStrings)] as string[]), - track: title, - album: al, + artists: creditsWithIds(namesToCredits([...new Set(artistStrings)] as string[]), splitByFirstFound(artistMbid, [',',';'], undefined), 'musicbrainz', 'artist'), + track: nameToCredit(title, mbMeta(mbid, 'recording')), + album: nameToCredit(al, mbMeta(albumMbid, 'release')), duration, playDate: time !== undefined ? dayjs.unix(typeof time === 'string' ? Number.parseInt(time, 10) : time) : undefined }, @@ -806,11 +790,6 @@ export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectO } } - if(brainz !== undefined && !objectIsEmpty(brainz)) { - play.data.meta = { - brainz - } - } return baseFormatPlayObj(obj, play); } @@ -1040,19 +1019,19 @@ export const playToScrobbleApiResponseJson = (play: PlayObject) => { scrobble: { track: { corrected: 0, - '#text': play.data.track ?? '' + '#text': play.data.track?.name ?? '' }, artist: { corrected: 0, - '#text': play.data.artists?.join(',') as string + '#text': creditsToNames(play.data.artists).join(',') }, album: { corrected: 0, - '#text': play.data.album ?? '' + '#text': play.data.album?.name ?? '' }, albumArtist: { corrected: 0, - '#text': play.data.albumArtists?.join(',') as string + '#text': creditsToNames(play.data.albumArtists).join(',') }, timestamp: dayjs().unix(), ignoredMessage: { @@ -1070,19 +1049,19 @@ export const playToNowPlayingApiResponseJson = (play: PlayObject) => { nowplaying: { track: { corrected: 0, - '#text': play.data.track ?? '' + '#text': play.data.track?.name ?? '' }, artist: { corrected: 0, - '#text': play.data.artists?.join(',') + '#text': creditsToNames(play.data.artists).join(',') }, album: { corrected: 0, - '#text': play.data.album ?? '' + '#text': play.data.album?.name ?? '' }, albumArtist: { corrected: 0, - '#text': play.data.albumArtists?.join(',') + '#text': creditsToNames(play.data.albumArtists).join(',') }, ignoredMessage: { code: 0, @@ -1103,19 +1082,19 @@ export const playToScrobbleApiResponseXml = (play: PlayObject) => { scrobble: { track: { $: {corrected: 0}, - _: play.data.track ?? '' + _: play.data.track?.name ?? '' }, artist: { $: {corrected: 0}, - _: play.data.artists?.join(',') ?? '' + _: creditsToNames(play.data.artists).join(',') }, album: { $: {corrected: 0}, - _: play.data.album ?? '' + _: play.data.album?.name ?? '' }, albumArtist: { $: {corrected: 0}, - _: play.data.albumArtists?.join(',') ?? '' + _: creditsToNames(play.data.albumArtists).join(',') }, timestamp: { _: dayjs().unix(), @@ -1138,19 +1117,19 @@ export const playToNowPlayingApiResponseXml = (play: PlayObject) => { nowplaying: { track: { $: { corrected: 0 }, - _: play.data.track ?? '' + _: play.data.track?.name ?? '' }, artist: { $: { corrected: 0 }, - _: play.data.artists?.join(',') ?? '' + _: creditsToNames(play.data.artists).join(',') }, album: { $: { corrected: 0 }, - _: play.data.album ?? '' + _: play.data.album?.name ?? '' }, albumArtist: { $: { corrected: 0 }, - _: play.data.albumArtists?.join(',') ?? '' + _: creditsToNames(play.data.albumArtists).join(',') }, ignoredMessage: { $: { code: 0 } diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index cccace86..4a75c7f6 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -2,8 +2,8 @@ import { stringSameness } from '@foxxmd/string-sameness'; import dayjs from "dayjs"; import type { Request, Response } from 'superagent'; import request from 'superagent'; -import type {BrainzMeta, PlayObject, PlayObjectMinimal, ScrobbleActionResult, UnixTimestamp, URLData} from "../../../core/Atomic.ts"; -import { artistNamesToCredits, combinePartsToString, slice } from "../../../core/StringUtils.ts"; +import type {BrainzMeta, Credit, PlayObject, PlayObjectMinimal, ScrobbleActionResult, UnixTimestamp, URLData} from "../../../core/Atomic.ts"; +import { namesToCredits, combinePartsToString, slice, nameToCredit } from "../../../core/StringUtils.ts"; import { normalizeListenbrainzUrl, normalizeStr, @@ -13,6 +13,7 @@ import { uniqueNormalizedStrArr, } from "../../utils/StringUtils.ts"; import { findDelimiters } from "../../../core/StringUtils.ts"; +import { creditsWithIds, mbMeta, mergeCredit, withMetadata } from "../../../core/MusicMetadata.ts"; import { UpstreamError } from "../errors/UpstreamError.ts"; import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type PagelessListensTimeRangeOptions, type PagelessTimeRangeListens, type PagelessTimeRangeListensResult } from "../infrastructure/Atomic.ts"; import { DELIMITERS } from '../../../core/Atomic.ts'; @@ -257,10 +258,11 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless const respPlay = { ...playObj, }; + const {release_name, track_name} = submitObj.payload[0].track_metadata ?? {}; respPlay.data = { ...playObj.data, - album: submitObj.payload[0].track_metadata?.release_name ?? playObj.data.album, - track: submitObj.payload[0].track_metadata?.track_name ?? playObj.data.album, + album: release_name !== undefined ? mergeCredit(playObj.data.album, nameToCredit(release_name)) : playObj.data.album, + track: track_name !== undefined ? mergeCredit(playObj.data.track, nameToCredit(track_name)) : playObj.data.track, }; return respPlay; } @@ -374,6 +376,7 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { } const mappedArtists = artistMappings.length > 0 ? artistMappings.map(x => x.artist_credit_name) : []; + const mappedArtistCredit = (name: string): Credit => nameToCredit(name, mbMeta(artistMappings.find(y => y.artist_credit_name === name)?.artist_mbid, 'artist')); let normalTrackName: string = track_name; let primaryArtistHint: string | undefined; @@ -567,13 +570,7 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { ...naivePlay, data: { ...naivePlay.data, - artists: derivedArtists.map(x => { - const mappedArtist = artistMappings.find(y => y.artist_credit_name === x); - if(mappedArtist !== undefined) { - return {name: mappedArtist.artist_credit_name, mbid: mappedArtist.artist_mbid} - } - return {name: x}; - }) + artists: derivedArtists.map(mappedArtistCredit) } }; } @@ -582,45 +579,27 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { normalTrackName = recording_name; } - const brainzMetaRaw: BrainzMeta = { - ...(naivePlay.data.meta?.brainz ?? {}), - artist: artistMappings.map(x => x.artist_mbid), - album: release_mbid, - releaseGroup: release_group_mbid - } - + let albumArtists = naivePlay.data.albumArtists; // this should always find an artist but to be safe... const primaryArtistMBMapping = artistMappings.find(x => x.artist_credit_name === primaryArtist); - if(primaryArtistMBMapping !== undefined) { + if(primaryArtistMBMapping !== undefined && albumArtists !== undefined) { // only include as primary if musicbrainz does not disagree with us if(release_artist_names.length === 0 || (release_artist_names.length > 0 && release_artist_names.includes(primaryArtistMBMapping.artist_credit_name))) { - brainzMetaRaw.albumArtist = [primaryArtistMBMapping.artist_mbid]; + albumArtists = creditsWithIds(albumArtists, [primaryArtistMBMapping.artist_mbid], 'musicbrainz', 'artist'); } } - const brainzMeta = removeUndefinedKeys(brainzMetaRaw); - const play: PlayObject = { data: { ...naivePlay.data, - track: normalTrackName, - artists: derivedArtists.map(x => { - const mappedArtist = artistMappings.find(y => y.artist_credit_name === x); - if(mappedArtist !== undefined) { - return {name: mappedArtist.artist_credit_name, mbid: mappedArtist.artist_mbid} - } - return {name: x}; - }) + track: mergeCredit(naivePlay.data.track, nameToCredit(normalTrackName)), + album: withMetadata(naivePlay.data.album, mbMeta(release_mbid, 'release'), mbMeta(release_group_mbid, 'release-group')), + albumArtists, + artists: derivedArtists.map(mappedArtistCredit) }, meta: naivePlay.meta } - if(brainzMeta !== undefined) { - play.data.meta = { - brainz: brainzMeta - } - } - return baseFormatPlayObj(listen, play); } @@ -703,13 +682,15 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { } + const artistMbids = artist_mbids.filter(x => x.trim() !== ""); + const play: PlayObjectMinimal = { data: { playDate: dayjs.unix(listened_at), - track: normalTrackName, - artists: artistNamesToCredits(artists), - album: release_name, - albumArtists: albumArtists !== undefined ? artistNamesToCredits(albumArtists) : undefined, + track: nameToCredit(normalTrackName, mbMeta(trackId, 'recording')), + artists: creditsWithIds(namesToCredits(artists), artistMbids, 'musicbrainz', 'artist'), + album: nameToCredit(release_name, mbMeta(release_mbid, 'release'), mbMeta(release_group_mbid, 'release-group')), + albumArtists: albumArtists !== undefined ? namesToCredits(albumArtists) : undefined, duration: dur, isrc: isrc !== undefined ? isrcNoHyphens(isrc) : undefined, meta: { @@ -727,9 +708,6 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { } const brainzMeta = removeUndefinedKeys({ - album: release_mbid, - releaseGroup: release_group_mbid, - recording: trackId, trackNumber: tracknumber }) ?? {}; @@ -737,9 +715,8 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { brainzMeta.additionalInfo = additional_info; } - if(artist_mbids.filter(x => x.trim() !== "").length > 0) { - brainzMeta.artist = artist_mbids.filter(x => x.trim() !== ""); - brainzMeta.additionalInfo = {...(brainzMeta.additionalInfo ?? {}), artist_mbids: brainzMeta.artist}; + if(artistMbids.length > 0) { + brainzMeta.additionalInfo = {...(brainzMeta.additionalInfo ?? {}), artist_mbids: artistMbids}; } if(Object.keys(brainzMeta).length > 0) { diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 71d9467f..b376cc96 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -1,6 +1,7 @@ +import { creditId, creditMbid, mbMeta, playImage, withAlbumArt } from "../../../core/MusicMetadata.ts"; import dayjs from "dayjs"; -import {rockskyRequiredFields, type ArtistCredit, type LifecycleInput, type PlayObject, type PlayObjectMinimal, type RockskyConfidenceField, type RockskyMissingField, type ScrobbleActionResult, type URLData} from "../../../core/Atomic.ts"; -import { artistCreditsToNames, artistNamesToCredits, nonEmptyStringOrDefault } from "../../../core/StringUtils.ts"; +import {rockskyRequiredFields, type Credit, type LifecycleInput, type PlayObject, type PlayObjectMinimal, type RockskyConfidenceField, type RockskyMissingField, type ScrobbleActionResult, type URLData} from "../../../core/Atomic.ts"; +import { creditsToNames, namesToCredits, nonEmptyStringOrDefault, nameToCredit, creditToName } from "../../../core/StringUtils.ts"; import { UpstreamError } from "../errors/UpstreamError.ts"; import type {AbstractApiOptions, FormatPlayObjectOptions} from "../infrastructure/Atomic.ts"; import type {RockSkyClientData, RockSkyData, RockSkyOptions} from "../infrastructure/config/client/rocksky.ts"; @@ -311,13 +312,13 @@ interface RsMatchSongInput { export const hasRequiredScrobbleFields = (play: PlayObject): RockskyMissingField[] => { const found: RockskyMissingField[] = []; - if(play.data.track !== undefined && play.data.track.trim() !== '') { + if(play.data.track !== undefined && play.data.track.name.trim() !== '') { found.push('track'); } if(play.data.artists !== undefined && play.data.artists.length > 0) { found.push('artists'); } - if(play.data.album !== undefined && play.data.album.trim() !== '') { + if(play.data.album !== undefined && play.data.album.name.trim() !== '') { found.push('album'); } return found; @@ -329,10 +330,10 @@ export const hasScrobbleConfidenceFields = (play: PlayObject): RockskyConfidence if(play.data.isrc) { found.push('isrc'); } - if(play.data.meta?.brainz?.recording) { + if(creditMbid(play.data.track, 'recording')) { found.push('mbid'); } - if(play.data.meta?.spotify?.track) { + if(creditId(play.data.track, 'spotify', 'track')) { found.push('spotify'); } return found; @@ -340,10 +341,10 @@ export const hasScrobbleConfidenceFields = (play: PlayObject): RockskyConfidence const playToMatchSongInput = (play: PlayObject): RsMatchSongInput => ({ title: requirePlayTrack(play), - artist: artistCreditsToNames(play.data.artists).join(', '), - mbId: play.data.meta?.brainz?.track ?? play.data.meta?.brainz?.recording, + artist: creditsToNames(play.data.artists).join(', '), + mbId: creditMbid(play.data.track, 'track') ?? creditMbid(play.data.track, 'recording'), isrc: play.data.isrc, - album: play.data.album + album: creditToName(play.data.album) }) const mergeSongViewWithPlay = (song: SongViewDetailedMS, play: PlayObject): PlayObject => { @@ -354,37 +355,24 @@ const mergeSongViewWithPlay = (song: SongViewDetailedMS, play: PlayObject): Play track: svPlay.data.track ?? play.data.track, album: svPlay.data.album ?? play.data.album, duration: svPlay.data.duration ?? play.data.duration, - isrc: svPlay.data.isrc ?? play.data.isrc + isrc: svPlay.data.isrc ?? play.data.isrc, + meta: { ...play.data.meta } }, meta: { ...svPlay.meta, ...play.meta } }; - if (svPlay.data.meta?.brainz?.recording !== undefined) { - const { - brainz, - ...rest - } = play.data.meta ?? {}; - mergedPlay.data.meta = { - ...rest, - brainz: { - recording: svPlay.data.meta.brainz.recording - } - } - } else { - mergedPlay.data.meta = { ...play.data.meta }; - } return mergedPlay; } export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { - let artists: ArtistCredit[] = [], - albumArtists: ArtistCredit[]; + let artists: Credit[] = [], + albumArtists: Credit[]; if(song.mbArtists !== undefined && song.mbArtists !== null && song.mbArtists.length > 0) { - artists = song.mbArtists.map(x => ({name: x.name, mbid: x.mbid})); + artists = song.mbArtists.map(x => nameToCredit(x.name, mbMeta(x.mbid, 'artist'))); } else if(song.artists !== undefined && song.artists !== null && song.artists.length > 0) { artists = song.artists.flatMap(x => x.name !== undefined ? [{name: x.name}] : []) } else if(song.artist !== undefined && song.artist !== null) { @@ -401,12 +389,15 @@ export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { albumArtists = artists; } + // api returns mbId but type says mbid + const mb = song.mbid ?? (song as any).mbId; + const play: PlayObject = { data: { - track: song.title, + track: nameToCredit(song.title, mbMeta(mb, 'recording')), artists, albumArtists, - album: song.album !== '' ? song.album : undefined, + album: nameToCredit(song.album !== '' ? song.album : undefined), duration: song.duration !== undefined && song.duration !== 0 ? song.duration / 1000 : undefined, isrc: song.isrc }, @@ -418,16 +409,6 @@ export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { } } }; - // api returns mbId but type says mbid - const mb = song.mbid ?? (song as any).mbId; - if(mb !== undefined) { - play.data.meta = { - brainz: { - recording: mb - } - } - } - let albumArt = song.albumArt; if(albumArt === undefined) { const match = song.matches?.[0]; @@ -436,9 +417,7 @@ export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => { } } - if(albumArt !== undefined) { - play.meta.art = {album: albumArt}; - } + play.data = withAlbumArt(play.data, albumArt); return baseFormatPlayObj(song, play); } @@ -451,10 +430,10 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble, opts: {playId?: stri } = opts; const play: PlayObjectMinimal = { data: { - track: obj.title, - artists: artistNamesToCredits(obj.artist !== undefined && nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), - albumArtists: artistNamesToCredits(obj.albumArtist !== undefined && nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), - album: nonEmptyStringOrDefault(obj.album), + track: nameToCredit(obj.title), + artists: namesToCredits(obj.artist !== undefined && nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), + albumArtists: namesToCredits(obj.albumArtist !== undefined && nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), + album: nameToCredit(nonEmptyStringOrDefault(obj.album)), playDate: dayjs.utc(obj.createdAt).local() }, meta: { @@ -472,7 +451,7 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble, opts: {playId?: stri // play.meta.playId = obj.id as string; // } if('albumArt' in obj) { - play.meta.art = {album: obj.albumArt as string} + play.data = withAlbumArt(play.data, obj.albumArt as string); } if(obj.uri !== undefined) { @@ -499,16 +478,16 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble, opts: {playId?: stri type RealCreateScrobbleInput = CreateScrobbleInput & {albumArtist: string}; export const playToRockskyClientRecord = (play: PlayObject): RealCreateScrobbleInput => { - const artistStr = artistCreditsToNames(play.data.artists).join(', '); + const artistStr = creditsToNames(play.data.artists).join(', '); const csi: RealCreateScrobbleInput = { title: requirePlayTrack(play), artist: artistStr, // albumArtist is a required field on rocksky server-side // tsiry's advice is that if there really is no album artists then just use the same value as artist - albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : artistCreditsToNames(play.data.albumArtists).join(', '), - album: play.data.album, - mbId: play.data.meta?.brainz?.recording, + albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : creditsToNames(play.data.albumArtists).join(', '), + album: creditToName(play.data.album), + mbId: creditMbid(play.data.track, 'recording'), isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, duration: play.data.duration !== undefined ? play.data.duration * 1000 : 0, spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, @@ -518,7 +497,7 @@ export const playToRockskyClientRecord = (play: PlayObject): RealCreateScrobbleI } export const playToRockskyAgentRecord = (play: PlayObject): ScrobbleInput => { - const artistStr = artistCreditsToNames(play.data.artists).join(', '); + const artistStr = creditsToNames(play.data.artists).join(', '); const {album} = play.data; if(album === undefined) { throw new SimpleError('Play must have an album to be converted to a Rocksky record'); @@ -529,9 +508,9 @@ export const playToRockskyAgentRecord = (play: PlayObject): ScrobbleInput => { artist: artistStr, // albumArtist is a required field on rocksky server-side // tsiry's advice is that if there really is no album artists then just use the same value as artist - albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : artistCreditsToNames(play.data.albumArtists).join(', '), - album, - mbid: play.data.meta?.brainz?.recording, + albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : creditsToNames(play.data.albumArtists).join(', '), + album: album.name, + mbid: creditMbid(play.data.track, 'recording'), isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, duration: play.data.duration !== undefined ? play.data.duration * 1000 : 0, spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, @@ -542,19 +521,19 @@ export const playToRockskyAgentRecord = (play: PlayObject): ScrobbleInput => { export const playToActorTrackView = (play: PlayObject): ActorTrackView => removeUndefinedKeys({ name: requirePlayTrack(play), - artist: artistCreditsToNames(play.data.artists).join(', '), - album: play.data.album, - albumCoverUrl: play.meta.art?.track ?? play.meta.art?.album ?? play.meta.art?.artist, + artist: creditsToNames(play.data.artists).join(', '), + album: creditToName(play.data.album), + albumCoverUrl: playImage(play.data), durationMs: play.data.duration !== undefined ? Math.floor(play.data.duration * 1000) : undefined, source: play.meta.musicService, - recordingMbId: play.data.meta?.brainz?.recording + recordingMbId: creditMbid(play.data.track, 'recording') }, false) const requirePlayTrack = (play: PlayObject): string => { if(play.data.track === undefined) { throw new SimpleError('Play must have a track title to be converted to a Rocksky record'); } - return play.data.track; + return play.data.track.name; } const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?(?did.*?)\/app\.rocksky\.scrobble\/(?.*))/); diff --git a/src/backend/common/vendor/chromecast/ChromecastClientUtils.ts b/src/backend/common/vendor/chromecast/ChromecastClientUtils.ts index bf365393..f0b65eeb 100644 --- a/src/backend/common/vendor/chromecast/ChromecastClientUtils.ts +++ b/src/backend/common/vendor/chromecast/ChromecastClientUtils.ts @@ -1,3 +1,4 @@ +import { stripCredits } from "../../../../core/MusicMetadata.ts"; import type { Media, MediaController, Result } from "@foxxmd/chromecast-client"; import type {PlayObject} from "../../../../core/Atomic.ts"; import { REPORTED_PLAYER_STATUSES } from '../../../../core/Atomic.ts'; @@ -72,5 +73,5 @@ export const genPlayHash = (play: PlayObject) => { } } = play; - return hashObject({artists, track, album, albumArtists, mediaType}); + return hashObject({...stripCredits({artists, track, album, albumArtists}), mediaType}); } diff --git a/src/backend/common/vendor/discord/DiscordAbstractClient.ts b/src/backend/common/vendor/discord/DiscordAbstractClient.ts index b66021f3..10295ffc 100644 --- a/src/backend/common/vendor/discord/DiscordAbstractClient.ts +++ b/src/backend/common/vendor/discord/DiscordAbstractClient.ts @@ -1,3 +1,4 @@ +import { creditMbid } from "../../../../core/MusicMetadata.ts"; import { isPlayObject } from "../../../../core/Atomic.ts"; import { getRoot } from "../../../ioc.ts"; import { isDebugMode } from "../../../utils.ts"; @@ -110,17 +111,19 @@ export class DiscordAbstractClient extends AbstractApiClient { if(art === artworkDefaultUrl) { const play = isPlayObject(data) ? data : data.play; - if(play.data.meta?.brainz?.album !== undefined || play.data.meta?.brainz?.releaseGroup !== undefined) { + const releaseMbid = creditMbid(play.data.album, 'release'), + releaseGroupMbid = creditMbid(play.data.album, 'release-group'); + if(releaseMbid !== undefined || releaseGroupMbid !== undefined) { let usedCAA = false; - if(play.data.meta?.brainz?.album !== undefined) { - const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.album, 'release', {size: 250}); + if(releaseMbid !== undefined) { + const albumArt = await this.covertArtApi.getCoverThumb(releaseMbid, 'release', {size: 250}); if(albumArt !== undefined) { art = albumArt; usedCAA = true; } } - if(!usedCAA && play.data.meta?.brainz?.releaseGroup !== undefined) { - const albumArt = await this.covertArtApi.getCoverThumb(play.data.meta?.brainz?.releaseGroup, 'release-group', {size: 250}); + if(!usedCAA && releaseGroupMbid !== undefined) { + const albumArt = await this.covertArtApi.getCoverThumb(releaseGroupMbid, 'release-group', {size: 250}); if(albumArt !== undefined) { art = albumArt; usedCAA = true; diff --git a/src/backend/common/vendor/discord/DiscordUtils.ts b/src/backend/common/vendor/discord/DiscordUtils.ts index c30cda62..7259bdda 100644 --- a/src/backend/common/vendor/discord/DiscordUtils.ts +++ b/src/backend/common/vendor/discord/DiscordUtils.ts @@ -1,3 +1,4 @@ +import { creditMbid, playImage } from "../../../../core/MusicMetadata.ts"; import dayjs from "dayjs"; import type {PlayObject, SourcePlayerObj} from "../../../../core/Atomic.ts"; import { GatewayOpcodes, PresenceUpdateStatus } from "discord.js"; @@ -48,12 +49,12 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?: statusDisplayType: 1, // state name: activityName, - details: play.data.track?.padEnd(2,'\u200B'), + details: play.data.track?.name.padEnd(2,'\u200B'), state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.map(x => x.name.padEnd(2, '\u200B')).join(' / ') : undefined, // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image assets: play.data.album !== undefined ? { - largeText: play.data.album.padEnd(2, '\u200B') + largeText: play.data.album.name.padEnd(2, '\u200B') } : undefined, createdAt: dayjs().unix() }, false); @@ -73,14 +74,8 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?: origin } = {}, }, - data: { - meta: { - brainz: { - recording - } = {} - } = {} - } = {} } = play; + const recording = creditMbid(play.data?.track, 'recording'); const url = origin ?? web; if(url !== undefined) { const knownService = urlToMusicService(url); @@ -113,7 +108,7 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?: // activity.buttons = buttons; // } - const artUrl = play.meta?.art?.album ?? play.meta?.art?.track ?? play.meta?.art?.artist; + const artUrl = playImage(play.data, ['album', 'track', 'artist']); return { activity, artUrl }; }; diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts index c31dde1d..21bb166a 100644 --- a/src/backend/common/vendor/koito/KoitoApiClient.ts +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -1,3 +1,4 @@ +import { mbMeta } from "../../../../core/MusicMetadata.ts"; import dayjs from "dayjs"; import type {PlayObject, PlayObjectMinimal, ScrobbleActionResult, URLData} from "../../../../core/Atomic.ts"; import type {AbstractApiOptions, PaginatedListensTimeRangeOptions, PaginatedTimeRangeListens, PaginatedTimeRangeListensResult} from "../../infrastructure/Atomic.ts"; @@ -14,7 +15,7 @@ import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts"; import { AuthError, ScrobbleSubmitError } from "../../errors/MSErrors.ts"; import { tryApiCall } from "../../../utils/RequestUtils.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; -import { artistNamesToCredits } from "../../../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../../../core/StringUtils.ts"; import { findCauseByFunc } from "../../../utils/ErrorUtils.ts"; import { isSuperAgentResponseError } from "../../errors/ErrorUtils.ts"; @@ -230,8 +231,8 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { newFromSource?: boolean, url?: URL } = {}): PlayObject => { const play: PlayObjectMinimal = { data: { - track: obj.track.title, - artists: artistNamesToCredits((obj.track.artists ?? []).map(x => x.name)), + track: nameToCredit(obj.track.title, mbMeta(obj.track.musicbrainz_id, 'recording')), + artists: namesToCredits((obj.track.artists ?? []).map(x => x.name)), duration: obj.track.duration, playDate: dayjs(obj.time) }, @@ -244,12 +245,5 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { } } } - if (obj.track.musicbrainz_id !== null) { - play.data.meta = { - brainz: { - recording: obj.track.musicbrainz_id - } - } - } return baseFormatPlayObj(obj, play); } diff --git a/src/backend/common/vendor/listenbrainz/lzUtils.ts b/src/backend/common/vendor/listenbrainz/lzUtils.ts index 0cf5400b..7e5c29d1 100644 --- a/src/backend/common/vendor/listenbrainz/lzUtils.ts +++ b/src/backend/common/vendor/listenbrainz/lzUtils.ts @@ -5,7 +5,8 @@ import { getScrobbleTsSOCDate } from "../../../utils/TimeUtils.ts"; import type {SubmitOptions} from "../ListenbrainzApiClient.ts"; import type {ListenPayload, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload} from "../../../../core/vendor/listenbrainz/interfaces.ts"; import {version as appVersion } from '../../../version.ts'; -import { artistCreditsToNames, artistCreditToName } from "../../../../core/StringUtils.ts"; +import { creditsToNames } from "../../../../core/StringUtils.ts"; +import { creditId, creditIds, creditMbid } from "../../../../core/MusicMetadata.ts"; export type AllowDeviceList = Record; /** @@ -64,7 +65,6 @@ export const playToListenPayload = (play: PlayObject, options: PlayToListenPaylo duration, meta: { brainz = {}, - spotify = {}, } = {} }, meta: { mediaPlayerName, mediaPlayerVersion, musicService, source, deviceId @@ -77,10 +77,10 @@ export const playToListenPayload = (play: PlayObject, options: PlayToListenPaylo let addInfo: SubmitListenAdditionalTrackInfo = { // primary artists - artist_names: Array.from(new Set([...artists.map(artistCreditToName)])), + artist_names: Array.from(new Set(creditsToNames(artists))), // primary artist release_artist_name: albumArtists.length === 1 ? albumArtists[0].name : undefined, - release_artist_names: albumArtists.length > 0 ? artistCreditsToNames(albumArtists) : undefined, + release_artist_names: albumArtists.length > 0 ? creditsToNames(albumArtists) : undefined, // use data from LZ response, if this Play was originally from LZ Source media_player: mediaPlayerName ?? msAdditionalInfo.media_player ?? matchDeviceLabel(deviceId, allowDeviceList), media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version, @@ -98,42 +98,48 @@ export const playToListenPayload = (play: PlayObject, options: PlayToListenPaylo addInfo.position_ms = play.meta.trackProgressPosition * 1000; } - if (Object.keys(spotify).length > 0) { - if (spotify.track !== undefined) { - const trackUrl = `https://open.spotify.com/track/${spotify.track}`; - if (addInfo.origin_url === undefined) { - addInfo.origin_url = trackUrl; - } - if (addInfo.spotify_id === undefined) { - addInfo.spotify_id = trackUrl; - } - } - if (isEmptyArrayOrUndefined(addInfo.spotify_artist_ids) && spotify.artist !== undefined && spotify.artist.length > 0) { - addInfo.spotify_artist_ids = spotify.artist.map(x => `https://open.spotify.com/artist/${x}`); - } - if (isEmptyArrayOrUndefined(addInfo.spotify_album_artist_ids) && spotify.albumArtist !== undefined && spotify.albumArtist.length > 0) { - addInfo.spotify_album_artist_ids = spotify.albumArtist.map(x => `https://open.spotify.com/artist/${x}`); + const spotifyUrl = (type: 'track' | 'album' | 'artist', id: string) => `https://open.spotify.com/${type}/${id}`; + const spotifyTrack = creditId(track, 'spotify', 'track'), + spotifyAlbum = creditId(album, 'spotify', 'album'), + spotifyArtists = creditIds(artists, 'spotify', 'artist'), + spotifyAlbumArtists = creditIds(albumArtists, 'spotify', 'artist'); + + if (spotifyTrack !== undefined) { + const trackUrl = spotifyUrl('track', spotifyTrack); + if (addInfo.origin_url === undefined) { + addInfo.origin_url = trackUrl; } - if (addInfo.spotify_album_id === undefined && spotify.album !== undefined) { - addInfo.spotify_album_id = `https://open.spotify.com/album/${spotify.album}`; + if (addInfo.spotify_id === undefined) { + addInfo.spotify_id = trackUrl; } } + if (isEmptyArrayOrUndefined(addInfo.spotify_artist_ids) && spotifyArtists.length > 0) { + addInfo.spotify_artist_ids = spotifyArtists.map(x => spotifyUrl('artist', x)); + } + if (isEmptyArrayOrUndefined(addInfo.spotify_album_artist_ids) && spotifyAlbumArtists.length > 0) { + addInfo.spotify_album_artist_ids = spotifyAlbumArtists.map(x => spotifyUrl('artist', x)); + } + if (addInfo.spotify_album_id === undefined && spotifyAlbum !== undefined) { + addInfo.spotify_album_id = spotifyUrl('album', spotifyAlbum); + } addInfo = removeUndefinedKeys(addInfo, false); // possible lastfm provides an empty album field when no album data is found - let al = album; + let al = album?.name; if (al !== undefined && al !== null) { if (al.trim() === '') { al = undefined; } } + // only mbids we know belong to an artist are attached to credits, fall back to the mbids LZ originally gave us (if any) + const artistMbids = creditIds(artists, 'musicbrainz', 'artist'); const minTrackData = removeUndefinedKeys({ - artist_name: Array.from(new Set([...artists.map(artistCreditToName)])).join(', '), + artist_name: Array.from(new Set(creditsToNames(artists))).join(', '), // track name is required by LZ, an empty value will be rejected upstream // but we don't throw here since this is also used to build payloads for logging failed scrobbles - track_name: track ?? '', + track_name: track?.name ?? '', release_name: al, }, false); @@ -143,11 +149,11 @@ export const playToListenPayload = (play: PlayObject, options: PlayToListenPaylo ...minTrackData, additional_info: { duration: duration !== undefined ? Math.round(duration) : undefined, - track_mbid: brainz.track, - recording_mbid: brainz.recording, - artist_mbids: brainz.artist, - release_mbid: brainz.album, - release_group_mbid: brainz.releaseGroup, + track_mbid: creditMbid(track, 'track'), + recording_mbid: creditMbid(track, 'recording'), + artist_mbids: artistMbids.length > 0 ? artistMbids : msAdditionalInfo.artist_mbids, + release_mbid: creditMbid(album, 'release'), + release_group_mbid: creditMbid(album, 'release-group'), submission_client: 'multi-scrobbler', submission_client_version: version ?? appVersion, ...addInfo diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts index 813ae03d..990718b5 100644 --- a/src/backend/common/vendor/maloja/MalojaApiClient.ts +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -14,7 +14,7 @@ import { removeUndefinedKeys } from '../../../../core/DataUtils.ts'; import { UpstreamError } from "../../errors/UpstreamError.ts"; import { getMalojaResponseError, isMalojaAPIErrorBody, type MalojaResponseV3CommonData, type MalojaScrobbleData, type MalojaScrobbleV3RequestData, type MalojaScrobbleV3ResponseData, type MalojaScrobbleWarning } from "./interfaces.ts"; import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.ts'; -import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.ts'; +import { creditsToNames, namesToCredits, buildTrackString, nameToCredit } from "../../../../core/StringUtils.ts"; import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts'; import { AuthError, ScrobbleSubmitError } from '../../errors/MSErrors.ts'; import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.ts'; @@ -276,8 +276,8 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR album: malojaAlbum = {}, } = track; trackResponse.track.album = { - name: album, - artists: artistCreditsToNames(albumArtists), + name: album.name, + artists: creditsToNames(albumArtists), ...malojaAlbum, } } @@ -417,9 +417,9 @@ export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObject const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title as string]]); const play: PlayObjectMinimal = { data: removeUndefinedKeys({ - artists: artistNamesToCredits([...new Set(artistStrings)] as string[]), - track: title, - album, + artists: namesToCredits([...new Set(artistStrings)] as string[]), + track: nameToCredit(title), + album: nameToCredit(album), duration, listenedFor, playDate: dayjs.unix(time), @@ -452,9 +452,9 @@ export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): Mal const scrobbleData: MalojaScrobbleV3RequestData = { // title is required by maloja, an empty value will be rejected upstream // but we don't throw here since this is also used to build payloads for logging failed scrobbles - title: track ?? '', - artists: artistCreditsToNames(artists), - album, + title: track?.name ?? '', + artists: creditsToNames(artists), + album: album?.name, key: apiKey, time: pd.unix(), // https://github.com/FoxxMD/multi-scrobbler/issues/42#issuecomment-1100184135 @@ -470,7 +470,7 @@ export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): Mal // https://github.com/krateng/maloja/blob/master/maloja/web/static/js/manualscrobble.js#L136 // BUT this is not actually working! if (albumArtists.length > 0) { - scrobbleData.albumartists = artistCreditsToNames(albumArtists); + scrobbleData.albumartists = creditsToNames(albumArtists); } // see also https://github.com/krateng/maloja/issues/96#issuecomment-1490562761 // https://github.com/FoxxMD/multi-scrobbler/issues/454#issuecomment-3806367420 diff --git a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClientPool.ts b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClientPool.ts index 6189aa34..de7d483c 100644 --- a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClientPool.ts +++ b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClientPool.ts @@ -1,5 +1,5 @@ import type { Response } from 'superagent'; -import {type ArtistCredit, type OptionalCacheUsage, type PlayObject, type PlayObjectMinimal, type URLData} from "../../../../core/Atomic.ts"; +import {type Credit, type OptionalCacheUsage, type PlayObject, type PlayObjectMinimal, type URLData} from "../../../../core/Atomic.ts"; import { DEVELOPER_CONTACT } from "../../infrastructure/Atomic.ts"; import { type AbstractApiOptions, type FormatPlayObjectOptions, MUSICBRAINZ_URL, type MusicbrainzApiConfigData } from "../../infrastructure/Atomic.ts"; import AbstractApiClient from "../AbstractApiClient.ts"; @@ -9,14 +9,15 @@ import { difference } from "../../../utils.ts"; import type { Cacheable } from "cacheable"; import { getRoot } from "../../../ioc.ts"; import { hashObject } from "../../../utils/StringUtils.ts"; -import { playContentInvariantTransform } from "../../../utils/PlayComparisonUtils.ts"; +import { playContentCacheHash } from "../../../utils/PlayComparisonUtils.ts"; +import { creditIds, creditMbid, mbMeta } from "../../../../core/MusicMetadata.ts"; import { AsyncLocalStorage } from "async_hooks"; import { nanoid } from "nanoid"; import { stripIndents } from "common-tags"; import { SimpleError } from '../../errors/MSErrors.ts'; import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts'; import type {IRecordingMSList} from '../../transforms/MusicbrainzTransformer.ts'; -import { artistCreditsToNames } from '../../../../core/StringUtils.ts'; +import { creditsToNames, creditToName, nameToCredit } from '../../../../core/StringUtils.ts'; import { isrcNoHyphens } from '../../../../core/PlayUtils.ts'; import {ProxyWithCircuitBreaker, type CircuitBreakerProxy} from '@foxxmd/load-balancer-proxy'; import {ConsecutiveBreaker} from 'cockatiel'; @@ -194,7 +195,7 @@ export class MusicbrainzApiClientPool extends AbstractApiClient { useCachedResult } = options || {}; - const cacheKey = `mb-recSearch-${hashObject({...playContentInvariantTransform(play), using})}`; + const cacheKey = `mb-recSearch-${hashObject({play: playContentCacheHash(play), using})}`; this.logger.debug(`Starting search`); let q = ''; @@ -214,29 +215,33 @@ export class MusicbrainzApiClientPool extends AbstractApiClient { } = { }; - if(play.data?.meta?.brainz?.recording !== undefined && using.includes('mbidrecording')) { - query.recording_mbid = play.data.meta.brainz.recording + const recordingMbid = creditMbid(play.data.track, 'recording'), + trackMbid = creditMbid(play.data.track, 'track'), + releaseMbid = creditMbid(play.data.album, 'release'), + artistMbids = creditIds(play.data.artists, 'musicbrainz', 'artist'); + if(recordingMbid !== undefined && using.includes('mbidrecording')) { + query.recording_mbid = recordingMbid; } - if(play.data?.meta?.brainz?.track !== undefined && using.includes('mbidtrack')) { - query.track_mbid = play.data.meta.brainz.track + if(trackMbid !== undefined && using.includes('mbidtrack')) { + query.track_mbid = trackMbid; } - if(play.data?.meta?.brainz?.album !== undefined && using.includes('mbidrelease')) { - query.release_mbid = play.data.meta.brainz.album + if(releaseMbid !== undefined && using.includes('mbidrelease')) { + query.release_mbid = releaseMbid; } - if(play.data?.meta?.brainz?.artist !== undefined && play.data?.meta?.brainz?.artist.length > 0 && using.includes('mbidartist')) { - query.artist_mbids = play.data.meta.brainz.artist + if(artistMbids.length > 0 && using.includes('mbidartist')) { + query.artist_mbids = artistMbids; } if(play.data.isrc !== undefined && using.includes('isrc')) { query.isrc = isrcNoHyphens(play.data.isrc); } if(using.includes('title')) { - query.recording = play.data.track; + query.recording = creditToName(play.data.track); } if(play.data.artists !== undefined && play.data.artists.length > 0 && using.includes('artist')) { - query.artist = artistCreditsToNames(play.data.artists); + query.artist = creditsToNames(play.data.artists); } if(play.data.album !== undefined && using.includes('album')) { - query.release = play.data.album; + query.release = creditToName(play.data.album); } if(escapeCharacters) { for(const [k,v] of Object.entries(query)) { @@ -359,41 +364,29 @@ export const recordingToPlay = (data: IRecording, options?: {ignoreVA?: boolean} let album: IRelease | undefined; - let albumArtists: ArtistCredit[] | undefined; - let albumArtistIds: string[] | undefined; - const artists = (data["artist-credit"] ?? []).map(x => ({ name: x.name, mbid: x.artist.id})); + let albumArtists: Credit[] | undefined; + const artists = (data["artist-credit"] ?? []).map(x => nameToCredit(x.name, mbMeta(x.artist.id, 'artist'))); if(data.releases !== undefined && data.releases.length > 0) { album = data.releases[0]; if(album["artist-credit"] !== undefined) { if(difference(album["artist-credit"].map(x => x.artist.id), (data["artist-credit"] ?? []).map(x => x.artist.id)).length > 0) { - albumArtists = album["artist-credit"].map(x => ({name: x.artist.name, mbid: x.artist.id})); - albumArtistIds = album["artist-credit"].map(x => x.artist.id); + albumArtists = album["artist-credit"].map(x => nameToCredit(x.artist.name, mbMeta(x.artist.id, 'artist'))); } if(albumArtists !== undefined && ignoreVA && albumArtists.map(x => x.name).includes('Various Artists')) { albumArtists = undefined; - albumArtistIds = undefined; } } } const play: PlayObjectMinimal = { data: { - track: data.title, + track: nameToCredit(data.title, mbMeta(data.id, 'recording')), artists, - album: album !== undefined ? album.title : undefined, + album: nameToCredit(album?.title, mbMeta(album?.id, 'release'), mbMeta(album?.["release-group"]?.id, 'release-group')), albumArtists, // recording length is in milliseconds duration: data.length !== undefined ? Math.round(data.length / 1000) : undefined, isrc: data.isrcs !== undefined && data.isrcs.length > 0 ? data.isrcs[0] : undefined, - meta: { - brainz: { - recording: data.id, - artist: data["artist-credit"] !== undefined ? data["artist-credit"].map(x => x.artist.id) : undefined, - albumArtist: albumArtistIds, - album: album !== undefined ? album.id : undefined, - releaseGroup: album !== undefined ? album["release-group"]?.id : undefined - } - } }, meta: { source: 'musicbrainz', diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts index 09ffb240..4b6c4858 100644 --- a/src/backend/common/vendor/spotify/SpotifyApiClient.ts +++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts @@ -2,7 +2,8 @@ import SpotifyWebApi from "spotify-web-api-node"; import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible'; import type { Cacheable } from "cacheable"; import type { PlayObject, PlayObjectMinimal } from "../../../../core/Atomic.ts"; -import { artistNameToCredit } from "../../../../core/StringUtils.ts"; +import { nameToCredit } from "../../../../core/StringUtils.ts"; +import { spotifyMeta, withImage } from "../../../../core/MusicMetadata.ts"; import { isrcNoHyphens } from "../../../../core/PlayUtils.ts"; import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts"; import { hashObject } from "../../../utils/StringUtils.ts"; @@ -104,7 +105,7 @@ export class SpotifyApiClient extends AbstractApiClient { const parts: string[] = []; if (play.data.track !== undefined) { - parts.push(`track:${luceneQuoteIfNeeded(play.data.track)}`); + parts.push(`track:${luceneQuoteIfNeeded(play.data.track.name)}`); } if (play.data.artists !== undefined && play.data.artists.length > 0) { // use only the primary artist because Spotify's search does not support matching multiple artist filters well @@ -189,20 +190,12 @@ export const trackToPlay = (track: SpotifyApi.TrackObjectFull): PlayObject => { const play: PlayObjectMinimal = { data: { - track: name, - artists: artists.map(x => artistNameToCredit(x.name)), - albumArtists: actualAlbumArtists.map(x => artistNameToCredit(x.name)), - album: album?.name, + track: nameToCredit(name, spotifyMeta(id, 'track')), + artists: artists.map(x => nameToCredit(x.name, spotifyMeta(x.id, 'artist'))), + albumArtists: actualAlbumArtists.map(x => nameToCredit(x.name, spotifyMeta(x.id, 'artist'))), + album: nameToCredit(album?.name, spotifyMeta(album?.id, 'album')), duration: duration_ms !== undefined ? Math.round(duration_ms / 1000) : undefined, isrc, - meta: { - spotify: { - track: id, - artist: artists.map(x => x.id), - albumArtist: actualAlbumArtists.map(x => x.id), - album: album?.id - } - } }, meta: { source: 'spotify', @@ -211,9 +204,7 @@ export const trackToPlay = (track: SpotifyApi.TrackObjectFull): PlayObject => { } if((album?.images ?? []).length > 0) { - play.meta.art = { - album: chooseImageByResolution(album.images, {fallbackBest: true}).url - } + play.data.album = withImage(play.data.album, chooseImageByResolution(album.images, {fallbackBest: true}).url); } return baseFormatPlayObj(track, play); diff --git a/src/backend/common/vendor/teal/TealApiClient.ts b/src/backend/common/vendor/teal/TealApiClient.ts index 78b7e971..fb51ab42 100644 --- a/src/backend/common/vendor/teal/TealApiClient.ts +++ b/src/backend/common/vendor/teal/TealApiClient.ts @@ -1,5 +1,7 @@ +import { creditMbid, mbMeta } from "../../../../core/MusicMetadata.ts"; +import { nameToCredit } from "../../../../core/StringUtils.ts"; import dayjs, { type Dayjs, type ManipulateType } from "dayjs"; -import {type PlayObject, type PlayObjectMinimal, type BrainzMeta, type MBID, type ScrobbleActionResult, PARSED_FROM} from "../../../../core/Atomic.ts"; +import {type PlayObject, type PlayObjectMinimal, type MBID, type ScrobbleActionResult, PARSED_FROM} from "../../../../core/Atomic.ts"; import { getRoot } from "../../../ioc.ts"; import { removeUndefinedKeys } from '../../../../core/DataUtils.ts'; import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts"; @@ -165,11 +167,11 @@ export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {} const play: PlayObjectMinimal = { data: { - track: record.trackName, - artists: artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })), + track: nameToCredit(record.trackName, mbMeta(record.recordingMbId, 'recording')), + artists: artists.filter(x => x.artistName !== undefined).map(x => nameToCredit(x.artistName, mbMeta(x.artistMbId, 'artist'))), duration: record.duration, playDate: dayjs(record.playedTime), - album: record.releaseName, + album: nameToCredit(record.releaseName, mbMeta(record.releaseMbId, 'release')), isrc: record.isrc }, meta: { @@ -185,17 +187,6 @@ export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {} } }; - const artistMbids = artists.flatMap(x => x.artistMbId !== undefined ? [x.artistMbId] : []); - const brainz = removeUndefinedKeys({ - recording: record.recordingMbId, - album: record.releaseMbId, - artist: artistMbids.length > 0 ? artistMbids : undefined - }); - - if (brainz !== undefined) { - play.data.meta = { brainz }; - } - return baseFormatPlayObj(record, play); } @@ -254,18 +245,18 @@ export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => { } const record: FmTealFeedPlay.Main = { $type: "fm.teal.feed.play", - trackName: play.data.track, - artists: (play.data.artists ?? []).map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) }, false)), + trackName: play.data.track.name, + artists: (play.data.artists ?? []).map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(creditMbid(x, 'artist') as MBID) }, false)), duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined, playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(), - releaseName: play.data.album, + releaseName: play.data.album?.name, submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, musicServiceUri: asMusicServiceUri(musicService), originUri: isGenericUri(play.meta.url?.origin) ? play.meta.url?.origin : undefined, isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, - trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID), - recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID), - releaseMbId: mbidUriOrUndefined(play.data.meta?.brainz?.album as MBID) + trackMbId: mbidUriOrUndefined(creditMbid(play.data.track, 'track') as MBID), + recordingMbId: mbidUriOrUndefined(creditMbid(play.data.track, 'recording') as MBID), + releaseMbId: mbidUriOrUndefined(creditMbid(play.data.album, 'release') as MBID) }; return record; diff --git a/src/backend/scrobblers/LastfmScrobbler.ts b/src/backend/scrobblers/LastfmScrobbler.ts index 86142dc7..9b5bd0a9 100644 --- a/src/backend/scrobblers/LastfmScrobbler.ts +++ b/src/backend/scrobblers/LastfmScrobbler.ts @@ -67,7 +67,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { track, } = {}, } = playObj; - return (track ?? '').toLocaleLowerCase().trim(); + return (track?.name ?? '').toLocaleLowerCase().trim(); } public playToClientPayload(playObject: PlayObject): object { diff --git a/src/backend/sources/AppleMusicSource.ts b/src/backend/sources/AppleMusicSource.ts index b55189cd..29e0f138 100644 --- a/src/backend/sources/AppleMusicSource.ts +++ b/src/backend/sources/AppleMusicSource.ts @@ -6,7 +6,7 @@ import { COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type Play import type { InternalConfig } from "../common/infrastructure/Atomic.ts"; import type { AppleMusicSourceConfig } from "../common/infrastructure/config/source/applemusic.ts"; import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { getPlaysDiff, @@ -131,9 +131,9 @@ export default class AppleMusicSource extends AbstractSource { const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits([track.artistName]), - album: albumName, - track: track.name, + artists: namesToCredits([track.artistName]), + album: nameToCredit(albumName), + track: nameToCredit(track.name), duration: track.durationInMillis ? Math.round(track.durationInMillis / 1000) : undefined, isrc: track.isrc }, diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index 9367c3e6..94f4f6e3 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -16,7 +16,7 @@ import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse} from "../common/infrastructure/config/source/azuracast.ts"; import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; export class AzuracastSource extends MemorySource { @@ -243,9 +243,9 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), - album: album !== '' ? album : undefined, - track, + artists: namesToCredits(artist !== undefined && artist !== '' ? [artist] : []), + album: nameToCredit(album !== '' ? album : undefined), + track: nameToCredit(track), duration }, meta: { diff --git a/src/backend/sources/ChromecastSource.ts b/src/backend/sources/ChromecastSource.ts index 93ed7fa4..866a39b5 100644 --- a/src/backend/sources/ChromecastSource.ts +++ b/src/backend/sources/ChromecastSource.ts @@ -6,7 +6,7 @@ import { Client as CastClient } from 'castv2'; import dayjs from "dayjs"; import type { EventEmitter } from "events"; import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; -import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.ts"; +import { namesToCredits, buildTrackString, nameToCredit } from "../../core/StringUtils.ts"; import { NETWORK_ERROR_FAILURE_CODES } from "../common/errors/NodeErrors.ts"; import { type FormatPlayObjectOptions, @@ -718,10 +718,10 @@ export class ChromecastSource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - track, - album, - albumArtists: artistNamesToCredits(albumArtists), - artists: artistNamesToCredits(artists), + track: nameToCredit(track), + album: nameToCredit(album), + albumArtists: namesToCredits(albumArtists), + artists: namesToCredits(artists), duration, playDate: dayjs() }, diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index ec6582e9..082ba431 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -1,3 +1,4 @@ +import { withAlbumArt } from "../../core/MusicMetadata.ts"; import dayjs from "dayjs"; import type EventEmitter from "events"; import type { Request } from 'superagent'; @@ -16,7 +17,7 @@ import type {TemporalPlayComparisonOptions} from "../utils/TimeUtils.ts"; import { findAsync, findIndexAsync } from "../utils/AsyncUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; interface DeezerHistoryResponse { errors: [] @@ -116,9 +117,9 @@ export default class DeezerInternalSource extends MemorySource { const {newFromSource = false} = options; const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits([obj.ART_NAME]), - album: obj.ALB_TITLE, - track: obj.SNG_TITLE, + artists: namesToCredits([obj.ART_NAME]), + album: nameToCredit(obj.ALB_TITLE), + track: nameToCredit(obj.SNG_TITLE), duration: obj.DURATION, playDate: dayjs(obj.TS * 1000), }, @@ -133,9 +134,7 @@ export default class DeezerInternalSource extends MemorySource { } }; if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') { - play.meta.art = { - album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg` - } + play.data = withAlbumArt(play.data, `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`); } return baseFormatPlayObj(obj, play); } diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index 4f77a9d4..674c55f1 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -1,3 +1,4 @@ +import { nameToCredit, namesToCredits } from "../../core/StringUtils.ts"; import dayjs from "dayjs"; import type EventEmitter from "events"; import passport from "passport"; @@ -75,9 +76,9 @@ export default class DeezerSource extends AbstractSource { } = obj; const play: PlayObjectMinimal = { data: { - artists: [artistName], - album: albumName, - track: name, + artists: namesToCredits([artistName]), + album: nameToCredit(albumName), + track: nameToCredit(name), duration, playDate: dayjs(timestamp * 1000), }, diff --git a/src/backend/sources/ExampleSource.ts b/src/backend/sources/ExampleSource.ts index 8a186532..e572a2f0 100644 --- a/src/backend/sources/ExampleSource.ts +++ b/src/backend/sources/ExampleSource.ts @@ -11,7 +11,7 @@ import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; import { AuthError } from "../common/errors/MSErrors.ts"; import MemorySource from "./MemorySource.ts"; import type { CoolPlayerSourceConfig } from "../common/infrastructure/config/source/example.ts"; @@ -106,15 +106,17 @@ export class CoolPlayerSource extends MemorySource { const play: PlayObjectMinimal = { // data includes all of the essential properties for scrobbling - // plus a `meta` object for metaservice ids like musicbrainz MBIDs or spotify track id + // + // track, album, and artists are Credits: a name with (optionally) an image and ids from music services like musicbrainz MBIDs or spotify ids + // use nameToCredit/namesToCredits to build them, with mbMeta()/serviceMeta()/withImage() from core/MusicMetadata.ts if the Source provides ids or art data: { - artists: artistNamesToCredits(obj.data.track_artists), - album: obj.data.track_album, - track: obj.data.track_name, + artists: namesToCredits(obj.data.track_artists), + album: nameToCredit(obj.data.track_album), + track: nameToCredit(obj.data.track_name), duration: obj.data.length, }, // meta is data that is optional or used only for MS processing - // like device id, unique track id (for comparing duplicates), album art, etc... + // like device id, unique track id (for comparing duplicates), etc... meta: { // this is the only "essential" property that you need to include in meta // if this Source is a history or ingress based Source this need to be set to be set appropriately diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index dd59dc23..67de82bb 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -16,7 +16,7 @@ import IcecastMetadataStats from "icecast-metadata-stats"; import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.ts"; import { isDebugMode, sleep } from "../utils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; export class IcecastSource extends MemorySource { @@ -213,8 +213,8 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = const play: PlayObjectMinimal = { data: { - track, - artists: artistNamesToCredits(artists) + track: nameToCredit(track), + artists: namesToCredits(artists) }, meta: { source: 'icecast', diff --git a/src/backend/sources/JRiverSource.ts b/src/backend/sources/JRiverSource.ts index 2d26d5f2..c0c98a06 100644 --- a/src/backend/sources/JRiverSource.ts +++ b/src/backend/sources/JRiverSource.ts @@ -9,7 +9,7 @@ import { type Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRive import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; export class JRiverSource extends MemoryPositionalSource { declare config: JRiverSourceConfig; @@ -104,9 +104,9 @@ export class JRiverSource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - track: Name, - album: album, - artists: artistNamesToCredits(artists), + track: nameToCredit(Name), + album: nameToCredit(album), + artists: namesToCredits(artists), duration: Math.round(length), playDate: dayjs() }, diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index d22d1952..fbfe17bd 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -1,3 +1,4 @@ +import { creditsWithIds, mbMeta, withAlbumArt } from "../../core/MusicMetadata.ts"; import { WS } from "iso-websocket"; import type { Api } from "@jellyfin/sdk"; import { Jellyfin } from "@jellyfin/sdk/lib/jellyfin.js"; @@ -25,9 +26,9 @@ import { import dayjs from "dayjs"; import type EventEmitter from "events"; import { FixedSizeList } from "fixed-size-list"; -import type {ArtistCredit, BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import type {Credit, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { genGroupIdStr } from '../../core/PlayUtils.ts'; -import { artistNameToCredit, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; +import { nameToCredit, namesToCredits, creditsToNames, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; import type {FormatPlayObjectOptions, InternalConfig, PlayerStateDataMaybePlay} from "../common/infrastructure/Atomic.ts"; import { COMPONENT_AUTH_TYPE, NO_DEVICE, NO_USER, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {JellyApiSourceConfig} from "../common/infrastructure/config/source/jellyfin.ts"; @@ -390,9 +391,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource { if(typeof AlbumId === 'string' && AlbumPrimaryImageTag !== undefined) { - const existingArt = play.meta?.art || {}; - existingArt.album = this.replaceUrlIfNeeded(this.imageApi.getItemImageUrlById(AlbumId, undefined, {maxHeight: 500})); - play.meta.art = existingArt; + play.data = withAlbumArt(play.data, this.replaceUrlIfNeeded(this.imageApi.getItemImageUrlById(AlbumId, undefined, {maxHeight: 500}))); } if(typeof ParentId === 'string') { const u = joinedUrl(new URL(this.address), '/web/#/details') @@ -436,60 +435,33 @@ export default class JellyfinApiSource extends MemoryPositionalSource { MusicBrainzAlbumArtist } = ProviderIds ?? {}; - const meta: BrainzMeta = {}; - - if(typeof MusicBrainzAlbum === 'string') { - meta.album = MusicBrainzAlbum; - } - // jellyfin can return both the Track MBID and Recording MBID - // https://github.com/jellyfin/jellyfin/blob/0a0aaefad55ed16f88d3a3d61549331342e52377/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs#L421 - if(typeof MusicBrainzTrack === 'string') { - meta.track = MusicBrainzTrack; - } - if(typeof MusicBrainzRecording === 'string') { - meta.recording = MusicBrainzRecording; - } - if(typeof MusicBrainzArtist === 'string') { - meta.artist = [MusicBrainzArtist]; - } - if(typeof MusicBrainzAlbumArtist === 'string') { - meta.albumArtist = [MusicBrainzAlbumArtist]; - } - // artists may be strings or objects with (differently cased) name/id props - const toCredits = (list: unknown[] | null | undefined): ArtistCredit[] => (list ?? []) - .map(x => typeof x === 'string' ? artistNameToCredit(x) : artistNameToCredit(noCasePropObj(x as object) as Partial)) - .filter((x): x is ArtistCredit => x !== undefined); + const toCredits = (list: unknown[] | null | undefined): Credit[] => (list ?? []) + .map(x => nameToCredit(typeof x === 'string' ? x : (noCasePropObj(x as object) as {name?: string}).name)) + .filter((x): x is Credit => x !== undefined); - let normalizedArtists: ArtistCredit[] = []; + let normalizedArtists: Credit[] = []; if((Artists ?? []).length > 0) { normalizedArtists = toCredits(Artists); } else if((ArtistItems ?? []).length > 0) { normalizedArtists = toCredits(ArtistItems); } - let playArtists: ArtistCredit[] = []; - if(normalizedArtists.length === 1 && meta.artist !== undefined) { - playArtists.push({...normalizedArtists[0], mbid: meta.artist[0]}); - } else { - playArtists = normalizedArtists; - } + // jellyfin only gives us one artist mbid so we only know which artist it belongs to if there is only one artist + const playArtists = creditsWithIds(normalizedArtists, typeof MusicBrainzArtist === 'string' ? [MusicBrainzArtist] : undefined, 'musicbrainz', 'artist'); let normalizedAlbumArtists = toCredits(AlbumArtists); if(typeof AlbumArtist === 'string') { normalizedAlbumArtists.push({name: AlbumArtist}); - normalizedAlbumArtists = Array.from(new Set(normalizedAlbumArtists.map(x => x.name))).map(x => ({name: x})) - } - let playAlbumArtists: ArtistCredit[] = []; - if(normalizedAlbumArtists.length === 1 && meta.albumArtist !== undefined) { - playAlbumArtists.push({...normalizedAlbumArtists[0], mbid: meta.albumArtist[0]}); - } else { - playAlbumArtists = normalizedAlbumArtists; + normalizedAlbumArtists = namesToCredits(Array.from(new Set(creditsToNames(normalizedAlbumArtists)))); } + const playAlbumArtists = creditsWithIds(normalizedAlbumArtists, typeof MusicBrainzAlbumArtist === 'string' ? [MusicBrainzAlbumArtist] : undefined, 'musicbrainz', 'artist'); const play: PlayObjectMinimal = { data: { artists: playArtists, - album: Album ?? undefined, - track: Name ?? undefined, + album: nameToCredit(Album, mbMeta(MusicBrainzAlbum, 'release')), + // jellyfin can return both the Track MBID and Recording MBID + // https://github.com/jellyfin/jellyfin/blob/0a0aaefad55ed16f88d3a3d61549331342e52377/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs#L421 + track: nameToCredit(Name, mbMeta(MusicBrainzTrack, 'track'), mbMeta(MusicBrainzRecording, 'recording')), albumArtists: playAlbumArtists, playDate: UserData !== undefined ? dayjs(UserData.LastPlayedDate) : undefined, duration: typeof RunTimeTicks === 'number' ? ticksToSeconds(RunTimeTicks) : undefined @@ -501,9 +473,6 @@ export default class JellyfinApiSource extends MemoryPositionalSource { source: 'Jellyfin', } } - if(Object.keys(meta).length > 0) { - play.data.meta = { brainz: meta }; - } return baseFormatPlayObj(obj,play); } diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 042560b5..d676e12a 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -1,13 +1,13 @@ +import { creditsWithIds, mbMeta } from "../../core/MusicMetadata.ts"; import dayjs from "dayjs"; import type { EventEmitter } from "events"; import path from 'path'; import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js'; -import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import type {ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { type FormatPlayObjectOptions, type InternalConfig, - type PlayerStateDataMaybePlay, -} from "../common/infrastructure/Atomic.ts"; + type PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.ts"; import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {ReportedPlayerStatus} from '../../core/Atomic.ts'; @@ -17,14 +17,13 @@ import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { isDebugMode, sleep } from "../utils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_PLAYER_STATE: Record = { 'play': REPORTED_PLAYER_STATUSES.playing, 'pause': REPORTED_PLAYER_STATUSES.paused, - 'stop': REPORTED_PLAYER_STATUSES.stopped, -} + 'stop': REPORTED_PLAYER_STATUSES.stopped } export class MPDSource extends MemoryPositionalSource { declare config: MPDSourceConfig; @@ -66,8 +65,7 @@ export class MPDSource extends MemoryPositionalSource { const { data: { url, - path, - } = {} + path } = {} } = this.config; if(path === undefined) { @@ -159,7 +157,7 @@ export class MPDSource extends MemoryPositionalSource { albumArtists: string[] | undefined = [], duration: number | undefined, position: number | undefined, - brainz: BrainzMeta = {}; + brainz: {albumArtist?: string[], album?: string, recording?: string, artist?: string[]} = {}; const { state: { @@ -184,8 +182,7 @@ export class MPDSource extends MemoryPositionalSource { musicBrainzAlbumId: musicbrainz_albumid, musicBrainzArtistId: musicbrainz_artistid, musicBrainzReleaseTrackId: musicbrainz_releasetrackid, - musicBrainzTrackId: musicbrainz_trackid, - } = obj; + musicBrainzTrackId: musicbrainz_trackid } = obj; trackName = title; if(trackName === undefined && name !== undefined) { @@ -262,15 +259,12 @@ export class MPDSource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - artists: artists !== undefined ? artistNamesToCredits(artists) : [], - albumArtists: albumArtists !== undefined ? artistNamesToCredits(albumArtists) : [], - album, - track: trackName, - duration, - meta: { - brainz - } - }, + // mpd only gives us one artist mbid so we only know which artist it belongs to if there is only one artist + artists: creditsWithIds(artists !== undefined ? namesToCredits(artists) : [], brainz.artist, 'musicbrainz', 'artist'), + albumArtists: creditsWithIds(albumArtists !== undefined ? namesToCredits(albumArtists) : [], brainz.albumArtist, 'musicbrainz', 'artist'), + album: nameToCredit(album, mbMeta(brainz.album, 'release')), + track: nameToCredit(trackName, mbMeta(brainz.recording, 'recording')), + duration }, meta: { trackProgressPosition: position, mediaPlayerName: 'mpd' diff --git a/src/backend/sources/MPRISSource.ts b/src/backend/sources/MPRISSource.ts index 21dab8d7..d091796b 100644 --- a/src/backend/sources/MPRISSource.ts +++ b/src/backend/sources/MPRISSource.ts @@ -22,7 +22,7 @@ import type { Readable, Writable } from 'stream'; import net from 'net'; import pEvent from 'p-event'; import { baseFormatPlayObj } from '../utils/PlayTransformUtils.ts'; -import { artistNamesToCredits } from '../../core/StringUtils.ts'; +import { namesToCredits, nameToCredit } from '../../core/StringUtils.ts'; export class MPRISSource extends MemorySource { @@ -76,10 +76,10 @@ export class MPRISSource extends MemorySource { const play: PlayObjectMinimal = { data: { - track: title, - album, - artists: artistNamesToCredits(artist), - albumArtists:artistNamesToCredits(actualAlbumArtists), + track: nameToCredit(title), + album: nameToCredit(album), + artists: namesToCredits(artist), + albumArtists:namesToCredits(actualAlbumArtists), duration: length, playDate: dayjs() }, diff --git a/src/backend/sources/MopidySource.ts b/src/backend/sources/MopidySource.ts index 3d35ebc1..3d068150 100644 --- a/src/backend/sources/MopidySource.ts +++ b/src/backend/sources/MopidySource.ts @@ -6,7 +6,7 @@ import normalizeUrl from 'normalize-url'; import pEvent from 'p-event'; import { URL } from "url"; import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; -import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.ts"; +import { namesToCredits, buildTrackString, nameToCredit } from "../../core/StringUtils.ts"; import { type FormatPlayObjectOptions, type InternalConfig, @@ -162,10 +162,10 @@ export class MopidySource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - track: name, - album: albumName, - albumArtists: artistNamesToCredits(actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : []), - artists: artistNamesToCredits(artists.length > 0 ? artists.map(x => x.name) : []), + track: nameToCredit(name), + album: nameToCredit(albumName), + albumArtists: namesToCredits(actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : []), + artists: namesToCredits(artists.length > 0 ? artists.map(x => x.name) : []), duration: length !== undefined ? Math.round(length / 1000) : undefined, playDate: dayjs() }, diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index 7436cfda..c1470015 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -12,7 +12,7 @@ import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils import { type DeviceInfoResponse, type DeviceStatusResponse, MusicCastResponseCodes, type MusicCastSourceConfig, playbackToReportedStatus, type PlayInfoCDResponse, type PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.ts"; import request from 'superagent'; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; export class MusicCastSource extends MemoryPositionalSource { @@ -150,9 +150,9 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), - album: album !== '' ? album : undefined, - track, + artists: namesToCredits(artist !== undefined && artist !== '' ? [artist] : []), + album: nameToCredit(album !== '' ? album : undefined), + track: nameToCredit(track), // we should treat 0 time as the same as not being provided duration: total_time === 0 ? undefined : total_time }, diff --git a/src/backend/sources/MusikcubeSource.ts b/src/backend/sources/MusikcubeSource.ts index 1f576eae..3e857b9b 100644 --- a/src/backend/sources/MusikcubeSource.ts +++ b/src/backend/sources/MusikcubeSource.ts @@ -18,7 +18,7 @@ import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { normalizeWSAddress } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_STATE = { @@ -187,10 +187,10 @@ export class MusikcubeSource extends MemoryPositionalSource { } const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits(artists), - albumArtists: artistNamesToCredits(albumArtists), - album: album === '' ? undefined : album, - track: title === '' ? undefined: title, + artists: namesToCredits(artists), + albumArtists: namesToCredits(albumArtists), + album: nameToCredit(album === '' ? undefined : album), + track: nameToCredit(title === '' ? undefined: title), duration: playing_duration }, meta: { diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 6c91311c..e978d96b 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -1,6 +1,7 @@ +import { creditsWithIds, mbMeta, withImage, withMetadata } from "../../core/MusicMetadata.ts"; import type EventEmitter from "events"; -import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; -import { artistNamesToCredits, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; +import type {ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; +import { namesToCredits, creditsToNames, combinePartsToString, truncateStringToLength, nameToCredit } from "../../core/StringUtils.ts"; import { asPlayerStateDataMaybePlay, type FormatPlayObjectOptions, @@ -336,15 +337,7 @@ export default class PlexApiSource extends MemoryPositionalSource { if(thumb !== undefined) { const res = parseRegexSingle(THUMB_REGEX, thumb) if(res !== undefined) { - return { - ...play, - meta: { - ...play.meta, - art: { - track: `/api/components/${this.componentId}/art?ratingKey=${res.named.ratingkey}` - } - } - } + play.data.track = withImage(play.data.track, `/api/components/${this.componentId}/art?ratingKey=${res.named.ratingkey}`); } } @@ -390,10 +383,10 @@ export default class PlexApiSource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits(realArtists), - albumArtists: artistNamesToCredits(albumArtists), - album, - track, + artists: namesToCredits(realArtists), + albumArtists: namesToCredits(albumArtists), + album: nameToCredit(album), + track: nameToCredit(track), // albumArtists: AlbumArtists !== undefined ? AlbumArtists.map(x => x.Name) : undefined, duration: duration !== undefined ? duration / 1000 : undefined }, @@ -435,16 +428,12 @@ export default class PlexApiSource extends MemoryPositionalSource { this.getMusicBrainzId(sessionData[1].grandparentRatingKey), ]); - const playMeta = sessionPlay.data.meta ?? {}; - sessionPlay.data.meta = playMeta; const playArtists = sessionPlay.data.artists ?? []; const playAlbumArtists = sessionPlay.data.albumArtists ?? []; + const albumArtistNames = creditsToNames(playAlbumArtists); - const computedBrainz: BrainzMeta = { - ...(playMeta.brainz ?? {}), - track: trackMbId ?? playMeta.brainz?.track, - album: albumMbId ?? playMeta.brainz?.album, - } + sessionPlay.data.track = withMetadata(sessionPlay.data.track, mbMeta(trackMbId, 'track')); + sessionPlay.data.album = withMetadata(sessionPlay.data.album, mbMeta(albumMbId, 'release')); // Plex doesn't store MBIDs for track artists, so we use the // album artist MBID instead BUT ONLY if // * album artists aren't populated @@ -455,28 +444,18 @@ export default class PlexApiSource extends MemoryPositionalSource { if(albumArtistMbId !== undefined && albumArtistMbId !== MBID_VARIOUS_ARTISTS && (playAlbumArtists.length === 0 - || playArtists.every(y => playAlbumArtists.includes(y)))) { - computedBrainz.artist = [...new Set([...(computedBrainz.artist ?? []), albumArtistMbId])]; - + || playArtists.every(y => albumArtistNames.includes(y.name)))) { // since we don't get artist and mbid at the same time we only be sure these are actually associated // if there is only one of each - if(computedBrainz.artist.length === 1 && playArtists.length === 1) { - playArtists[0].mbid = computedBrainz.artist[0]; - } + sessionPlay.data.artists = creditsWithIds(playArtists, [albumArtistMbId], 'musicbrainz', 'artist'); } if(albumArtistMbId !== undefined) { - computedBrainz.albumArtist = [...new Set([...(computedBrainz.albumArtist ?? []), albumArtistMbId])]; - // since we don't get albumartist and mbid at the same time we only be sure these are actually associated // if there is only one of each - if(computedBrainz.albumArtist.length === 1 && playAlbumArtists.length === 1) { - playAlbumArtists[0].mbid = computedBrainz.albumArtist[0]; - } + sessionPlay.data.albumArtists = creditsWithIds(playAlbumArtists, [albumArtistMbId], 'musicbrainz', 'artist'); } - playMeta.brainz = computedBrainz; - // need to add this to original object since lifecycle has already been set in sessionToPlayerState //sessionData[0].play.meta.lifecycle.original = clone(sessionData[0].play); diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 9bb7190f..7020dfb7 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -1,3 +1,4 @@ +import { withAlbumArt } from "../../core/MusicMetadata.ts"; import type { EventEmitter } from "events"; import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { @@ -22,7 +23,7 @@ import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybe import { isDebugMode, playObjDataMatch, sleep } from "../utils.ts"; import dayjs, { type Dayjs } from "dayjs"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; export interface DeviceState { device: SonosDevice @@ -374,20 +375,17 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions } const play: PlayObjectMinimal = { - data: { - track: titleStr, - album: Album, - artists: Artist === undefined ? undefined : artistNamesToCredits([Artist]), + data: withAlbumArt({ + track: nameToCredit(titleStr), + album: nameToCredit(Album), + artists: Artist === undefined ? undefined : namesToCredits([Artist]), duration: dur, - }, + }, AlbumArtUri), meta: { user: NO_USER, deviceId: Name === undefined ? NO_DEVICE : `${Name}-${GroupName ?? 'NoGroup'}`, sessionId: Uuid, trackProgressPosition: progress, - art: { - album: AlbumArtUri - }, source: 'Sonos' } } diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 57def6d9..dcfb2ac0 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -3,7 +3,8 @@ import type EventEmitter from "events"; import SpotifyWebApi from "spotify-web-api-node"; import request from 'superagent'; import { type BrainzMeta, COMPONENT_AUTH_TYPE, type PlayObject, type PlayObjectMinimal, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, type ScrobbleTsSOC } from "../../core/Atomic.ts"; -import { artistNameToCredit, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; +import { nameToCredit, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; +import { spotifyMeta, withImage } from "../../core/MusicMetadata.ts"; import { isNodeNetworkException } from "../common/errors/NodeErrors.ts"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.ts"; import { @@ -215,22 +216,14 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag const play: PlayObjectMinimal = { data: { - artists: artists.map(x => artistNameToCredit(x.name)), - albumArtists: actualAlbumArtists.map(x => artistNameToCredit(x.name)), - album: albumName, - track: name, + artists: artists.map(x => nameToCredit(x.name, spotifyMeta(x.id, 'artist'))), + albumArtists: actualAlbumArtists.map(x => nameToCredit(x.name, spotifyMeta(x.id, 'artist'))), + album: withImage(nameToCredit(albumName, spotifyMeta(albumId, 'album')), imageData?.url), + track: nameToCredit(name, spotifyMeta(id, 'track')), duration: duration_ms / 1000, playDate: played_at, playDateCompleted, isrc: isrcString, - meta: { - spotify: { - track: id, - artist: artists.map(x => x.id), - albumArtist: actualAlbumArtists.map(x => x.id), - album: albumId - } - } }, meta: { deviceId: deviceId ?? `${NO_DEVICE}`, @@ -255,10 +248,6 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag play.data.meta = {...play.data.meta, brainz}; } - if(imageData !== undefined) { - play.meta.art = {album: imageData.url}; - } - return baseFormatPlayObj(obj, play); } diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index bbfaad89..216f46a3 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -20,7 +20,7 @@ import type {PlayerStateOptions} from './PlayerState/AbstractPlayerState.ts'; import type {Logger} from '@foxxmd/logging'; import { baseFormatPlayObj } from '../utils/PlayTransformUtils.ts'; import { noRetryOnUpstreamError, tryApiCall } from '../utils/RequestUtils.ts'; -import { artistNameToCredit } from '../../core/StringUtils.ts'; +import { nameToCredit } from '../../core/StringUtils.ts'; import { timeToHumanTimestamp, todayAwareFormat } from '../../core/TimeUtils.ts'; import type { SubsonicSourceApiJson } from '../../core/Api.ts'; import { isSuperAgentResponseError } from '../common/errors/ErrorUtils.ts'; @@ -92,9 +92,9 @@ export class SubsonicSource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - artists: [artistNameToCredit(artist)], - album, - track: title, + artists: [nameToCredit(artist)], + album: nameToCredit(album), + track: nameToCredit(title), duration, // subsonic doesn't return an exact datetime, only how many whole minutes ago it was played // so we need to force the time to be 0 seconds always so that when we compare against scrobbles from client the time isn't off diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 1d384d5d..3e538544 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -18,7 +18,7 @@ import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { isDebugMode } from "../utils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../core/StringUtils.ts"; import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_PLAYER_STATE: Record = { @@ -222,10 +222,10 @@ export class VLCSource extends MemoryPositionalSource { const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits(artists), - albumArtists: artistNamesToCredits(albumArtists), - album, - track: trackName, + artists: namesToCredits(artists), + albumArtists: namesToCredits(albumArtists), + album: nameToCredit(album), + track: nameToCredit(trackName), duration: length }, meta: { diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index 0ee56ce6..83b854ab 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -1,3 +1,4 @@ +import { mbMeta } from "../../core/MusicMetadata.ts"; import dayjs, { type Dayjs } from "dayjs"; import type EventEmitter from "events"; import { PARSED_FROM, type PlayObjectMinimal, SOURCE_SOT } from "../../core/Atomic.ts"; @@ -19,7 +20,7 @@ import type {Logger} from "@foxxmd/logging"; import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts"; import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; -import { artistNameToCredit } from "../../core/StringUtils.ts"; +import { nameToCredit } from "../../core/StringUtils.ts"; interface WebScrobbleMeta { scrobbleAllowed?: boolean @@ -130,17 +131,12 @@ export class WebScrobblerSource extends MemorySource { const play: PlayObjectMinimal = { data: { - track, - artists: artist !== undefined ? [artistNameToCredit(artist)] : [], - album: album === null ? undefined : album, - albumArtists: albumArtist === null ? undefined : albumArtist === undefined ? undefined : [artistNameToCredit(albumArtist)], + track: nameToCredit(track), + artists: artist !== undefined ? [nameToCredit(artist)] : [], + album: nameToCredit(album, mbMeta(albumMbId, 'release')), + albumArtists: albumArtist === null ? undefined : albumArtist === undefined ? undefined : [nameToCredit(albumArtist)], playDate: dayjs.unix(startTimestamp), duration: duration === null ? undefined : duration, - meta: { - brainz: { - album: albumMbId - } - } }, meta: { trackId: uniqueID ?? undefined, diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index 499025e7..0e8eb0dd 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -18,7 +18,7 @@ import { playsAreSortConsistent } from "../utils/PlayComparisonUtils.ts"; import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts"; -import { artistNamesToCredits, buildTrackString, truncateStringToLength } from "../../core/StringUtils.ts"; +import { namesToCredits, buildTrackString, truncateStringToLength, nameToCredit } from "../../core/StringUtils.ts"; import { joinedUrl } from "../utils/NetworkUtils.ts"; import { todayAwareFormat } from "../../core/TimeUtils.ts"; import { parseArrayFromMaybeString, parseArtistCredits } from "../utils/StringUtils.ts"; @@ -446,10 +446,10 @@ Redirect URI : ${this.redirectUri}`); } const play: PlayObjectMinimal = { data: { - artists: artistNamesToCredits(artists), - albumArtists: artistNamesToCredits(albumArtists), - album, - track: title, + artists: namesToCredits(artists), + albumArtists: namesToCredits(albumArtists), + album: nameToCredit(album), + track: nameToCredit(title), duration, // if object is new from source then we know we've picked it up AFTER we started polling so it can't be older than 1 minute (default polling interval) playDate: newFromSource ? dayjs().startOf('minute') : undefined, diff --git a/src/backend/sources/YandexMusicBridgeSource.ts b/src/backend/sources/YandexMusicBridgeSource.ts index 08e23434..a7aa4e39 100644 --- a/src/backend/sources/YandexMusicBridgeSource.ts +++ b/src/backend/sources/YandexMusicBridgeSource.ts @@ -1,3 +1,4 @@ +import { withAlbumArt } from "../../core/MusicMetadata.ts"; import type { EventEmitter } from "events"; import request from 'superagent'; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; @@ -14,7 +15,7 @@ import type {YandexMusicBridgeSourceConfig} from "../common/infrastructure/confi import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; -import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit, creditsToNames } from "../../core/StringUtils.ts"; interface BridgeTrackData { title?: string @@ -202,13 +203,13 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { private getSyntheticKey(bridgeData: BridgeTrackData, play: PlayObject): string { const artists = Array.isArray(bridgeData.artists_list) && bridgeData.artists_list.length > 0 ? bridgeData.artists_list.join(',') - : (bridgeData.artists ?? play.data.artists?.join(',') ?? ''); + : (bridgeData.artists ?? creditsToNames(play.data.artists).join(',')); return [ this.getPlayerId(bridgeData), bridgeData.track_id ?? '', - bridgeData.title ?? play.data.track ?? '', + bridgeData.title ?? play.data.track?.name ?? '', artists, - bridgeData.album ?? play.data.album ?? '', + bridgeData.album ?? play.data.album?.name ?? '', ].join('::'); } @@ -375,7 +376,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) { const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? play.data.duration ?? 0; - this.logger.info(`Synthetic playback exceeded track duration for player ${playerId} '${play.data.artists?.join(', ') ?? 'Unknown'} - ${play.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`); + this.logger.info(`Synthetic playback exceeded track duration for player ${playerId} '${creditsToNames(play.data.artists).join(', ') || 'Unknown'} - ${play.data.track?.name ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`); const stoppedState = this.buildStoppedState(playerId); this.removePlayerState(playerId); if (stoppedState !== undefined) { @@ -408,7 +409,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) { const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? lastPlay.data.duration ?? 0; - this.logger.info(`Synthetic keepalive exceeded track duration for player ${playerId} '${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`); + this.logger.info(`Synthetic keepalive exceeded track duration for player ${playerId} '${creditsToNames(lastPlay.data.artists).join(', ') || 'Unknown'} - ${lastPlay.data.track?.name ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`); const stoppedState = this.buildStoppedState(playerId); this.removePlayerState(playerId); if (stoppedState !== undefined) { @@ -417,7 +418,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { continue; } - this.logger.trace(`Bridge returned no current track for player ${playerId}; keeping synthetic playback alive for ${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}`); + this.logger.trace(`Bridge returned no current track for player ${playerId}; keeping synthetic playback alive for ${creditsToNames(lastPlay.data.artists).join(', ') || 'Unknown'} - ${lastPlay.data.track?.name ?? 'Unknown'}`); states.push({ platformId: [playerId, NO_USER], sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id ?? playerId, @@ -441,21 +442,20 @@ const formatPlayObj = (obj: BridgeTrackData, playerId: string): PlayObject => { : (obj.artists ? obj.artists.split(/\s*,\s*/).filter(x => x.trim() !== '') : []); const play: PlayObjectMinimal = { - data: { - artists: artistNamesToCredits(artists), - album: obj.album ?? undefined, - track: obj.title ?? undefined, + data: withAlbumArt({ + artists: namesToCredits(artists), + album: nameToCredit(obj.album), + track: nameToCredit(obj.title), duration: obj.duration_ms !== undefined && obj.duration_ms !== null ? obj.duration_ms / 1000 : undefined, - }, + }, obj.cover), meta: { trackProgressPosition: getSafePositionSec(obj), deviceId: playerId || NO_DEVICE, mediaPlayerName: obj.device_name ?? (obj.source === 'station-local' ? 'Yandex Station' : 'Yandex Music'), mediaPlayerVersion: obj.platform ?? (obj.source === 'station-local' ? 'station-local' : 'bridge'), comment: obj.track_id !== undefined ? `Yandex Track ${obj.track_id}` : undefined, - art: obj.cover !== undefined && obj.cover !== null && obj.cover !== '' ? { album: obj.cover } : undefined, } } diff --git a/src/backend/tests/applemusic/applemusic.test.ts b/src/backend/tests/applemusic/applemusic.test.ts index 3b049934..5ca60fb1 100644 --- a/src/backend/tests/applemusic/applemusic.test.ts +++ b/src/backend/tests/applemusic/applemusic.test.ts @@ -84,8 +84,8 @@ describe('Apple Music - History Consistency & Deduplication', function () { expect(reboundResult.plays).to.have.length(2); // Oldest first, newest last - expect(reboundResult.plays[0].data.track).to.equal(interimPlay.data.track); - expect(reboundResult.plays[1].data.track).to.equal(plays[0].data.track); + expect(reboundResult.plays[0].data.track?.name).to.equal(interimPlay.data.track?.name); + expect(reboundResult.plays[1].data.track?.name).to.equal(plays[0].data.track?.name); }); it(`Ignores top-rebound when recoverUnchangedTopHistory is false`, async function () { @@ -296,11 +296,11 @@ describe('Apple Music - Format Play Object', function () { const playLowercase = AppleMusicSource.formatPlayObj(trackLowercase, formatOptions); // Assert the suffixes are removed - expect(playEP.data.album).to.equal('ALBUM A'); - expect(playSingle.data.album).to.equal('ALBUM B'); - expect(playLowercase.data.album).to.equal('album d'); + expect(playEP.data.album?.name).to.equal('ALBUM A'); + expect(playSingle.data.album?.name).to.equal('ALBUM B'); + expect(playLowercase.data.album?.name).to.equal('album d'); // Assert normal album names are untouched - expect(playNormal.data.album).to.equal(trackNormal.albumName); + expect(playNormal.data.album?.name).to.equal(trackNormal.albumName); }); }); \ No newline at end of file diff --git a/src/backend/tests/component/transformers.test.ts b/src/backend/tests/component/transformers.test.ts index 2087acf6..da8074b9 100644 --- a/src/backend/tests/component/transformers.test.ts +++ b/src/backend/tests/component/transformers.test.ts @@ -18,7 +18,7 @@ import TransformerManager from "../../common/transforms/TransformerManager.ts"; import { transientCache } from "../utils/TransientTestUtils.ts"; import dayjs from "dayjs"; import clone from "clone"; -import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.ts"; +import { creditsToNames, namesToCredits, nameToCredit } from "../../../core/StringUtils.ts"; import { COMPONENT_STATE, type ComponentState } from "../../../core/Api.ts"; import type { PlayWith, PlaySelectWithQueueStates } from "../../common/database/drizzle/drizzleTypes.ts"; import type { PlayProcessingResult } from "../../common/infrastructure/PlayProcessing.ts"; @@ -310,9 +310,9 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ track: 'My coolsomething track' }); + const play = generatePlay({ track: nameToCredit('My coolsomething track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); - expect(transformed.data.track).equal('My cool track'); + expect(transformed.data.track?.name).equal('My cool track'); }); it('Transforms consecutively when hook is present with multiple values', async function () { @@ -329,9 +329,9 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ track: 'My coolsomething track' }); + const play = generatePlay({ track: nameToCredit('My coolsomething track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); - expect(transformed.data.track).equal('My track'); + expect(transformed.data.track?.name).equal('My track'); }); it('Transforms using parsed regex', async function () { @@ -353,9 +353,9 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ track: 'My cool something track' }); + const play = generatePlay({ track: nameToCredit('My cool something track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); - expect(transformed.data.track).equal('My cool thing track'); + expect(transformed.data.track?.name).equal('My cool thing track'); }); @@ -378,7 +378,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ artists: artistNamesToCredits(['My Artist One / My Artist Two / Another Guy']) }); + const play = generatePlay({ artists: namesToCredits(['My Artist One / My Artist Two / Another Guy']) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists).length(1) expect(transformed.data.artists![0].name).equal('My Artist One'); @@ -398,7 +398,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ track: 'something' }); + const play = generatePlay({ track: nameToCredit('something') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).is.undefined; }); @@ -417,7 +417,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ album: 'something' }); + const play = generatePlay({ album: nameToCredit('something') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.album).is.undefined; }); @@ -436,7 +436,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']) }); + const play = generatePlay({ artists: namesToCredits(['something', 'big']) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) expect(transformed.data.artists![0].name).is.eq('big') @@ -455,10 +455,10 @@ describe('Play Transforms', function () { await t.initialize(); const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); - const play = generatePlay({artists: artistNamesToCredits([str])}); + const play = generatePlay({artists: namesToCredits([str])}); const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); - expect(artistCreditsToNames(transformedPlay.data.artists!)).eql(primaries.concat(secondaries)); + expect(creditsToNames(transformedPlay.data.artists!)).eql(primaries.concat(secondaries)); }); it('Ignores artists', async function() { @@ -469,10 +469,10 @@ describe('Play Transforms', function () { await t.initialize(); - const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); + const play = generatePlay({artists: namesToCredits([str]), track: nameToCredit('My Test')}); const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); - expect(artistCreditsToNames(transformedPlay.data.artists!)).eql([str]); + expect(creditsToNames(transformedPlay.data.artists!)).eql([str]); }); it('Uses custom delimiters artists', async function() { @@ -490,10 +490,10 @@ describe('Play Transforms', function () { await t.initialize(); - const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); + const play = generatePlay({artists: namesToCredits([str]), track: nameToCredit('My Test')}); const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); - expect(artistCreditsToNames(transformedPlay.data.artists!)).eql(primaries.concat(secondaries)); + expect(creditsToNames(transformedPlay.data.artists!)).eql(primaries.concat(secondaries)); }); }); @@ -520,7 +520,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has No Match' }); + const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has No Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(2) expect(transformed.data.artists![0].name).is.eq('something') @@ -545,7 +545,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has This Match' }); + const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has This Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) expect(transformed.data.artists![0].name).is.eq('big') @@ -577,7 +577,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has No Match' }); + const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has No Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(2) expect(transformed.data.artists![0].name).is.eq('something') @@ -607,7 +607,7 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has This Match' }); + const play = generatePlay({ artists: namesToCredits(['something', 'big']), album: nameToCredit('It Has This Match') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) expect(transformed.data.artists![0].name).is.eq('big') @@ -648,9 +648,9 @@ describe('Play Transforms', function () { } await component.buildTransformRules(); - const play = generatePlay({ track: 'My cool something track' }); + const play = generatePlay({ track: nameToCredit('My cool something track') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); - expect(transformed.data.track).equal('My cool final thing track'); + expect(transformed.data.track?.name).equal('My cool final thing track'); }); it('Accumulates transforms across multiple stages', async function () { @@ -679,10 +679,10 @@ describe('Play Transforms', function () { const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); await component.buildTransformRules(); - const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]) }); + const play = generatePlay({ track: nameToCredit('My cool something track'), artists: namesToCredits([str]) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); - expect(transformed.data.track).equal('My cool bar track'); - expect(artistCreditsToNames(transformed.data.artists!)).eql(primaries.concat(secondaries)); + expect(transformed.data.track?.name).equal('My cool bar track'); + expect(creditsToNames(transformed.data.artists!)).eql(primaries.concat(secondaries)); }); }); @@ -716,17 +716,17 @@ describe('Play Transforms', function () { const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); await component.buildTransformRules(); - const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]), playDate: dayjs().subtract(10, 'm') }); + const play = generatePlay({ track: nameToCredit('My cool something track'), artists: namesToCredits([str]), playDate: dayjs().subtract(10, 'm') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, {log: 'all'}); - expect(transformed.data.track).equal('My cool bar track'); - expect(artistCreditsToNames(transformed.data.artists!)).eql(primaries.concat(secondaries)); + expect(transformed.data.track?.name).equal('My cool bar track'); + expect(creditsToNames(transformed.data.artists!)).eql(primaries.concat(secondaries)); const cachablePlay = clone(play); const laterDate = dayjs().subtract(5, 'm'); cachablePlay.data.playDate = laterDate; const cacheTransformed = await component.transformPlay(cachablePlay, TRANSFORM_HOOK.preCompare, {log: 'all'}); - expect(cacheTransformed.data.track).equal('My cool bar track'); - expect(artistCreditsToNames(cacheTransformed.data.artists!)).eql(primaries.concat(secondaries)); + expect(cacheTransformed.data.track?.name).equal('My cool bar track'); + expect(creditsToNames(cacheTransformed.data.artists!)).eql(primaries.concat(secondaries)); expect(cacheTransformed.data.playDate!.isSame(cachablePlay.data.playDate)); }); @@ -770,7 +770,7 @@ describe('Play Transforms', function () { await tmanager.register(t); } - const play = generatePlay({track: 'My Cool Track'}); + const play = generatePlay({track: nameToCredit('My Cool Track')}); const multiTransformComponent = createTestComponent({transformManager: tmanager}); multiTransformComponent.config.options = { @@ -789,7 +789,7 @@ describe('Play Transforms', function () { }; await multiTransformComponent.buildTransformRules(); const transformed = await multiTransformComponent.transformPlay(play, TRANSFORM_HOOK.preCompare); - expect(transformed.data.track).eq('My Bar Title'); + expect(transformed.data.track?.name).eq('My Bar Title'); }); it('Handles transform config case-insensitive', async function() { diff --git a/src/backend/tests/database/creditMigration.test.ts b/src/backend/tests/database/creditMigration.test.ts new file mode 100644 index 00000000..18d019bb --- /dev/null +++ b/src/backend/tests/database/creditMigration.test.ts @@ -0,0 +1,212 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import dayjs from 'dayjs'; +import { loggerTest } from '@foxxmd/logging'; +import { eq } from 'drizzle-orm'; +import type { LifecycleStep, PlayObject } from '../../../core/Atomic.ts'; +import { diffObjects, patchObject } from '../../../core/DataUtils.ts'; +import { creditId, creditIds, creditMbid } from '../../../core/MusicMetadata.ts'; +import { namesToCredits, nameToCredit } from '../../../core/StringUtils.ts'; +import { generateMbid } from '../../../core/tests/utils/PlayTestUtils.ts'; +import { isLegacyData, legacyPlayToCredits, rebuildLifecycle, up } from '../../common/database/appMigrations/005_creditShape.ts'; +import { components, playEvents, playsHistorical } from '../../common/database/drizzle/schema/schema.ts'; +import { generatePlayHistoricalEntity } from '../../common/database/drizzle/entityUtils.ts'; +import { DrizzlePlayRepository } from '../../common/database/drizzle/repositories/PlayRepository.ts'; +import { playContentBasicInvariantTransform } from '../../utils/PlayComparisonUtils.ts'; +import { hashObject } from '../../utils/StringUtils.ts'; +import { fixtureCreateComponent, fixtureCreatePlay } from '../utils/databaseFixtures.ts'; +import { transientDb } from '../utils/TransientTestUtils.ts'; + +const json = (val: T): T => JSON.parse(JSON.stringify(val)); + +const legacyFixture = () => { + const ids = { + recording: generateMbid(), + release: generateMbid(), + releaseGroup: generateMbid(), + artistA: generateMbid(), + artistB: generateMbid(), + }; + const playDate = dayjs().subtract(1, 'h'); + const inputPlay = { + data: { + track: 'Old Title', + artists: [{ name: 'Artist A', mbid: ids.artistA }, { name: 'Artist B' }], + albumArtists: [{ name: 'Artist A' }, { name: 'Someone Else' }], + album: 'An Album', + duration: 200, + playDate, + meta: { + brainz: { + trackNumber: 3 + }, + spotify: { + track: 'spTrack' + } + } + }, + meta: { + source: 'Spotify', + art: { album: 'https://example.com/input.jpg' } + } + }; + const finalData = { + ...inputPlay.data, + track: 'New Title', + meta: { + brainz: { + trackNumber: 3, + recording: ids.recording, + album: ids.release, + releaseGroup: ids.releaseGroup, + artist: [ids.artistA, ids.artistB], + // does not line up with album artists so should be dropped + albumArtist: [ids.artistA] + }, + spotify: { + track: 'spTrack' + } + } + }; + const lifecycle: LifecycleStep[] = [ + { hook: 'preCompare', stageType: 'native', stageName: 'noop', source: 'test', flowResult: 'continue', createdAt: dayjs().toISOString() }, + { hook: 'preCompare', stageType: 'musicbrainz', stageName: 'mb', source: 'test', flowResult: 'continue', createdAt: dayjs().toISOString(), patch: diffObjects(json(inputPlay.data), json(finalData)) } + ]; + const finalPlay = { + data: finalData, + meta: { + source: 'Spotify', + art: { album: 'https://example.com/final.jpg' } + }, + lifecycle, + scrobble: { + mergedScrobble: { data: finalData, meta: {} } + } + }; + return { ids, inputPlay, finalPlay }; +} + +const assertConvertedFinal = (play: PlayObject, ids: ReturnType['ids']) => { + const { track, album, artists, albumArtists, meta } = play.data; + expect(isLegacyData(play.data as any)).is.false; + expect(track!.name).eq('New Title'); + expect(creditMbid(track, 'recording')).eq(ids.recording); + expect(creditId(track, 'spotify', 'track')).eq('spTrack'); + expect(album!.name).eq('An Album'); + expect(album!.image).eq('https://example.com/final.jpg'); + expect(creditMbid(album, 'release')).eq(ids.release); + expect(creditMbid(album, 'release-group')).eq(ids.releaseGroup); + expect(artists!.map(x => x.name)).eql(['Artist A', 'Artist B']); + expect(creditIds(artists, 'musicbrainz', 'artist')).eql([ids.artistA, ids.artistB]); + expect(albumArtists!.map(x => x.name)).eql(['Artist A', 'Someone Else']); + expect(creditIds(albumArtists, 'musicbrainz', 'artist')).is.empty; + expect(meta).eql({ brainz: { trackNumber: 3 } }); + expect((play.meta as any).art).is.undefined; +} + +describe('#Credit shape migration', function () { + + it('converts legacy play data, art, and embedded plays', function () { + const { ids, finalPlay } = legacyFixture(); + const converted = legacyPlayToCredits(finalPlay) as unknown as PlayObject; + assertConvertedFinal(converted, ids); + expect(converted.scrobble!.mergedScrobble!.data.track!.name).eq('New Title'); + expect(creditMbid(converted.scrobble!.mergedScrobble!.data.track, 'recording')).eq(ids.recording); + }); + + it('does not modify already converted plays', function () { + const { finalPlay } = legacyFixture(); + const converted = legacyPlayToCredits(finalPlay); + expect(hashObject(json(legacyPlayToCredits(converted)))).eq(hashObject(json(converted))); + }); + + it('sets album art on track when there is no album', function () { + const converted = legacyPlayToCredits({ data: { track: 'A Track', artists: ['An Artist'] }, meta: { art: { album: 'https://example.com/a.jpg' } } }) as unknown as PlayObject; + expect(converted.data.album).is.undefined; + expect(converted.data.track!.image).eq('https://example.com/a.jpg'); + expect(converted.data.artists).eql([{ name: 'An Artist' }]); + }); + + it('rebuilds lifecycle patches so they apply to converted data', function () { + const { inputPlay, finalPlay } = legacyFixture(); + const steps = rebuildLifecycle(finalPlay.lifecycle, inputPlay, finalPlay.meta.art); + expect(steps).length(2); + expect(steps[0].patch).is.undefined; + expect(steps[1].patch).to.not.be.undefined; + + const convertedInput = json(legacyPlayToCredits(inputPlay).data); + const convertedFinal = json(legacyPlayToCredits(finalPlay).data); + expect(hashObject(patchObject(convertedInput, steps[1].patch!))).eq(hashObject(convertedFinal)); + }); + + it('removes lifecycle patches that cannot be rebuilt', function () { + const { finalPlay } = legacyFixture(); + const steps = rebuildLifecycle(finalPlay.lifecycle, undefined); + expect(steps).length(2); + expect(steps.every(x => x.patch === undefined)).is.true; + expect(steps[1].stageName).eq('mb'); + }); + + it('migrates stored plays, inputs, and historical plays', async function () { + const db = await transientDb(); + const { ids, inputPlay, finalPlay } = legacyFixture(); + + const [component] = await db.insert(components).values(fixtureCreateComponent()).returning(); + const repo = new DrizzlePlayRepository(db); + await repo.createPlays([{ + ...fixtureCreatePlay({ play: finalPlay as unknown as PlayObject }), + componentId: component.id, + state: 'queued' as const, + input: { data: { foo: 'bar' }, play: inputPlay as unknown as PlayObject } + }]); + const [created] = await repo.findPlays({}); + await db.insert(playEvents).values({ playId: created.id, eventName: 'scrobbleResult', data: finalPlay.scrobble }); + await db.insert(playsHistorical).values(generatePlayHistoricalEntity(finalPlay as unknown as PlayObject, { componentId: component.id })); + + const ctx = { db, logger: loggerTest }; + await up(db.$client, ctx); + + const expectedHash = hashObject(playContentBasicInvariantTransform({ + data: { + track: nameToCredit('New Title'), + artists: namesToCredits(['Artist A', 'Artist B']), + albumArtists: namesToCredits(['Artist A', 'Someone Else']), + album: nameToCredit('An Album'), + duration: 200, + }, + meta: {} + }).data); + + const check = async () => { + const [row] = await repo.findPlays({ with: ['input'] }) as any[]; + assertConvertedFinal(row.play, ids); + expect(row.playHash).eq(expectedHash); + expect(row.mbidIdentifier).eq(`${ids.recording}-${ids.release}`); + + const input = row.input.play as PlayObject; + expect(input.data.track!.name).eq('Old Title'); + expect(input.data.album!.image).eq('https://example.com/input.jpg'); + expect(creditIds(input.data.artists, 'musicbrainz', 'artist')).eql([ids.artistA]); + expect((input.meta as any).art).is.undefined; + + // lifecycle steps are stored as transform events, patches should replay from converted input to converted play + const events = await db.select().from(playEvents).where(eq(playEvents.playId, row.id)); + const steps = events.filter(x => x.eventName === 'transform').flatMap(x => x.data as LifecycleStep[]); + expect(steps).length(2); + const patched = steps.reduce((acc: object, curr: LifecycleStep) => curr.patch !== undefined ? patchObject(acc, curr.patch) : acc, json(input.data)); + expect(hashObject(patched)).eq(hashObject(json(row.play.data))); + + const scrobbleEvent = events.find(x => x.eventName === 'scrobbleResult')!; + expect((scrobbleEvent.data as any).mergedScrobble.data.track.name).eq('New Title'); + + const [historical] = await db.select().from(playsHistorical); + assertConvertedFinal(historical.play, ids); + expect(historical.playHash).eq(expectedHash); + } + + await check(); + // running again should not change anything + await up(db.$client, ctx); + await check(); + }); +}); diff --git a/src/backend/tests/database/drizzle.test.ts b/src/backend/tests/database/drizzle.test.ts index d4425df5..16d03d24 100644 --- a/src/backend/tests/database/drizzle.test.ts +++ b/src/backend/tests/database/drizzle.test.ts @@ -484,7 +484,7 @@ describe('App Migrations', function() { const plays = await repo.findPlays({}); - expect(plays[0].play.data.track).eq('foo') + expect(plays[0].play.data.track?.name).eq('foo') }); }); \ No newline at end of file diff --git a/src/backend/tests/database/playRepository.test.ts b/src/backend/tests/database/playRepository.test.ts index f3e54a35..ff16a182 100644 --- a/src/backend/tests/database/playRepository.test.ts +++ b/src/backend/tests/database/playRepository.test.ts @@ -1,3 +1,4 @@ +import { nameToCredit } from "../../../core/StringUtils.ts"; import { expect } from 'chai'; import { components, plays } from '../../common/database/drizzle/schema/schema.ts'; import dayjs from 'dayjs'; @@ -35,7 +36,7 @@ describe('Repository Operations', function () { fullPlays.forEach((play, index) => { const ref = playData[index]; - expect(play.play.data.track).eq(ref.play.data.track); + expect(play.play.data.track?.name).eq(ref.play.data.track?.name); expect(play.input).to.not.undefined; expect(objectsEqual(play.input.data!, ref.input.data!)).is.true; }) @@ -67,7 +68,7 @@ describe('Repository Operations', function () { const plays = await repo.findPlays({ state: ['discovered'] }); expect(plays).length(1); - expect(plays[0].play.data.track).eq(discovered.play.data.track); + expect(plays[0].play.data.track?.name).eq(discovered.play.data.track?.name); }); it('finds Plays by date range', async function () { @@ -105,17 +106,17 @@ describe('Repository Operations', function () { const newerPlays = await repo.findPlays({ playedAt: { type: 'gt', date: dayjs().subtract(3, 'm') } }); expect(newerPlays).length(1); - expect(newerPlays[0].play.data.track).eq(playData[0].play.data.track); + expect(newerPlays[0].play.data.track?.name).eq(playData[0].play.data.track?.name); const olderPlays = await repo.findPlays({ playedAt: { type: 'lt', date: dayjs().subtract(6, 'm').subtract(5, 's') } }); expect(olderPlays).length(2); - expect(olderPlays[0].play.data.track).eq(playData[2].play.data.track); - expect(olderPlays[1].play.data.track).eq(playData[3].play.data.track); + expect(olderPlays[0].play.data.track?.name).eq(playData[2].play.data.track?.name); + expect(olderPlays[1].play.data.track?.name).eq(playData[3].play.data.track?.name); const bwPlays = await repo.findPlays({ playedAt: { type: 'between', range: [dayjs().subtract(9, 'm'), dayjs().subtract(3, 'm')] } }); expect(bwPlays).length(2); - expect(bwPlays[0].play.data.track).eq(playData[1].play.data.track); - expect(bwPlays[1].play.data.track).eq(playData[2].play.data.track); + expect(bwPlays[0].play.data.track?.name).eq(playData[1].play.data.track?.name); + expect(bwPlays[1].play.data.track?.name).eq(playData[2].play.data.track?.name); }); it('finds Plays by component', async function () { @@ -153,12 +154,12 @@ describe('Repository Operations', function () { const plays = await repo.findPlays({ componentId: component3[0].id }); expect(plays).length(2); - expect(plays[0].play.data.track).eq(playData[1].play.data.track); - expect(plays[1].play.data.track).eq(playData[2].play.data.track); + expect(plays[0].play.data.track?.name).eq(playData[1].play.data.track?.name); + expect(plays[1].play.data.track?.name).eq(playData[2].play.data.track?.name); const plays1 = await repo.findPlays({ componentId: component1[0].id }); expect(plays1).length(1); - expect(plays1[0].play.data.track).eq(playData[0].play.data.track); + expect(plays1[0].play.data.track?.name).eq(playData[0].play.data.track?.name); const noPlays = await repo.findPlays({ componentId: component2[0].id }); expect(noPlays).length(0); @@ -257,7 +258,7 @@ describe('Repository Operations', function () { const repoB = new DrizzlePlayRepository(db, { componentId: componentB[0].id }); const childPlay = clone(playDataA[1].play); - childPlay.data.track = `${childPlay.data.track} (transformed)`; + childPlay.data.track = nameToCredit(`${childPlay.data.track?.name} (transformed)`); const rowsB = await repoB.createPlays([{ ...fixtureCreatePlay({ play: childPlay }), @@ -275,7 +276,7 @@ describe('Repository Operations', function () { expect(existing, 'checkExisting should return the client play whose parent the clone is based off of').to.not.be.undefined; expect(existing!.id).eq(rowsB[0].id); - expect(existing!.play.data.track).eq(childPlay.data.track); + expect(existing!.play.data.track?.name).eq(childPlay.data.track?.name); }); }); @@ -290,8 +291,8 @@ describe('Repository Operations', function () { const component = await db.insert(components).values(fixtureCreateComponent()).returning(); const playRows = await db.insert(plays).values([ - fixtureCreatePlay({ componentId: component[0].id, play: generatePlay({track: 'foo'}, { source: 'test1' }) }), - fixtureCreatePlay({ componentId: component[0].id, play: generatePlay({track: 'bar'}, { source: 'test2' }) }) + fixtureCreatePlay({ componentId: component[0].id, play: generatePlay({track: nameToCredit('foo')}, { source: 'test1' }) }), + fixtureCreatePlay({ componentId: component[0].id, play: generatePlay({track: nameToCredit('bar')}, { source: 'test2' }) }) ]).returning(); // https://github.com/drizzle-team/drizzle-orm/discussions/938#discussioncomment-6542336 @@ -301,7 +302,7 @@ describe('Repository Operations', function () { { OR: [ { - RAW: (p) => sql`lower(json_extract(${p.play}, '$.data.track')) LIKE '%'|| ${playRows[0].play.data.track!.substring(0, 5).toLocaleLowerCase()} || '%'` + RAW: (p) => sql`lower(json_extract(${p.play}, '$.data.track.name')) LIKE '%'|| ${playRows[0].play.data.track!.name.substring(0, 5).toLocaleLowerCase()} || '%'` } ] } diff --git a/src/backend/tests/database/testAppMigrations/001_testFoo.ts b/src/backend/tests/database/testAppMigrations/001_testFoo.ts index a1b18873..0f76d21f 100644 --- a/src/backend/tests/database/testAppMigrations/001_testFoo.ts +++ b/src/backend/tests/database/testAppMigrations/001_testFoo.ts @@ -1,3 +1,4 @@ +import { nameToCredit } from "../../../../core/StringUtils.ts"; import type { SqliteDatabase, Migration } from 'sqlite-up'; import { DrizzlePlayRepository } from '../../../common/database/drizzle/repositories/PlayRepository.ts'; import type {MigrateBaseContext} from '../../../common/database/appMigrator.ts'; @@ -20,7 +21,7 @@ export const up: Migration['up'] = async (db: SqliteDatabase while(more) { const batch = await repo.findPlays({with: ['input'], limit: 100, offset}); for(const row of batch) { - repo.updateById(row.id, {play: {...row.play, data: {...row.play.data, track: 'foo'}}}); + repo.updateById(row.id, {play: {...row.play, data: {...row.play.data, track: nameToCredit('foo')}}}); } updated += batch.length; ctx.logger.verbose(`Updated ${updated} total`); diff --git a/src/backend/tests/jellyfin/jellyfin.test.ts b/src/backend/tests/jellyfin/jellyfin.test.ts index 8d1fbe19..9c11f1fb 100644 --- a/src/backend/tests/jellyfin/jellyfin.test.ts +++ b/src/backend/tests/jellyfin/jellyfin.test.ts @@ -1,3 +1,4 @@ +import { creditMbid, playImage } from "../../../core/MusicMetadata.ts"; import { loggerTest } from "@foxxmd/logging"; import { expect } from 'chai'; import EventEmitter from "events"; @@ -115,6 +116,8 @@ describe("Jellyfin API Source", function() { describe('Parses and replaces frontendUrlOverride correctly if set', function () { const item: BaseItemDto = { + Name: 'A Track', + Album: 'An Album', AlbumId: '123', AlbumPrimaryImageTag: 'Primary', ParentId: '456', @@ -130,7 +133,7 @@ describe("Jellyfin API Source", function() { jf.api = jf.client.createApi(jf.address); jf.imageApi = getImageApi(jf.api); - expect(jf.formatPlayObjAware(item).meta.art!.album).to.be.eql(`${sourceUrl}/Items/123/Images/Primary?maxHeight=500`); + expect(playImage(jf.formatPlayObjAware(item).data, ['album', 'track'])).to.be.eql(`${sourceUrl}/Items/123/Images/Primary?maxHeight=500`); expect(jf.formatPlayObjAware(item).meta.url!.web).to.be.eql(`${sourceUrl}/web/#/details?id=456&serviceId=789`); await jf.destroy(); @@ -143,7 +146,7 @@ describe("Jellyfin API Source", function() { jf.api = jf.client.createApi(jf.address); jf.imageApi = getImageApi(jf.api); - expect(jf.formatPlayObjAware(item).meta.art!.album).to.be.eql(`${frontendUrlOverride}/Items/123/Images/Primary?maxHeight=500`); + expect(playImage(jf.formatPlayObjAware(item).data, ['album', 'track'])).to.be.eql(`${frontendUrlOverride}/Items/123/Images/Primary?maxHeight=500`); expect(jf.formatPlayObjAware(item).meta.url!.web).to.be.eql(`${frontendUrlOverride}/web/#/details?id=456&serviceId=789`); await jf.destroy(); @@ -498,8 +501,8 @@ describe("Jellyfin API Source", function() { const playStr = JellyfinApiSource.formatPlayObj(itemStr); - expect(playStr.data.artists![0].mbid).eq(itemStr.ProviderIds!.MusicBrainzArtist); - expect(playStr.data.albumArtists![0].mbid).eq(itemStr.ProviderIds!.MusicBrainzAlbumArtist); + expect(creditMbid(playStr.data.artists![0], 'artist')).eq(itemStr.ProviderIds!.MusicBrainzArtist); + expect(creditMbid(playStr.data.albumArtists![0], 'artist')).eq(itemStr.ProviderIds!.MusicBrainzAlbumArtist); }); it('Does not add mbids to artist props if there are multiple artists', async function () { @@ -515,8 +518,8 @@ describe("Jellyfin API Source", function() { const playStr = JellyfinApiSource.formatPlayObj(itemStr); - expect(playStr.data.artists![0].mbid).is.undefined; - expect(playStr.data.albumArtists![0].mbid).is.undefined; + expect(creditMbid(playStr.data.artists![0], 'artist')).is.undefined; + expect(creditMbid(playStr.data.albumArtists![0], 'artist')).is.undefined; }); }); diff --git a/src/backend/tests/lastfm/lastfm.test.ts b/src/backend/tests/lastfm/lastfm.test.ts index dcd7a3fe..29dd0d31 100644 --- a/src/backend/tests/lastfm/lastfm.test.ts +++ b/src/backend/tests/lastfm/lastfm.test.ts @@ -1,10 +1,11 @@ +import { creditIds, creditMbid } from "../../../core/MusicMetadata.ts"; import chai, { expect } from 'chai'; import asPromised from 'chai-as-promised'; import { describe, it } from 'mocha'; import { generateLastfmTrackObject, generateMbid, generatePlay } from "../../../core/tests/utils/PlayTestUtils.ts"; import LastfmApiClient, { playToClientPayload, formatPlayObj } from '../../common/vendor/LastfmApiClient.ts'; -import { artistNamesToCredits } from '../../../core/StringUtils.ts'; +import { namesToCredits } from '../../../core/StringUtils.ts'; import { withRequestInterception } from '../utils/networking.ts'; import { http, HttpResponse } from "msw"; import { loggerDebug, loggerTest } from '@foxxmd/logging'; @@ -19,10 +20,10 @@ chai.use(asPromised); describe('#LFM Scrobble Payload Behavior', function () { it('Should remove VA from album artist', function() { - const play = generatePlay({albumArtists: artistNamesToCredits(['VA'])}); + const play = generatePlay({albumArtists: namesToCredits(['VA'])}); expect(playToClientPayload(play).albumArtist).to.be.undefined; - const okPlay = generatePlay({albumArtists: artistNamesToCredits(['My Dude'])}); + const okPlay = generatePlay({albumArtists: namesToCredits(['My Dude'])}); expect(playToClientPayload(okPlay).albumArtist).eq('My Dude'); }); }); @@ -34,24 +35,24 @@ describe('#LFM Track to Play', function() { const to = generateLastfmTrackObject(); const play = formatPlayObj(to); - expect(play.data.meta?.brainz?.album).to.be.undefined; - expect(play.data.meta?.brainz?.artist).to.be.undefined; - expect(play.data.meta?.brainz?.recording).to.be.undefined; + expect(creditMbid(play.data.album, 'release')).to.be.undefined; + expect(creditIds(play.data.artists, 'musicbrainz', 'artist')).to.be.empty; + expect(creditMbid(play.data.track, 'recording')).to.be.undefined; }); - it('Sets brainz if any mbid is not undefined', function() { + it('Sets mbids on credits if any mbid is not undefined', function() { const toAlbum = generateLastfmTrackObject(); toAlbum.album.mbid = generateMbid(); - expect(formatPlayObj(toAlbum).data.meta?.brainz?.album).to.not.be.undefined; + expect(creditMbid(formatPlayObj(toAlbum).data.album, 'release')).to.eq(toAlbum.album.mbid); const toArtist = generateLastfmTrackObject(); toArtist.artist.mbid = generateMbid(); - expect(formatPlayObj(toArtist).data.meta?.brainz?.artist).to.not.be.undefined; + expect(creditIds(formatPlayObj(toArtist).data.artists, 'musicbrainz', 'artist')).to.eql([toArtist.artist.mbid]); const toTrack = generateLastfmTrackObject(); toTrack.mbid = generateMbid(); - expect(formatPlayObj(toTrack).data.meta?.brainz?.recording).to.not.be.undefined; + expect(creditMbid(formatPlayObj(toTrack).data.track, 'recording')).to.eq(toTrack.mbid); }); it('Sets artist correctly from #text or name', function() { diff --git a/src/backend/tests/listenbrainz/listenbrainz.test.ts b/src/backend/tests/listenbrainz/listenbrainz.test.ts index 230223d0..a4e58206 100644 --- a/src/backend/tests/listenbrainz/listenbrainz.test.ts +++ b/src/backend/tests/listenbrainz/listenbrainz.test.ts @@ -24,7 +24,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 "../../../core/tests/utils/PlayTestUtils.ts"; -import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.ts"; +import { creditsToNames, namesToCredits, nameToCredit } from "../../../core/StringUtils.ts"; interface LZTestFixture { data: ListenResponse @@ -36,24 +36,24 @@ describe('#PlayParse Listenbrainz Listen Parsing', function () { it('Uses user submitted values when no artist mappings', async function () { for(const test of noArtistMapping as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); it('Uses user-submitted values when when either mapped track/artist do not match', async function () { for(const test of veryWrong as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers( artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers( creditsToNames(play.data.artists!), test.expected.artists); } }); it('Should extract additional artists from track name', async function () { for(const test of incorrectMultiArtistsTrackName as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); }) @@ -64,48 +64,48 @@ describe('#PlayParse Listenbrainz Listen Parsing', function () { it('Detects slightly different track names as equal', async function () { for(const test of slightlyDifferentNames as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); it('Uses all mapped artists', async function () { for(const test of multiMappedArtistsWithSingleUserArtist as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); it('Respects artists with joiner symbols in proper names', async function () { for(const test of artistWithProperJoiner as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers( artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers( creditsToNames(play.data.artists!), test.expected.artists); } }); it('Detects user-submitted artists have joiners', async function () { for(const test of multiArtistInArtistName as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); it('Detects artists in user-submitted track', async function () { for(const test of multiArtistsInTrackName as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); it('Detects and uses normalized artist/track names', async function () { for(const test of normalizedValues as unknown as LZTestFixture[]) { const play = listenResponseToPlay(test.data); - assert.equal(play.data.track, test.expected.track); - assert.sameDeepMembers(artistCreditsToNames(play.data.artists!), test.expected.artists); + assert.equal(play.data.track?.name, test.expected.track); + assert.sameDeepMembers(creditsToNames(play.data.artists!), test.expected.artists); } }); }); @@ -128,9 +128,9 @@ describe('Listenbrainz Response Behavior', function() { async function() { const play: PlayObject = { data: { - artists: artistNamesToCredits(['Celldweller']), - album: 'The Complete Cellout, Volume 01', - track: 'Frozen', + artists: namesToCredits(['Celldweller']), + album: nameToCredit('The Complete Cellout, Volume 01'), + track: nameToCredit('Frozen'), duration: 299, playDate: dayjs(), meta: { @@ -171,9 +171,9 @@ describe('Listenbrainz Response Behavior', function() { async function() { const play: PlayObject = { data: { - artists: artistNamesToCredits(['Celldweller']), - album: 'The Complete Cellout, Volume 01', - track: 'Frozen', + artists: namesToCredits(['Celldweller']), + album: nameToCredit('The Complete Cellout, Volume 01'), + track: nameToCredit('Frozen'), duration: 299, playDate: dayjs(), meta: { @@ -200,7 +200,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should combine artist and artist_names', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}); const submitPayload = playToListenPayload(play); const additionalArtists = [...submitPayload.track_metadata.additional_info!.artist_names!, 'Artist B']; @@ -209,13 +209,13 @@ describe('Listenbrainz Endpoint Behavior', function() { const playFromPayload = listenPayloadToPlay(submitPayload); - expect(artistCreditsToNames(playFromPayload.data.artists!)).to.be.eql(additionalArtists) + expect(creditsToNames(playFromPayload.data.artists!)).to.be.eql(additionalArtists) }); it('Should combine artist and artist_names into a unique array', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}); const submitPayload = playToListenPayload(play); const additionalArtists = ['Artist A', 'Artist B']; @@ -224,13 +224,13 @@ describe('Listenbrainz Endpoint Behavior', function() { const playFromPayload = listenPayloadToPlay(submitPayload); - expect(artistCreditsToNames(playFromPayload.data.artists!)).to.be.eql(['Artist A', 'Artist B']) + expect(creditsToNames(playFromPayload.data.artists!)).to.be.eql(['Artist A', 'Artist B']) }); it('Should set music_service_name from source', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {source: 'Plex'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {source: 'Plex'}); const submitPayload = playToListenPayload(play); expect(submitPayload.track_metadata.additional_info!.music_service_name).to.be.eql('Plex') @@ -239,7 +239,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should not include any device info as media_player by default', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.undefined; @@ -248,7 +248,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should submit the allowlist KEY, never the raw device id, when the label is empty', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('iphone'); @@ -257,7 +257,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should submit the allowlist LABEL when the key has one', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'a1b2c3d4e5': 'phone'}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('phone'); @@ -266,7 +266,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should prefer the most specific (longest) matching key regardless of order', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': '', 'a1b2c3d4e5-iphone': 'kitchen ipad'}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('kitchen ipad'); @@ -275,7 +275,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should not report devices that are not enumerated in the allowlist', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'SmithsLivingRoom-Roku'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'SmithsLivingRoom-Roku'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': '', 'android-auto': ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.undefined; @@ -284,7 +284,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should not report a device when the device is not known', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: NO_DEVICE}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: NO_DEVICE}); const submitPayload = playToListenPayload(play, {allowDeviceList: {[NO_DEVICE.toLocaleLowerCase()]: ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.undefined; @@ -293,7 +293,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should prefer mediaPlayerName over the allowlist label for media_player', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone', mediaPlayerName: 'Rhythmbox'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone', mediaPlayerName: 'Rhythmbox'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('Rhythmbox'); @@ -302,7 +302,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should not include any device info as media_player by default', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.undefined; @@ -311,7 +311,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should submit the allowlist KEY, never the raw device id, when the label is empty', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('iphone'); @@ -320,7 +320,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should submit the allowlist LABEL when the key has one', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'a1b2c3d4e5': 'phone'}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('phone'); @@ -329,7 +329,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should prefer the most specific (longest) matching key regardless of order', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': '', 'a1b2c3d4e5-iphone': 'kitchen ipad'}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('kitchen ipad'); @@ -338,7 +338,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should not report devices that are not enumerated in the allowlist', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'SmithsLivingRoom-Roku'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'SmithsLivingRoom-Roku'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': '', 'android-auto': ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.undefined; @@ -347,7 +347,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should not report a device when the device is not known', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: NO_DEVICE}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: NO_DEVICE}); const submitPayload = playToListenPayload(play, {allowDeviceList: {[NO_DEVICE.toLocaleLowerCase()]: ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.undefined; @@ -356,7 +356,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should prefer mediaPlayerName over the allowlist label for media_player', function() { - const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone', mediaPlayerName: 'Rhythmbox'}); + const play = generatePlay({artists: namesToCredits(['Artist A']), albumArtists: []}, {deviceId: 'a1b2c3d4e5-iPhone', mediaPlayerName: 'Rhythmbox'}); const submitPayload = playToListenPayload(play, {allowDeviceList: {'iphone': ''}}); expect(submitPayload.track_metadata.additional_info!.media_player).to.be.eql('Rhythmbox'); @@ -367,7 +367,7 @@ describe('Listenbrainz Endpoint Behavior', function() { const playFromPayload = listenPayloadToPlay(submit); - expect(artistCreditsToNames(playFromPayload.data.artists!)).to.be.eql(submit.track_metadata.additional_info!.artist_names); + expect(creditsToNames(playFromPayload.data.artists!)).to.be.eql(submit.track_metadata.additional_info!.artist_names); }); diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index 70fa21f6..91423a6c 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -1,3 +1,4 @@ +import { creditMbid, mbMeta, withImage } from "../../../core/MusicMetadata.ts"; import * as dotenv from 'dotenv'; import { loggerTest } from "@foxxmd/logging"; import chai, { expect, assert } from 'chai'; @@ -16,7 +17,7 @@ import { http, HttpResponse, delay } from "msw"; import { generatePlay, withBrainz } from '../../../core/tests/utils/PlayTestUtils.ts'; import { intersect, missingMbidTypes, sleep } from '../../utils.ts'; import { CoverArtApiClient } from '../../common/vendor/musicbrainz/CoverArtApiClient.ts'; -import { artistNamesToCredits, artistNameToCredit } from '../../../core/StringUtils.ts'; +import { namesToCredits, nameToCredit } from '../../../core/StringUtils.ts'; import dayjs from 'dayjs'; import { MusicbrainzApiWrapped } from '../../common/vendor/musicbrainz/MusicbrainzApi.ts'; import { AsyncLocalStorage } from 'async_hooks'; @@ -68,9 +69,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Little Joe and Mary ii", - artists: artistNamesToCredits(["Khruangbin"]), - album: "The Universe Smiles Upon You ii" + track: nameToCredit("Little Joe and Mary ii"), + artists: namesToCredits(["Khruangbin"]), + album: nameToCredit("The Universe Smiles Upon You ii") }, meta: { @@ -93,10 +94,10 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: 'Cyber Space (CrossWorlds Remix): Final Lap (No Chants)', - album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions", + track: nameToCredit('Cyber Space (CrossWorlds Remix): Final Lap (No Chants)'), + album: nameToCredit("Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions"), artists: [ - artistNameToCredit("Kanon Oguni")! + nameToCredit("Kanon Oguni")! ] }, meta: { @@ -124,14 +125,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Fake", - artists: artistNamesToCredits(["Fake"]), - album: "Fake", - meta: { - brainz: { - recording: '026fa041-3917-4c73-9079-ed16e36f20f8' - } - } + track: nameToCredit("Fake", mbMeta('026fa041-3917-4c73-9079-ed16e36f20f8', 'recording')), + artists: namesToCredits(["Fake"]), + album: nameToCredit("Fake"), }, meta: { @@ -157,9 +153,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Fake", - artists: artistNamesToCredits(["Fake"]), - album: "Fake", + track: nameToCredit("Fake"), + artists: namesToCredits(["Fake"]), + album: nameToCredit("Fake"), isrc: 'GBAHT1600302' }, meta: { @@ -183,15 +179,10 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Berghain", - artists: artistNamesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), - albumArtists: artistNamesToCredits(["ROSALÍA"]), - album: "LUX", - meta: { - brainz: { - track: '47d5358a-d9eb-48db-babb-56284da8056b' - } - } + track: nameToCredit("Berghain", mbMeta('47d5358a-d9eb-48db-babb-56284da8056b', 'track')), + artists: namesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), + albumArtists: namesToCredits(["ROSALÍA"]), + album: nameToCredit("LUX"), }, meta: { @@ -209,7 +200,7 @@ describe('Musicbrainz API', function () { expect(res.recordings).to.exist; expect(res.recordings).to.not.be.empty; const postFetch = await mbTransformer.handlePostFetch(play, res, stageConfig); - expect(postFetch.data.meta!.brainz!.album).to.eq('e5913eac-3d74-47af-a3f2-7aa6618f140a'); + expect(creditMbid(postFetch.data.album, 'release')).to.eq('e5913eac-3d74-47af-a3f2-7aa6618f140a'); }); it('uses correct release if release mbid is explict', async function (){ @@ -217,15 +208,10 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Berghain", - artists: artistNamesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), - albumArtists: artistNamesToCredits(["ROSALÍA"]), - album: "LUX", - meta: { - brainz: { - album: 'e5913eac-3d74-47af-a3f2-7aa6618f140a' - } - } + track: nameToCredit("Berghain"), + artists: namesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), + albumArtists: namesToCredits(["ROSALÍA"]), + album: nameToCredit("LUX", mbMeta('e5913eac-3d74-47af-a3f2-7aa6618f140a', 'release')), }, meta: { @@ -243,7 +229,7 @@ describe('Musicbrainz API', function () { expect(res.recordings).to.exist; expect(res.recordings).to.not.be.empty; const postFetch = await mbTransformer.handlePostFetch(play, res, stageConfig); - expect(postFetch.data.meta!.brainz!.album).to.eq('e5913eac-3d74-47af-a3f2-7aa6618f140a'); + expect(creditMbid(postFetch.data.album, 'release')).to.eq('e5913eac-3d74-47af-a3f2-7aa6618f140a'); }); it('tries second query using only track and album', async function () { @@ -252,9 +238,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Roulette Road (CrossWorlds Remix)", - artists: artistNamesToCredits(["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"]), - album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions" + track: nameToCredit("Roulette Road (CrossWorlds Remix)"), + artists: namesToCredits(["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"]), + album: nameToCredit("Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions") }, meta: { @@ -277,8 +263,8 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Undefeatable (feat. Kellin Quinn)", - artists: artistNamesToCredits(["SEGA Sound Team / Tomoya Ohtani"]), + track: nameToCredit("Undefeatable (feat. Kellin Quinn)"), + artists: namesToCredits(["SEGA Sound Team / Tomoya Ohtani"]), }, meta: { @@ -302,9 +288,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Bad Apple!! feat.SEKAI", - artists: artistNamesToCredits(["、ナイトコードで。"]), - album: "25時、ナイトコードで。 SEKAI ALBUM Vol.3" + track: nameToCredit("Bad Apple!! feat.SEKAI"), + artists: namesToCredits(["、ナイトコードで。"]), + album: nameToCredit("25時、ナイトコードで。 SEKAI ALBUM Vol.3") }, meta: { @@ -327,9 +313,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "HIBANA - Reloaded - (feat. 星乃一歌 & Hatsune Miku)", - artists: artistNamesToCredits(["Leo/need"]), - album: "Leo / need SEKAI ALBUM Vol.1" + track: nameToCredit("HIBANA - Reloaded - (feat. 星乃一歌 & Hatsune Miku)"), + artists: namesToCredits(["Leo/need"]), + album: nameToCredit("Leo / need SEKAI ALBUM Vol.1") }, meta: { @@ -356,9 +342,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Price", - artists: artistNamesToCredits(["ATLUS Sound Team"]), - album: "PERSONA5 ORIGINAL SOUNDTRACK", + track: nameToCredit("Price"), + artists: namesToCredits(["ATLUS Sound Team"]), + album: nameToCredit("PERSONA5 ORIGINAL SOUNDTRACK"), isrc: 'JPK651601515' }, meta: { @@ -382,7 +368,7 @@ describe('Musicbrainz API', function () { titleWeight: 0.3, artistWeight: 0.3, }); - expect(chosenPlay.data.meta!.brainz!.album).to.eq("82de33b1-1cd6-4236-b116-561d0ecc8acf") + expect(creditMbid(chosenPlay.data.album, 'release')).to.eq("82de33b1-1cd6-4236-b116-561d0ecc8acf") }); it('records prerequisite failures', async function () { @@ -391,9 +377,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Hopes And Dreams (10th Anniversary Arrangement)", - artists: artistNamesToCredits(["KrakenPower"]), - album: "Hopes And Dreams (10th Anniversary Arrangement) - Single", + track: nameToCredit("Hopes And Dreams (10th Anniversary Arrangement)"), + artists: namesToCredits(["KrakenPower"]), + album: nameToCredit("Hopes And Dreams (10th Anniversary Arrangement) - Single"), duration: 278 }, meta: { @@ -436,28 +422,11 @@ describe('Musicbrainz API', function () { const play: PlayObject = { "data": { - "artists": [ - { - "name": "Au5", - "mbid": "3569c2ed-2315-40d9-b041-3c48dae1be43" - } - ], + "artists": [nameToCredit("Au5", mbMeta("3569c2ed-2315-40d9-b041-3c48dae1be43", 'artist'))], "albumArtists": [], - "album": "Inverse", - "track": "Scission", + "album": nameToCredit("Inverse", mbMeta("cbc89dff-3555-498c-bb1e-2ff983b13e59", 'release')), + "track": withImage(nameToCredit("Scission", mbMeta("c63b7e96-928c-48dd-b558-a181cb245cb6", 'track')), "/api/source/art?name=PlexBox&type=plex&data=84389"), "duration": 214.125, - "meta": { - "brainz": { - "track": "c63b7e96-928c-48dd-b558-a181cb245cb6", - "album": "cbc89dff-3555-498c-bb1e-2ff983b13e59", - "artist": [ - "3569c2ed-2315-40d9-b041-3c48dae1be43" - ], - "albumArtist": [ - "3569c2ed-2315-40d9-b041-3c48dae1be43" - ] - } - }, "playDate": dayjs(), "listenedFor": 209.601, "listenRanges": [], @@ -471,10 +440,7 @@ describe('Musicbrainz API', function () { "library": "Music", "deviceId": "sonos-0116-Plex for Sonos", "sessionId": "106", - "trackProgressPosition": 214.125, - "art": { - "track": "/api/source/art?name=PlexBox&type=plex&data=84389" - } + "trackProgressPosition": 214.125 }, }; @@ -515,9 +481,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Little Joe and Mary ii", - artists: artistNamesToCredits(["Khruangbin"]), - album: "The Universe Smiles Upon You ii" + track: nameToCredit("Little Joe and Mary ii"), + artists: namesToCredits(["Khruangbin"]), + album: nameToCredit("The Universe Smiles Upon You ii") }, meta: { @@ -549,9 +515,9 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { - track: "Little Joe and Mary ii", - artists: artistNamesToCredits(["Khruangbin"]), - album: "The Universe Smiles Upon You ii" + track: nameToCredit("Little Joe and Mary ii"), + artists: namesToCredits(["Khruangbin"]), + album: nameToCredit("The Universe Smiles Upon You ii") }, meta: { @@ -624,10 +590,10 @@ describe('#MB Missing Types', function() { expect(missing[0]).eq('duration'); }); - it('Finds brainz missing when fields are undefined', function() { + it('Finds brainz missing when credits have no mbids', function() { const play = generatePlay(); - play.data.meta = {brainz: {}}; + play.data.track = {...play.data.track!, metadata: []}; const missing = missingMbidTypes(play); expect(missing).to.have.members(['title','album','artists']); }); diff --git a/src/backend/tests/musiccast/musiccast.test.ts b/src/backend/tests/musiccast/musiccast.test.ts index ebc4a5de..a6ffb13f 100644 --- a/src/backend/tests/musiccast/musiccast.test.ts +++ b/src/backend/tests/musiccast/musiccast.test.ts @@ -105,8 +105,8 @@ describe('MusicCast State Handling', function() { await source.getRecentlyPlayed(); expect(source.players.size).to.eq(1); const playerState = source.players.get(source.players.keys().next().value!)!.getApiState(); - expect(playerState.play!.data.album).to.eq('Test Album'); - expect(playerState.play!.data.track).to.eq('Cool Track'); + expect(playerState.play!.data.album?.name).to.eq('Test Album'); + expect(playerState.play!.data.track?.name).to.eq('Cool Track'); expect(playerState.play!.data.duration).to.eq(60); expect(playerState.play!.meta.trackProgressPosition).to.eq(10); expect(playerState.play!.meta.deviceId).to.eq('av1'); @@ -148,8 +148,8 @@ describe('MusicCast State Handling', function() { await source.getRecentlyPlayed(); expect(source.players.size).to.eq(1); const playerState = source.players.get(source.players.keys().next().value!)!.getApiState(); - expect(playerState.play!.data.album).to.eq('Test Album'); - expect(playerState.play!.data.track).to.eq('Cool Track'); + expect(playerState.play!.data.album?.name).to.eq('Test Album'); + expect(playerState.play!.data.track?.name).to.eq('Cool Track'); expect(playerState.play!.data.duration).to.eq(60); expect(playerState.play!.meta.trackProgressPosition).to.eq(10); expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); diff --git a/src/backend/tests/player/player.test.ts b/src/backend/tests/player/player.test.ts index cc7ebe30..1dcf7297 100644 --- a/src/backend/tests/player/player.test.ts +++ b/src/backend/tests/player/player.test.ts @@ -1,3 +1,4 @@ +import { nameToCredit } from "../../../core/StringUtils.ts"; import { loggerTest } from "@foxxmd/logging"; import { assert, expect } from 'chai'; import clone from "clone"; @@ -271,7 +272,7 @@ describe('Player listen ranges', function () { player.update(testState({play: positioned, position: 20, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds')); const otherPlay = clone(positioned); - otherPlay.data.track = "A New Track"; + otherPlay.data.track = nameToCredit("A New Track"); player.currentListenRange!.rtPlayer.setPosition(30000); const [currPlay, prevPlay] = player.update(testState({play: otherPlay, position: 2, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(30, 'seconds')); diff --git a/src/backend/tests/plays/mixedDuration.json b/src/backend/tests/plays/mixedDuration.json index b3c856fb..373c5f88 100644 --- a/src/backend/tests/plays/mixedDuration.json +++ b/src/backend/tests/plays/mixedDuration.json @@ -4,7 +4,7 @@ "artists": [ {"name": "Kaidi Tatham"} ], - "track": "Fricassee", + "track": {"name": "Fricassee"}, "duration": 305, "playDate": "2023-09-20T14:32:39.000Z" } @@ -14,7 +14,7 @@ "artists": [ {"name": "Kuna Maze"} ], - "track": "Jimbó", + "track": {"name": "Jimbó"}, "playDate": "2023-09-20T14:46:00.000Z" } }, @@ -24,7 +24,7 @@ {"name": "Norman Person"}, {"name": "Shamek Farrah"} ], - "track": "Aisha", + "track": {"name": "Aisha"}, "duration": 559, "playDate": "2023-09-20T15:10:09.000Z" } @@ -35,7 +35,7 @@ {"name": "Frédéric Chopin"}, {"name": "Krystian Zimerman"} ], - "track": "Ballade No. 4 in F Minor, Op. 52", + "track": {"name": "Ballade No. 4 in F Minor, Op. 52"}, "duration": 575, "playDate": "2023-09-20T15:17:12.000Z" } @@ -46,7 +46,7 @@ {"name": "Nidia Gongora"}, {"name": "The Bongo Hop"} ], - "track": "Sonora", + "track": {"name": "Sonora"}, "duration": 327, "listenedFor": 327, "playDate": "2023-09-27T20:02:17.000Z" @@ -57,7 +57,7 @@ "artists": [ {"name": "Cheo Feliciano"} ], - "track": "Aprieta (Oye Cómo Va)", + "track": {"name": "Aprieta (Oye Cómo Va)"}, "playDate": "2023-09-20T15:38:12.000Z" } }, @@ -66,7 +66,7 @@ "artists": [ {"name": "Pink Floyd"} ], - "track": "Another Brick in the Wall, Pt. 1", + "track": {"name": "Another Brick in the Wall, Pt. 1"}, "playDate": "2023-09-20T15:39:12.000Z" } }, @@ -76,7 +76,7 @@ {"name": "Frédéric Chopin"}, {"name": "Krystian Zimerman"} ], - "track": "Ballade No. 1 in G Minor, Op. 23", + "track": {"name": "Ballade No. 1 in G Minor, Op. 23"}, "duration": 575, "listenedFor": 570, "playDate": "2023-09-20T15:41:12.000Z" @@ -89,7 +89,7 @@ {"name": "Jazz Funk"}, {"name": "Disco with Mehdi El"} ], - "track": "Aquil", + "track": {"name": "Aquil"}, "listenedFor": 300, "playDate": "2023-09-20T17:55:05.000Z" } @@ -99,7 +99,7 @@ "artists": [ {"name": "Outkast"} ], - "track": "Da Art of Storytellin' (Pt. 1)", + "track": {"name": "Da Art of Storytellin' (Pt. 1)"}, "duration": 422, "playDate": "2023-09-20T15:39:12.000Z" } @@ -115,8 +115,8 @@ {"name": "Robert Glasper"}, {"name": "Terrace Martin"} ], - "track": "Freeze Tag", - "album": "Dinner Party: Dessert", + "track": {"name": "Freeze Tag"}, + "album": {"name": "Dinner Party: Dessert"}, "playDate": "2024-01-01T16:29:26.000Z" } } diff --git a/src/backend/tests/plays/withDuration.json b/src/backend/tests/plays/withDuration.json index f20e32e3..c9b24e27 100644 --- a/src/backend/tests/plays/withDuration.json +++ b/src/backend/tests/plays/withDuration.json @@ -4,7 +4,7 @@ "artists": [ {"name": "Kaidi Tatham"} ], - "track": "Fricassee", + "track": {"name": "Fricassee"}, "duration": 305, "listenedFor": 305, "playDate": "2023-09-20T14:32:39.000Z" @@ -18,7 +18,7 @@ "artists": [ {"name": "Kuna Maze"} ], - "track": "Jimbó", + "track": {"name": "Jimbó"}, "duration": 255, "listenedFor": 254, "playDate": "2023-09-20T14:46:00.000Z" @@ -33,7 +33,7 @@ {"name": "Norman Person"}, {"name": "Shamek Farrah"} ], - "track": "Aisha", + "track": {"name": "Aisha"}, "duration": 559, "listenedFor": 559, "playDate": "2023-09-20T15:10:09.000Z" @@ -48,7 +48,7 @@ {"name": "Frédéric Chopin"}, {"name": "Krystian Zimerman"} ], - "track": "Ballade No. 4 in F Minor, Op. 52", + "track": {"name": "Ballade No. 4 in F Minor, Op. 52"}, "duration": 575, "listenedFor": 550, "playDate": "2023-09-20T15:17:12.000Z" @@ -63,7 +63,7 @@ {"name": "Nidia Gongora"}, {"name": "The Bongo Hop"} ], - "track": "Sonora", + "track": {"name": "Sonora"}, "duration": 327, "listenedFor": 300, "playDate": "2023-09-27T20:02:17.000Z" @@ -77,7 +77,7 @@ "artists": [ {"name": "Cheo Feliciano"} ], - "track": "Aprieta (Oye Cómo Va)", + "track": {"name": "Aprieta (Oye Cómo Va)"}, "duration": 195, "listenedFor": 190, "playDate": "2023-09-20T15:38:12.000Z" @@ -91,7 +91,7 @@ "artists": [ {"name": "Pink Floyd"} ], - "track": "Another Brick in the Wall, Pt. 1", + "track": {"name": "Another Brick in the Wall, Pt. 1"}, "duration": 192, "listenedFor": 190, "playDate": "2023-09-20T15:39:12.000Z" @@ -106,7 +106,7 @@ {"name": "Frédéric Chopin"}, {"name": "Krystian Zimerman"} ], - "track": "Ballade No. 1 in G Minor, Op. 23", + "track": {"name": "Ballade No. 1 in G Minor, Op. 23"}, "duration": 575, "listenedFor": 560, "playDate": "2023-09-20T15:41:12.000Z" @@ -122,7 +122,7 @@ {"name": "Jazz Funk"}, {"name": "Disco with Mehdi El"} ], - "track": "Aquil", + "track": {"name": "Aquil"}, "duration": 230, "listenedFor": 200, "playDate": "2023-09-20T17:55:05.000Z" @@ -136,7 +136,7 @@ "artists": [ {"name": "Outkast"} ], - "track": "Da Art of Storytellin' (Pt. 1)", + "track": {"name": "Da Art of Storytellin' (Pt. 1)"}, "duration": 422, "listenedFor": 400, "playDate": "2023-09-20T15:39:12.000Z" diff --git a/src/backend/tests/rocksky/rocksky.test.ts b/src/backend/tests/rocksky/rocksky.test.ts index bc2dc442..6d2745ef 100644 --- a/src/backend/tests/rocksky/rocksky.test.ts +++ b/src/backend/tests/rocksky/rocksky.test.ts @@ -8,7 +8,7 @@ import { Cacheable } from "cacheable"; import { DEFAULT_ROCKSKY_MISSING_TYPES, type PlayObject } from "../../../core/Atomic.ts"; import { getPathFromCWD } from '../../common/index.ts'; import path from 'path'; -import { artistNamesToCredits } from '../../../core/StringUtils.ts'; +import { namesToCredits, nameToCredit } from '../../../core/StringUtils.ts'; import type { RockskyApiClientConfig } from '../../common/vendor/rocksky/interfaces.ts'; import type { MarkRequired } from 'ts-essentials'; import RockskyTransformer, { DEFAULT_SEARCHTYPE_ORDER } from '../../common/transforms/rocksky/RockskyTransformer.ts'; @@ -47,9 +47,9 @@ describe('Rocksky API', function () { const play: PlayObject = { data: { - track: "Little Joe and Mary ii", - artists: artistNamesToCredits(["Khruangbin"]), - album: "The Universe Smiles Upon You ii" + track: nameToCredit("Little Joe and Mary ii"), + artists: namesToCredits(["Khruangbin"]), + album: nameToCredit("The Universe Smiles Upon You ii") }, meta: { @@ -72,7 +72,7 @@ describe('Rocksky API', function () { // const play: PlayObject = { // data: { // track: "Come Together", - // artists: artistNamesToCredits(["The Beatles"]), + // artists: namesToCredits(["The Beatles"]), // album: "Abbey Road", // isrc: 'GBAHT1600302' // won't work // }, @@ -96,9 +96,9 @@ describe('Rocksky API', function () { const play: PlayObject = { data: { - track: "JUST A TEST IT WON'T WORK", - artists: artistNamesToCredits(["ASDNVLKUFDOSF"]), - album: "FSDFDFDFDD" + track: nameToCredit("JUST A TEST IT WON'T WORK"), + artists: namesToCredits(["ASDNVLKUFDOSF"]), + album: nameToCredit("FSDFDFDFDD") }, meta: { diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 8713d113..fceb11fc 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -27,7 +27,7 @@ import { generateArray } from '../../../core/DataUtils.ts'; import type {RepositoryCreatePlayOpts} from '../../common/database/drizzle/repositories/PlayRepository.ts'; import { fixtureCreatePlay } from '../utils/databaseFixtures.ts'; import { isAbortError } from 'abort-controller-x'; -import { artistNamesToCredits } from '../../../core/StringUtils.ts'; +import { namesToCredits, nameToCredit } from '../../../core/StringUtils.ts'; import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; import { WildcardEmitter } from '../../common/WildcardEmitter.ts'; import type { CommonClientConfig } from '../../common/infrastructure/config/client/index.ts'; @@ -153,8 +153,8 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu testScrobbler.testRecentScrobbles = normalizedWithMixedDur; const uniquePlay = generatePlay({ - artists: artistNamesToCredits(["2814"]), - track: "新宿ゴールデン街", + artists: namesToCredits(["2814"]), + track: nameToCredit("新宿ゴールデン街"), duration: 130, playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate!.add(6, 'minutes') }); @@ -173,7 +173,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu const diffPlay = clone(normalizedWithMixedDur[1]); diffPlay.data.playDate = diffPlay.data.playDate!.add(9, 's'); - diffPlay.data.track = 'A Totally Different Track' + diffPlay.data.track = nameToCredit('A Totally Different Track') assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]); }); @@ -185,7 +185,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu const diffPlay = clone(normalizedWithMixedDur[1]); diffPlay.data.playDate = diffPlay.data.playDate!.add(9, 's'); - diffPlay.data.artists = artistNamesToCredits(['A Different Artist']); + diffPlay.data.artists = namesToCredits(['A Different Artist']); assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]); }); @@ -232,22 +232,22 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithDur; - const brickPt1 = normalizedWithDur.find(x => x.data.track!.includes('Another Brick')); + const brickPt1 = normalizedWithDur.find(x => x.data.track!.name.includes('Another Brick')); const brickPt2 = clone(brickPt1); - brickPt2!.data.track = 'Another Brick in the Wall, Pt. 2'; + brickPt2!.data.track = nameToCredit('Another Brick in the Wall, Pt. 2'); brickPt2!.data.playDate = brickPt1!.data.playDate!.add(brickPt1!.data.duration! + 1, 'seconds'); assert.isFalse((await testScrobbler.alreadyScrobbled(brickPt2!))[0]); - const story1 = normalizedWithDur.find(x => x.data.track!.includes('Da Art of')); + const story1 = normalizedWithDur.find(x => x.data.track!.name.includes('Da Art of')); const story2 = clone(story1); - story2!.data.track = `Da Art of Storytellin' (Pt. 2)`; + story2!.data.track = nameToCredit(`Da Art of Storytellin' (Pt. 2)`); story2!.data.playDate = story2!.data.playDate!.add(story1!.data.duration! + 1, 'seconds'); assert.isFalse((await testScrobbler.alreadyScrobbled(story2!))[0]); - const ballad1 = normalizedWithDur.find(x => x.data.track!.includes('Ballade No. 1')); + const ballad1 = normalizedWithDur.find(x => x.data.track!.name.includes('Ballade No. 1')); const ballad2 = clone(ballad1); - ballad2!.data.track = `Ballade No. 2 in G Minor, Op. 27`; + ballad2!.data.track = nameToCredit(`Ballade No. 2 in G Minor, Op. 27`); ballad2!.data.playDate = ballad2!.data.playDate!.add(ballad1!.data.duration! + 1, 'seconds'); assert.isFalse((await testScrobbler.alreadyScrobbled(ballad2!))[0]); @@ -274,8 +274,8 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu it('Is not detected as duplicate when play date matches fuzzy and play source SOT is history', async function () { const play = generatePlay({ - artists: artistNamesToCredits(['Nejad']), - track: 'CODE', + artists: namesToCredits(['Nejad']), + track: nameToCredit('CODE'), album: undefined, playDate: dayjs().subtract(179, 's'), duration: 179 @@ -311,13 +311,13 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu diffPlay.data.playDate = diffPlay.data.playDate!.add(9, 's'); - diffPlay.data.track = ref.data.track!.toUpperCase(); + diffPlay.data.track = nameToCredit(ref.data.track!.name.toUpperCase()); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); - diffPlay.data.track = ` ${ref.data.track} `; + diffPlay.data.track = nameToCredit(` ${ref.data.track?.name} `); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); - diffPlay.data.track = ref.data.track!.replaceAll(' ', ' '); + diffPlay.data.track = nameToCredit(ref.data.track!.name.replaceAll(' ', ' ')); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); diffPlay.data.artists = ref.data.artists!.map(x => ({...x, name: x.name.toUpperCase()})); @@ -330,11 +330,11 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu it('Is detected as duplicate when artist/title differences are from unicode normalization', async function () { await using testScrobbler = await generateTestScrobbler(); testScrobbler.testRecentScrobbles = normalizedWithMixedDur; - const ref = normalizedWithMixedDur.find(x => x.data.track === 'Jimbó'); + const ref = normalizedWithMixedDur.find(x => x.data.track?.name === 'Jimbó'); const diffPlay = clone(ref); diffPlay!.data.playDate = diffPlay!.data.playDate!.add(9, 's'); - diffPlay!.data.track = 'Jimbo'; + diffPlay!.data.track = nameToCredit('Jimbo'); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay!))[0]); }); @@ -353,7 +353,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]); // 10 seconds fuzzy diff inclusive - const son = normalizedWithMixedDurOlder.find(x => x.data.track === 'Sonora') + const son = normalizedWithMixedDurOlder.find(x => x.data.track?.name === 'Sonora') son!.data.playDate = dayjs().subtract(1, 'hour').set('minute', 26).set('second', 20); son!.data.duration = 267; son!.data.listenedFor = undefined; @@ -397,7 +397,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); - const son = normalizedWithMixedDur.find(x => x.data.track === 'Sonora') + const son = normalizedWithMixedDur.find(x => x.data.track?.name === 'Sonora') const sonDiffPlay = clone(son); sonDiffPlay!.data.playDate = sonDiffPlay!.data.playDate!.subtract(son!.data.duration! + 1, 's'); @@ -405,12 +405,12 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu }); it('Is detected as duplicate when artists are included in joiner', async function () { - const ref = normalizedWithMixedDurOlder.find(x => x.data.track === 'Freeze Tag'); + const ref = normalizedWithMixedDurOlder.find(x => x.data.track?.name === 'Freeze Tag'); ref!.data.playDate = dayjs().subtract(1, 'hour').set('minute', 29).set('second', 26) const spotifyPlay: PlayObject = { data: { - artists: artistNamesToCredits([ + artists: namesToCredits([ "Terrace Martin", "Robert Glasper", "9th Wonder", @@ -419,8 +419,8 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu "Cordae", "Phoelix" ]), - album: "Dinner Party: Dessert", - track: "Freeze Tag (feat. Cordae & Phoelix)", + album: nameToCredit("Dinner Party: Dessert"), + track: nameToCredit("Freeze Tag (feat. Cordae & Phoelix)"), "duration": 191.375, "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27) }, @@ -658,7 +658,7 @@ describe('Scrobble client uses transform plays correctly', function() { // track: 'my cool track' // }); // testScrobbler.queuePlay(newScrobble, 'test'); - // expect(testScrobbler.queuedScrobbles[0].play.data.track).is.eq('my track'); + // expect(testScrobbler.queuedScrobbles[0].play.data.track?.name).is.eq('my track'); // }); it('Transforms play on scrobble when postCompare is present', async function() { @@ -675,16 +675,16 @@ describe('Scrobble client uses transform plays correctly', function() { } await testScrobbler.buildTransformRules(); const newScrobble = generatePlay({ - track: 'my cool track' + track: nameToCredit('my cool track') }); await testScrobbler.queuePlay(newScrobble); const queuedPlayedData = await testScrobbler.playRepoTest.getQueued(INGRESS_QUEUE); - expect(queuedPlayedData.data[0].play.data.track).is.eq('my cool track'); + expect(queuedPlayedData.data[0].play.data.track?.name).is.eq('my cool track'); testScrobbler.scrobbleSleep = 100; testScrobbler.initScrobbleMonitoring().catch(console.error); const e = (await pEvent(testScrobbler.emitter, 'scrobble')) as {data: {play: PlayObject }}; - expect(e.data.play.data.track).is.eq('my track'); + expect(e.data.play.data.track?.name).is.eq('my track'); }); it('Transforms candidate play on comparison', async function() { @@ -702,7 +702,7 @@ describe('Scrobble client uses transform plays correctly', function() { } } const newScrobble = generatePlay({ - track: 'my hugely cool and very different track title' + track: nameToCredit('my hugely cool and very different track title') }); testScrobbler.testRecentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate}); @@ -726,7 +726,7 @@ describe('Scrobble client uses transform plays correctly', function() { } } const newScrobble = generatePlay({ - track: 'my hugely cool and very different track title' + track: nameToCredit('my hugely cool and very different track title') }); testScrobbler.testRecentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate}); diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index 90806579..db58e3bf 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -18,7 +18,7 @@ import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePl import { sleep } from "../../utils.ts"; import DeezerInternalSource from "../../sources/DeezerInternalSource.ts"; import type {DeezerInternalSourceOptions} from "../../common/infrastructure/config/source/deezer.ts"; -import { artistCreditsToNames } from "../../../core/StringUtils.ts"; +import { creditsToNames, nameToCredit } from "../../../core/StringUtils.ts"; import type { MarkOptional } from "ts-essentials"; import { WildcardEmitter } from "../../common/WildcardEmitter.ts"; import type { MSBackendEventMap } from "../../common/infrastructure/MSBackendEventMap.ts"; @@ -68,13 +68,13 @@ describe('Sources use transform plays correctly', function () { }; await source.buildTransformRules(); const newScrobble = generatePlay({ - track: 'my cool track' + track: nameToCredit('my cool track') }); await source.queuePlay([newScrobble]); await sleep(3); const discovered = await source.getRecentlyDiscoveredPlays(); expect(discovered.length).eq(1); - expect(discovered[0].data.track).is.eq('my fun track'); + expect(discovered[0].data.track?.name).is.eq('my fun track'); }); it('Transforms play on postCompare', async function() { @@ -94,7 +94,7 @@ describe('Sources use transform plays correctly', function () { }; await source.buildTransformRules(); const newScrobble = generatePlay({ - track: 'my cool track' + track: nameToCredit('my cool track') }); const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise; @@ -109,7 +109,7 @@ describe('Sources use transform plays correctly', function () { //await sleep(3); const discovered = await source.getRecentlyDiscoveredPlays(); expect(discovered.length).eq(1); - expect(discovered[0].data.track).is.eq('my cool track'); + expect(discovered[0].data.track?.name).is.eq('my cool track'); //const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise; //source.handle(discovered); @@ -117,7 +117,7 @@ describe('Sources use transform plays correctly', function () { expect(e).is.not.undefined; const res: PlayObject[] = !Array.isArray(e.data.data) ? [e.data.data] : e.data.data; expect(res.length).is.eq(1); - expect(res[0].data.track).is.eq('my fun track'); + expect(res[0].data.track?.name).is.eq('my fun track'); }); // TODO need to rework these @@ -145,7 +145,7 @@ describe('Sources use transform plays correctly', function () { // }); // const discovered = await source.discover([newScrobble]) // expect(discovered.length).eq(1); - // expect(discovered[0].data.track).is.eq('my hugely cool and very different track title'); + // expect(discovered[0].data.track?.name).is.eq('my hugely cool and very different track title'); // expect((await source.discover([newScrobble])).length).is.eq(1); // }); @@ -173,7 +173,7 @@ describe('Sources use transform plays correctly', function () { // }); // const discovered = await source.discover([newScrobble]) // expect(discovered.length).eq(1); - // expect(discovered[0].data.track).is.eq('my hugely cool and very different track title'); + // expect(discovered[0].data.track?.name).is.eq('my hugely cool and very different track title'); // expect((await source.discover([newScrobble])).length).is.eq(1); // }); @@ -186,28 +186,28 @@ describe('Sources correctly parse incoming payloads', function () { const noAAPayload = clone(spotifyPayload) noAAPayload.item.album.artists = []; const play = SpotifySource.formatPlayObj(noAAPayload as SpotifyApi.CurrentPlaybackResponse); - expect(play.data.track).eq('The Sandpits Of Zonhoven'); - expect(play.data.album).eq('Bloodbags And Downtube Shifters'); - expect(artistCreditsToNames(play.data.artists!)).eql(['Dubmood', 'MASTER BOOT RECORD']); + expect(play.data.track?.name).eq('The Sandpits Of Zonhoven'); + expect(play.data.album?.name).eq('Bloodbags And Downtube Shifters'); + expect(creditsToNames(play.data.artists!)).eql(['Dubmood', 'MASTER BOOT RECORD']); expect(play.data.albumArtists).to.be.empty; }); it('Spotify parses payload with different album artists correctly', function() { const play = SpotifySource.formatPlayObj(spotifyPayload as SpotifyApi.CurrentPlaybackResponse); - expect(play.data.track).eq('The Sandpits Of Zonhoven'); - expect(play.data.album).eq('Bloodbags And Downtube Shifters'); - expect(artistCreditsToNames(play.data.artists!)).eql(['Dubmood', 'MASTER BOOT RECORD']); - expect(artistCreditsToNames(play.data.albumArtists!)).eql(['Dubmood']); + expect(play.data.track?.name).eq('The Sandpits Of Zonhoven'); + expect(play.data.album?.name).eq('Bloodbags And Downtube Shifters'); + expect(creditsToNames(play.data.artists!)).eql(['Dubmood', 'MASTER BOOT RECORD']); + expect(creditsToNames(play.data.albumArtists!)).eql(['Dubmood']); }); it('Spotify parses payload with identical album artists correctly', function() { const identicalArtistsPayload = clone(spotifyPayload) identicalArtistsPayload.item.album.artists = identicalArtistsPayload.item.artists; const identicalArtistsPlay = SpotifySource.formatPlayObj(identicalArtistsPayload as SpotifyApi.CurrentPlaybackResponse); - expect(identicalArtistsPlay.data.track).eq('The Sandpits Of Zonhoven'); - expect(identicalArtistsPlay.data.album).eq('Bloodbags And Downtube Shifters'); - expect(artistCreditsToNames(identicalArtistsPlay.data.artists!)).eql(['Dubmood', 'MASTER BOOT RECORD']); - expect(artistCreditsToNames(identicalArtistsPlay.data.albumArtists!)).to.be.empty; + expect(identicalArtistsPlay.data.track?.name).eq('The Sandpits Of Zonhoven'); + expect(identicalArtistsPlay.data.album?.name).eq('Bloodbags And Downtube Shifters'); + expect(creditsToNames(identicalArtistsPlay.data.artists!)).eql(['Dubmood', 'MASTER BOOT RECORD']); + expect(creditsToNames(identicalArtistsPlay.data.albumArtists!)).to.be.empty; }); }); diff --git a/src/backend/tests/spotify/spotify.test.ts b/src/backend/tests/spotify/spotify.test.ts index deec6174..09a47885 100644 --- a/src/backend/tests/spotify/spotify.test.ts +++ b/src/backend/tests/spotify/spotify.test.ts @@ -113,7 +113,7 @@ describe('Spotify - ISRC Enrichment', function () { expect(play).to.not.be.undefined; expect(play?.data.isrc).to.be.undefined; - expect(play?.data.track).to.equal('The Sandpits Of Zonhoven'); + expect(play?.data.track?.name).to.equal('The Sandpits Of Zonhoven'); }); }); diff --git a/src/backend/tests/spotify/spotifyTransformer.test.ts b/src/backend/tests/spotify/spotifyTransformer.test.ts index 00482a02..a96d4405 100644 --- a/src/backend/tests/spotify/spotifyTransformer.test.ts +++ b/src/backend/tests/spotify/spotifyTransformer.test.ts @@ -1,3 +1,5 @@ +import { creditId, creditIds, spotifyMeta, withImage } from "../../../core/MusicMetadata.ts"; +import { nameToCredit, creditsToNames } from "../../../core/StringUtils.ts"; import { loggerTest } from '@foxxmd/logging'; import { Cacheable } from 'cacheable'; import chai, { expect } from 'chai'; @@ -20,9 +22,9 @@ chai.use(asPromised); const basePlay = (data: Partial = {}, meta: Partial = {}): PlayObject => ({ data: { - track: 'My Track', + track: nameToCredit('My Track'), artists: [{ name: 'My Artist' }], - album: 'My Album', + album: nameToCredit('My Album'), isrc: '1234', duration: 180, ...data, @@ -127,15 +129,21 @@ describe('Spotify Transformer', function () { }); it('returns empty when all missing types are present', function () { - const play = basePlay({}, {art: {album: 'https://example.com'}}); - play.data.meta = { spotify: { track: 't1', album: 'a1', artist: ['ar1'] } }; + const play = basePlay({ + track: nameToCredit('My Track', spotifyMeta('t1', 'track')), + album: withImage(nameToCredit('My Album', spotifyMeta('a1', 'album')), 'https://example.com'), + artists: [nameToCredit('My Artist', spotifyMeta('ar1', 'artist'))] + }); const missing = missingSpotifyTypes(play); expect(missing).to.be.empty; }); it('flags artists as missing when spotify artist id count does not match play artist count', function () { - const play = basePlay({ artists: [{ name: 'One' }, { name: 'Two' }] }); - play.data.meta = { spotify: { track: 't1', album: 'a1', artist: ['ar1'] } }; + const play = basePlay({ + track: nameToCredit('My Track', spotifyMeta('t1', 'track')), + album: nameToCredit('My Album', spotifyMeta('a1', 'album')), + artists: [nameToCredit('One', spotifyMeta('ar1', 'artist')), nameToCredit('Two')] + }); const missing = missingSpotifyTypes(play); expect(missing).to.include('ids'); }); @@ -146,14 +154,15 @@ describe('Spotify Transformer', function () { it('maps core fields and does not duplicate album artists that match track artists', function () { const track = fakeTrack(); const play = trackToPlay(track); - expect(play.data.track).to.equal('My Track'); - expect(play.data.album).to.equal('My Album'); + expect(play.data.track?.name).to.equal('My Track'); + expect(play.data.album?.name).to.equal('My Album'); expect(play.data.isrc).to.equal('USRC17607839'); expect(play.data.duration).to.equal(180); - expect(play.data.artists).to.deep.equal([{ name: 'My Artist' }]); + expect(creditsToNames(play.data.artists)).to.deep.equal(['My Artist']); + expect(creditIds(play.data.artists, 'spotify', 'artist')).to.deep.equal(['artist1']); expect(play.data.albumArtists).to.deep.equal([]); - expect(play.data.meta!.spotify!.track).to.equal('track1'); - expect(play.data.meta!.spotify!.album).to.equal('album1'); + expect(creditId(play.data.track, 'spotify', 'track')).to.equal('track1'); + expect(creditId(play.data.album, 'spotify', 'album')).to.equal('album1'); }); it('includes album artists when they differ from track artists', function () { @@ -162,7 +171,7 @@ describe('Spotify Transformer', function () { albumArtists: [artist('artist2', 'Various Artists')], }); const play = trackToPlay(track); - expect(play.data.albumArtists).to.deep.equal([{ name: 'Various Artists' }]); + expect(creditsToNames(play.data.albumArtists)).to.deep.equal(['Various Artists']); }); }); @@ -178,7 +187,7 @@ describe('Spotify Transformer', function () { const stageConfig = { type: 'spotify' } as SpotifyTransformerDataStage; it('ranks the candidate closest to the original scrobble highest', function () { - const play = basePlay({ track: 'Little Joe and Mary', artists: [{ name: 'Khruangbin' }], album: 'The Universe Smiles Upon You' }); + const play = basePlay({ track: nameToCredit('Little Joe and Mary'), artists: [{ name: 'Khruangbin' }], album: nameToCredit('The Universe Smiles Upon You') }); const goodMatch = fakeTrack({ id: 'good', @@ -199,7 +208,7 @@ describe('Spotify Transformer', function () { }); it('deprioritizes compilation matches when configured', function () { - const play = basePlay({ track: 'Little Joe and Mary', artists: [{ name: 'Khruangbin' }], album: 'The Universe Smiles Upon You' }); + const play = basePlay({ track: nameToCredit('Little Joe and Mary'), artists: [{ name: 'Khruangbin' }], album: nameToCredit('The Universe Smiles Upon You') }); // identical text match on both candidates -- only the compilation flag differs const compilationMatch = fakeTrack({ id: 'comp', albumType: 'compilation' }); @@ -229,27 +238,27 @@ describe('Spotify Transformer', function () { it('uses an ISRC match even when its title/artist text scores below the minimum threshold', async function () { // scrobble source title is drastically different from the Spotify catalog title (localized/theatrical // edition naming) but the ISRC identifies it as the same recording - const play = basePlay({ track: 'KAISEI:Movie Edition from Project SEKAI', artists: [{ name: 'Project SEKAI' }], isrc: 'JPPO02201234' }); + const play = basePlay({ track: nameToCredit('KAISEI:Movie Edition from Project SEKAI'), artists: [{ name: 'Project SEKAI' }], isrc: 'JPPO02201234' }); const track = fakeTrack({ name: '快晴「劇場版プロジェクトセカイ」ver.', artists: [artist('a1', 'Project SEKAI')], isrc: 'JPPO02201234' }); const result = await transformer.handlePostFetch(play, { tracks: [track], requestQueries: [], searchType: 'isrc' }, stageConfig); - expect(result.data.track).to.equal('快晴「劇場版プロジェクトセカイ」ver.'); + expect(result.data.track?.name).to.equal('快晴「劇場版プロジェクトセカイ」ver.'); }); it('still filters a basic-search match by the minimum score threshold', async function () { - const play = basePlay({ track: 'KAISEI:Movie Edition from Project SEKAI', artists: [{ name: 'Project SEKAI' }], album: 'Original Soundtrack' }); + const play = basePlay({ track: nameToCredit('KAISEI:Movie Edition from Project SEKAI'), artists: [{ name: 'Project SEKAI' }], album: nameToCredit('Original Soundtrack') }); const track = fakeTrack({ name: '快晴「劇場版プロジェクトセカイ」ver.', artists: [artist('a1', 'Project SEKAI')], albumName: 'Theatrical Edition Single' }); await expect(transformer.handlePostFetch(play, { tracks: [track], requestQueries: [], searchType: 'basic' }, stageConfig)).to.be.rejected; }); it('picks the best-matching candidate by fuzzy score when an ISRC returns more than one album', async function () { - const play = basePlay({ track: 'My Track', artists: [{ name: 'My Artist' }], album: 'The Real Album', isrc: 'USRC17607839' }); + const play = basePlay({ track: nameToCredit('My Track'), artists: [{ name: 'My Artist' }], album: nameToCredit('The Real Album'), isrc: 'USRC17607839' }); const wrongAlbum = fakeTrack({ id: 'wrong', albumName: 'Some Compilation', isrc: 'USRC17607839' }); const rightAlbum = fakeTrack({ id: 'right', albumName: 'The Real Album', isrc: 'USRC17607839' }); const result = await transformer.handlePostFetch(play, { tracks: [wrongAlbum, rightAlbum], requestQueries: [], searchType: 'isrc' }, stageConfig); - expect(result.data.meta!.spotify!.track).to.equal('right'); + expect(creditId(result.data.track, 'spotify', 'track')).to.equal('right'); }); }); }); diff --git a/src/backend/tests/subsonic/subsonic.test.ts b/src/backend/tests/subsonic/subsonic.test.ts index 07673d63..9628115f 100644 --- a/src/backend/tests/subsonic/subsonic.test.ts +++ b/src/backend/tests/subsonic/subsonic.test.ts @@ -176,7 +176,7 @@ describe('Subsonic playback reports', () => { })); expect(state.platformId).to.deep.equal(['player-id', 'user']); - expect(state.play!.data.track).to.equal('Track'); + expect(state.play!.data.track?.name).to.equal('Track'); expect(state.status).to.equal(REPORTED_PLAYER_STATUSES.playing); expect(state.position).to.equal(12.345); expect(state.play!.meta.trackProgressPosition).to.equal(12.345); diff --git a/src/backend/tests/tealfm/tealfm.test.ts b/src/backend/tests/tealfm/tealfm.test.ts index 99b1ef62..6f1ff5e6 100644 --- a/src/backend/tests/tealfm/tealfm.test.ts +++ b/src/backend/tests/tealfm/tealfm.test.ts @@ -1,3 +1,4 @@ +import { creditIds, creditMbid } from "../../../core/MusicMetadata.ts"; import chai, { expect } from 'chai'; import asPromised from 'chai-as-promised'; import { before, describe, it } from 'mocha'; @@ -5,7 +6,7 @@ import { generateArtistCredits, generatePlay, generateTealPlayRecord, withBrainz import { listRecordToPlay } from "../../common/vendor/teal/TealApiClient.ts"; import { playToRecord } from "../../common/vendor/teal/TealApiClient.ts"; import dayjs from 'dayjs'; -import { artistCreditsToNames } from '../../../core/StringUtils.ts'; +import { creditsToNames } from '../../../core/StringUtils.ts'; import TealScrobbler from '../../scrobblers/TealfmScrobbler.ts'; import { EventEmitter } from "events"; import path from 'node:path'; @@ -22,11 +23,11 @@ describe('#tealfm Record to Play', function() { const [rec, {tid, did}] = generateTealPlayRecord(); const play = listRecordToPlay(rec); - expect(play.data.track).eq(rec.value.trackName); - expect(play.data.album).eq(rec.value.releaseName); + expect(play.data.track?.name).eq(rec.value.trackName); + expect(play.data.album?.name).eq(rec.value.releaseName); expect(play.data.playDate!.unix()).eq(dayjs(rec.value.playedTime).unix()); expect(play.data.duration).eq(rec.value.duration); - expect(artistCreditsToNames(play.data.artists!)).eql(rec.value.artists!.map(x => x.artistName)); + expect(creditsToNames(play.data.artists!)).eql(rec.value.artists!.map(x => x.artistName)); expect(play.meta.user).eq(`did:plc:${did}`); expect(play.meta.playId).eq(tid); }); @@ -36,29 +37,30 @@ describe('#tealfm Record to Play', function() { const [rec, {tid, did}] = generateTealPlayRecord(); const play = listRecordToPlay(rec); - expect(play.data.meta!.brainz).to.not.be.undefined; - expect(play.data.meta!.brainz!.album).eq(rec.value.releaseMbId); - expect(play.data.meta!.brainz!.recording).eq(rec.value.recordingMbId); - expect(play.data.meta!.brainz!.artist).eql(rec.value.artists!.map(x => x.artistMbId)); + expect(creditMbid(play.data.album, 'release')).eq(rec.value.releaseMbId); + expect(creditMbid(play.data.track, 'recording')).eq(rec.value.recordingMbId); + expect(creditIds(play.data.artists, 'musicbrainz', 'artist')).eql(rec.value.artists!.map(x => x.artistMbId)); expect(play.data.isrc).eq(rec.value.isrc); }); - it('Removes brainz if no mbids', function() { + it('Does not add credit metadata if no mbids', function() { const [rec, {tid, did}] = generateTealPlayRecord({ withMbids : false}); const play = listRecordToPlay(rec); - expect(play.data.meta?.brainz).to.be.undefined; + expect(play.data.track!.metadata).to.be.undefined; + expect(play.data.album?.metadata).to.be.undefined; + expect(play.data.artists!.every(x => x.metadata === undefined)).to.be.true; }); - it('Leaves brainz artists undefined if no artist mbids', function() { + it('Leaves artist mbids undefined if no artist mbids', function() { const [rec, {tid, did}] = generateTealPlayRecord(); rec.value.artists = rec.value.artists!.map(x => ({artistName: x.artistName})); const play = listRecordToPlay(rec); - expect(play.data.meta?.brainz).to.not.be.undefined; - expect(play.data.meta?.brainz!.artist).to.be.undefined; + expect(creditMbid(play.data.track, 'recording')).to.not.be.undefined; + expect(creditIds(play.data.artists, 'musicbrainz', 'artist')).to.be.empty; }); it('Parses production namespace records', function() { @@ -90,11 +92,11 @@ describe('#tealfm Play To Record', function () { const record = playToRecord(play); expect(record.$type).to.eq('fm.teal.feed.play'); - expect(record.recordingMbId).to.eq(`mbid:${play.data.meta!.brainz!.recording}`); + expect(record.recordingMbId).to.eq(`mbid:${creditMbid(play.data.track, 'recording')}`); expect(record.releaseMbId).is.undefined; expect(record.artists).length(2); expect(record.artists![0].artistName).eq(play.data.artists![0].name); - expect(record.artists![0].artistMbId).eq(`mbid:${play.data.artists![0].mbid}`); + expect(record.artists![0].artistMbId).eq(`mbid:${creditMbid(play.data.artists![0], 'artist')}`); expect(record.musicServiceUri).eq('https://spotify.com'); expect(record.originUri).eq(play.meta.url!.origin); }); @@ -108,11 +110,11 @@ describe('#tealfm Play To Record', function () { const play = withBrainz(generatePlay({artists: generateArtistCredits(2)}), {include: ['recording']}); const record = playToRecord(play); - expect(record.recordingMbId).to.eq(`mbid:${play.data.meta!.brainz!.recording}`); + expect(record.recordingMbId).to.eq(`mbid:${creditMbid(play.data.track, 'recording')}`); expect(record.releaseMbId).is.undefined; expect(record.artists).length(2); expect(record.artists![0].artistName).eq(play.data.artists![0].name); - expect(record.artists![0].artistMbId).eq(`mbid:${play.data.artists![0].mbid}`); + expect(record.artists![0].artistMbId).eq(`mbid:${creditMbid(play.data.artists![0], 'artist')}`); }); }); diff --git a/src/backend/tests/utils/invariants.test.ts b/src/backend/tests/utils/invariants.test.ts new file mode 100644 index 00000000..b25a9b7a --- /dev/null +++ b/src/backend/tests/utils/invariants.test.ts @@ -0,0 +1,95 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import clone from 'clone'; +import type { PlayObject } from '../../../core/Atomic.ts'; +import { generateArtistCredits, generatePlay, withBrainz } from '../../../core/tests/utils/PlayTestUtils.ts'; +import { spotifyMeta, stripCredits, withImage, withMetadata } from '../../../core/MusicMetadata.ts'; +import { nameToCredit } from '../../../core/StringUtils.ts'; +import { + metaInvariantTransform, + playContentBasicInvariantTransform, + playContentCacheHash, + playContentInvariantTransform, + playDateInvariantTransform, + type PlayTransformer +} from '../../utils/PlayComparisonUtils.ts'; +import { hashObject } from '../../utils/StringUtils.ts'; + +const invariants: Record = { + metaInvariantTransform, + playDateInvariantTransform, + playContentInvariantTransform, + playContentBasicInvariantTransform +}; + +/** The same play with and without service ids/images on its credits */ +const playPair = (): [PlayObject, PlayObject] => { + const plain = generatePlay({ artists: generateArtistCredits(2, 2, { mbidVal: false }), albumArtists: generateArtistCredits(1, 1, { mbidVal: false }) }); + const rich = withBrainz(clone(plain), { include: ['track', 'recording', 'album', 'releaseGroup', 'artist'] }); + rich.data.track = withMetadata(rich.data.track, spotifyMeta('abc', 'track')); + rich.data.album = withImage(rich.data.album, 'https://example.com/album.jpg'); + rich.data.albumArtists = rich.data.albumArtists!.map(x => withImage(withMetadata(x, spotifyMeta('def', 'artist')), 'https://example.com/artist.jpg')); + return [plain, rich]; +} + +describe('#Play Invariant Transforms', function () { + + for (const [name, transform] of Object.entries(invariants)) { + + describe(name, function () { + + it('is equal for plays that differ only by credit metadata', function () { + const [plain, rich] = playPair(); + expect(hashObject(plain.data)).to.not.eq(hashObject(rich.data)); + expect(hashObject(transform(rich))).to.eq(hashObject(transform(plain))); + }); + + it('returns credits with only names', function () { + const [, rich] = playPair(); + const { track, album, artists, albumArtists } = transform(rich).data; + expect(hashObject({ track, album, artists, albumArtists })).to.eq(hashObject(stripCredits({ + track: rich.data.track, + album: rich.data.album, + artists: rich.data.artists, + albumArtists: rich.data.albumArtists + }))); + for (const credit of [track, album, ...artists!, ...albumArtists!]) { + expect(Object.keys(credit!)).to.eql(['name']); + } + }); + + it('is not equal for plays with different names', function () { + const [plain] = playPair(); + const other = clone(plain); + other.data.track = nameToCredit(`${plain.data.track!.name} (Different)`); + expect(hashObject(transform(other))).to.not.eq(hashObject(transform(plain))); + + const otherArtist = clone(plain); + otherArtist.data.artists![0] = nameToCredit(`${plain.data.artists![0].name} (Different)`); + expect(hashObject(transform(otherArtist))).to.not.eq(hashObject(transform(plain))); + }); + + it('does not modify the original play', function () { + const [, rich] = playPair(); + const before = hashObject(rich.data); + transform(rich); + expect(hashObject(rich.data)).to.eq(before); + }); + }); + } + + describe('playContentCacheHash', function () { + + it('differs for plays that differ only by credit metadata', function () { + const [plain, rich] = playPair(); + expect(playContentCacheHash(rich)).to.not.eq(playContentCacheHash(plain)); + }); + + it('is equal for plays that differ only by play date', function () { + const [, rich] = playPair(); + const later = clone(rich); + later.data.playDate = rich.data.playDate!.add(10, 'm'); + expect(playContentCacheHash(later)).to.eq(playContentCacheHash(rich)); + }); + }); +}); diff --git a/src/backend/tests/utils/playComparisons.test.ts b/src/backend/tests/utils/playComparisons.test.ts index c13f4a74..fab460a2 100644 --- a/src/backend/tests/utils/playComparisons.test.ts +++ b/src/backend/tests/utils/playComparisons.test.ts @@ -4,7 +4,7 @@ import dayjs from "dayjs"; import { describe, it } from 'mocha'; import { existingScrobble, genericSourcePlayMatch, playsAreAddedOnly, playsAreBumpedOnly, playsAreSortConsistent } from "../../utils/PlayComparisonUtils.ts"; import { generatePlay, generatePlays } from "../../../core/tests/utils/PlayTestUtils.ts"; -import { artistNamesToCredits } from "../../../core/StringUtils.ts"; +import { namesToCredits, nameToCredit } from "../../../core/StringUtils.ts"; import { SCROBBLE_TS_SOC_END, type PlayObject } from "../../../core/Atomic.ts"; const newPlay = generatePlay(); @@ -26,7 +26,7 @@ describe('Compare lists by order', function () { it('Non-identical lists with modifications are not equal', function () { const modified = [...existingList.map(x => clone(x))]; modified.splice(2, 1, generatePlay()); - modified[6].data.track = 'A CHANGE'; + modified[6].data.track = nameToCredit('A CHANGE'); modified.splice(8, 0, generatePlay()); const modded = [...modified, generatePlay()]; assert.isFalse(playsAreSortConsistent(existingList, modded)); @@ -291,8 +291,8 @@ describe('Compare lists by order', function () { const backlogTimestamp = start.add(223, 's'); // 13:22:21 -- inside duration window, outside listenedFor window, and not within 10s of matching duration exactly (diff from duration is 21s) const existingLiveTrackedPlay: PlayObject = generatePlay({ - track: 'Empty Threat', - artists: artistNamesToCredits(['CHVRCHES']), + track: nameToCredit('Empty Threat'), + artists: namesToCredits(['CHVRCHES']), duration: trackDuration, playDate: start, // finalized live plays are normalized to scrobbleTsSOC END with playDateCompleted set -- @@ -306,8 +306,8 @@ describe('Compare lists by order', function () { }); const backlogCandidate: PlayObject = generatePlay({ - track: 'Empty Threat', - artists: artistNamesToCredits(['CHVRCHES']), + track: nameToCredit('Empty Threat'), + artists: namesToCredits(['CHVRCHES']), duration: trackDuration, playDate: backlogTimestamp }); @@ -340,8 +340,8 @@ describe('Compare lists by order', function () { const pausedBacklogTimestamp = dayjs('2026-09-08T04:05:50.420Z'); // after playDate + duration (04:04:30), still before playDateCompleted const pausedLiveTrackedPlay: PlayObject = generatePlay({ - track: "weren't for the wind", - artists: artistNamesToCredits(['Ella Langley']), + track: nameToCredit("weren't for the wind"), + artists: namesToCredits(['Ella Langley']), duration: pausedDuration, playDate: pausedStart, playDateCompleted: pausedCompleted, @@ -355,8 +355,8 @@ describe('Compare lists by order', function () { }); const pausedBacklogCandidate: PlayObject = generatePlay({ - track: "weren't for the wind", - artists: artistNamesToCredits(['Ella Langley']), + track: nameToCredit("weren't for the wind"), + artists: namesToCredits(['Ella Langley']), duration: pausedDuration, playDate: pausedBacklogTimestamp }); diff --git a/src/backend/tests/ytm/ytm.test.ts b/src/backend/tests/ytm/ytm.test.ts index 8270339f..08119587 100644 --- a/src/backend/tests/ytm/ytm.test.ts +++ b/src/backend/tests/ytm/ytm.test.ts @@ -194,7 +194,7 @@ describe('Handles interim tracks', function () { const prependedPlays = [firstPlay, ...interimPlays, ...plays]; const prependResult = source.parseRecentAgainstResponse(prependedPlays); expect(prependResult.plays).length(1); - expect(prependResult.plays[prependResult.plays.length - 1].data.track).eq(firstPlay.data.track) + expect(prependResult.plays[prependResult.plays.length - 1].data.track?.name).eq(firstPlay.data.track?.name) }); it(`Adds interim plays when discover time is plausible`, async function () { @@ -221,6 +221,6 @@ describe('Handles interim tracks', function () { const prependedPlays = [firstPlay, ...interimPlays, ...plays]; const prependResult = source.parseRecentAgainstResponse(prependedPlays); expect(prependResult.plays).length(1); - expect(prependResult.plays[prependResult.plays.length - 1].data.track).eq(firstPlay.data.track) + expect(prependResult.plays[prependResult.plays.length - 1].data.track?.name).eq(firstPlay.data.track?.name) }); }); \ No newline at end of file diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 30555e8c..946f6880 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -6,15 +6,16 @@ import utc from 'dayjs/plugin/utc.js'; import type {Request} from "express"; // https://github.com/jfromaniello/url-join#in-nodejs import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; -import { DEFAULT_MISSING_MBIDS_TYPES, type MissingMbidType, type PlayObject } from "../core/Atomic.ts"; +import { type MissingMbidType, type PlayObject } from "../core/Atomic.ts"; import { asPlayerStateDataMaybePlay, type PlayerStateDataMaybePlay, type ProgressAwarePlayObject, type RemoteIdentityParts, - type ScrobbleThresholdResult, -} from "./common/infrastructure/Atomic.ts"; + type ScrobbleThresholdResult } from "./common/infrastructure/Atomic.ts"; import { NO_USER } from '../core/Atomic.ts'; +import { creditIds, creditMbid } from '../core/MusicMetadata.ts'; +import { creditsToNames } from '../core/StringUtils.ts'; import { NO_DEVICE } from '../core/Atomic.ts'; import type {PlayPlatformId} from '../core/Atomic.ts'; import { genGroupIdStr } from '../core/PlayUtils.ts'; @@ -88,25 +89,19 @@ export const playObjDataMatch = (a: PlayObject, b: PlayObject) => { data: { artists: aArtists = [], album: aAlbum, - track: aTrack, - } = {}, + track: aTrack } = {}, meta: { source: aSource, - trackId: atrackId, - } = {}, - } = a; + trackId: atrackId } = {} } = a; const { data: { artists: bArtists = [], album: bAlbum, - track: bTrack, - } = {}, + track: bTrack } = {}, meta: { source: bSource, - trackId: btrackId, - } = {}, - } = b; + trackId: btrackId } = {} } = b; // if sources are the same and both plays have source ids then we can just compare by id if(aSource === bSource && atrackId !== undefined && btrackId !== undefined) { @@ -115,17 +110,19 @@ export const playObjDataMatch = (a: PlayObject, b: PlayObject) => { } } - if (aTrack !== bTrack) { + if (aTrack?.name !== bTrack?.name) { return false; } - if (aAlbum !== bAlbum) { + if (aAlbum?.name !== bAlbum?.name) { return false; } if (aArtists.length !== bArtists.length) { return false; } // check if every artist from either playObj matches (one way or another) with the artists from the other play obj - if (!aArtists.every((x: any) => bArtists.includes(x)) && bArtists.every((x: any) => aArtists.includes(x))) { + const aNames = creditsToNames(aArtists), + bNames = creditsToNames(bArtists); + if (!aNames.every((x) => bNames.includes(x)) && bNames.every((x) => aNames.includes(x))) { return false } @@ -366,7 +363,7 @@ export const doubleReturnNewline = new TemplateTag( // remove instances of single line breaks replaceResultTransformer(/(?<=.)\n(?!\n+)/g, ''), // replace instances of two or more line breaks with one line break - replaceResultTransformer(/(?<=.)\n{2,}/g, '\n'), + replaceResultTransformer(/(?<=.)\n{2 }/g, '\n'), trimResultTransformer(), ); @@ -385,42 +382,32 @@ export const durationToTimestamp = (dur: Duration): string => { export const comparingMultipleArtists = (existing: PlayObject, candidate: PlayObject): boolean => { const { data: { - artists: eArtists = [], - } = {} + artists: eArtists = [] } = {} } = existing; const { data: { - artists: cArtists = [], - } = {} + artists: cArtists = [] } = {} } = candidate; return eArtists.length > 1 || cArtists.length > 1; } export const missingMbidTypes = (play: PlayObject): MissingMbidType[] => { - let missing: MissingMbidType[] = []; + const missing: MissingMbidType[] = []; if(play.data.duration === undefined) { missing.push('duration'); } - if(play.data.meta?.brainz === undefined) { - missing = missing.concat(DEFAULT_MISSING_MBIDS_TYPES); - return missing; - } - const { - recording: track, - album, - artist - } = play.data.meta.brainz; + const {artists = []} = play.data; - if(track === undefined) { + if(creditMbid(play.data.track, 'recording') === undefined) { missing.push('title'); } - if(album === undefined) { + if(creditMbid(play.data.album, 'release') === undefined) { missing.push('album'); } - if(artist === undefined || (artist ?? []).length !== (play.data.artists ?? []).length) { + if(artists.length === 0 || creditIds(artists, 'musicbrainz', 'artist').length !== artists.length) { missing.push('artists') } diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index 57d11340..c005f5a9 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -1,9 +1,9 @@ import { getListDiff, type ListDiff } from "@donedeal0/superdiff"; -import { type AcceptableTemporalDuringReference, type ArtistCredit, type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, type SOURCE_SOT_TYPES, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy, type TrackStringOptions } from "../../core/Atomic.ts"; +import { type AcceptableTemporalDuringReference, type Credit, type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, type SOURCE_SOT_TYPES, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy, type TrackStringOptions } from "../../core/Atomic.ts"; import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.ts"; import { comparingMultipleArtists, playObjDataMatch, setIntersection } from "../utils.ts"; import { comparePlayTemporally, hasAcceptableTemporalAccuracy, temporalAccuracyToString, type TemporalPlayComparisonOptions, temporalPlayComparisonSummary } from "./TimeUtils.ts"; -import { compareNormalizedStrings, compareScrobbleArtistCredits, compareScrobbleArtists, compareScrobbleTracks, compareTracks, normalizeStr, type TrackSamenessResults } from "./StringUtils.ts"; +import { compareNormalizedStrings, compareScrobbleArtistCredits, compareScrobbleArtists, compareScrobbleTracks, compareTracks, hashObject, normalizeStr, type TrackSamenessResults } from "./StringUtils.ts"; import { ARTIST_WEIGHT, DUP_SCORE_THRESHOLD, type ScrobbledPlayObject, TIME_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.ts"; import type {StringSamenessResult} from "@foxxmd/string-sameness"; import type {Duration} from "dayjs/plugin/duration.js"; @@ -13,6 +13,7 @@ import { loggerNoop } from '../common/MaybeLogger.ts'; import { statefulInvariantTransform } from "../../core/PlayUtils.ts"; import { findAsyncSequential } from "./AsyncUtils.ts"; import dayjs from "dayjs"; +import { creditMbid, stripCredits } from "../../core/MusicMetadata.ts"; import { SimpleError } from "../common/errors/MSErrors.ts"; @@ -24,7 +25,7 @@ export const metaInvariantTransform = (play: PlayObject): PlayObjectMinimal => { data = {} } = play; return { - data, + data: stripCredits(data), meta: { trackId } @@ -40,7 +41,7 @@ export const playDateInvariantTransform = (play: PlayObject): PlayObject => { return { ...play, data: { - ...play.data, + ...stripCredits(play.data), playDate: undefined } } @@ -57,13 +58,23 @@ export const playContentInvariantTransform = (play: PlayObject): PlayObjectMinim } } = play; return { - data: { - ...rest - }, + data: stripCredits(rest), meta: {} } } +/** + * Hash of play content including credit metadata (service ids, images) + * + * Use for cache keys where the result depends on the ids a play has. For identifying a play by its surface-level values use the invariant transforms instead. + */ +export const playContentCacheHash = (play: PlayObject): string => hashObject([playContentInvariantTransform(play), playCreditMetadata(play)]); + +const playCreditMetadata = (play: PlayObject) => { + const {track, album, artists = [], albumArtists = []} = play.data; + return [track, album, ...artists, ...albumArtists].map(x => x === undefined ? undefined : [x.image, x.metadata]); +} + export const playContentBasicInvariantTransform = (play: PlayObject): PlayObjectMinimal => { const { data: { @@ -77,25 +88,15 @@ export const playContentBasicInvariantTransform = (play: PlayObject): PlayObject } } = play; return { - data: { - ...rest - }, + data: stripCredits(rest), meta: {} } } export const playMbidIdentifier = (play: PlayObject): string | undefined => { - const { - data: { - meta: { - brainz: { - recording, - album, - track - } = {} - } = {} - } = {} - } = play; + const track = creditMbid(play.data?.track, 'track'), + recording = creditMbid(play.data?.track, 'recording'), + album = creditMbid(play.data?.album, 'release'); // track mbid is a unique combo of recording on release // so we only need it to identifier what should be track + album + artist @@ -328,7 +329,7 @@ export const comparePlayArtistsNormalized = (existing: PlayObject, candidate: Pl return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches] } -export const compareArtistCreditsNormalized = (existingArtists: ArtistCredit[], candidateArtists: ArtistCredit[]): [number, number] => { +export const compareArtistCreditsNormalized = (existingArtists: Credit[], candidateArtists: Credit[]): [number, number] => { const normExisting = existingArtists.map(x => normalizeStr(x.name, {keepSingleWhitespace: true})); const candidateExisting = candidateArtists.map(x => normalizeStr(x.name, {keepSingleWhitespace: true})); @@ -361,9 +362,9 @@ export const scoreTrackWeightedAndNormalized = (ref: string, candidate: string, } export const comparePlayAlbumNormalized = (existing: PlayObject, candidate: PlayObject): [number,{result: StringSamenessResult, exact: boolean}] => { - const sameness = compareNormalizedStrings(existing.data.album ?? '', candidate.data.album ?? ''); + const sameness = compareNormalizedStrings(existing.data.album?.name ?? '', candidate.data.album?.name ?? ''); - const exact = existing.data.album === candidate.data.album; + const exact = existing.data.album?.name === candidate.data.album?.name; return [Math.min(sameness.highScore/100, 1), {result: sameness, exact}]; } diff --git a/src/backend/utils/PlayTransformUtils.ts b/src/backend/utils/PlayTransformUtils.ts index fba6d83c..14df1b6f 100644 --- a/src/backend/utils/PlayTransformUtils.ts +++ b/src/backend/utils/PlayTransformUtils.ts @@ -15,6 +15,7 @@ import { type WhenParts } from "../../core/Transform.ts"; import dayjs from "dayjs"; +import { playImages } from "../../core/MusicMetadata.ts"; export const isWhenCondition = (val: unknown): val is WhenParts => { if (val !== null && typeof val === 'object') { @@ -141,7 +142,7 @@ export const testWhen = (parts: WhenParts, play: PlayObject, options?: S } = options || {} if(parts.title !== undefined) { - if(!testMaybeRegex(parts.title, play.data.track ?? '')[0]) { + if(!testMaybeRegex(parts.title, play.data.track?.name ?? '')[0]) { return false; } } @@ -154,25 +155,19 @@ export const testWhen = (parts: WhenParts, play: PlayObject, options?: S } } if(parts.album !== undefined) { - if(!testMaybeRegex(parts.album, play.data.album ?? '')[0]) { + if(!testMaybeRegex(parts.album, play.data.album?.name ?? '')[0]) { return false; } } if(parts.art !== undefined) { + const images = playImages(play.data); if(parts.art.trim() === '') { // user is testing to see if there is no art // if test is empty string and there is no art then it passes - return play.meta.art === undefined || Object.keys(play.meta.art).length === 0 + return images.length === 0; } - const {art = {}} = play.meta; - for(const [k,v] of Object.entries(art)) { - // passes if any art url matches the search - if(testMaybeRegex(parts.art, v ?? '')[0]) { - return true; - } - } - // otherwise fails - return false; + // passes if any art url matches the search + return images.some(x => testMaybeRegex(parts.art!, x)[0]); } return true; } diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 1eddfe0b..16b3ff7c 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -1,6 +1,6 @@ import { strategies, stringSameness, type StringSamenessResult } from "@foxxmd/string-sameness"; import { hasher } from 'node-object-hash'; -import type {ArtistCredit, PlayObject} from "../../core/Atomic.ts"; +import type {Credit, PlayObject} from "../../core/Atomic.ts"; import { asPlayerStateData, type PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.ts"; import { DELIMITERS_NO_AMP } from '../../core/Atomic.ts'; import { DELIMITERS } from '../../core/Atomic.ts'; @@ -296,7 +296,7 @@ export const compareScrobbleTracks = (existing: PlayObject, candidate: PlayObjec } } = candidate; - return compareTracks(existingTrack ?? '', candidateTrack ?? ''); + return compareTracks(existingTrack?.name ?? '', candidateTrack?.name ?? ''); } export const compareTracks = (existingTrack: string, candidateTrack: string): [StringSamenessResult, TrackSamenessResults] => { @@ -334,7 +334,7 @@ export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObje return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr.name}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr.name}`, '')).highScore; } -export const compareScrobbleArtistCredits = (existingArtists: ArtistCredit[], candidateArtists: ArtistCredit[]): number => { +export const compareScrobbleArtistCredits = (existingArtists: Credit[], candidateArtists: Credit[]): number => { return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr.name}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr.name}`, '')).highScore; } diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index 194475f0..a0ba323f 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -125,7 +125,7 @@ export const ActivitySummary = (props: ActivitySummaryProps) => { return ( - {play.data.track}{ephemeralStatus} + {play.data.track?.name}{ephemeralStatus} {/* */} {(play.data.artists ?? []).map(x => x.name).join(' / ')} diff --git a/src/client/components/ArtistCreditDisplay.tsx b/src/client/components/ArtistCreditDisplay.tsx index be913891..1fb4cffc 100644 --- a/src/client/components/ArtistCreditDisplay.tsx +++ b/src/client/components/ArtistCreditDisplay.tsx @@ -1,11 +1,11 @@ import { Fragment } from 'react'; -import type { ArtistCredit as AC } from '../../core/Atomic'; +import type { Credit as AC } from '../../core/Atomic'; import { HStack, Tag } from "@chakra-ui/react"; import { MusicServiceIndicators } from './musicServices/MusicServiceIndicators'; import type { MusicServicesAny } from '../../core/MusicMetadata'; -export const ArtistCredit = (props: { data: AC, showLinks?: boolean, showMbid?: boolean }) => { +export const Credit = (props: { data: AC, showLinks?: boolean, showMbid?: boolean }) => { const { data, @@ -17,10 +17,7 @@ export const ArtistCredit = (props: { data: AC, showLinks?: boolean, showMbid?: return data.name; } - const musicServices: MusicServicesAny[] = []; - if(data.mbid) { - musicServices.push({name: 'musicbrainz', idHint: 'artist', id: data.mbid}); - } + const musicServices: MusicServicesAny[] = data.metadata ?? []; return @@ -35,7 +32,7 @@ export const ArtistCreditTags = (props: { data: AC[], showLinks?: boolean, showM {props.data.map((x, index) => ( - + ))} diff --git a/src/client/components/PlayData.tsx b/src/client/components/PlayData.tsx index 39a9e1ac..01006ca9 100644 --- a/src/client/components/PlayData.tsx +++ b/src/client/components/PlayData.tsx @@ -13,7 +13,7 @@ import { MSErrorBoundary } from './ErrorBoundary.js'; import { EllipsisButton, EyeClosedIcon, EyeIcon, getMusicServiceIconElement } from './icons/ChakraIcons.js'; import type { IconType } from 'react-icons/lib'; import { capitalize } from '../../core/StringUtils.js'; -import { MusicServiceIndicators, playMetaIdsToMusicServices } from './musicServices/MusicServiceIndicators.js'; +import { MusicServiceIndicators } from './musicServices/MusicServiceIndicators.js'; const EmptyPlayData = () => { return ( @@ -169,9 +169,6 @@ export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDa listenedFor, duration, repeat, - meta: { - brainz = {} - } = {} } = {}, meta: { url: { @@ -188,7 +185,7 @@ export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDa titleLinks.push({getMusicServiceIconElement(musicService ?? source)}); } - const titleElm = {track}{titleLinks} + const titleElm = {track?.name}{titleLinks} return ( @@ -209,8 +206,8 @@ export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDa Album - {play.data.album} - + {play.data.album?.name} + diff --git a/src/client/components/TransformSteps.tsx b/src/client/components/TransformSteps.tsx index 046b619a..03a01f18 100644 --- a/src/client/components/TransformSteps.tsx +++ b/src/client/components/TransformSteps.tsx @@ -47,18 +47,6 @@ export const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): continue; } const left: JsonPlayObject = structuredClone(currentPlay); // JSON.parse(JSON.stringify(currentPlay)); - left.data.meta = { - ...(left.data.meta ?? {}), - brainz: { - ...(left.data.meta?.brainz ?? {}) - } - } - currentPlay.data.meta = { - ...(currentPlay.data.meta ?? {}), - brainz: { - ...(currentPlay.data.meta?.brainz ?? {}) - } - } try { currentPlay.data = patchObject(currentPlay.data, patch)// jdiff.patch(currentPlay, patch) as JsonPlayObject; diff --git a/src/client/components/chakraPlayer/Player.tsx b/src/client/components/chakraPlayer/Player.tsx index e06077c1..bd47d60f 100644 --- a/src/client/components/chakraPlayer/Player.tsx +++ b/src/client/components/chakraPlayer/Player.tsx @@ -4,6 +4,7 @@ import { TextMuted } from "../TextMuted"; import { SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson } from "../../../core/Atomic"; import { timeToHumanTimestamp } from "../../../core/TimeUtils"; import { capitalize } from "../../../core/StringUtils"; +import { playImage } from "../../../core/MusicMetadata"; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ErrorAlert } from "../ErrorAlert"; import { @@ -47,13 +48,11 @@ export const ChakraPlayer = (props: PlayerProps) => { const { play: { data: { - track = '???', + track, + album, artists = [{ name: '???' }], duration = 0 } = {}, - meta: { - art = {}, - } = {} } = {}, play, listenedDuration = 0, @@ -70,7 +69,7 @@ export const ChakraPlayer = (props: PlayerProps) => { const [positionBuffer, setProgressBuffer] = useState(undefined); const [intervalId, setIntervalId] = useState(undefined); const [lastUpdated, setLastUpdated] = useState(undefined); - const playArt = art.track ?? art.album ?? art.artist ?? undefined; + const playArt = playImage({track, album, artists}); const isNowPlaying = nowPlaying || nowPlayingMode; @@ -164,7 +163,7 @@ export const ChakraPlayer = (props: PlayerProps) => { {playArt !== undefined ? : null}
- {calculated !== 'stopped' ? track : '-'} + {calculated !== 'stopped' ? (track?.name ?? '???') : '-'} {calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}
@@ -207,12 +206,8 @@ export const ClientNowPlaying = (props: PlayerProps) => { data: { play: { data: { - track = '???', + track, artists = [{ name: '???' }], - duration = 0 - } = {}, - meta: { - art = {}, } = {}, } = {}, expiration @@ -230,7 +225,7 @@ export const ClientNowPlaying = (props: PlayerProps) => {
- {artists.map(x => x.name).join('/')} - {track} + {artists.map(x => x.name).join('/')} - {track?.name ?? '???'} ) } diff --git a/src/client/components/musicServices/MusicServiceIndicators.tsx b/src/client/components/musicServices/MusicServiceIndicators.tsx index 7ea64e0e..138ecf5d 100644 --- a/src/client/components/musicServices/MusicServiceIndicators.tsx +++ b/src/client/components/musicServices/MusicServiceIndicators.tsx @@ -14,7 +14,6 @@ import { Tooltip } from "../ChakraTooltip"; import type React from "react"; import { LuExternalLink } from "react-icons/lu"; import type { ComponentProps } from "react"; -import type { JsonPlayObject } from "../../../core/Atomic"; export interface MusicServiceInfoIconProps { @@ -108,39 +107,3 @@ export const MusicServiceIndicators = (props: { ) } - -export const playMetaIdsToMusicServices = (play: JsonPlayObject, type: 'track' | 'album'): MusicServicesAny[] => { - const { - data: { - meta: { - brainz, - spotify - } = {} - } = {} - } = play; - - const services: MusicServicesAny[] = []; - if(type === 'track') { - if(brainz?.track !== undefined) { - services.push({name: 'musicbrainz', idHint: 'track', id: brainz.track}); - } else if(brainz?.recording !== undefined) { - services.push({name: 'musicbrainz', idHint: 'recording', id: brainz.recording}); - } - if(spotify?.track !== undefined) { - services.push({name: 'spotify', idType: 'track', id: spotify.track}); - } - return services; - } - if(type === 'album') { - if(brainz?.album !== undefined) { - services.push({name: 'musicbrainz', idHint: 'release', id: brainz.album}); - } else if(brainz?.releaseGroup !== undefined) { - services.push({name: 'musicbrainz', idHint: 'release-group', id: brainz.releaseGroup}); - } - if(spotify?.album !== undefined) { - services.push({name: 'spotify', idType: 'album', id: spotify.album}); - } - return services; - } - return services; -} \ No newline at end of file diff --git a/src/client/components/play/AlbumSearch.tsx b/src/client/components/play/AlbumSearch.tsx index e89fe712..827ce2c6 100644 --- a/src/client/components/play/AlbumSearch.tsx +++ b/src/client/components/play/AlbumSearch.tsx @@ -2,29 +2,17 @@ import { Box, useListCollection, Stack, Text, HStack } from "@chakra-ui/react" import { useDebouncedState } from '@tanstack/react-pacer' import { useQuery } from '@tanstack/react-query'; import { tanQueries } from "../../queries/index.ts"; -import type { AlbumSearchResult } from "../../../core/Api.ts"; +import { albumSearchResultToCredit, artistSearchResultToCredit, type AlbumSearchResult } from "../../../core/Api.ts"; import React, { useCallback, useEffect, useState } from "react"; import { ArtistCreditTags } from "../ArtistCreditDisplay.tsx"; import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.tsx"; -import type { ArtistCredit, ArtMeta, BrainzMeta, SpotifyMeta, TrackMeta } from "../../../core/Atomic.ts"; -import { removeUndefinedKeys } from "../../../core/DataUtils.ts"; -import type { MusicServicesAny } from '../../../core/MusicMetadata.ts'; +import type { Credit } from "../../../core/Atomic.ts"; import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.tsx"; const albumPartials: MetadataPartials = { album: { label: 'Album only', pick: ({ artists, ...rest }) => rest }, }; -export const albumSearchResultToMusicServices = (val: MinimalResult): MusicServicesAny[] => { - const musicServices: MusicServicesAny[] = []; - if (val.mbidRelease) { - musicServices.push({ name: 'musicbrainz', id: val.mbidRelease, idHint: 'release' }); - } else if (val.mbidReleaseGroup) { - musicServices.push({ name: 'musicbrainz', id: val.mbidReleaseGroup, idHint: 'release-group' }); - } - return musicServices; -} - export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: (val: AlbumSearchResult) => void }) => { const { @@ -35,7 +23,7 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: let artistTags: React.JSX.Element | undefined = undefined; if(artists.length > 0) { - artistTags = + artistTags = } return ( @@ -46,7 +34,7 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: - {name}{type !== undefined ? ({type}) : undefined} + {name}{type !== undefined ? ({type}) : undefined} @@ -56,46 +44,23 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: ) } -type MinimalResult = Pick - -interface MinimalOnChange { - data: { - artists?: ArtistCredit[] - album: string - meta?: TrackMeta - } - meta: { - art?: ArtMeta - } +/** Play data to change when an album is selected. Artists are only included if the selected album has them. */ +export interface AlbumOnChange { + album: Credit + artists?: Credit[] } -const albumSearchResultToOnChange = (val: AlbumSearchResult): MinimalOnChange => { - const playPartial: MinimalOnChange = { - data: { - album: val.name, - artists: val.artists !== undefined && val.artists.length > 0 ? val.artists.map((x) => ({name: x.name, mbid: x.mbid})) : undefined, - meta: removeUndefinedKeys({ - brainz: removeUndefinedKeys({ - album: val.mbidRelease, - releaseGroup: val.mbidReleaseGroup - }), - spotify: removeUndefinedKeys({ - album: val.spotifyId - }) - }) - }, - meta: { - art: removeUndefinedKeys({ - album: val.image - }) - } +const albumSearchResultToOnChange = (val: AlbumSearchResult): AlbumOnChange => { + const change: AlbumOnChange = { album: albumSearchResultToCredit(val) }; + if (val.artists !== undefined && val.artists.length > 0) { + change.artists = val.artists.map(artistSearchResultToCredit); } - - return playPartial; + return change; } + export interface AlbumSearchProps { - initial?: MinimalResult - onChange: (val: MinimalOnChange) => void + initial?: Credit + onChange: (val: AlbumOnChange) => void } export const AlbumSearch = (props: AlbumSearchProps) => { @@ -105,7 +70,7 @@ export const AlbumSearch = (props: AlbumSearchProps) => { onChange = (val) => console.log(val, 'Selected value for prop') } = props; - const [selectedItem, setSelectedItem] = useState(initial ?? {name: ''}); + const [selectedItem, setSelectedItem] = useState(initial ?? {name: ''}); const [debouncedQuery, setDebouncedQuery] = useDebouncedState(initial?.name ?? '', { wait: 500 }); const query = useQuery({ @@ -126,11 +91,11 @@ export const AlbumSearch = (props: AlbumSearchProps) => { }, [query, set]) const doChange = useCallback((val: AlbumSearchResult) => { - setSelectedItem(val); + setSelectedItem(albumSearchResultToCredit(val)); onChange(albumSearchResultToOnChange(val)); },[setSelectedItem, onChange]); - const services = albumSearchResultToMusicServices(selectedItem); + const services = selectedItem.metadata ?? []; let groupContent: React.JSX.Element | undefined = undefined; if(services.length > 0) { groupContent = diff --git a/src/client/components/play/ArtistSearch.tsx b/src/client/components/play/ArtistSearch.tsx index 8f563c74..09066044 100644 --- a/src/client/components/play/ArtistSearch.tsx +++ b/src/client/components/play/ArtistSearch.tsx @@ -2,34 +2,19 @@ import { useListCollection, Stack, Text, HStack } from "@chakra-ui/react" import { useDebouncedState } from '@tanstack/react-pacer' import { useQuery } from '@tanstack/react-query'; import { tanQueries } from "../../queries/index.js"; -import type { ArtistSearchResult } from "../../../core/Api.js"; +import { artistSearchResultToCredit, type ArtistSearchResult } from "../../../core/Api.js"; import { useCallback, useEffect, useState } from "react"; import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.js"; -import { type ArtistCredit, type PlayObjectMinimal } from "../../../core/Atomic.js"; -import type { MusicServicesAny } from '../../../core/MusicMetadata.js'; +import { type Credit } from "../../../core/Atomic.js"; import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.js"; const artistPartials: MetadataPartials = { name: { label: 'Name only', pick: ({ mbid, ...rest }) => rest }, }; -export const artistSearchResultToMusicServices = (val: ArtistCredit): MusicServicesAny[] => { - const musicServices: MusicServicesAny[] = []; - if (val.mbid) { - musicServices.push({ name: 'musicbrainz', id: val.mbid, idHint: 'artist' }); - } - // } else if (val.spotifyId) { - // musicServices.push({ name: 'spotify', id: val.spotifyId, idHint: 'artist' }); - // } - return musicServices; -} - export const ArtistSearchResultItem = (props: { data: ArtistSearchResult, onPick?: (val: ArtistSearchResult) => void }) => { - const { - name, - mbid - } = props.data; + const { name } = props.data; return ( @@ -37,7 +22,7 @@ export const ArtistSearchResultItem = (props: { data: ArtistSearchResult, onPick - {name} + {name} @@ -45,31 +30,9 @@ export const ArtistSearchResultItem = (props: { data: ArtistSearchResult, onPick ) } -const artistResultToPlay = (val: ArtistSearchResult): PlayObjectMinimal => { - const { - name, - mbid, - spotifyId, - image - } = val; - - const play: PlayObjectMinimal = { - data: { - artists: [{name, mbid}], - }, - meta: {} - } - if(image !== undefined) { - play.meta.art = { - artist: image - } - } - return play; -} - export interface ArtistSearchProps { - initial?: ArtistCredit - onChange: (val: ArtistCredit) => void + initial?: Credit + onChange: (val: Credit) => void } export const ArtistSearch = (props: ArtistSearchProps) => { @@ -79,7 +42,7 @@ export const ArtistSearch = (props: ArtistSearchProps) => { onChange = (val) => console.log(val, 'Selected value for prop') } = props; - const [selectedItem, setSelectedItem] = useState(initial ?? {name: ''}); + const [selectedItem, setSelectedItem] = useState(initial ?? {name: ''}); const [debouncedQuery, setDebouncedQuery] = useDebouncedState(initial?.name ?? '', { wait: 500 }); const query = useQuery({ @@ -99,12 +62,13 @@ export const ArtistSearch = (props: ArtistSearchProps) => { } }, [query, set]) - const doChange = useCallback((val: ArtistCredit) => { - setSelectedItem(val); - onChange(val); + const doChange = useCallback((val: ArtistSearchResult) => { + const credit = artistSearchResultToCredit(val); + setSelectedItem(credit); + onChange(credit); },[setSelectedItem, onChange]); - const services = artistSearchResultToMusicServices(selectedItem); + const services = selectedItem.metadata ?? []; let groupContent: React.JSX.Element | undefined = undefined; if(services.length > 0) { groupContent = diff --git a/src/client/components/play/PlayEdit.tsx b/src/client/components/play/PlayEdit.tsx index 1fd8887b..bf70712e 100644 --- a/src/client/components/play/PlayEdit.tsx +++ b/src/client/components/play/PlayEdit.tsx @@ -3,7 +3,6 @@ import { playEditStrictCreateSchema, type PlayObjectMinimal } from '../../../cor import { MSErrorBoundary } from '../ErrorBoundary.js'; import { useForm, formOptions } from '@tanstack/react-form'; import { TrackSearch } from "./TrackSearch.js"; -import { deepmergeCustom } from "deepmerge-ts"; import { ArtistSearch } from "./ArtistSearch.js"; import { TrashIconButton } from "../icons/ChakraIcons.js"; import { useState } from "react"; @@ -16,8 +15,6 @@ export interface PlayEditProps { onSubmit?: (vals: PlayObjectMinimal) => void } -const merge = deepmergeCustom({ mergeArrays: (values) => values.at(-1) }); - const logSubmit: PlayEditProps['onSubmit'] = (val) => console.log(val, 'Play Edit Submit'); export const PlayEdit = (props: PlayEditProps) => { @@ -33,9 +30,8 @@ export const PlayEdit = (props: PlayEditProps) => { const opts = formOptions.strictSchema(playEditStrictCreateSchema, { defaultValues: { data: { - track: '', + track: {name: ''}, artists: [], - album: '', ...data }, meta: { @@ -82,12 +78,10 @@ export const PlayEdit = (props: PlayEditProps) => { 0}> Track (Title) - { - const merged = merge(field.form.state.values, val) as typeof field.form.state.values; - //field.form.state.values = merged; - field.form.setFieldValue('meta', merged.meta); - field.form.setFieldValue('data', merged.data); + // selected credits replace existing ones entirely so ids/images from a previous selection are not kept + field.form.setFieldValue('data', {...field.form.state.values.data, ...val}); setArtistsVersion(v => v + 1); }} /> {field.errors.map((error) => ( @@ -139,18 +133,10 @@ export const PlayEdit = (props: PlayEditProps) => { Album { - const merged = merge(field.form.state.values, val) as typeof field.form.state.values; - //field.form.state.values = merged; - field.form.setFieldValue('meta', merged.meta); - field.form.setFieldValue('data', merged.data); + field.form.setFieldValue('data', {...field.form.state.values.data, ...val}); setArtistsVersion(v => v + 1); }} /> {field.errors.map((error) => ( diff --git a/src/client/components/play/TrackSearch.tsx b/src/client/components/play/TrackSearch.tsx index 1514baf2..8a6b0072 100644 --- a/src/client/components/play/TrackSearch.tsx +++ b/src/client/components/play/TrackSearch.tsx @@ -2,15 +2,12 @@ import { useListCollection, Stack, Text, HStack } from "@chakra-ui/react" import { useDebouncedState } from '@tanstack/react-pacer' import { useQuery } from '@tanstack/react-query'; import { tanQueries } from "../../queries/index.js"; -import type { TrackSearchResult } from "../../../core/Api.js"; +import { albumSearchResultToCredit, artistSearchResultToCredit, trackSearchResultToCredit, type TrackSearchResult } from "../../../core/Api.js"; import React, { useCallback, useEffect, useState } from "react"; import { ArtistCreditTags } from "../ArtistCreditDisplay.js"; import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.js"; -import { type ArtMeta, type PlayObjectMinimal } from "../../../core/Atomic.js"; -import { removeUndefinedKeys } from "../../../core/DataUtils.js"; +import type { Credit } from "../../../core/Atomic.js"; import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.js"; -import { albumSearchResultToMusicServices } from "./AlbumSearch.js"; -import type { MusicServicesAny } from '../../../core/MusicMetadata.js'; const trackPartials: MetadataPartials = { track: { label: 'Track only', pick: ({ artists, album, albumCount, ...rest }) => rest }, @@ -18,19 +15,6 @@ const trackPartials: MetadataPartials = { album: { label: 'Track + album', pick: ({ artists, ...rest }) => rest }, }; -export const trackSearchResultToMusicServices = (val: MinimalResult): MusicServicesAny[] => { - const musicServices: MusicServicesAny[] = []; - if (val.mbidTrack) { - musicServices.push({ name: 'musicbrainz', id: val.mbidTrack, idHint: 'track' }); - } else if (val.mbidRecording) { - musicServices.push({ name: 'musicbrainz', id: val.mbidRecording, idHint: 'recording' }); - } - if(val.spotifyId !== undefined) { - musicServices.push({ name: 'spotify', id: val.spotifyId, idType: 'track' }); - } - return musicServices; -} - export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: (val: TrackSearchResult) => void }) => { const { onPick } = props; @@ -51,14 +35,14 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: ) : undefined; albumContent = ( - {album.name} {album.type !== undefined ? `(${album.type})` : ''} {andCount} + {album.name} {album.type !== undefined ? `(${album.type})` : ''} {andCount} ) } let artistTags: React.JSX.Element | undefined = undefined; if (artists.length > 0) { - artistTags = + artistTags = } return ( @@ -67,7 +51,7 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: - {name} + {name} {artistTags} @@ -77,55 +61,27 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: ) } -type MinimalResult = Pick +/** Play data to change when a track is selected. Artists and album are only included if the selected track has them. */ +export interface TrackOnChange { + track: Credit + artists?: Credit[] + album?: Credit +} + export interface TrackSearchProps { - initial?: PlayObjectMinimal - onChange: (val: PlayObjectMinimal) => void + initial?: Credit + onChange: (val: TrackOnChange) => void } -const trackResultToPlay = (val: TrackSearchResult): PlayObjectMinimal => { - const { - name, - mbidTrack, - mbidRecording, - spotifyId, - artists, - album, - image - } = val; - - const play = removeUndefinedKeys>({ - data: { - track: name, - artists: artists !== undefined && artists.length > 0 ? artists.map((x) => ({name: x.name, mbid: x.mbid, spotifyId: x.spotifyId})) : undefined, - album: album?.name, - meta: removeUndefinedKeys({ - brainz: removeUndefinedKeys({ - track: mbidTrack, - recording: mbidRecording, - album: album?.mbidRelease, - releaseGroup: album?.mbidReleaseGroup - }), - spotify: removeUndefinedKeys({ - track: spotifyId - }) - }) - }, - meta: {} - }, false); - - const art = removeUndefinedKeys({ - album: album?.image, - track: image, - artist: (artists ?? []).find(x => x.image !== undefined)?.image - }); - if(art !== undefined) { - play.meta = { - art - } +const trackSearchResultToOnChange = (val: TrackSearchResult): TrackOnChange => { + const change: TrackOnChange = { track: trackSearchResultToCredit(val) }; + if (val.artists !== undefined && val.artists.length > 0) { + change.artists = val.artists.map(artistSearchResultToCredit); } - - return play; + if (val.album !== undefined) { + change.album = albumSearchResultToCredit(val.album); + } + return change; } export const TrackSearch = (props: TrackSearchProps) => { @@ -135,28 +91,8 @@ export const TrackSearch = (props: TrackSearchProps) => { onChange = (val) => console.log(val, 'Selected value for prop') } = props; - const { - data: { - track, - meta: { - brainz: { - recording, - track: mbidTrack - } = {}, - spotify: { - track: spotifyId - } = {} - } = {} - } = {}, - } = initial ?? {}; - - const [selectedItem, setSelectedItem] = useState(removeUndefinedKeys({ - name: track ?? '', - mbidTrack, - mbidRecording: recording, - spotifyId - }, false)); - const [debouncedQuery, setDebouncedQuery] = useDebouncedState(track ?? '', { wait: 500 }); + const [selectedItem, setSelectedItem] = useState(initial ?? {name: ''}); + const [debouncedQuery, setDebouncedQuery] = useDebouncedState(initial?.name ?? '', { wait: 500 }); const query = useQuery({ enabled: debouncedQuery !== '', @@ -176,11 +112,11 @@ export const TrackSearch = (props: TrackSearchProps) => { }, [query, set]) const doChange = useCallback((val: TrackSearchResult) => { - setSelectedItem(val); - onChange(trackResultToPlay(val)); + setSelectedItem(trackSearchResultToCredit(val)); + onChange(trackSearchResultToOnChange(val)); },[setSelectedItem, onChange]); - const services = trackSearchResultToMusicServices(selectedItem); + const services = selectedItem.metadata ?? []; let groupContent: React.JSX.Element | undefined = undefined; if(services.length > 0) { groupContent = diff --git a/src/client/components/playActivity/ActivityList.tsx b/src/client/components/playActivity/ActivityList.tsx index 1c6c01de..73077328 100644 --- a/src/client/components/playActivity/ActivityList.tsx +++ b/src/client/components/playActivity/ActivityList.tsx @@ -212,7 +212,7 @@ const playInWindow = (data: PlayApiCommonDetailed, query: QueryPlaysOptsJson): b if (query.text !== undefined) { let someFound = false; for (const t of query.text) { - if (data.play.data.track?.toLocaleLowerCase().includes(t)) { + if (data.play.data.track?.name.toLocaleLowerCase().includes(t)) { someFound = true; break; } diff --git a/src/core/Api.ts b/src/core/Api.ts index 7647de32..606f7fd1 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -1,11 +1,13 @@ import type { CompareOpKey, ComponentMinimalSelect } from "../backend/common/database/drizzle/drizzleTypes.ts" -import { artistCreditSchema, type ClientType, type ComponentAuthType, type DeepReplaceValue, type MonitoringStatus, type QueueContext } from "./Atomic.ts" +import { mbidSchema, type Credit, type ClientType, type ComponentAuthType, type DeepReplaceValue, type MonitoringStatus, type QueueContext } from "./Atomic.ts" import type { SourceType } from "./Atomic.ts" import type { ComponentType, DateLike, ErrorLike, JsonPlayObject, PlayState, QueueName, SOURCE_SOT_TYPES, SourcePlayerJson } from "./Atomic.ts" import type { Dayjs } from "dayjs" import type { ErrorIsh } from "./ErrorUtils.ts" import type { PlayEvent } from "./PlayEvent.ts" import * as z from "zod" +import { mbMeta, spotifyMeta, withImage } from "./MusicMetadata.ts" +import { nameToCredit } from "./StringUtils.ts" import { httpUrl } from "../backend/utils/ZodUtils.ts" export interface PlayApiCommon { @@ -260,7 +262,8 @@ export const metadataResultImageSchema = z.object({ export type MetadataResultImage = z.infer; export const artistSearchResultSchema = z.object({ - ...artistCreditSchema.shape, + mbid: mbidSchema.optional(), + spotifyId: z.string().optional(), ...metadataResultServiceScoreSchema.shape, ...metadataResultBaseSchema.shape, ...metadataResultImageSchema.shape, @@ -305,4 +308,13 @@ export type TrackSearchResult = z.infer; export const trackSearchResultResponseSchema = z.object({ data: trackSearchResultSchema.array() }); -export type TrackSearchResultResponse = z.infer; \ No newline at end of file +export type TrackSearchResultResponse = z.infer; + +export const artistSearchResultToCredit = (val: Pick): Credit => + withImage(nameToCredit(val.name, mbMeta(val.mbid, 'artist'), spotifyMeta(val.spotifyId, 'artist')), val.image); + +export const albumSearchResultToCredit = (val: Pick): Credit => + withImage(nameToCredit(val.name, mbMeta(val.mbidRelease, 'release'), mbMeta(val.mbidReleaseGroup, 'release-group'), spotifyMeta(val.spotifyId, 'album')), val.image); + +export const trackSearchResultToCredit = (val: Pick): Credit => + withImage(nameToCredit(val.name, mbMeta(val.mbidTrack, 'track'), mbMeta(val.mbidRecording, 'recording'), spotifyMeta(val.spotifyId, 'track')), val.image); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index fff0f92e..0f4dead5 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -6,8 +6,7 @@ import type { FlowControlTerm, TransformHook } from "./Transform.ts"; import type {Changeset} from "json-diff-ts"; import type {IParseBaseOptions} from 'qs'; import * as z from "zod"; -import { musicServicesSchema, type MusicServicesAny } from "./MusicMetadata.ts"; -import { httpUrl } from "../backend/utils/ZodUtils.ts"; +import { musicServicesSchema } from "./MusicMetadata.ts"; export const componentTypeClientSchema = z.literal('client'); export type ComponentTypeClient = z.infer; @@ -98,91 +97,33 @@ export interface ListenRangeData extends ListenRangeDataAmb { export const mbidSchema = z.stringFormat('mbid',/[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}/); -/** https://musicbrainz.org/doc/MusicBrainz_Database/Schema#Overview */ +/** Non-id musicbrainz data for a track. MBIDs are stored in the `metadata` of the Credit they identify */ export interface BrainzMeta { - /** - * artist_mbids - * - * All artists, including ft guests etc... go here */ - artist?: string[] - /** - * artists_mbid - * - * If multiple artists for track this is the "original" artist(s) who is releasing the single/album */ - albumArtist?: string[] - /** - * release_mbid - * - * The unique release like --> 1984 US release of "The Wall" by "Pink Floyd", release on label "Columbia Records" with catalog number "C2K 36183" - * */ - album?: string - /** Unique track id, recording_mbid */ - recording?: string - /** - * - * The "consolidated" album like --> "The Wall" by "Pink Floyd" */ - releaseGroup?: string additionalInfo?: AdditionalTrackInfoResponse /** Position of track within Release */ trackNumber?: number - - /** Track MBID (tid), not visible to end users and is only relevant in the context of a Release - * - * Specifies the track on a specific Release. Not the same as the Recording MBID. - */ - track?: string } export const brainzMetaSchema = z.object({ - artists: mbidSchema.array().optional(), - albumArtists: mbidSchema.array().optional(), - album: mbidSchema.optional(), - recording: mbidSchema.optional(), - releaseGroup: mbidSchema.optional(), trackNumber: z.int().positive().optional(), - track: mbidSchema.optional() }) +/** A named thing (track, artist, album) with optional art and any number of ids from music services that identify it */ export const creditSchema = z.object({ name: z.string(), - image: httpUrl.optional(), + // not httpUrl because some sources (plex) use relative proxy urls + image: z.string().optional(), metadata: musicServicesSchema.array().optional() }) export type Credit = z.infer; -export interface ArtistCredit { - name: string - mbid?: string -} - -export const artistCreditSchema = z.object({ - name: z.string(), - mbid: mbidSchema.optional(), - spotifyId: z.string().optional() -}) - -export interface SpotifyMeta { - artist?: string[] - albumArtist?: string[] - album?: string - track?: string -} - -export const spotifyMeta = z.object({ - artists: z.string().array().optional(), - albumArtist: z.string().array().optional(), - album: z.string().optional(), - track: z.string().optional() -}) export interface TrackMeta { brainz?: BrainzMeta - spotify?: SpotifyMeta } export const trackMetaSchema = z.object({ brainz: brainzMetaSchema.optional(), - spotify: spotifyMeta.optional() }) export interface TrackMetaIsrc extends TrackMeta { @@ -190,10 +131,10 @@ export interface TrackMetaIsrc extends TrackMeta { } export interface TrackData { - artists?: ArtistCredit[] - albumArtists?: ArtistCredit[] - album?: string - track?: string + artists?: Credit[] + albumArtists?: Credit[] + album?: Credit + track?: Credit /** * The length of the track, in seconds * */ @@ -209,10 +150,10 @@ export interface TrackData { } export const playTrackDataSchema = z.object({ - track: z.string().optional(), - artists: artistCreditSchema.array().optional(), - albumArtists: artistCreditSchema.array().optional(), - album: z.string().optional(), + track: creditSchema.optional(), + artists: creditSchema.array().optional(), + albumArtists: creditSchema.array().optional(), + album: creditSchema.optional(), duration: z.int().nonnegative().optional(), isrc: z.string().optional(), meta: trackMetaSchema.optional() @@ -220,8 +161,8 @@ export const playTrackDataSchema = z.object({ export const playTrackStrictDataSchema = z.object({ ...playTrackDataSchema.shape, - track: z.string().nonempty(), - artists: artistCreditSchema.array().min(1) + track: z.object({...creditSchema.shape, name: z.string().nonempty()}), + artists: creditSchema.array().min(1) }) export interface PlayData extends TrackData { @@ -249,18 +190,6 @@ export const playDataStrictSchema = z.object({ }); -export interface ArtMeta { - album?: string - track?: string - artist?: string -} - -export const artMetaSchema = z.object({ - album: z.string().optional(), - track: z.string().optional(), - artist: z.string().optional() -}); - export const playMetaUrlSchema = z.looseObject({ web: z.string().optional(), origin: z.string().optional() @@ -317,10 +246,6 @@ export interface PlayMetaBase { */ origin?: string } - /** - * Hot-linkable images for use with displaying art for this play - */ - art?: ArtMeta user?: string mediaType?: string server?: string @@ -366,8 +291,7 @@ export const playMetaSchema = z.looseObject({ musicService: z.string().optional(), trackId: z.string().optional(), parsedFrom: parsedFromSchema.optional(), - url: playMetaUrlSchema.optional(), - art: artMetaSchema.optional() + url: playMetaUrlSchema.optional() }) export interface LifecycleInput { diff --git a/src/core/MusicMetadata.ts b/src/core/MusicMetadata.ts index b3209b9f..2c337863 100644 --- a/src/core/MusicMetadata.ts +++ b/src/core/MusicMetadata.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import type { Credit, TrackData } from "./Atomic.ts"; export const musicServiceName = z.enum(['spotify', 'musicbrainz', 'youtube', 'jellyfin', 'plex', 'listenbrainz', 'rocksky']); export type MusicServiceName = z.infer; @@ -32,7 +33,138 @@ export const musicServicesSchema = z.discriminatedUnion('name', [musicServiceMBS export type MusicServices = z.infer; export type MusicServicesAny = (MusicServices | MusicServiceBase); -export const hasMusicMetadata = (meta: MusicServicesAny[], name: MusicServiceName, idType: string) => getMusicMetadata(meta, name, idType) !== undefined +export const hasMusicMetadata = (meta: MusicServicesAny[] | undefined, name: MusicServiceName, idType?: string) => getMusicMetadata(meta, name, idType) !== undefined -export const getMusicMetadata = (meta: MusicServicesAny[], name: MusicServiceName, idType: string) => - meta.find(x => x.name === name && (idType === undefined || ('idType' in x && x.idType === idType))) \ No newline at end of file +export const getMusicMetadata = (meta: MusicServicesAny[] = [], name: MusicServiceName, idType?: string) => + meta.find(x => x.name === name && (idType === undefined || ('idType' in x && x.idType === idType))) + +export type MBIdType = 'recording' | 'release' | 'track' | 'artist' | 'release-group'; + +/** Build a metadata entry for a service id. Returns undefined if there is no id so it can be passed straight to `withMetadata` */ +export const serviceMeta = (name: MusicServiceName, id: string | null | undefined, idType?: string): MusicServices | undefined => { + if (id === undefined || id === null || id === '') { + return undefined; + } + return (idType === undefined ? { name, id } : { name, id, idType }) as MusicServices; +} +export const mbMeta = (id: string | null | undefined, idType: MBIdType) => serviceMeta('musicbrainz', id, idType); +export const spotifyMeta = (id: string | null | undefined, idType: 'track' | 'album' | 'artist') => serviceMeta('spotify', id, idType); + +const sameMeta = (a: MusicServices, b: MusicServices) => a.name === b.name && a.idType === b.idType; + +/** Add metadata entries to a credit, replacing any existing entry for the same service + idType. Undefined entries are ignored. */ +export function withMetadata(credit: Credit, ...entries: (MusicServices | undefined)[]): Credit; +// eslint-disable-next-line no-redeclare +export function withMetadata(credit: Credit | undefined, ...entries: (MusicServices | undefined)[]): Credit | undefined; +// eslint-disable-next-line no-redeclare +export function withMetadata(credit: Credit | undefined, ...entries: (MusicServices | undefined)[]): Credit | undefined { + const valid = entries.filter(x => x !== undefined); + if (credit === undefined || valid.length === 0) { + return credit; + } + const existing = (credit.metadata ?? []).filter(x => !valid.some(y => sameMeta(x, y))); + return { ...credit, metadata: [...existing, ...valid] }; +} + +/** Set the image on a credit, if both exist */ +export function withImage(credit: Credit, image: string | null | undefined): Credit; +// eslint-disable-next-line no-redeclare +export function withImage(credit: Credit | undefined, image: string | null | undefined): Credit | undefined; +// eslint-disable-next-line no-redeclare +export function withImage(credit: Credit | undefined, image: string | null | undefined): Credit | undefined { + if (credit === undefined || image === undefined || image === null || image === '') { + return credit; + } + return { ...credit, image }; +} + +/** Set album art for a play. If the play has no album the image is set on the track instead so it is not lost. */ +export const withAlbumArt = >(data: T, image: string | null | undefined): T => { + if (data.album !== undefined) { + return { ...data, album: withImage(data.album, image) }; + } + return { ...data, track: withImage(data.track, image) }; +} + +/** Get the id for a service (and idType) from a credit */ +export const creditId = (credit: Credit | undefined, name: MusicServiceName, idType?: string): string | undefined => { + const m = getMusicMetadata(credit?.metadata, name, idType); + return m !== undefined && 'id' in m ? m.id : undefined; +} +export const creditMbid = (credit: Credit | undefined, idType: MBIdType) => creditId(credit, 'musicbrainz', idType); + +/** Get the ids for a service from all credits that have one */ +export const creditIds = (credits: Credit[] = [], name: MusicServiceName, idType?: string): string[] => + credits.map(x => creditId(x, name, idType)).filter(x => x !== undefined); + +/** + * Attach service ids to credits by position. + * + * Ids are only attached if there is exactly one id per credit, otherwise we can't know which id belongs to which credit and they are dropped. + */ +export const creditsWithIds = (credits: Credit[], ids: (string | null | undefined)[] | undefined, name: MusicServiceName, idType?: string): Credit[] => { + if (ids === undefined || ids.length !== credits.length) { + return credits; + } + return credits.map((x, i) => withMetadata(x, serviceMeta(name, ids[i], idType))); +} + +/** Combine an existing credit with a newer version of it. The newer name and image win, metadata from both is kept. */ +export const mergeCredit = (base: Credit | undefined, next: Credit): Credit => { + if (base === undefined) { + return next; + } + return withImage(withMetadata({ ...base, name: next.name }, ...(next.metadata ?? [])), next.image); +} + +/** + * Replace a list of credits with a newer list, keeping metadata from any existing credit with the same name + */ +export const mergeCredits = (base: Credit[] = [], next: Credit[]): Credit[] => + next.map(x => mergeCredit(base.find(y => y.name === x.name), x)); + +/** + * Add metadata `from` credits to `base` credits without changing the names of base credits + * + * Metadata is only added when we know which credit it belongs to: by position if lists are the same length, otherwise by name + */ +export const mergeCreditsMetadata = (base: Credit[], from: Credit[] = []): Credit[] => + base.map((x, i) => { + const match = base.length === from.length ? from[i] : from.find(y => y.name === x.name); + return withMetadata(x, ...(match?.metadata ?? [])); + }); + +/** All images found on the track, album, and artists of a play */ +export const playImages = (data: Pick): string[] => + [data.track, data.album, ...(data.artists ?? [])].map(x => x?.image).filter(x => x !== undefined); + +/** The first image found on the track, album, or artists of a play, in that order unless otherwise specified */ +export const playImage = (data: Pick, order: ('track' | 'album' | 'artist')[] = ['track', 'album', 'artist']): string | undefined => { + const images = { + track: data.track?.image, + album: data.album?.image, + artist: data.artists?.find(x => x.image !== undefined)?.image + }; + return order.map(x => images[x]).find(x => x !== undefined); +} + +/** Credit with only the name -- no image or metadata */ +export const stripCredit = (credit: Credit): Credit => ({ name: credit.name }); + +/** Reduce all credits in track data to only their names */ +export const stripCredits = >(data: T): T => { + const stripped = { ...data }; + if (data.track !== undefined) { + stripped.track = stripCredit(data.track); + } + if (data.album !== undefined) { + stripped.album = stripCredit(data.album); + } + if (data.artists !== undefined) { + stripped.artists = data.artists.map(stripCredit); + } + if (data.albumArtists !== undefined) { + stripped.albumArtists = data.albumArtists.map(stripCredit); + } + return stripped; +} diff --git a/src/core/StringUtils.ts b/src/core/StringUtils.ts index a0f3ddd5..4bd94dd6 100644 --- a/src/core/StringUtils.ts +++ b/src/core/StringUtils.ts @@ -6,8 +6,7 @@ import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; import { type AmbPlayObject, - type ArtistCredit, - type PlayData, + type Credit, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, type ScrobbleTsSOC, @@ -16,6 +15,7 @@ import { import { DELIMETERS_REGEX, DELIMITERS } from './Atomic.ts'; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { removeUndefinedKeys } from './DataUtils.ts'; +import { type MusicServices, withMetadata } from './MusicMetadata.ts'; import { nanoid } from "nanoid"; dayjs.extend(utc) @@ -110,13 +110,13 @@ export const buildTrackString = (playObj: AmbPlayObject, options: Tr strParts.push(`(${trackId})`); } if (include.includes('artist')) { - strParts.push(artistsFunc(artistCreditsToNames(artists))) + strParts.push(artistsFunc(creditsToNames(artists))) } if (include.includes('track')) { - strParts.push(trackFunc(track, playObj, strParts.length > 0)); + strParts.push(trackFunc(creditToName(track), playObj, strParts.length > 0)); } if (include.includes('album')) { - strParts.push(albumFunc(album, playObj, strParts.length > 0)); + strParts.push(albumFunc(creditToName(album), playObj, strParts.length > 0)); } if (include.includes('time')) { strParts.push(timeFunc(pd, usedTsSOC)); @@ -138,47 +138,6 @@ export const buildTrackString = (playObj: AmbPlayObject, options: Tr return reducer(strParts); //strParts.join(' '); } -export const buildPlayHumanDiffable = (play: PlayData, options?: {expandMeta?: boolean}): string => { - const { - expandMeta = false - } = options || {}; - - const meta: string[] = []; - if(play.meta !== undefined) { - for(const [metaType,metaObject] of Object.entries(play.meta)) { - for(const [metaKey, metaValue] of Object.entries(metaObject)) { - if(metaValue === undefined) { - continue; - } - const id = `${metaType}-${metaKey}`; - if(expandMeta) { - meta.push(`${id}: ${metaValue}`); - } else { - meta.push(id); - } - } - } - } - let metaStr = '(None)'; - if(meta.length > 0) { - if(expandMeta) { - metaStr = `\n${meta.map(x => `* ${x}`).join('\n')}`; - } else { - metaStr = meta.join(', '); - } - } - const parts: string[] = [ - `${'Title'.padEnd(13)}: ${play.track ?? '(None)'}`, - `${'Artists'.padEnd(13)}: ${play.artists === undefined || play.artists.length === 0 ? '(None)' : play.artists.join(', ')}`, - `${'Album Artists'.padEnd(13)}: ${play.albumArtists === undefined || play.albumArtists.length === 0 ? '(None)' : play.albumArtists.join(', ')}`, - `${'Album'.padEnd(13)}: ${play.album ?? '(None)'}`, - `${'Meta'.padEnd(13)}: ${metaStr}` - ]; - - const final = parts.join('\n'); - return final; -} - export const slice = (str: string, index: number, count: number, add?: string): string => { // We cannot pass negative indexes directly to the 2nd slicing operation. if (index < 0) { @@ -311,32 +270,37 @@ export const containsDelimiters = (str: string) => null !== str.match(/[,&/\\]+/ const NUMBERS_REGEX = new RegExp(/^\s*\d+\s*$/); export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str); -export const artistNamesToCredits = (names: (string | Partial)[] | undefined): ArtistCredit[] => { +export const namesToCredits = (names: (string | Partial)[] | undefined): Credit[] => { if(names === undefined) { throw new Error('Must pass names'); } - return names.map(x => artistNameToCredit(x)!).filter(x => x !== undefined); + return names.map(x => nameToCredit(x)!).filter(x => x !== undefined); }; +/** Build a Credit from a name, optionally with service metadata. Empty/undefined names return undefined. */ // typing overloading here is ok -export function artistNameToCredit(val: string): ArtistCredit; +export function nameToCredit(val: string, ...metadata: (MusicServices | undefined)[]): Credit; // eslint-disable-next-line no-redeclare -export function artistNameToCredit(val: string | undefined | Partial): ArtistCredit | undefined; +export function nameToCredit(val: string | null | undefined | Partial, ...metadata: (MusicServices | undefined)[]): Credit | undefined; // eslint-disable-next-line no-redeclare -export function artistNameToCredit(val: string | undefined | Partial): ArtistCredit | undefined { - if(val === undefined) { +export function nameToCredit(val: string | null | undefined | Partial, ...metadata: (MusicServices | undefined)[]): Credit | undefined { + if(val === undefined || val === null) { return undefined; } if(typeof val === 'string') { - return {name: val}; + return withMetadata({name: val}, ...metadata); } - const { - name, - mbid, - ...rest - } = val; - return removeUndefinedKeys({name, mbid, ...rest}) as ArtistCredit; + if(val.name === undefined || val.name === null) { + return undefined; + } + return withMetadata(removeUndefinedKeys({...val}) as Credit, ...metadata); +} +export function creditToName(a: Credit): string; +// eslint-disable-next-line no-redeclare +export function creditToName(a: Credit | undefined): string | undefined; +// eslint-disable-next-line no-redeclare +export function creditToName(a: Credit | undefined): string | undefined { + return a?.name; } -export const artistCreditToName = (a: ArtistCredit): string => a.name; -export const artistCreditsToNames = (a: ArtistCredit[] = []): string[] => a.map((x) => x.name); +export const creditsToNames = (a: Credit[] = []): string[] => a.map((x) => x.name); export const generatePlayUid = () => nanoid(20); \ No newline at end of file diff --git a/src/core/tests/utils/PlayTestUtils.ts b/src/core/tests/utils/PlayTestUtils.ts index d3a9736f..9455932d 100644 --- a/src/core/tests/utils/PlayTestUtils.ts +++ b/src/core/tests/utils/PlayTestUtils.ts @@ -5,7 +5,7 @@ import isBetween from "dayjs/plugin/isBetween.js"; import relativeTime from "dayjs/plugin/relativeTime.js"; import timezone from "dayjs/plugin/timezone.js"; import utc from "dayjs/plugin/utc.js"; -import { type ArtistCredit, type BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, type JsonPlayObject, type MBID, type ObjectPlayData, type PlayMeta, type PlayObject, type SourcePlayerObj } from "../../Atomic.ts"; +import { type Credit, FEAT, JOINERS, JOINERS_FINAL, type JsonPlayObject, type MBID, type ObjectPlayData, type PlayMeta, type PlayObject, type SourcePlayerObj } from "../../Atomic.ts"; import { genGroupIdStr } from '../../PlayUtils.ts'; import { sortByNewestPlayDate } from '../../PlayUtils.ts'; import type { PlayerStateDataMaybePlay } from '../../../backend/common/infrastructure/Atomic.ts'; @@ -15,13 +15,13 @@ import { NO_DEVICE } from '../../Atomic.ts'; import { CALCULATED_PLAYER_STATUSES } from '../../Atomic.ts'; import { REPORTED_PLAYER_STATUSES } from '../../Atomic.ts'; import type {PlayPlatformId} from '../../Atomic.ts'; -import { arrayListAnd, artistNamesToCredits } from '../../StringUtils.ts'; +import { arrayListAnd, namesToCredits, nameToCredit } from '../../StringUtils.ts'; +import { creditMbid, mbMeta, type MBIdType, withMetadata } from '../../MusicMetadata.ts'; import { findDelimiters } from "../../StringUtils.ts"; import type {ListRecord} from '../../../backend/common/infrastructure/config/client/tealfm.ts'; import { nanoid } from 'nanoid'; import type {LastFMTrackObject} from '../../../backend/common/vendor/LastfmApiClient.ts'; import clone from 'clone'; -import { removeUndefinedKeys } from '../../DataUtils.ts'; import type { FmTealFeedPlay } from '../../../backend/common/vendor/teal/lexicons/index.ts'; dayjs.extend(utc) @@ -179,11 +179,11 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}, opt const duration = faker.number.int({min: 30, max: 300}); const play: PlayObject = { data: { - track: faker.music.songName(), - artists: artistNamesToCredits(faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}})), + track: nameToCredit(faker.music.songName()), + artists: namesToCredits(faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}})), duration, playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})), - album: faker.music.album(), + album: nameToCredit(faker.music.album()), listenedFor: faker.number.int({min: duration * 0.5, max: duration}), ...data }, @@ -249,52 +249,32 @@ export const generateJsonPlays = (...args: Parameters): Js export interface WithBrainzOptions { include: ('track' | 'artist' | 'album' | 'recording' | 'releaseGroup')[] } -export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => { +/** Add generated MBIDs to the credits of a Play, for any that do not already have one */ +export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => { const {include} = opts; - const brainz: BrainzMeta = {}; + const {track, album, artists = []} = play.data; + const missing = (credit: Credit | undefined, idType: MBIdType) => creditMbid(credit, idType) === undefined ? mbMeta(generateMbid(), idType) : undefined; for(const i of include) { switch(i) { case 'track': - if(play.data.meta?.brainz?.track === undefined) { - brainz.track = generateMbid(); - } + play.data.track = withMetadata(play.data.track, missing(track, 'track')); break; case 'recording': - if(play.data.meta?.brainz?.recording === undefined) { - brainz.recording = generateMbid(); - } + play.data.track = withMetadata(play.data.track, missing(track, 'recording')); break; case 'album': - if(play.data.meta?.brainz?.album === undefined && play.data.album !== undefined) { - brainz.album = generateMbid(); - } + play.data.album = withMetadata(play.data.album, missing(album, 'release')); break; case 'releaseGroup': - if(play.data.meta?.brainz?.releaseGroup === undefined) { - brainz.releaseGroup = generateMbid(); - } + play.data.album = withMetadata(play.data.album, missing(album, 'release-group')); break; case 'artist': - if(play.data.meta?.brainz?.artist === undefined && (play.data.artists ?? []).length > 0) { - const artistMbids = play.data.artists!.map(x => generateMbid()); - brainz.artist = artistMbids; + if(artists.length > 0) { + play.data.artists = artists.map(x => withMetadata(x, missing(x, 'artist'))); } break; } } - - return brainz -} - -export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => { - const brainz = generateBrainz(play, opts); - play.data.meta = { - ...(play.data.meta ?? {}), - brainz : { - ...(play.data.meta?.brainz ?? {}), - ...brainz - } - } return play; } @@ -353,17 +333,17 @@ export const generateArtists = (num?: number, max: number = 3, opts: ArtistGener return artists; } -export const generateArtistCredit = (name: string = faker.music.artist(), mbidVal: MBID | boolean = true): ArtistCredit => { +export const generateArtistCredit = (name: string = faker.music.artist(), mbidVal: MBID | boolean = true): Credit => { let mbid: MBID | undefined; if(mbidVal === true) { mbid = generateMbid(); } else if(typeof mbidVal === 'string') { mbid = mbidVal as MBID; } - return removeUndefinedKeys({name, mbid})!; + return nameToCredit(name, mbMeta(mbid, 'artist')); } -export const generateArtistCredits = (num?: number, max?: number, opts: ArtistGenerationOptions & {mbidVal?: boolean} = {}): ArtistCredit[] => { +export const generateArtistCredits = (num?: number, max?: number, opts: ArtistGenerationOptions & {mbidVal?: boolean} = {}): Credit[] => { const artistNames = generateArtists(num, max, opts); return artistNames.map(x => generateArtistCredit(x, opts.mbidVal)); } diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 2bfc43c6..33c50e6a 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -1,10 +1,11 @@ import { faker } from "@faker-js/faker"; import type {AlbumSearchResult, ArtistSearchResult, ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentHistoricalApi, ComponentSourceApi, ComponentSourceApiJson, ComponentState, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi, TrackSearchResult} from "../../Api.ts"; import { INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes, type MBID } from "../../Atomic.ts"; -import { generateArtist, generateArtistCredit, generateMbid, generatePlay, normalizePlays } from "./PlayTestUtils.ts"; +import { generateArtist, generateMbid, generatePlay, normalizePlays } from "./PlayTestUtils.ts"; import { generatePlayInput, generatePlayWithLifecycle, playWithLifecycleScrobble, randomPlayState } from "./fixtures.ts"; import { asJsonPlayObject } from "../../PlayMarshalUtils.ts"; import { generatePlayUid } from "../../StringUtils.ts"; +import { withAlbumArt } from "../../MusicMetadata.ts"; import dayjs, { type Dayjs } from "dayjs"; import { isSourceType } from "../../Atomic.ts"; import { sourceTypes } from "../../Atomic.ts"; @@ -67,8 +68,7 @@ export const generatePlayInputApi = (inputData: Partial = {}, ...a createdAt, data: res.data, play: res.play !== undefined ? asJsonPlayObject(res.play) : undefined, - ...inputData, - } + ...inputData } } export const generateQueueStateApi = (data: Partial): QueueStateApi => { @@ -92,8 +92,7 @@ export const generatePlayApiCommonDetailed = (opts: { const { playOpts = [], inputOpts = [], - queueOpts = [], - } = opts; + queueOpts = [] } = opts; const playCommon = generatePlayApiCommon(...playOpts); const inputRes = generatePlayInputApi(...inputOpts); @@ -208,8 +207,7 @@ export const generateSourceApiJson = (data: Partial = {}): C supportsUpstreamRecentlyPlayed = faker.datatype.boolean(), tracksDiscovered = faker.number.int({min: 1, max: 2000}), players = (data.players ?? {}), - sleeping = false, - } = data; + sleeping = false } = data; return { lastImport: undefined, lastImportSuccess: undefined, @@ -239,8 +237,7 @@ export const generateClientApiJson = (data: Partial = {}): C ...rest }); const { - players = (data.players ?? {}), - } = data; + players = (data.players ?? {}) } = data; return { lastImport: undefined, lastImportSuccess: undefined, @@ -301,12 +298,8 @@ export const generateSourcePlayerJson = (data: Partial = {}, o } = data; if(opts.art) { - play.meta = { - ...(play.meta), - art: { - album: 'https://placehold.co/400', - ...(play.meta?.art ?? {}) - } + if(play.data.album?.image === undefined) { + play.data = withAlbumArt(play.data, 'https://placehold.co/400'); } } @@ -472,16 +465,14 @@ export function generateFakeError(depth: number = 0): Error { Object.defineProperty(error, 'name', { value: 'CustomError', writable: true, - enumerable: false, - }); + enumerable: false }); // Create a fake stack trace const stackTrace = `CustomError: ${message}\n${generateStackTrace(func)}`; Object.defineProperty(error, 'stack', { value: stackTrace, writable: true, - enumerable: false, - }); + enumerable: false }); // Add nested cause if depth allows if (currentDepth < depth) { @@ -489,8 +480,7 @@ export function generateFakeError(depth: number = 0): Error { Object.defineProperty(error, 'cause', { value: causeError, writable: true, - enumerable: true, - }) + enumerable: true }) } return error; @@ -525,7 +515,8 @@ export const generateArtistSearchResult = (partial: Partial score: faker.number.int({min: 10, max: 100}), service: faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']), image: placeholderImage([300]), - ...generateArtistCredit(partial.name, opts.mbidVal), + name: faker.music.artist(), + mbid: opts.mbidVal === false ? undefined : (typeof opts.mbidVal === 'string' ? opts.mbidVal : generateMbid()), ...partial }) diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index bfa21c6a..0222503e 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -1,7 +1,6 @@ -import { 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 { generatePlay, type GeneratePlayOpts, generatePlays, withBrainz } from './PlayTestUtils.ts'; import { statefulInvariantTransform } from '../../PlayUtils.ts'; import clone from 'clone'; import { diffObjects } from '../../DataUtils.ts'; @@ -165,7 +164,7 @@ export interface GenerateLifecycleOptions { inputCount?: number } -const modifiableKeys: PropertyKey[] = ['track', 'album', 'albumArtists', 'artists', 'duration', 'meta','brainz']; +const modifiableKeys = ['track', 'album', 'albumArtists', 'artists', 'duration'] as const; export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleOptions = {}): [LifecycleStep, PlayObject] => { const { @@ -218,38 +217,22 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO 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 as keyof typeof randomPlay.data]); - } - } + for (const key of modifiableKeys) { + if (faker.datatype.boolean(0.3)) { + somethingModified = true; + (modifiedPlay.data as Record)[key] = randomPlay.data[key]; } - }); + } + if (faker.datatype.boolean(0.3)) { + // adds mbids to any credits that don't already have them + const before = JSON.stringify(modifiedPlay.data); + withBrainz(modifiedPlay, {include: ['album', 'artist', 'track', 'recording']}); + somethingModified = somethingModified || before !== JSON.stringify(modifiedPlay.data); + } } step.patch = diffObjects(play.data, modifiedPlay.data);// jdiff.diff(play, modifiedPlay); diff --git a/src/stories/plays/ActivityList.stories.tsx b/src/stories/plays/ActivityList.stories.tsx index 88bb4111..d9655231 100644 --- a/src/stories/plays/ActivityList.stories.tsx +++ b/src/stories/plays/ActivityList.stories.tsx @@ -266,7 +266,7 @@ export const ListLiveUpdates = meta.story({ newState = randomPlayState(); } liveUpdateData[index].state = newState; - liveUpdateData[index].play.data.track = faker.music.songName(); + liveUpdateData[index].play.data.track = {name: faker.music.songName()}; liveUpdateData[index].updatedAt = dayjs().toISOString(); client.send({ diff --git a/src/stories/plays/PlayInfo.stories.tsx b/src/stories/plays/PlayInfo.stories.tsx index a2d40195..787b8bfa 100644 --- a/src/stories/plays/PlayInfo.stories.tsx +++ b/src/stories/plays/PlayInfo.stories.tsx @@ -44,7 +44,7 @@ export const PlayInfoStory = meta.story({ if(args.defaultFinal && args.final === undefined) { const final = clone(args.play); - final.data.track = `${final.data.track} (Album Version)`; + final.data.track = {...final.data.track, name: `${final.data.track?.name} (Album Version)`}; args.final = final; } -- 2.51.2