From 1b53d22e000a3f61cd93efef58de489e3d29de18 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 7 May 2026 01:15:05 +0000 Subject: [PATCH] feat: Refactor artists as an object Refactor from string to artist so we can support adding mbids etc. directly to artists #425 --- .../transforms/AtomicPartsTransformer.ts | 6 +-- .../transforms/MusicbrainzTransformer.ts | 6 +-- .../common/transforms/NativeTransformer.ts | 21 +++++---- .../common/transforms/UserTransformer.ts | 14 +++--- src/backend/common/vendor/KodiApiClient.ts | 5 +- src/backend/common/vendor/LastfmApiClient.ts | 14 +++--- .../common/vendor/ListenbrainzApiClient.ts | 10 ++-- src/backend/common/vendor/RockSkyApiClient.ts | 6 +-- .../bluesky/AbstractBlueSkyApiClient.ts | 4 +- .../common/vendor/discord/DiscordUtils.ts | 2 +- .../common/vendor/koito/KoitoApiClient.ts | 3 +- .../common/vendor/listenbrainz/lzUtils.ts | 7 +-- .../common/vendor/maloja/MalojaApiClient.ts | 10 ++-- .../musicbrainz/MusicbrainzApiClient.ts | 24 +++++++--- src/backend/sources/AzuracastSource.ts | 3 +- src/backend/sources/ChromecastSource.ts | 6 +-- src/backend/sources/DeezerInternalSource.ts | 3 +- src/backend/sources/IcecastSource.ts | 3 +- src/backend/sources/JRiverSource.ts | 3 +- src/backend/sources/MPDSource.ts | 5 +- src/backend/sources/MPRISSource.ts | 5 +- src/backend/sources/MopidySource.ts | 6 +-- src/backend/sources/MusicCastSource.ts | 3 +- src/backend/sources/PlexApiSource.ts | 6 +-- src/backend/sources/SonosSource.ts | 3 +- src/backend/sources/SpotifySource.ts | 6 +-- src/backend/sources/WebScrobblerSource.ts | 5 +- src/backend/sources/YTMusicSource.ts | 8 ++-- .../sources/YandexMusicBridgeSource.ts | 3 +- .../tests/component/transformers.test.ts | 47 ++++++++++--------- src/backend/tests/lastfm/lastfm.test.ts | 11 +++-- .../tests/listenbrainz/listenbrainz.test.ts | 35 +++++++------- .../tests/musicbrainz/musicbrainz.test.ts | 31 ++++++------ src/backend/tests/plays/mixedDuration.json | 46 +++++++++--------- src/backend/tests/plays/withDuration.json | 32 ++++++------- .../tests/scrobbler/scrobblers.test.ts | 21 ++++----- src/backend/tests/source/source.test.ts | 11 +++-- src/backend/tests/tealfm/tealfm.test.ts | 3 +- src/backend/utils/PlayComparisonUtils.ts | 4 +- src/backend/utils/PlayTransformUtils.ts | 4 +- src/backend/utils/StringUtils.ts | 2 +- src/client/components/player/Player.tsx | 4 +- src/core/Atomic.ts | 9 +++- src/core/PlayTestUtils.ts | 4 +- src/core/StringUtils.ts | 10 +++- 45 files changed, 257 insertions(+), 217 deletions(-) diff --git a/src/backend/common/transforms/AtomicPartsTransformer.ts b/src/backend/common/transforms/AtomicPartsTransformer.ts index 14a298c4..8386aae8 100644 --- a/src/backend/common/transforms/AtomicPartsTransformer.ts +++ b/src/backend/common/transforms/AtomicPartsTransformer.ts @@ -1,4 +1,4 @@ -import { isPlayObject, ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; +import { ArtistCredit, isPlayObject, ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; import { AtomicStageConfig, StageConfig } from "../infrastructure/Transform.js"; import AbstractTransformer from "./AbstractTransformer.js"; @@ -135,8 +135,8 @@ 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 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; diff --git a/src/backend/common/transforms/MusicbrainzTransformer.ts b/src/backend/common/transforms/MusicbrainzTransformer.ts index e9749cb2..26cd66df 100644 --- a/src/backend/common/transforms/MusicbrainzTransformer.ts +++ b/src/backend/common/transforms/MusicbrainzTransformer.ts @@ -1,4 +1,4 @@ -import { asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, isMBReleasePrimaryGroupType, MBReleaseGroupPrimaryType, MBReleaseGroupSecondaryType, MBReleaseStatus, MissingMbidType, PlayObject, TrackMeta, TransformerCommon, TransformOptions } from "../../../core/Atomic.js"; +import { ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, isMBReleasePrimaryGroupType, MBReleaseGroupPrimaryType, MBReleaseGroupSecondaryType, MBReleaseStatus, MissingMbidType, PlayObject, TrackMeta, TransformerCommon, TransformOptions } from "../../../core/Atomic.js"; import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.js"; import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; import { ExternalMetadataTerm, PlayTransformMetadataStage } from "../infrastructure/Transform.js"; @@ -661,7 +661,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.artists; } @@ -676,7 +676,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer { + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise { if (parts === false) { return play.data.albumArtists; } diff --git a/src/backend/common/transforms/NativeTransformer.ts b/src/backend/common/transforms/NativeTransformer.ts index 4e6f20e6..34d81178 100644 --- a/src/backend/common/transforms/NativeTransformer.ts +++ b/src/backend/common/transforms/NativeTransformer.ts @@ -1,4 +1,4 @@ -import { PlayObject, TransformerCommon } from "../../../core/Atomic.js"; +import { ArtistCredit, PlayObject, TransformerCommon } from "../../../core/Atomic.js"; import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.js"; import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; import { ExternalMetadataTerm, PlayTransformNativeStage, StageConfig } from "../infrastructure/Transform.js"; @@ -10,6 +10,7 @@ import { DELIMITERS_NO_AMP } from "../infrastructure/Atomic.js"; import { asArray } from "../../utils/DataUtils.js"; import { MaybeLogger } from '../MaybeLogger.js'; import { childLogger } from "@foxxmd/logging"; +import { artistCreditToName, artistNameToCredit } from "../../../core/StringUtils.js"; export type ArtistParseSource = 'artists' | 'title' @@ -167,7 +168,7 @@ export default class NativeTransformer extends AtomicPartsTransformer { 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; } @@ -182,7 +183,7 @@ 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 { @@ -205,7 +206,7 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr logger = new MaybeLogger() } = options || {}; - let artists = []; + let artists: ArtistCredit[] = []; let track = play.data.track; if(artistsParseFrom.includes('artists')) { @@ -214,18 +215,18 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr for(const artist of play.data.artists) { - const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist)})).filter(x => x.res !== undefined); + const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist.name)})).filter(x => x.res !== undefined); if(matchedIgnoreArtists.length > 0) { logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res.match}`).join('\n')}`); artists.push(artist); } else { - const artistCredits = parseArtistCredits(artist, delimiters); + const artistCredits = parseArtistCredits(artist.name, delimiters); if (artistCredits !== undefined) { if (artistCredits.primary !== undefined) { - artists.push(artistCredits.primary); + artists.push({name: artistCredits.primary}); } if (artistCredits.secondary !== undefined) { - artists = artists.concat(artistCredits.secondary); + artists = artists.concat(artistCredits.secondary.map(artistNameToCredit)); } } else { // couldn't parse anything from artist string, use as-is @@ -246,14 +247,14 @@ export const nativeParse = (play: PlayObject, options?: NativeTransformerDataStr if(artistsParseFrom.includes('title')) { const trackArtists = parseTrackCredits(play.data.track, delimiters); if (trackArtists !== undefined && trackArtists.secondary !== undefined) { - artists = artists.concat(trackArtists.secondary); + artists = artists.concat(trackArtists.secondary.map(artistNameToCredit)); if(titleClean) { track = trackArtists.primary; } } } - artists = uniqueNormalizedStrArr([...artists]); + artists = (uniqueNormalizedStrArr([...artists.map(artistCreditToName)])).map(artistNameToCredit); return { ...play, diff --git a/src/backend/common/transforms/UserTransformer.ts b/src/backend/common/transforms/UserTransformer.ts index 50ea477e..c16bbc76 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 { PlayObject } from "../../../core/Atomic.js"; +import { ArtistCredit, PlayObject } from "../../../core/Atomic.js"; import { configValToSearchReplace, isSearchAndReplaceTerm, isUserStage, testWhenConditions } from "../../utils/PlayTransformUtils.js"; import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; import { ConditionalSearchAndReplaceRegExp, PlayTransformUserStage, StageConfig } from "../infrastructure/Transform.js"; @@ -74,30 +74,30 @@ export default class UserTransformer extends AtomicPartsTransformer { + protected async handleArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { if(play.data.artists === undefined || play.data.artists.length === 0) { return play.data.artists; } const mapper = this.generateMapper(play); const transformedArtists = []; for(const artist of play.data.artists) { - const a = searchAndReplace(artist, parts.map(mapper)); + const a = searchAndReplace(artist.name, parts.map(mapper)); if(a.trim() !== '') { - transformedArtists.push(a); + transformedArtists.push({...artist, name: a}); } } return transformedArtists; } - protected async handleAlbumArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { + protected async handleAlbumArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise { if(play.data.albumArtists === undefined || play.data.albumArtists.length === 0) { return play.data.albumArtists; } const mapper = this.generateMapper(play); const transformedArtists = []; for(const artist of play.data.albumArtists) { - const a = searchAndReplace(artist, parts.map(mapper)); + const a = searchAndReplace(artist.name, parts.map(mapper)); if(a.trim() !== '') { - transformedArtists.push(a); + transformedArtists.push({...artist, name: a}); } } return transformedArtists; diff --git a/src/backend/common/vendor/KodiApiClient.ts b/src/backend/common/vendor/KodiApiClient.ts index a8bac833..ef81cdc3 100644 --- a/src/backend/common/vendor/KodiApiClient.ts +++ b/src/backend/common/vendor/KodiApiClient.ts @@ -8,6 +8,7 @@ import { AbstractApiOptions, FormatPlayObjectOptions } from "../infrastructure/A import { KodiData } from "../infrastructure/config/source/kodi.js"; import AbstractApiClient from "./AbstractApiClient.js"; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; +import { artistNamesToCredits } from "../../../core/StringUtils.js"; interface KodiDuration { hours: number @@ -109,8 +110,8 @@ export class KodiApiClient extends AbstractApiClient { data: { track: title, album: album, - albumArtists: albumartist, - artists, + albumArtists: artistNamesToCredits(albumartist), + artists: artistNamesToCredits(artistVal), duration, playDate: dayjs() }, diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index 524a06f9..0c0f6f5f 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -1,6 +1,6 @@ import dayjs, { Dayjs, ManipulateType } from "dayjs"; import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, URLData, Writeable } from "../../../core/Atomic.js"; -import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; +import { artistNamesToCredits, artistNameToCredit, nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; import { removeUndefinedKeys, sleep } from "../../utils.js"; import { writeFile } from '../../utils/FSUtils.js'; import { objectIsEmpty, readJson } from '../../utils/DataUtils.js'; @@ -570,10 +570,10 @@ export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayOb data: { track, album: nonEmptyStringOrDefault(album), - albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined, + albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [artistNameToCredit(albumArtist)] : undefined, duration: typeof duration === 'string' ? parseInt(duration, 10) : duration, playDate: ts, - artists + artists: artistNamesToCredits(artists) }, meta: { source: 'lastfm', @@ -615,7 +615,7 @@ export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload if (artists.length === 0) { artist = ""; } else { - artist = artists[0]; + artist = artists[0].name; } const additionalRichPayload: Partial = {}; @@ -634,11 +634,11 @@ export const playToClientPayload = (playObj: PlayObject): LastFMScrobblePayload // LFM ignores scrobbles where album artist is VA // https://github.com/FoxxMD/multi-scrobbler/issues/340#issuecomment-3220774257 - const nonVaAlbumArtists = albumArtists.filter(x => x.trim().toLocaleLowerCase() !== 'va'); + const nonVaAlbumArtists = albumArtists.filter(x => x.name.trim().toLocaleLowerCase() !== 'va'); // LFM does not support multiple artists in scrobble payload // https://www.last.fm/api/show/track.scrobble if (nonVaAlbumArtists.length > 0) { - rawPayload.albumArtist = nonVaAlbumArtists[0]; + rawPayload.albumArtist = nonVaAlbumArtists[0].name; } // I don't know if its lastfm-node-client building the request params incorrectly @@ -694,7 +694,7 @@ export const formatPlayObj = (obj: LastFMTrackObject, options: FormatPlayObjectO const play: PlayObjectLifecycleless = { data: { - artists: [...new Set(artistStrings)] as string[], + artists: artistNamesToCredits([...new Set(artistStrings)] as string[]), track: title, album: al, duration, diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 1ba6327a..62c75b2d 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -2,7 +2,7 @@ import { stringSameness } from '@foxxmd/string-sameness'; import dayjs from "dayjs"; import request, { Request, Response } from 'superagent'; import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, URLData } from "../../../core/Atomic.js"; -import { combinePartsToString, slice } from "../../../core/StringUtils.js"; +import { artistNamesToCredits, combinePartsToString, slice } from "../../../core/StringUtils.js"; import { normalizeListenbrainzUrl, normalizeStr, @@ -548,7 +548,7 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { ...naivePlay, data: { ...naivePlay.data, - artists: derivedArtists + artists: artistNamesToCredits(derivedArtists) } }; } @@ -579,7 +579,7 @@ export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { data: { ...naivePlay.data, track: normalTrackName, - artists: derivedArtists + artists: artistNamesToCredits(derivedArtists) }, meta: naivePlay.meta } @@ -675,9 +675,9 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { data: { playDate: dayjs.unix(listened_at), track: normalTrackName, - artists: artists, + artists: artistNamesToCredits(artists), album: release_name, - albumArtists, + albumArtists: artistNamesToCredits(albumArtists), duration: dur, isrc: isrc !== undefined ? isrc : undefined, meta: { diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index c7950c4f..275ed6d0 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import request, { Request, Response } from 'superagent'; import { PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../core/Atomic.js"; -import { nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; +import { artistNamesToCredits, nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; import { UpstreamError } from "../errors/UpstreamError.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; import { RockSkyClientData, RockSkyData, RockSkyOptions } from "../infrastructure/config/client/rocksky.js"; @@ -221,8 +221,8 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble): PlayObject => { const play: PlayObjectLifecycleless = { data: { track: obj.title, - artists: nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : [], - albumArtists: nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : [], + artists: artistNamesToCredits(nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), + albumArtists: artistNamesToCredits(nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), album: nonEmptyStringOrDefault(obj.album), playDate: dayjs.utc(obj.createdAt).local() }, diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index 90ae04a1..850904b8 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -94,7 +94,7 @@ 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 => ({ artistName: x })), + artists: play.data.artists.map(x => ({ artistName: x.name })), duration: Math.round(play.data.duration), playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(), releaseName: play.data.album, @@ -124,7 +124,7 @@ export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {} const play: PlayObjectLifecycleless = { data: { track: record.trackName, - artists: record.artists.filter(x => x.artistName !== undefined).map(x => x.artistName), + artists: record.artists.filter(x => x.artistName !== undefined).map(x => ({name: x.artistName, mbid: x.artistMbId})), duration: record.duration, playDate: dayjs(record.playedTime), album: record.releaseName, diff --git a/src/backend/common/vendor/discord/DiscordUtils.ts b/src/backend/common/vendor/discord/DiscordUtils.ts index 03c8f845..a9ba5888 100644 --- a/src/backend/common/vendor/discord/DiscordUtils.ts +++ b/src/backend/common/vendor/discord/DiscordUtils.ts @@ -46,7 +46,7 @@ export const playStateToActivityData = (data: SourcePlayerObj, opts: { useArt?: name: activityName, details: play.data.track.padEnd(2,'\u200B'), - state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.map(x => x.padEnd(2, '\u200B')).join(' / ') : undefined, + 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: { diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts index eb451e4a..8dfc2020 100644 --- a/src/backend/common/vendor/koito/KoitoApiClient.ts +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -13,6 +13,7 @@ import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; import { tryApiCall } from "../../../utils/RequestUtils.js"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; +import { artistNamesToCredits } from "../../../../core/StringUtils.js"; interface SubmitOptions { log?: boolean @@ -218,7 +219,7 @@ export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: { const play: PlayObjectLifecycleless = { data: { track: obj.track.title, - artists: (obj.track.artists ?? []).map(x => x.name), + artists: artistNamesToCredits((obj.track.artists ?? []).map(x => x.name)), duration: obj.track.duration, playDate: dayjs(obj.time) }, diff --git a/src/backend/common/vendor/listenbrainz/lzUtils.ts b/src/backend/common/vendor/listenbrainz/lzUtils.ts index 4226be1a..53985214 100644 --- a/src/backend/common/vendor/listenbrainz/lzUtils.ts +++ b/src/backend/common/vendor/listenbrainz/lzUtils.ts @@ -4,6 +4,7 @@ import { getScrobbleTsSOCDate } from "../../../utils/TimeUtils.js"; import { SubmitOptions } from "../ListenbrainzApiClient.js"; import { ListenPayload, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from "./interfaces.js"; import {version as appVersion } from '../../../version.js'; +import { artistCreditsToNames, artistCreditToName } from "../../../../core/StringUtils.js"; export const playToListenPayload = (play: PlayObject, version?: string): ListenPayload => { const { @@ -25,10 +26,10 @@ export const playToListenPayload = (play: PlayObject, version?: string): ListenP let addInfo: SubmitListenAdditionalTrackInfo = { // primary artists - artist_names: Array.from(new Set([...artists])), + artist_names: Array.from(new Set([...artists.map(artistCreditToName)])), // primary artist - release_artist_name: albumArtists.length === 1 ? albumArtists[0] : undefined, - release_artist_names: albumArtists.length > 0 ? albumArtists : undefined, + release_artist_name: albumArtists.length === 1 ? albumArtists[0].name : undefined, + release_artist_names: albumArtists.length > 0 ? artistCreditsToNames(albumArtists) : undefined, // use data from LZ response, if this Play was originally from LZ Source media_player: mediaPlayerName ?? msAdditionalInfo.media_player, media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version, diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts index 7a90c183..4682f1a7 100644 --- a/src/backend/common/vendor/maloja/MalojaApiClient.ts +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -12,7 +12,7 @@ import { getNonEmptyVal, parseRetryAfterSecsFromObj, removeUndefinedKeys, sleep import { UpstreamError } from "../../errors/UpstreamError.js"; import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; -import { buildTrackString } from '../../../../core/StringUtils.js'; +import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.js'; import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; import { ScrobbleSubmitError } from '../../errors/MSErrors.js'; import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.js'; @@ -272,7 +272,7 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR } = track; scrobbleResponse.track.album = { name: album, - artists: albumArtists, + artists: artistCreditsToNames(albumArtists), ...malojaAlbum, } } @@ -412,7 +412,7 @@ export const formatPlayObj = (obj: MalojaScrobbleData, options: FormatPlayObject const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); const play: PlayObjectLifecycleless = { data: removeUndefinedKeys({ - artists: [...new Set(artistStrings)] as string[], + artists: artistNamesToCredits([...new Set(artistStrings)] as string[]), track: title, album, duration, @@ -446,7 +446,7 @@ export const playToScrobblePayload = (playObj: PlayObject, apiKey?: string): Mal const scrobbleData: MalojaScrobbleV3RequestData = { title: track, - artists, + artists: artistCreditsToNames(artists), album, key: apiKey, time: pd.unix(), @@ -463,7 +463,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 = albumArtists; + scrobbleData.albumartists = artistCreditsToNames(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/MusicbrainzApiClient.ts b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts index e5b5f6db..850aed40 100644 --- a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts +++ b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts @@ -1,5 +1,5 @@ import { Response } from 'superagent'; -import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; +import { ArtistCredit, PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; import { UpstreamError } from "../../errors/UpstreamError.js"; import { AbstractApiOptions, FormatPlayObjectOptions, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../../infrastructure/Atomic.js"; import AbstractApiClient from "../AbstractApiClient.js"; @@ -21,6 +21,7 @@ import { SimpleError } from '../../errors/MSErrors.js'; import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; import { IRecordingMSList } from '../../transforms/MusicbrainzTransformer.js'; import dayjs, { Dayjs } from 'dayjs'; +import { artistCreditsToNames } from '../../../../core/StringUtils.js'; export interface SubmitResponse { payload?: { @@ -235,7 +236,16 @@ export class MusicbrainzApiClient extends AbstractApiClient { // https://wiki.musicbrainz.org/MusicBrainz_API/Search#Recording // https://beta.musicbrainz.org/doc/MusicBrainz_API/Search const res = await this.callApi((mb) => { - const query: Record = { + const query: { + recording_mbid?: string + track_mbid?: string + release_mbid?: string + artist_mbids?: string[] + isrc?: string + recording?: string + artist?: string[] + release?: string + } = { }; if(play.data?.meta?.brainz?.recording !== undefined && using.includes('mbidrecording')) { @@ -257,7 +267,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { query.recording = play.data.track; } if(play.data.artists !== undefined && play.data.artists.length > 0 && using.includes('artist')) { - query.artist = play.data.artists; + query.artist = artistCreditsToNames(play.data.artists); } if(play.data.album !== undefined && using.includes('album')) { query.release = play.data.album; @@ -380,17 +390,17 @@ export const recordingToPlay = (data: IRecording, options?: {ignoreVA?: boolean} let album: IRelease; - let albumArtists: string[]; + let albumArtists: ArtistCredit[]; let albumArtistIds: string[]; - const artists = (data["artist-credit"] ?? []).map(x => x.name); + const artists = (data["artist-credit"] ?? []).map(x => ({ name: x.name, mbid: x.artist.id})); 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 => x.artist.name); + albumArtists = album["artist-credit"].map(x => ({name: x.artist.name, mbid: x.artist.id})); albumArtistIds = album["artist-credit"].map(x => x.artist.id); } - if(albumArtists !== undefined && ignoreVA && albumArtists.includes('Various Artists')) { + if(albumArtists !== undefined && ignoreVA && albumArtists.map(x => x.name).includes('Various Artists')) { albumArtists = undefined; albumArtistIds = undefined; } diff --git a/src/backend/sources/AzuracastSource.ts b/src/backend/sources/AzuracastSource.ts index 554ae4fd..9df4c25a 100644 --- a/src/backend/sources/AzuracastSource.ts +++ b/src/backend/sources/AzuracastSource.ts @@ -17,6 +17,7 @@ import { import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js"; import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; export class AzuracastSource extends MemorySource { @@ -250,7 +251,7 @@ const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOp const play: PlayObjectLifecycleless = { data: { - artists: artist !== undefined && artist !== '' ? [artist] : [], + artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), album: album !== '' ? album : undefined, track, duration diff --git a/src/backend/sources/ChromecastSource.ts b/src/backend/sources/ChromecastSource.ts index d608af37..d7df22a3 100644 --- a/src/backend/sources/ChromecastSource.ts +++ b/src/backend/sources/ChromecastSource.ts @@ -6,7 +6,7 @@ import dayjs from "dayjs"; import { EventEmitter } from "events"; import e from "express"; import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; -import { buildTrackString } from "../../core/StringUtils.js"; +import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.js"; import { NETWORK_ERROR_FAILURE_CODES } from "../common/errors/NodeErrors.js"; import { FormatPlayObjectOptions, @@ -722,8 +722,8 @@ export class ChromecastSource extends MemoryPositionalSource { data: { track, album, - albumArtists, - artists, + albumArtists: artistNamesToCredits(albumArtists), + artists: artistNamesToCredits(artists), duration, playDate: dayjs() }, diff --git a/src/backend/sources/DeezerInternalSource.ts b/src/backend/sources/DeezerInternalSource.ts index 9359030d..a8112c47 100644 --- a/src/backend/sources/DeezerInternalSource.ts +++ b/src/backend/sources/DeezerInternalSource.ts @@ -15,6 +15,7 @@ import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js"; import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; interface DeezerHistoryResponse { errors: [] @@ -113,7 +114,7 @@ export default class DeezerInternalSource extends MemorySource { const {newFromSource = false} = options; const play: PlayObjectLifecycleless = { data: { - artists: [obj.ART_NAME], + artists: artistNamesToCredits([obj.ART_NAME]), album: obj.ALB_TITLE, track: obj.SNG_TITLE, duration: obj.DURATION, diff --git a/src/backend/sources/IcecastSource.ts b/src/backend/sources/IcecastSource.ts index 1ccad5c3..a748e323 100644 --- a/src/backend/sources/IcecastSource.ts +++ b/src/backend/sources/IcecastSource.ts @@ -15,6 +15,7 @@ import IcecastMetadataStats from "icecast-metadata-stats"; import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js"; import { isDebugMode, sleep } from "../utils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; export class IcecastSource extends MemorySource { @@ -211,7 +212,7 @@ const formatPlayObj = (obj: IcecastMetadata, options: FormatPlayObjectOptions = const play: PlayObjectLifecycleless = { data: { track, - artists + artists: artistNamesToCredits(artists) }, meta: { source: 'icecast', diff --git a/src/backend/sources/JRiverSource.ts b/src/backend/sources/JRiverSource.ts index d5a94c11..f7816e49 100644 --- a/src/backend/sources/JRiverSource.ts +++ b/src/backend/sources/JRiverSource.ts @@ -9,6 +9,7 @@ import { Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiC import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; export class JRiverSource extends MemoryPositionalSource { declare config: JRiverSourceConfig; @@ -104,7 +105,7 @@ export class JRiverSource extends MemoryPositionalSource { data: { track: Name, album: album, - artists, + artists: artistNamesToCredits(artists), duration: Math.round(length), playDate: dayjs() }, diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index b14b0c40..238792a3 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -20,6 +20,7 @@ import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; import { isDebugMode, sleep } from "../utils.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; const CLIENT_PLAYER_STATE: Record = { 'play': REPORTED_PLAYER_STATUSES.playing, @@ -256,8 +257,8 @@ export class MPDSource extends MemoryPositionalSource { const play: PlayObjectLifecycleless = { data: { - artists: artists, - albumArtists, + artists: artistNamesToCredits(artists), + albumArtists: artistNamesToCredits(albumArtists), album, track: trackName, duration diff --git a/src/backend/sources/MPRISSource.ts b/src/backend/sources/MPRISSource.ts index 57473236..7084fce3 100644 --- a/src/backend/sources/MPRISSource.ts +++ b/src/backend/sources/MPRISSource.ts @@ -21,6 +21,7 @@ import { Readable, Writable } from 'stream'; import net from 'net'; import pEvent from 'p-event'; import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js'; +import { artistNamesToCredits } from '../../core/StringUtils.js'; export class MPRISSource extends MemorySource { @@ -76,8 +77,8 @@ export class MPRISSource extends MemorySource { data: { track: title, album, - artists: artist, - albumArtists: actualAlbumArtists, + artists: artistNamesToCredits(artist), + albumArtists:artistNamesToCredits(actualAlbumArtists), duration: length, playDate: dayjs() }, diff --git a/src/backend/sources/MopidySource.ts b/src/backend/sources/MopidySource.ts index e5641bad..5cdd8e56 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 { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; -import { buildTrackString } from "../../core/StringUtils.js"; +import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.js"; import { FormatPlayObjectOptions, InternalConfig, @@ -164,8 +164,8 @@ export class MopidySource extends MemoryPositionalSource { data: { track: name, album: albumName, - albumArtists: actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : [], - artists: artists.length > 0 ? artists.map(x => x.name) : [], + albumArtists: artistNamesToCredits(actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : []), + artists: artistNamesToCredits(artists.length > 0 ? artists.map(x => x.name) : []), duration: Math.round(length / 1000), playDate: dayjs() }, diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index 44cbb7a2..a48d4d46 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -12,6 +12,7 @@ import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; import request, { Request, Response } from 'superagent'; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; export class MusicCastSource extends MemoryPositionalSource { @@ -149,7 +150,7 @@ const formatPlayObj = (obj: PlayInfoCDResponse | PlayInfoNetResponse, options: F const play: PlayObjectLifecycleless = { data: { - artists: artist !== undefined && artist !== '' ? [artist] : [], + artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), album: album !== '' ? album : undefined, track, // we should treat 0 time as the same as not being provided diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 26ca83fc..fdeb94d2 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -1,6 +1,6 @@ import EventEmitter from "events"; import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; -import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; +import { artistNamesToCredits, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { asPlayerStateDataMaybePlay, FormatPlayObjectOptions, @@ -376,8 +376,8 @@ export default class PlexApiSource extends MemoryPositionalSource { const play: PlayObjectLifecycleless = { data: { - artists: realArtists, - albumArtists, + artists: artistNamesToCredits(realArtists), + albumArtists: artistNamesToCredits(albumArtists), album, track, // albumArtists: AlbumArtists !== undefined ? AlbumArtists.map(x => x.Name) : undefined, diff --git a/src/backend/sources/SonosSource.ts b/src/backend/sources/SonosSource.ts index 1af0b5e4..a53bbe51 100644 --- a/src/backend/sources/SonosSource.ts +++ b/src/backend/sources/SonosSource.ts @@ -22,6 +22,7 @@ import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybe import { isDebugMode, playObjDataMatch, sleep } from "../utils.js"; import dayjs, { Dayjs } from "dayjs"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; export interface DeviceState { device: SonosDevice @@ -377,7 +378,7 @@ export const formatPlayObj = (obj: SonosState, options: FormatPlayObjectOptions data: { track: titleStr, album: Album, - artists: Artist === undefined ? undefined : [Artist], + artists: Artist === undefined ? undefined : artistNamesToCredits([Artist]), duration: dur, }, meta: { diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 1ab5c0ec..f014164f 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -3,7 +3,7 @@ import EventEmitter from "events"; import SpotifyWebApi from "spotify-web-api-node"; import request from 'superagent'; import { BrainzMeta, PlayObject, PlayObjectLifecycleless, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; -import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; +import { artistNamesToCredits, artistNameToCredit, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; import { @@ -214,8 +214,8 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag const play: PlayObjectLifecycleless = { data: { - artists: artists.map(x => x.name), - albumArtists: actualAlbumArtists.map(x => x.name), + artists: artists.map(x => artistNameToCredit(x.name)), + albumArtists: actualAlbumArtists.map(x => artistNameToCredit(x.name)), album: albumName, track: name, duration: duration_ms / 1000, diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index 63fe36e9..f6857961 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -23,6 +23,7 @@ import { Logger } from "@foxxmd/logging"; import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { artistCreditToName, artistNameToCredit } from "../../core/StringUtils.js"; export class WebScrobblerSource extends MemorySource { @@ -127,9 +128,9 @@ export class WebScrobblerSource extends MemorySource { const play: PlayObjectLifecycleless = { data: { track, - artists: [artist], + artists: [artistNameToCredit(artist)], album: album === null ? undefined : album, - albumArtists: albumArtist === null ? undefined : [albumArtist], + albumArtists: albumArtist === null ? undefined : [artistNameToCredit(albumArtist)], playDate: dayjs.unix(startTimestamp), duration: duration === null ? undefined : duration, meta: { diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index 575abe2a..c19abc16 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -18,7 +18,7 @@ import { playsAreSortConsistent } from "../utils/PlayComparisonUtils.js"; import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; -import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; +import { artistNamesToCredits, buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; import { joinedUrl } from "../utils/NetworkUtils.js"; import { todayAwareFormat } from "../../core/TimeUtils.js"; import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js"; @@ -377,7 +377,7 @@ Redirect URI : ${this.redirectUri}`); duration: dur, // string timestamp } = obj; - let artists = [], + let artists: string[] = [], album = undefined, duration = undefined; if(artistsData !== undefined) { @@ -421,8 +421,8 @@ Redirect URI : ${this.redirectUri}`); } const play: PlayObjectLifecycleless = { data: { - artists, - albumArtists, + artists: artistNamesToCredits(artists), + albumArtists: artistNamesToCredits(albumArtists), album, track: title, duration, diff --git a/src/backend/sources/YandexMusicBridgeSource.ts b/src/backend/sources/YandexMusicBridgeSource.ts index e1b7ce4a..587cc920 100644 --- a/src/backend/sources/YandexMusicBridgeSource.ts +++ b/src/backend/sources/YandexMusicBridgeSource.ts @@ -15,6 +15,7 @@ import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/s import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; +import { artistNamesToCredits } from "../../core/StringUtils.js"; interface BridgeTrackData { title?: string @@ -437,7 +438,7 @@ const formatPlayObj = (obj: BridgeTrackData, playerId: string): PlayObject => { const play: PlayObjectLifecycleless = { data: { - artists, + artists: artistNamesToCredits(artists), album: obj.album ?? undefined, track: obj.title ?? undefined, duration: obj.duration_ms !== undefined && obj.duration_ms !== null diff --git a/src/backend/tests/component/transformers.test.ts b/src/backend/tests/component/transformers.test.ts index 26da476f..b7321829 100644 --- a/src/backend/tests/component/transformers.test.ts +++ b/src/backend/tests/component/transformers.test.ts @@ -18,6 +18,7 @@ import TransformerManager from "../../common/transforms/TransformerManager.js"; import { transientCache } from "../utils/TransientTestUtils.js"; import dayjs from "dayjs"; import clone from "clone"; +import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.js"; chai.use(asPromised); @@ -350,10 +351,10 @@ describe('Play Transforms', function () { } component.buildTransformRules(); - const play = generatePlay({ artists: ['My Artist One / My Artist Two / Another Guy'] }); + const play = generatePlay({ artists: artistNamesToCredits(['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]).equal('My Artist One'); + expect(transformed.data.artists[0].name).equal('My Artist One'); }); it('Removes title when transform replaces with empty string', async function () { @@ -402,10 +403,10 @@ describe('Play Transforms', function () { } component.buildTransformRules(); - const play = generatePlay({ artists: ['something', 'big'] }); + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.artists!.length).is.eq(1) - expect(transformed.data.artists![0]).is.eq('big') + expect(transformed.data.artists![0].name).is.eq('big') }); }); @@ -421,10 +422,10 @@ describe('Play Transforms', function () { await t.tryInitialize(); const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); - const play = generatePlay({artists: [str]}); + const play = generatePlay({artists: artistNamesToCredits([str])}); const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); - expect(transformedPlay.data.artists).eql(primaries.concat(secondaries)); + expect(artistCreditsToNames(transformedPlay.data.artists)).eql(primaries.concat(secondaries)); }); it('Ignores artists', async function() { @@ -435,10 +436,10 @@ describe('Play Transforms', function () { await t.tryInitialize(); - const play = generatePlay({artists: [str], track: 'My Test'}); + const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); - expect(transformedPlay.data.artists).eql([str]); + expect(artistCreditsToNames(transformedPlay.data.artists)).eql([str]); }); it('Uses custom delimiters artists', async function() { @@ -456,10 +457,10 @@ describe('Play Transforms', function () { await t.tryInitialize(); - const play = generatePlay({artists: [str], track: 'My Test'}); + const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); - expect(transformedPlay.data.artists).eql(primaries.concat(secondaries)); + expect(artistCreditsToNames(transformedPlay.data.artists)).eql(primaries.concat(secondaries)); }); }); @@ -484,10 +485,10 @@ describe('Play Transforms', function () { } component.buildTransformRules(); - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: '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]).is.eq('something') + expect(transformed.data.artists![0].name).is.eq('something') }); it('Does run hook if when conditions matches', async function () { @@ -507,10 +508,10 @@ describe('Play Transforms', function () { } component.buildTransformRules(); - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: '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]).is.eq('big') + expect(transformed.data.artists![0].name).is.eq('big') }); }); @@ -537,10 +538,10 @@ describe('Play Transforms', function () { } component.buildTransformRules(); - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: '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]).is.eq('something') + expect(transformed.data.artists![0].name).is.eq('something') }); it('Does run hook if when conditions matches', async function () { @@ -565,10 +566,10 @@ describe('Play Transforms', function () { } component.buildTransformRules(); - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: '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]).is.eq('big') + expect(transformed.data.artists![0].name).is.eq('big') }); }); @@ -632,10 +633,10 @@ describe('Play Transforms', function () { const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); component.buildTransformRules(); - const play = generatePlay({ track: 'My cool something track', artists: [str] }); + const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]) }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); expect(transformed.data.track).equal('My cool bar track'); - expect(transformed.data.artists).eql(primaries.concat(secondaries)); + expect(artistCreditsToNames(transformed.data.artists)).eql(primaries.concat(secondaries)); }); }); @@ -667,17 +668,17 @@ describe('Play Transforms', function () { const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); component.buildTransformRules(); - const play = generatePlay({ track: 'My cool something track', artists: [str], playDate: dayjs().subtract(10, 'm') }); + const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]), playDate: dayjs().subtract(10, 'm') }); const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, 'all'); expect(transformed.data.track).equal('My cool bar track'); - expect(transformed.data.artists).eql(primaries.concat(secondaries)); + expect(artistCreditsToNames(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, 'all'); expect(cacheTransformed.data.track).equal('My cool bar track'); - expect(cacheTransformed.data.artists).eql(primaries.concat(secondaries)); + expect(artistCreditsToNames(cacheTransformed.data.artists)).eql(primaries.concat(secondaries)); expect(cacheTransformed.data.playDate.isSame(cachablePlay.data.playDate)); }); diff --git a/src/backend/tests/lastfm/lastfm.test.ts b/src/backend/tests/lastfm/lastfm.test.ts index 1623f454..a9400e51 100644 --- a/src/backend/tests/lastfm/lastfm.test.ts +++ b/src/backend/tests/lastfm/lastfm.test.ts @@ -9,16 +9,17 @@ import { http, HttpResponse, delay } from "msw"; import { loggerDebug } from '@foxxmd/logging'; import { configDir, projectDir } from '../../common/index.js'; import { LastFMGeo } from 'lastfm-ts-api'; +import { artistNamesToCredits } from '../../../core/StringUtils.js'; chai.use(asPromised); describe('#LFM Scrobble Payload Behavior', function () { it('Should remove VA from album artist', function() { - const play = generatePlay({albumArtists: ['VA']}); + const play = generatePlay({albumArtists: artistNamesToCredits(['VA'])}); expect(playToClientPayload(play).albumArtist).to.be.undefined; - const okPlay = generatePlay({albumArtists: ['My Dude']}); + const okPlay = generatePlay({albumArtists: artistNamesToCredits(['My Dude'])}); expect(playToClientPayload(okPlay).albumArtist).eq('My Dude'); }); }); @@ -55,17 +56,17 @@ describe('#LFM Track to Play', function() { const toArtText = generateLastfmTrackObject(); delete toArtText.artist.name; expect(toArtText.artist['#text']).to.not.be.undefined; - expect(formatPlayObj(toArtText).data.artists[0]).to.eq(toArtText.artist['#text']); + expect(formatPlayObj(toArtText).data.artists[0].name).to.eq(toArtText.artist['#text']); const toArtTextEmptyNAme = generateLastfmTrackObject(); toArtTextEmptyNAme.artist.name = ''; expect(toArtTextEmptyNAme.artist['#text']).to.not.be.undefined; - expect(formatPlayObj(toArtTextEmptyNAme).data.artists[0]).to.eq(toArtTextEmptyNAme.artist['#text']); + expect(formatPlayObj(toArtTextEmptyNAme).data.artists[0].name).to.eq(toArtTextEmptyNAme.artist['#text']); const toArtName = generateLastfmTrackObject(); delete toArtName.artist['#text']; expect(toArtName.artist.name).to.not.be.undefined; - expect(formatPlayObj(toArtName).data.artists[0]).to.eq(toArtName.artist.name); + expect(formatPlayObj(toArtName).data.artists[0].name).to.eq(toArtName.artist.name); }); }); diff --git a/src/backend/tests/listenbrainz/listenbrainz.test.ts b/src/backend/tests/listenbrainz/listenbrainz.test.ts index 88155d7c..67b6ce1f 100644 --- a/src/backend/tests/listenbrainz/listenbrainz.test.ts +++ b/src/backend/tests/listenbrainz/listenbrainz.test.ts @@ -25,6 +25,7 @@ import incorrectMultiArtistsTrackName from './incorrectlyMapped/multiArtistsInTr import veryWrong from './incorrectlyMapped/veryWrong.json' with { type: "json" }; import { generatePlay } from "../../../core/PlayTestUtils.js"; import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; +import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.js"; interface LZTestFixture { data: ListenResponse @@ -37,7 +38,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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(play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -45,7 +46,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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( play.data.artists, test.expected.artists); + assert.sameDeepMembers( artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -53,7 +54,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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(play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); }) @@ -65,7 +66,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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(play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -73,7 +74,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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(play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -81,7 +82,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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( play.data.artists, test.expected.artists); + assert.sameDeepMembers( artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -89,7 +90,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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(play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -97,7 +98,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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( play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); @@ -105,7 +106,7 @@ describe('#PlayParse Listenbrainz Listen Parsing', 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( play.data.artists, test.expected.artists); + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); } }); }); @@ -128,7 +129,7 @@ describe('Listenbrainz Response Behavior', function() { async function() { const play: PlayObject = { data: { - artists: ['Celldweller'], + artists: artistNamesToCredits(['Celldweller']), album: 'The Complete Cellout, Volume 01', track: 'Frozen', duration: 299, @@ -172,7 +173,7 @@ describe('Listenbrainz Response Behavior', function() { async function() { const play: PlayObject = { data: { - artists: ['Celldweller'], + artists: artistNamesToCredits(['Celldweller']), album: 'The Complete Cellout, Volume 01', track: 'Frozen', duration: 299, @@ -202,7 +203,7 @@ describe('Listenbrainz Endpoint Behavior', function() { it('Should combine artist and artist_names', function() { - const play = generatePlay({artists: ['Artist A'], albumArtists: []}); + const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}); const submitPayload = playToListenPayload(play); const additionalArtists = [...submitPayload.track_metadata.additional_info.artist_names, 'Artist B']; @@ -211,13 +212,13 @@ describe('Listenbrainz Endpoint Behavior', function() { const playFromPayload = listenPayloadToPlay(submitPayload); - expect(playFromPayload.data.artists).to.be.eql(additionalArtists) + expect(artistCreditsToNames(playFromPayload.data.artists)).to.be.eql(additionalArtists) }); it('Should combine artist and artist_names into a unique array', function() { - const play = generatePlay({artists: ['Artist A'], albumArtists: []}); + const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}); const submitPayload = playToListenPayload(play); const additionalArtists = ['Artist A', 'Artist B']; @@ -226,13 +227,13 @@ describe('Listenbrainz Endpoint Behavior', function() { const playFromPayload = listenPayloadToPlay(submitPayload); - expect(playFromPayload.data.artists).to.be.eql(['Artist A', 'Artist B']) + expect(artistCreditsToNames(playFromPayload.data.artists)).to.be.eql(['Artist A', 'Artist B']) }); it('Should set music_service_name from source', function() { - const play = generatePlay({artists: ['Artist A'], albumArtists: []}, {source: 'Plex'}); + const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {source: 'Plex'}); const submitPayload = playToListenPayload(play); expect(submitPayload.track_metadata.additional_info.music_service_name).to.be.eql('Plex') @@ -243,7 +244,7 @@ describe('Listenbrainz Endpoint Behavior', function() { const playFromPayload = listenPayloadToPlay(submit); - expect(playFromPayload.data.artists).to.be.eql(submit.track_metadata.additional_info.artist_names); + expect(artistCreditsToNames(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 16150b2b..a07f1ac6 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -16,6 +16,7 @@ import { generatePlay, withBrainz } from '../../../core/PlayTestUtils.js'; import { intersect, missingMbidTypes } from '../../utils.js'; import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; import { CoverArtApiClient, CoverArtApiConfig } from '../../common/vendor/musicbrainz/CoverArtApiClient.js'; +import { artistCreditToName, artistNamesToCredits, artistNameToCredit } from '../../../core/StringUtils.js'; const envPath = path.join(projectDir, '.env'); dotenv.config({ path: envPath }); @@ -62,7 +63,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Little Joe and Mary ii", - artists: ["Khruangbin"], + artists: artistNamesToCredits(["Khruangbin"]), album: "The Universe Smiles Upon You ii" }, meta: { @@ -89,7 +90,7 @@ describe('Musicbrainz API', function () { track: 'Cyber Space (CrossWorlds Remix): Final Lap (No Chants)', album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions", artists: [ - "Kanon Oguni" + artistNameToCredit("Kanon Oguni") ] }, meta: { @@ -118,7 +119,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Fake", - artists: ["Fake"], + artists: artistNamesToCredits(["Fake"]), album: "Fake", meta: { brainz: { @@ -151,7 +152,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Fake", - artists: ["Fake"], + artists: artistNamesToCredits(["Fake"]), album: "Fake", isrc: 'GBAHT1600302' }, @@ -177,8 +178,8 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Berghain", - artists: ["ROSALÍA", "Björk", "Yves Tumor"], - albumArtists: ["ROSALÍA"], + artists: artistNamesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), + albumArtists: artistNamesToCredits(["ROSALÍA"]), album: "LUX", meta: { brainz: { @@ -211,8 +212,8 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Berghain", - artists: ["ROSALÍA", "Björk", "Yves Tumor"], - albumArtists: ["ROSALÍA"], + artists: artistNamesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), + albumArtists: artistNamesToCredits(["ROSALÍA"]), album: "LUX", meta: { brainz: { @@ -246,7 +247,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Roulette Road (CrossWorlds Remix)", - artists: ["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"], + artists: artistNamesToCredits(["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"]), album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions" }, meta: { @@ -271,7 +272,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Undefeatable (feat. Kellin Quinn)", - artists: ["SEGA Sound Team / Tomoya Ohtani"], + artists: artistNamesToCredits(["SEGA Sound Team / Tomoya Ohtani"]), }, meta: { lifecycle: defaultLifecycle() @@ -296,7 +297,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Bad Apple!! feat.SEKAI", - artists: ["、ナイトコードで。"], + artists: artistNamesToCredits(["、ナイトコードで。"]), album: "25時、ナイトコードで。 SEKAI ALBUM Vol.3" }, meta: { @@ -321,7 +322,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "HIBANA - Reloaded - (feat. 星乃一歌 & Hatsune Miku)", - artists: ["Leo/need"], + artists: artistNamesToCredits(["Leo/need"]), album: "Leo / need SEKAI ALBUM Vol.1" }, meta: { @@ -350,7 +351,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Price", - artists: ["ATLUS Sound Team"], + artists: artistNamesToCredits(["ATLUS Sound Team"]), album: "PERSONA5 ORIGINAL SOUNDTRACK", isrc: 'JPK651601515' }, @@ -395,7 +396,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Little Joe and Mary ii", - artists: ["Khruangbin"], + artists: artistNamesToCredits(["Khruangbin"]), album: "The Universe Smiles Upon You ii" }, meta: { @@ -429,7 +430,7 @@ describe('Musicbrainz API', function () { const play: PlayObject = { data: { track: "Little Joe and Mary ii", - artists: ["Khruangbin"], + artists: artistNamesToCredits(["Khruangbin"]), album: "The Universe Smiles Upon You ii" }, meta: { diff --git a/src/backend/tests/plays/mixedDuration.json b/src/backend/tests/plays/mixedDuration.json index a8ee964e..b3c856fb 100644 --- a/src/backend/tests/plays/mixedDuration.json +++ b/src/backend/tests/plays/mixedDuration.json @@ -2,7 +2,7 @@ { "data": { "artists": [ - "Kaidi Tatham" + {"name": "Kaidi Tatham"} ], "track": "Fricassee", "duration": 305, @@ -12,7 +12,7 @@ { "data": { "artists": [ - "Kuna Maze" + {"name": "Kuna Maze"} ], "track": "Jimbó", "playDate": "2023-09-20T14:46:00.000Z" @@ -21,8 +21,8 @@ { "data": { "artists": [ - "Norman Person", - "Shamek Farrah" + {"name": "Norman Person"}, + {"name": "Shamek Farrah"} ], "track": "Aisha", "duration": 559, @@ -32,8 +32,8 @@ { "data": { "artists": [ - "Frédéric Chopin", - "Krystian Zimerman" + {"name": "Frédéric Chopin"}, + {"name": "Krystian Zimerman"} ], "track": "Ballade No. 4 in F Minor, Op. 52", "duration": 575, @@ -43,8 +43,8 @@ { "data": { "artists": [ - "Nidia Gongora", - "The Bongo Hop" + {"name": "Nidia Gongora"}, + {"name": "The Bongo Hop"} ], "track": "Sonora", "duration": 327, @@ -55,7 +55,7 @@ { "data": { "artists": [ - "Cheo Feliciano" + {"name": "Cheo Feliciano"} ], "track": "Aprieta (Oye Cómo Va)", "playDate": "2023-09-20T15:38:12.000Z" @@ -64,7 +64,7 @@ { "data": { "artists": [ - "Pink Floyd" + {"name": "Pink Floyd"} ], "track": "Another Brick in the Wall, Pt. 1", "playDate": "2023-09-20T15:39:12.000Z" @@ -73,8 +73,8 @@ { "data": { "artists": [ - "Frédéric Chopin", - "Krystian Zimerman" + {"name": "Frédéric Chopin"}, + {"name": "Krystian Zimerman"} ], "track": "Ballade No. 1 in G Minor, Op. 23", "duration": 575, @@ -85,9 +85,9 @@ { "data": { "artists": [ - "A Collection of Boogie", - "Jazz Funk", - "Disco with Mehdi El" + {"name": "A Collection of Boogie"}, + {"name": "Jazz Funk"}, + {"name": "Disco with Mehdi El"} ], "track": "Aquil", "listenedFor": 300, @@ -97,7 +97,7 @@ { "data": { "artists": [ - "Outkast" + {"name": "Outkast"} ], "track": "Da Art of Storytellin' (Pt. 1)", "duration": 422, @@ -107,13 +107,13 @@ { "data": { "artists": [ - "9th Wonder", - "Cordae", - "Dinner Party", - "Kamasi Washington", - "Phoelix", - "Robert Glasper", - "Terrace Martin" + {"name": "9th Wonder"}, + {"name": "Cordae"}, + {"name": "Dinner Party"}, + {"name": "Kamasi Washington"}, + {"name": "Phoelix"}, + {"name": "Robert Glasper"}, + {"name": "Terrace Martin"} ], "track": "Freeze Tag", "album": "Dinner Party: Dessert", diff --git a/src/backend/tests/plays/withDuration.json b/src/backend/tests/plays/withDuration.json index 38949e7d..f20e32e3 100644 --- a/src/backend/tests/plays/withDuration.json +++ b/src/backend/tests/plays/withDuration.json @@ -2,7 +2,7 @@ { "data": { "artists": [ - "Kaidi Tatham" + {"name": "Kaidi Tatham"} ], "track": "Fricassee", "duration": 305, @@ -16,7 +16,7 @@ { "data": { "artists": [ - "Kuna Maze" + {"name": "Kuna Maze"} ], "track": "Jimbó", "duration": 255, @@ -30,8 +30,8 @@ { "data": { "artists": [ - "Norman Person", - "Shamek Farrah" + {"name": "Norman Person"}, + {"name": "Shamek Farrah"} ], "track": "Aisha", "duration": 559, @@ -45,8 +45,8 @@ { "data": { "artists": [ - "Frédéric Chopin", - "Krystian Zimerman" + {"name": "Frédéric Chopin"}, + {"name": "Krystian Zimerman"} ], "track": "Ballade No. 4 in F Minor, Op. 52", "duration": 575, @@ -60,8 +60,8 @@ { "data": { "artists": [ - "Nidia Gongora", - "The Bongo Hop" + {"name": "Nidia Gongora"}, + {"name": "The Bongo Hop"} ], "track": "Sonora", "duration": 327, @@ -75,7 +75,7 @@ { "data": { "artists": [ - "Cheo Feliciano" + {"name": "Cheo Feliciano"} ], "track": "Aprieta (Oye Cómo Va)", "duration": 195, @@ -89,7 +89,7 @@ { "data": { "artists": [ - "Pink Floyd" + {"name": "Pink Floyd"} ], "track": "Another Brick in the Wall, Pt. 1", "duration": 192, @@ -103,8 +103,8 @@ { "data": { "artists": [ - "Frédéric Chopin", - "Krystian Zimerman" + {"name": "Frédéric Chopin"}, + {"name": "Krystian Zimerman"} ], "track": "Ballade No. 1 in G Minor, Op. 23", "duration": 575, @@ -118,9 +118,9 @@ { "data": { "artists": [ - "A Collection of Boogie", - "Jazz Funk", - "Disco with Mehdi El" + {"name": "A Collection of Boogie"}, + {"name": "Jazz Funk"}, + {"name": "Disco with Mehdi El"} ], "track": "Aquil", "duration": 230, @@ -134,7 +134,7 @@ { "data": { "artists": [ - "Outkast" + {"name": "Outkast"} ], "track": "Da Art of Storytellin' (Pt. 1)", "duration": 422, diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index eac04f81..d911be6c 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -28,6 +28,7 @@ import { generateArray } from '../../../core/DataUtils.js'; import { RepositoryCreatePlayOpts } from '../../common/database/drizzle/repositories/PlayRepository.js'; import { fixtureCreatePlay } from '../utils/databaseFixtures.js'; import { isAbortError } from 'abort-controller-x'; +import { artistNamesToCredits } from '../../../core/StringUtils.js'; chai.use(asPromised); @@ -152,9 +153,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu testScrobbler.testRecentScrobbles = normalizedWithMixedDur; const uniquePlay = generatePlay({ - artists: [ - "2814" - ], + artists: artistNamesToCredits(["2814"]), track: "新宿ゴールデン街", duration: 130, playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(6, 'minutes') @@ -186,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 = ['A Different Artist']; + diffPlay.data.artists = artistNamesToCredits(['A Different Artist']); assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]); }); @@ -275,7 +274,7 @@ 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: ['Nejad'], + artists: artistNamesToCredits(['Nejad']), track: 'CODE', album: undefined, playDate: dayjs().subtract(179, 's'), @@ -321,10 +320,10 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu diffPlay.data.track = ref.data.track.replaceAll(' ', ' '); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); - diffPlay.data.artists = ref.data.artists.map(x => x.toUpperCase()); + diffPlay.data.artists = ref.data.artists.map(x => ({...x, name: x.name.toUpperCase()})); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); - diffPlay.data.artists = ref.data.artists.map(x => x.replaceAll(' ', ' ')); + diffPlay.data.artists = ref.data.artists.map(x => ({...x, name: x.name.replaceAll(' ', ' ')})); assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); }); @@ -411,7 +410,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu const spotifyPlay: PlayObject = { data: { - artists: [ + artists: artistNamesToCredits([ "Terrace Martin", "Robert Glasper", "9th Wonder", @@ -419,7 +418,7 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu "Dinner Party", "Cordae", "Phoelix" - ], + ]), album: "Dinner Party: Dessert", track: "Freeze Tag (feat. Cordae & Phoelix)", "duration": 191.375, @@ -1060,7 +1059,7 @@ describe('Now Playing', function() { await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); - const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); + const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]); expect(res).is.not.undefined; @@ -1068,7 +1067,7 @@ describe('Now Playing', function() { await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); - const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); + const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]); expect(resUpdate).is.not.undefined; }); diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index b372d201..760601ca 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -19,6 +19,7 @@ import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePl import { sleep } from "../../utils.js"; import DeezerInternalSource from "../../sources/DeezerInternalSource.js"; import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js"; +import { artistCreditsToNames } from "../../../core/StringUtils.js"; chai.use(asPromised); @@ -163,7 +164,7 @@ describe('Sources correctly parse incoming payloads', function () { 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(play.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); + expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); expect(play.data.albumArtists).to.be.empty; }); @@ -171,8 +172,8 @@ describe('Sources correctly parse incoming payloads', 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(play.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); - expect(play.data.albumArtists).eql(['Dubmood']); + expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); + expect(artistCreditsToNames(play.data.albumArtists)).eql(['Dubmood']); }); it('Spotify parses payload with identical album artists correctly', function() { @@ -181,8 +182,8 @@ describe('Sources correctly parse incoming payloads', function () { 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(identicalArtistsPlay.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); - expect(identicalArtistsPlay.data.albumArtists).to.be.empty; + expect(artistCreditsToNames(identicalArtistsPlay.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); + expect(artistCreditsToNames(identicalArtistsPlay.data.albumArtists)).to.be.empty; }); }); diff --git a/src/backend/tests/tealfm/tealfm.test.ts b/src/backend/tests/tealfm/tealfm.test.ts index f7ba19d3..0aa935f1 100644 --- a/src/backend/tests/tealfm/tealfm.test.ts +++ b/src/backend/tests/tealfm/tealfm.test.ts @@ -4,6 +4,7 @@ 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 dayjs from 'dayjs'; +import { artistCreditsToNames } from '../../../core/StringUtils.js'; chai.use(asPromised); @@ -19,7 +20,7 @@ describe('#tealfm Record to Play', function() { expect(play.data.album).eq(rec.value.releaseName); expect(play.data.playDate.unix()).eq(dayjs(rec.value.playedTime).unix()); expect(play.data.duration).eq(rec.value.duration); - expect(play.data.artists).eql(rec.value.artists.map(x => x.artistName)); + expect(artistCreditsToNames(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); }); diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index 366ef851..4672cf3b 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -312,8 +312,8 @@ export const comparePlayArtistsNormalized = (existing: PlayObject, candidate: Pl artists: candidateArtists = [], } = {} } = candidate; - const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); - const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); + const normExisting = existingArtists.map(x => normalizeStr(x.name, {keepSingleWhitespace: true})); + const candidateExisting = candidateArtists.map(x => normalizeStr(x.name, {keepSingleWhitespace: true})); const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size; return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches] diff --git a/src/backend/utils/PlayTransformUtils.ts b/src/backend/utils/PlayTransformUtils.ts index fb999739..fd8a243a 100644 --- a/src/backend/utils/PlayTransformUtils.ts +++ b/src/backend/utils/PlayTransformUtils.ts @@ -148,8 +148,8 @@ export const testWhen = (parts: WhenParts, play: PlayObject, options?: S } if(parts.artists !== undefined) { // allows user to test if artists are empty - const artists = parts.artists.length === 0 ? [''] : play.data.artists; - if(artists.every(x => !testMaybeRegex(parts.artists, x)[0])) { + const artists = parts.artists.length === 0 ? [{name: ''}] : play.data.artists; + if(artists.every(x => !testMaybeRegex(parts.artists, x.name)[0])) { return false; } } diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 15557bf5..fb7530dc 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -282,7 +282,7 @@ export const compareScrobbleArtists = (existing: PlayObject, candidate: PlayObje } } = candidate; - return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr}`, '')).highScore; + return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr.name}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr.name}`, '')).highScore; } /** diff --git a/src/client/components/player/Player.tsx b/src/client/components/player/Player.tsx index 73cf96e5..7f6625b8 100644 --- a/src/client/components/player/Player.tsx +++ b/src/client/components/player/Player.tsx @@ -33,7 +33,7 @@ const Player = (props: PlayerProps) => { play: { data: { track = '???', - artists = ['???'], + artists = [{name: '???'}], duration = 0 } = {}, meta: { @@ -95,7 +95,7 @@ art = {},
{playArt !== undefined ? : null}

