diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index 5089e70e..9db58dbe 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -53,6 +53,7 @@ export interface TealClientAIOConfig extends TealClientConfig { } export interface TealArtistCredit { artistName?: string, + /** The MusicBrainz artist ID URI, formatted as mbid: */ artistMbId?: string } /** @@ -70,12 +71,19 @@ export interface ScrobbleRecord { /** A metadata string specifying the user agent where the format is `/ (; ; )` */ submissionClientAgent: string, musicServiceBaseDomain?: string - // musicbrainz + /** The MusicBrainz ID URI of the track, formatted as mbid: */ + trackMbid?: string + /** The MusicBrainz recording ID URI of the track, formatted as mbid: */ recordingMbId?: string + /** The MusicBrainz release ID URI, formatted as mbid: */ releaseMbId?: string isrc?: string, /** The URL associated with this track */ originUrl?: string + /** Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities. */ + trackDiscriminant?: string; + /** Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities. */ + releaseDiscriminant?: string; [x: string]: unknown } diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index bc984c18..771cbd99 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -4,7 +4,7 @@ import { ListRecord, ScrobbleRecord, StatusRecord, TealClientData } from "../../ import AbstractApiClient from "../AbstractApiClient.js"; import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api"; import { MSCache } from "../../Cache.js"; -import { BrainzMeta, PlayObject, SourcePlayerObj, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; +import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, MBID } from "../../../../core/Atomic.js"; import { musicServiceToCononical } from '../listenbrainz/lzUtils.js'; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; @@ -109,20 +109,33 @@ export const playToRecord = (play: PlayObject): ScrobbleRecord => { const record: ScrobbleRecord = { $type: "fm.teal.alpha.feed.play", trackName: play.data.track, - artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: x.mbid })), + artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })), duration: Math.round(play.data.duration), playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(), releaseName: play.data.album, submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService, isrc: play.data.isrc, - recordingMbId: play.data.meta?.brainz?.recording, - releaseMbId: play.data.meta?.brainz?.album + 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) }; return record; } +type MBIDURI = `mbid:${MBID}`; + +const mbidUriOrUndefined = (mbid?: MBID): undefined | MBIDURI => { + if(mbid === undefined) { + return undefined; + } + return mbidToUri(mbid); +} +export const mbidToUri = (mbid: MBID): MBIDURI => { + return `mbid:${mbid}`; +} + export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): StatusRecord => { const { $type, ...item } = notPlaying ? { trackName: "", artists: [] } diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index 241af999..e2e839f3 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -457,7 +457,7 @@ describe('#MB Missing Types', function() { it('Finds none missing when all mbids are defined', function() { - const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'track']}); + const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'recording']}); const missing = missingMbidTypes(play); expect(missing.length).eq(0); }); @@ -471,7 +471,7 @@ describe('#MB Missing Types', function() { it('Finds duration missing', function() { - const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'track']}); + const play = withBrainz(generatePlay(), {include: ['album', 'artist', 'recording']}); delete play.data.duration; const missing = missingMbidTypes(play); expect(missing.length).eq(1); @@ -488,7 +488,7 @@ describe('#MB Missing Types', function() { it('intersect is not empty when missing any desired types', function() { - const play = withBrainz(generatePlay(), {include: ['album', 'track']}); + const play = withBrainz(generatePlay(), {include: ['album', 'recording']}); const missing = missingMbidTypes(play); expect(missing).to.have.members(['artists']); expect(intersect(DEFAULT_MISSING_TYPES, missing)).length.is.greaterThan(0); diff --git a/src/backend/tests/tealfm/tealfm.test.ts b/src/backend/tests/tealfm/tealfm.test.ts index 0aa935f1..46e3e422 100644 --- a/src/backend/tests/tealfm/tealfm.test.ts +++ b/src/backend/tests/tealfm/tealfm.test.ts @@ -1,8 +1,8 @@ import chai, { expect } from 'chai'; import asPromised from 'chai-as-promised'; import { after, before, describe, it } from 'mocha'; -import { generateLastfmTrackObject, generateMbid, generatePlay, generateTealPlayRecord } from "../../../core/PlayTestUtils.js"; -import { AbstractBlueSkyApiClient, listRecordToPlay } from '../../common/vendor/bluesky/AbstractBlueSkyApiClient.js'; +import { generateArtistCredits, generatePlay, generateTealPlayRecord, withBrainz } from "../../../core/PlayTestUtils.js"; +import { listRecordToPlay, playToRecord } from '../../common/vendor/bluesky/AbstractBlueSkyApiClient.js'; import dayjs from 'dayjs'; import { artistCreditsToNames } from '../../../core/StringUtils.js'; @@ -55,4 +55,20 @@ describe('#tealfm Record to Play', function() { expect(play.data.meta?.brainz.artist).to.be.undefined; }); +}); + +describe('#tealfm Play To Record', function () { + + it('Adds mbids with uri format', 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.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}`); + }); + }); \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index f2fd7f90..41f3296d 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -621,4 +621,6 @@ export const CLIENT_DEAD_QUEUE = 'dead'; */ export type TypesAreEqual = (() => G extends T ? 1 : 2) extends - (() => G extends U ? 1 : 2) ? Y : N; \ No newline at end of file + (() => G extends U ? 1 : 2) ? Y : N; + +export type MBID = `${string}-${string}-${string}-${string}-${string}` \ No newline at end of file diff --git a/src/core/PlayTestUtils.ts b/src/core/PlayTestUtils.ts index 9dab1b3a..c23f6310 100644 --- a/src/core/PlayTestUtils.ts +++ b/src/core/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 { BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js"; +import { ArtistCredit, BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MBID, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js"; import { genGroupIdStr } from './PlayUtils.js'; import { sortByNewestPlayDate } from './PlayUtils.js'; import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js'; @@ -17,6 +17,7 @@ import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js'; import { MarkOptional } from 'ts-essentials'; import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js'; import clone from 'clone'; +import { removeUndefinedKeys } from '../backend/utils.js'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -247,7 +248,7 @@ export const generateJsonPlays = (...args: Parameters): Js } export interface WithBrainzOptions { - include: ('track' | 'artist' | 'album')[] + include: ('track' | 'artist' | 'album' | 'recording')[] } export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => { const {include} = opts; @@ -255,6 +256,11 @@ export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): Brain for(const i of include) { switch(i) { case 'track': + if(play.data.meta?.brainz?.track === undefined) { + brainz.track = generateMbid(); + } + break; + case 'recording': if(play.data.meta?.brainz?.recording === undefined) { brainz.recording = generateMbid(); } @@ -339,6 +345,21 @@ 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 => { + let mbid: MBID; + if(mbidVal === true) { + mbid = generateMbid(); + } else if(typeof mbidVal === 'string') { + mbid = mbidVal as MBID; + } + return removeUndefinedKeys({name, mbid}); +} + +export const generateArtistCredits = (num?: number, max?: number, opts: ArtistGenerationOptions & {mbidVal?: boolean} = {}): ArtistCredit[] => { + const artistNames = generateArtists(num, max, opts); + return artistNames.map(x => generateArtistCredit(x, opts.mbidVal)); +} + export interface ArtistGenerateOptions extends ArtistGenerationOptions { num?: number max?: number @@ -442,14 +463,14 @@ export const generateLastfmTrackObject = (): LastFMTrackObject => { } } -export const generateMbid = (): string => { +export const generateMbid = (): MBID => { return [ faker.string.alphanumeric({length: 8}), faker.string.alphanumeric({length: 4}), faker.string.alphanumeric({length: 4}), faker.string.alphanumeric({length: 4}), faker.string.alphanumeric({length: 12}) - ].join('-') + ].join('-') as MBID; } export const generateTealPlayRecord = (opts: { diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index 7a2d3cfe..139042a9 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -219,7 +219,7 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO } somethingModified = true; if(ctx.key === 'brainz' && Object.keys(x ?? {}).length === 0) { - ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track']}), true); + 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 } })); diff --git a/src/stories/PlayInfo.stories.tsx b/src/stories/PlayInfo.stories.tsx index d38f88c9..c8f71270 100644 --- a/src/stories/PlayInfo.stories.tsx +++ b/src/stories/PlayInfo.stories.tsx @@ -57,7 +57,7 @@ export const PlayInfoStory = meta.story({ if(args.brainz) { // @ts-ignore - args.play = asJsonPlayObject(withBrainz(args.play, {include: ['album','track','artist']})); + args.play = asJsonPlayObject(withBrainz(args.play, {include: ['album','recording','track','artist']})); } return () }