{calculated !== 'stopped' ? track : '-'}

-

{calculated !== 'stopped' ? artists.join(' / ') : '-'}

+

{calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}

diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 42927dd5..2d04427c 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -147,6 +147,11 @@ export interface BrainzMeta { track?: string } +export interface ArtistCredit { + name: string + mbid?: string +} + export interface SpotifyMeta { artist?: string[] albumArtist?: string[] @@ -160,8 +165,8 @@ export interface TrackMeta { } export interface TrackData { - artists?: string[] - albumArtists?: string[] + artists?: ArtistCredit[] + albumArtists?: ArtistCredit[] album?: string track?: string /** diff --git a/src/core/PlayTestUtils.ts b/src/core/PlayTestUtils.ts index 82e3a344..9dab1b3a 100644 --- a/src/core/PlayTestUtils.ts +++ b/src/core/PlayTestUtils.ts @@ -9,7 +9,7 @@ import { BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidTy 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'; -import { arrayListAnd } from './StringUtils.js'; +import { arrayListAnd, artistNamesToCredits } from './StringUtils.js'; import { findDelimiters } from "./StringUtils.js"; import { ListRecord, ScrobbleRecord } from '../backend/common/infrastructure/config/client/tealfm.js'; import { nanoid } from 'nanoid'; @@ -175,7 +175,7 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional(playObj: AmbPlayObject, options: Tr strParts.push(`(${trackId})`); } if (include.includes('artist')) { - strParts.push(artistsFunc(artists)) + strParts.push(artistsFunc(artistCreditsToNames(artists))) } if (include.includes('track')) { strParts.push(trackFunc(track, playObj, strParts.length > 0)); @@ -306,4 +307,9 @@ export const findDelimiters = (str: string, delimiters = DELIMITERS) => { export const containsDelimiters = (str: string) => null !== str.match(/[,&/\\]+/i); const NUMBERS_REGEX = new RegExp(/^\s*\d+\s*$/); -export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str); \ No newline at end of file +export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str); + +export const artistNamesToCredits = (names: string[] | undefined): ArtistCredit[] => names === undefined ? undefined : names.map((x) => ({name: x})); +export const artistNameToCredit = (name: string | undefined): ArtistCredit => name === undefined ? undefined : ({ name }); +export const artistCreditToName = (a: ArtistCredit): string => a.name; +export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name); \ No newline at end of file -- 2.51.2