[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Refactor artists as an object

Refactor from string to artist so we can support adding mbids etc. directly to artists

#425

FoxxMD (May 7, 2026, 1:15 AM UTC) 1b53d22e 75c0230d

+257 -217
+3 -3
src/backend/common/transforms/AtomicPartsTransformer.ts
··· 1 - import { isPlayObject, ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 1 + import { ArtistCredit, isPlayObject, ObjectPlayData, PlayObject, TrackMeta } from "../../../core/Atomic.js"; 2 2 import { AtomicStageConfig, StageConfig } from "../infrastructure/Transform.js"; 3 3 import AbstractTransformer from "./AbstractTransformer.js"; 4 4 ··· 135 135 } 136 136 137 137 protected abstract handleTitle(play: PlayObject, parts: Y, transformData: T): Promise<string | undefined>; 138 - protected abstract handleArtists(play: PlayObject, parts: Y, transformData: T): Promise<string[] | undefined>; 139 - protected abstract handleAlbumArtists(play: PlayObject, parts: Y, transformData: T): Promise<string[] | undefined>; 138 + protected abstract handleArtists(play: PlayObject, parts: Y, transformData: T): Promise<ArtistCredit[] | undefined>; 139 + protected abstract handleAlbumArtists(play: PlayObject, parts: Y, transformData: T): Promise<ArtistCredit[] | undefined>; 140 140 protected abstract handleAlbum(play: PlayObject, parts: Y, transformData: T): Promise<string | undefined>; 141 141 protected async handleDuration(play: PlayObject, parts: Y, transformData: T): Promise<number | undefined> { 142 142 return play.data.duration;
+3 -3
src/backend/common/transforms/MusicbrainzTransformer.ts
··· 1 - import { asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, isMBReleasePrimaryGroupType, MBReleaseGroupPrimaryType, MBReleaseGroupSecondaryType, MBReleaseStatus, MissingMbidType, PlayObject, TrackMeta, TransformerCommon, TransformOptions } from "../../../core/Atomic.js"; 1 + import { ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, isMBReleasePrimaryGroupType, MBReleaseGroupPrimaryType, MBReleaseGroupSecondaryType, MBReleaseStatus, MissingMbidType, PlayObject, TrackMeta, TransformerCommon, TransformOptions } from "../../../core/Atomic.js"; 2 2 import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 3 3 import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; 4 4 import { ExternalMetadataTerm, PlayTransformMetadataStage } from "../infrastructure/Transform.js"; ··· 661 661 662 662 return transformData.data.track; 663 663 } 664 - protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<string[] | undefined> { 664 + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<ArtistCredit[] | undefined> { 665 665 if (parts === false) { 666 666 return play.data.artists; 667 667 } ··· 676 676 677 677 return transformData.data.artists; 678 678 } 679 - protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<string[] | undefined> { 679 + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<ArtistCredit[] | undefined> { 680 680 if (parts === false) { 681 681 return play.data.albumArtists; 682 682 }
+11 -10
src/backend/common/transforms/NativeTransformer.ts
··· 1 - import { PlayObject, TransformerCommon } from "../../../core/Atomic.js"; 1 + import { ArtistCredit, PlayObject, TransformerCommon } from "../../../core/Atomic.js"; 2 2 import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 3 3 import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; 4 4 import { ExternalMetadataTerm, PlayTransformNativeStage, StageConfig } from "../infrastructure/Transform.js"; ··· 10 10 import { asArray } from "../../utils/DataUtils.js"; 11 11 import { MaybeLogger } from '../MaybeLogger.js'; 12 12 import { childLogger } from "@foxxmd/logging"; 13 + import { artistCreditToName, artistNameToCredit } from "../../../core/StringUtils.js"; 13 14 14 15 export type ArtistParseSource = 'artists' | 'title' 15 16 ··· 167 168 protected async handleTitle(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<string | undefined> { 168 169 return play.data.track; 169 170 } 170 - protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<string[] | undefined> { 171 + protected async handleArtists(play: PlayObject, parts: ExternalMetadataTerm, transformData: PlayObject): Promise<ArtistCredit[] | undefined> { 171 172 if (parts === false) { 172 173 return play.data.artists; 173 174 } ··· 182 183 183 184 return transformData.data.artists; 184 185 } 185 - protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<string[] | undefined> { 186 + protected async handleAlbumArtists(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<ArtistCredit[] | undefined> { 186 187 return play.data.albumArtists; 187 188 } 188 189 protected async handleAlbum(play: PlayObject, parts: ExternalMetadataTerm, _transformData: undefined): Promise<string | undefined> { ··· 205 206 logger = new MaybeLogger() 206 207 } = options || {}; 207 208 208 - let artists = []; 209 + let artists: ArtistCredit[] = []; 209 210 let track = play.data.track; 210 211 211 212 if(artistsParseFrom.includes('artists')) { ··· 214 215 215 216 for(const artist of play.data.artists) { 216 217 217 - const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist)})).filter(x => x.res !== undefined); 218 + const matchedIgnoreArtists = ignoreArtistsRegex.map(x => ({reg: x.toString(), res: parseRegexSingle(x, artist.name)})).filter(x => x.res !== undefined); 218 219 if(matchedIgnoreArtists.length > 0) { 219 220 logger.debug(`Will not parse artist because it matched an ignore regex:\n${matchedIgnoreArtists.map(x => `Reg: ${x.reg} => ${x.res.match}`).join('\n')}`); 220 221 artists.push(artist); 221 222 } else { 222 - const artistCredits = parseArtistCredits(artist, delimiters); 223 + const artistCredits = parseArtistCredits(artist.name, delimiters); 223 224 if (artistCredits !== undefined) { 224 225 if (artistCredits.primary !== undefined) { 225 - artists.push(artistCredits.primary); 226 + artists.push({name: artistCredits.primary}); 226 227 } 227 228 if (artistCredits.secondary !== undefined) { 228 - artists = artists.concat(artistCredits.secondary); 229 + artists = artists.concat(artistCredits.secondary.map(artistNameToCredit)); 229 230 } 230 231 } else { 231 232 // couldn't parse anything from artist string, use as-is ··· 246 247 if(artistsParseFrom.includes('title')) { 247 248 const trackArtists = parseTrackCredits(play.data.track, delimiters); 248 249 if (trackArtists !== undefined && trackArtists.secondary !== undefined) { 249 - artists = artists.concat(trackArtists.secondary); 250 + artists = artists.concat(trackArtists.secondary.map(artistNameToCredit)); 250 251 if(titleClean) { 251 252 track = trackArtists.primary; 252 253 } 253 254 } 254 255 } 255 256 256 - artists = uniqueNormalizedStrArr([...artists]); 257 + artists = (uniqueNormalizedStrArr([...artists.map(artistCreditToName)])).map(artistNameToCredit); 257 258 258 259 return { 259 260 ...play,
+7 -7
src/backend/common/transforms/UserTransformer.ts
··· 1 1 import { searchAndReplace } from "@foxxmd/regex-buddy-core"; 2 - import { PlayObject } from "../../../core/Atomic.js"; 2 + import { ArtistCredit, PlayObject } from "../../../core/Atomic.js"; 3 3 import { configValToSearchReplace, isSearchAndReplaceTerm, isUserStage, testWhenConditions } from "../../utils/PlayTransformUtils.js"; 4 4 import { WebhookPayload } from "../infrastructure/config/health/webhooks.js"; 5 5 import { ConditionalSearchAndReplaceRegExp, PlayTransformUserStage, StageConfig } from "../infrastructure/Transform.js"; ··· 74 74 } 75 75 return result.trim(); 76 76 } 77 - protected async handleArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise<string[] | undefined> { 77 + protected async handleArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise<ArtistCredit[] | undefined> { 78 78 if(play.data.artists === undefined || play.data.artists.length === 0) { 79 79 return play.data.artists; 80 80 } 81 81 const mapper = this.generateMapper(play); 82 82 const transformedArtists = []; 83 83 for(const artist of play.data.artists) { 84 - const a = searchAndReplace(artist, parts.map(mapper)); 84 + const a = searchAndReplace(artist.name, parts.map(mapper)); 85 85 if(a.trim() !== '') { 86 - transformedArtists.push(a); 86 + transformedArtists.push({...artist, name: a}); 87 87 } 88 88 } 89 89 return transformedArtists; 90 90 } 91 - protected async handleAlbumArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise<string[] | undefined> { 91 + protected async handleAlbumArtists(play: PlayObject, parts: ConditionalSearchAndReplaceRegExp[], _transformData: undefined): Promise<ArtistCredit[] | undefined> { 92 92 if(play.data.albumArtists === undefined || play.data.albumArtists.length === 0) { 93 93 return play.data.albumArtists; 94 94 } 95 95 const mapper = this.generateMapper(play); 96 96 const transformedArtists = []; 97 97 for(const artist of play.data.albumArtists) { 98 - const a = searchAndReplace(artist, parts.map(mapper)); 98 + const a = searchAndReplace(artist.name, parts.map(mapper)); 99 99 if(a.trim() !== '') { 100 - transformedArtists.push(a); 100 + transformedArtists.push({...artist, name: a}); 101 101 } 102 102 } 103 103 return transformedArtists;
+3 -2
src/backend/common/vendor/KodiApiClient.ts
··· 8 8 import { KodiData } from "../infrastructure/config/source/kodi.js"; 9 9 import AbstractApiClient from "./AbstractApiClient.js"; 10 10 import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; 11 + import { artistNamesToCredits } from "../../../core/StringUtils.js"; 11 12 12 13 interface KodiDuration { 13 14 hours: number ··· 109 110 data: { 110 111 track: title, 111 112 album: album, 112 - albumArtists: albumartist, 113 - artists, 113 + albumArtists: artistNamesToCredits(albumartist), 114 + artists: artistNamesToCredits(artistVal), 114 115 duration, 115 116 playDate: dayjs() 116 117 },
+7 -7
src/backend/common/vendor/LastfmApiClient.ts
··· 1 1 import dayjs, { Dayjs, ManipulateType } from "dayjs"; 2 2 import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, URLData, Writeable } from "../../../core/Atomic.js"; 3 - import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; 3 + import { artistNamesToCredits, artistNameToCredit, nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; 4 4 import { removeUndefinedKeys, sleep } from "../../utils.js"; 5 5 import { writeFile } from '../../utils/FSUtils.js'; 6 6 import { objectIsEmpty, readJson } from '../../utils/DataUtils.js'; ··· 570 570 data: { 571 571 track, 572 572 album: nonEmptyStringOrDefault(album), 573 - albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined, 573 + albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [artistNameToCredit(albumArtist)] : undefined, 574 574 duration: typeof duration === 'string' ? parseInt(duration, 10) : duration, 575 575 playDate: ts, 576 - artists 576 + artists: artistNamesToCredits(artists) 577 577 }, 578 578 meta: { 579 579 source: 'lastfm', ··· 615 615 if (artists.length === 0) { 616 616 artist = ""; 617 617 } else { 618 - artist = artists[0]; 618 + artist = artists[0].name; 619 619 } 620 620 621 621 const additionalRichPayload: Partial<LastFMScrobblePayload> = {}; ··· 634 634 635 635 // LFM ignores scrobbles where album artist is VA 636 636 // https://github.com/FoxxMD/multi-scrobbler/issues/340#issuecomment-3220774257 637 - const nonVaAlbumArtists = albumArtists.filter(x => x.trim().toLocaleLowerCase() !== 'va'); 637 + const nonVaAlbumArtists = albumArtists.filter(x => x.name.trim().toLocaleLowerCase() !== 'va'); 638 638 // LFM does not support multiple artists in scrobble payload 639 639 // https://www.last.fm/api/show/track.scrobble 640 640 if (nonVaAlbumArtists.length > 0) { 641 - rawPayload.albumArtist = nonVaAlbumArtists[0]; 641 + rawPayload.albumArtist = nonVaAlbumArtists[0].name; 642 642 } 643 643 644 644 // I don't know if its lastfm-node-client building the request params incorrectly ··· 694 694 695 695 const play: PlayObjectLifecycleless = { 696 696 data: { 697 - artists: [...new Set(artistStrings)] as string[], 697 + artists: artistNamesToCredits([...new Set(artistStrings)] as string[]), 698 698 track: title, 699 699 album: al, 700 700 duration,
+5 -5
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 2 2 import dayjs from "dayjs"; 3 3 import request, { Request, Response } from 'superagent'; 4 4 import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, URLData } from "../../../core/Atomic.js"; 5 - import { combinePartsToString, slice } from "../../../core/StringUtils.js"; 5 + import { artistNamesToCredits, combinePartsToString, slice } from "../../../core/StringUtils.js"; 6 6 import { 7 7 normalizeListenbrainzUrl, 8 8 normalizeStr, ··· 548 548 ...naivePlay, 549 549 data: { 550 550 ...naivePlay.data, 551 - artists: derivedArtists 551 + artists: artistNamesToCredits(derivedArtists) 552 552 } 553 553 }; 554 554 } ··· 579 579 data: { 580 580 ...naivePlay.data, 581 581 track: normalTrackName, 582 - artists: derivedArtists 582 + artists: artistNamesToCredits(derivedArtists) 583 583 }, 584 584 meta: naivePlay.meta 585 585 } ··· 675 675 data: { 676 676 playDate: dayjs.unix(listened_at), 677 677 track: normalTrackName, 678 - artists: artists, 678 + artists: artistNamesToCredits(artists), 679 679 album: release_name, 680 - albumArtists, 680 + albumArtists: artistNamesToCredits(albumArtists), 681 681 duration: dur, 682 682 isrc: isrc !== undefined ? isrc : undefined, 683 683 meta: {
+3 -3
src/backend/common/vendor/RockSkyApiClient.ts
··· 1 1 import dayjs from "dayjs"; 2 2 import request, { Request, Response } from 'superagent'; 3 3 import { PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../core/Atomic.js"; 4 - import { nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; 4 + import { artistNamesToCredits, nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; 5 5 import { UpstreamError } from "../errors/UpstreamError.js"; 6 6 import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; 7 7 import { RockSkyClientData, RockSkyData, RockSkyOptions } from "../infrastructure/config/client/rocksky.js"; ··· 221 221 const play: PlayObjectLifecycleless = { 222 222 data: { 223 223 track: obj.title, 224 - artists: nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : [], 225 - albumArtists: nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : [], 224 + artists: artistNamesToCredits(nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), 225 + albumArtists: artistNamesToCredits(nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), 226 226 album: nonEmptyStringOrDefault(obj.album), 227 227 playDate: dayjs.utc(obj.createdAt).local() 228 228 },
+2 -2
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 94 94 const record: ScrobbleRecord = { 95 95 $type: "fm.teal.alpha.feed.play", 96 96 trackName: play.data.track, 97 - artists: play.data.artists.map(x => ({ artistName: x })), 97 + artists: play.data.artists.map(x => ({ artistName: x.name })), 98 98 duration: Math.round(play.data.duration), 99 99 playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(), 100 100 releaseName: play.data.album, ··· 124 124 const play: PlayObjectLifecycleless = { 125 125 data: { 126 126 track: record.trackName, 127 - artists: record.artists.filter(x => x.artistName !== undefined).map(x => x.artistName), 127 + artists: record.artists.filter(x => x.artistName !== undefined).map(x => ({name: x.artistName, mbid: x.artistMbId})), 128 128 duration: record.duration, 129 129 playDate: dayjs(record.playedTime), 130 130 album: record.releaseName,
+1 -1
src/backend/common/vendor/discord/DiscordUtils.ts
··· 46 46 name: activityName, 47 47 48 48 details: play.data.track.padEnd(2,'\u200B'), 49 - state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.map(x => x.padEnd(2, '\u200B')).join(' / ') : undefined, 49 + state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.map(x => x.name.padEnd(2, '\u200B')).join(' / ') : undefined, 50 50 // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets 51 51 // https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image 52 52 assets: {
+2 -1
src/backend/common/vendor/koito/KoitoApiClient.ts
··· 13 13 import { ScrobbleSubmitError } from "../../errors/MSErrors.js"; 14 14 import { tryApiCall } from "../../../utils/RequestUtils.js"; 15 15 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 16 + import { artistNamesToCredits } from "../../../../core/StringUtils.js"; 16 17 17 18 interface SubmitOptions { 18 19 log?: boolean ··· 218 219 const play: PlayObjectLifecycleless = { 219 220 data: { 220 221 track: obj.track.title, 221 - artists: (obj.track.artists ?? []).map(x => x.name), 222 + artists: artistNamesToCredits((obj.track.artists ?? []).map(x => x.name)), 222 223 duration: obj.track.duration, 223 224 playDate: dayjs(obj.time) 224 225 },
+4 -3
src/backend/common/vendor/listenbrainz/lzUtils.ts
··· 4 4 import { SubmitOptions } from "../ListenbrainzApiClient.js"; 5 5 import { ListenPayload, MinimumTrack, SubmitListenAdditionalTrackInfo, SubmitPayload } from "./interfaces.js"; 6 6 import {version as appVersion } from '../../../version.js'; 7 + import { artistCreditsToNames, artistCreditToName } from "../../../../core/StringUtils.js"; 7 8 8 9 export const playToListenPayload = (play: PlayObject, version?: string): ListenPayload => { 9 10 const { ··· 25 26 26 27 let addInfo: SubmitListenAdditionalTrackInfo = { 27 28 // primary artists 28 - artist_names: Array.from(new Set([...artists])), 29 + artist_names: Array.from(new Set([...artists.map(artistCreditToName)])), 29 30 // primary artist 30 - release_artist_name: albumArtists.length === 1 ? albumArtists[0] : undefined, 31 - release_artist_names: albumArtists.length > 0 ? albumArtists : undefined, 31 + release_artist_name: albumArtists.length === 1 ? albumArtists[0].name : undefined, 32 + release_artist_names: albumArtists.length > 0 ? artistCreditsToNames(albumArtists) : undefined, 32 33 // use data from LZ response, if this Play was originally from LZ Source 33 34 media_player: mediaPlayerName ?? msAdditionalInfo.media_player, 34 35 media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version,
+5 -5
src/backend/common/vendor/maloja/MalojaApiClient.ts
··· 12 12 import { UpstreamError } from "../../errors/UpstreamError.js"; 13 13 import { getMalojaResponseError, isMalojaAPIErrorBody, MalojaResponseV3CommonData, MalojaScrobbleData, MalojaScrobbleRequestData, MalojaScrobbleV3RequestData, MalojaScrobbleV3ResponseData, MalojaScrobbleWarning } from "./interfaces.js"; 14 14 import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.js'; 15 - import { buildTrackString } from '../../../../core/StringUtils.js'; 15 + import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.js'; 16 16 import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; 17 17 import { ScrobbleSubmitError } from '../../errors/MSErrors.js'; 18 18 import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.js'; ··· 272 272 } = track; 273 273 scrobbleResponse.track.album = { 274 274 name: album, 275 - artists: albumArtists, 275 + artists: artistCreditsToNames(albumArtists), 276 276 ...malojaAlbum, 277 277 } 278 278 } ··· 412 412 const urlParams = new URLSearchParams([['artist', artists[0]], ['title', title]]); 413 413 const play: PlayObjectLifecycleless = { 414 414 data: removeUndefinedKeys({ 415 - artists: [...new Set(artistStrings)] as string[], 415 + artists: artistNamesToCredits([...new Set(artistStrings)] as string[]), 416 416 track: title, 417 417 album, 418 418 duration, ··· 446 446 447 447 const scrobbleData: MalojaScrobbleV3RequestData = { 448 448 title: track, 449 - artists, 449 + artists: artistCreditsToNames(artists), 450 450 album, 451 451 key: apiKey, 452 452 time: pd.unix(), ··· 463 463 // https://github.com/krateng/maloja/blob/master/maloja/web/static/js/manualscrobble.js#L136 464 464 // BUT this is not actually working! 465 465 if (albumArtists.length > 0) { 466 - scrobbleData.albumartists = albumArtists; 466 + scrobbleData.albumartists = artistCreditsToNames(albumArtists); 467 467 } 468 468 // see also https://github.com/krateng/maloja/issues/96#issuecomment-1490562761 469 469 // https://github.com/FoxxMD/multi-scrobbler/issues/454#issuecomment-3806367420
+17 -7
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 1 1 import { Response } from 'superagent'; 2 - import { PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; 2 + import { ArtistCredit, PlayObject, PlayObjectLifecycleless, URLData } from "../../../../core/Atomic.js"; 3 3 import { UpstreamError } from "../../errors/UpstreamError.js"; 4 4 import { AbstractApiOptions, FormatPlayObjectOptions, MUSICBRAINZ_URL, MusicbrainzApiConfigData } from "../../infrastructure/Atomic.js"; 5 5 import AbstractApiClient from "../AbstractApiClient.js"; ··· 21 21 import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.js'; 22 22 import { IRecordingMSList } from '../../transforms/MusicbrainzTransformer.js'; 23 23 import dayjs, { Dayjs } from 'dayjs'; 24 + import { artistCreditsToNames } from '../../../../core/StringUtils.js'; 24 25 25 26 export interface SubmitResponse { 26 27 payload?: { ··· 235 236 // https://wiki.musicbrainz.org/MusicBrainz_API/Search#Recording 236 237 // https://beta.musicbrainz.org/doc/MusicBrainz_API/Search 237 238 const res = await this.callApi<IRecordingList>((mb) => { 238 - const query: Record<string, any> = { 239 + const query: { 240 + recording_mbid?: string 241 + track_mbid?: string 242 + release_mbid?: string 243 + artist_mbids?: string[] 244 + isrc?: string 245 + recording?: string 246 + artist?: string[] 247 + release?: string 248 + } = { 239 249 }; 240 250 241 251 if(play.data?.meta?.brainz?.recording !== undefined && using.includes('mbidrecording')) { ··· 257 267 query.recording = play.data.track; 258 268 } 259 269 if(play.data.artists !== undefined && play.data.artists.length > 0 && using.includes('artist')) { 260 - query.artist = play.data.artists; 270 + query.artist = artistCreditsToNames(play.data.artists); 261 271 } 262 272 if(play.data.album !== undefined && using.includes('album')) { 263 273 query.release = play.data.album; ··· 380 390 381 391 let album: IRelease; 382 392 383 - let albumArtists: string[]; 393 + let albumArtists: ArtistCredit[]; 384 394 let albumArtistIds: string[]; 385 - const artists = (data["artist-credit"] ?? []).map(x => x.name); 395 + const artists = (data["artist-credit"] ?? []).map(x => ({ name: x.name, mbid: x.artist.id})); 386 396 if(data.releases !== undefined && data.releases.length > 0) { 387 397 album = data.releases[0]; 388 398 if(album["artist-credit"] !== undefined) { 389 399 if(difference(album["artist-credit"].map(x => x.artist.id), (data["artist-credit"] ?? []).map(x => x.artist.id)).length > 0) { 390 - albumArtists = album["artist-credit"].map(x => x.artist.name); 400 + albumArtists = album["artist-credit"].map(x => ({name: x.artist.name, mbid: x.artist.id})); 391 401 albumArtistIds = album["artist-credit"].map(x => x.artist.id); 392 402 } 393 - if(albumArtists !== undefined && ignoreVA && albumArtists.includes('Various Artists')) { 403 + if(albumArtists !== undefined && ignoreVA && albumArtists.map(x => x.name).includes('Various Artists')) { 394 404 albumArtists = undefined; 395 405 albumArtistIds = undefined; 396 406 }
+2 -1
src/backend/sources/AzuracastSource.ts
··· 17 17 import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js"; 18 18 import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js"; 19 19 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 20 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 20 21 21 22 22 23 export class AzuracastSource extends MemorySource { ··· 250 251 251 252 const play: PlayObjectLifecycleless = { 252 253 data: { 253 - artists: artist !== undefined && artist !== '' ? [artist] : [], 254 + artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), 254 255 album: album !== '' ? album : undefined, 255 256 track, 256 257 duration
+3 -3
src/backend/sources/ChromecastSource.ts
··· 6 6 import { EventEmitter } from "events"; 7 7 import e from "express"; 8 8 import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 9 - import { buildTrackString } from "../../core/StringUtils.js"; 9 + import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.js"; 10 10 import { NETWORK_ERROR_FAILURE_CODES } from "../common/errors/NodeErrors.js"; 11 11 import { 12 12 FormatPlayObjectOptions, ··· 722 722 data: { 723 723 track, 724 724 album, 725 - albumArtists, 726 - artists, 725 + albumArtists: artistNamesToCredits(albumArtists), 726 + artists: artistNamesToCredits(artists), 727 727 duration, 728 728 playDate: dayjs() 729 729 },
+2 -1
src/backend/sources/DeezerInternalSource.ts
··· 15 15 import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js"; 16 16 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 17 17 import { UpstreamError } from "../common/errors/UpstreamError.js"; 18 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 18 19 19 20 interface DeezerHistoryResponse { 20 21 errors: [] ··· 113 114 const {newFromSource = false} = options; 114 115 const play: PlayObjectLifecycleless = { 115 116 data: { 116 - artists: [obj.ART_NAME], 117 + artists: artistNamesToCredits([obj.ART_NAME]), 117 118 album: obj.ALB_TITLE, 118 119 track: obj.SNG_TITLE, 119 120 duration: obj.DURATION,
+2 -1
src/backend/sources/IcecastSource.ts
··· 15 15 import { parseArtistCredits, parseTrackCredits } from "../utils/StringUtils.js"; 16 16 import { isDebugMode, sleep } from "../utils.js"; 17 17 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 18 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 18 19 19 20 20 21 export class IcecastSource extends MemorySource { ··· 211 212 const play: PlayObjectLifecycleless = { 212 213 data: { 213 214 track, 214 - artists 215 + artists: artistNamesToCredits(artists) 215 216 }, 216 217 meta: { 217 218 source: 'icecast',
+2 -1
src/backend/sources/JRiverSource.ts
··· 9 9 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 10 10 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 11 11 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 12 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 12 13 13 14 export class JRiverSource extends MemoryPositionalSource { 14 15 declare config: JRiverSourceConfig; ··· 104 105 data: { 105 106 track: Name, 106 107 album: album, 107 - artists, 108 + artists: artistNamesToCredits(artists), 108 109 duration: Math.round(length), 109 110 playDate: dayjs() 110 111 },
+3 -2
src/backend/sources/MPDSource.ts
··· 20 20 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 21 21 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 22 22 import { isDebugMode, sleep } from "../utils.js"; 23 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 23 24 24 25 const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = { 25 26 'play': REPORTED_PLAYER_STATUSES.playing, ··· 256 257 257 258 const play: PlayObjectLifecycleless = { 258 259 data: { 259 - artists: artists, 260 - albumArtists, 260 + artists: artistNamesToCredits(artists), 261 + albumArtists: artistNamesToCredits(albumArtists), 261 262 album, 262 263 track: trackName, 263 264 duration
+3 -2
src/backend/sources/MPRISSource.ts
··· 21 21 import net from 'net'; 22 22 import pEvent from 'p-event'; 23 23 import { baseFormatPlayObj } from '../utils/PlayTransformUtils.js'; 24 + import { artistNamesToCredits } from '../../core/StringUtils.js'; 24 25 25 26 26 27 export class MPRISSource extends MemorySource { ··· 76 77 data: { 77 78 track: title, 78 79 album, 79 - artists: artist, 80 - albumArtists: actualAlbumArtists, 80 + artists: artistNamesToCredits(artist), 81 + albumArtists:artistNamesToCredits(actualAlbumArtists), 81 82 duration: length, 82 83 playDate: dayjs() 83 84 },
+3 -3
src/backend/sources/MopidySource.ts
··· 6 6 import pEvent from 'p-event'; 7 7 import { URL } from "url"; 8 8 import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 9 - import { buildTrackString } from "../../core/StringUtils.js"; 9 + import { artistNamesToCredits, buildTrackString } from "../../core/StringUtils.js"; 10 10 import { 11 11 FormatPlayObjectOptions, 12 12 InternalConfig, ··· 164 164 data: { 165 165 track: name, 166 166 album: albumName, 167 - albumArtists: actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : [], 168 - artists: artists.length > 0 ? artists.map(x => x.name) : [], 167 + albumArtists: artistNamesToCredits(actualAlbumArtists.length > 0 ? actualAlbumArtists.map(x => x.name) : []), 168 + artists: artistNamesToCredits(artists.length > 0 ? artists.map(x => x.name) : []), 169 169 duration: Math.round(length / 1000), 170 170 playDate: dayjs() 171 171 },
+2 -1
src/backend/sources/MusicCastSource.ts
··· 12 12 import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; 13 13 import request, { Request, Response } from 'superagent'; 14 14 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 15 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 15 16 16 17 17 18 export class MusicCastSource extends MemoryPositionalSource { ··· 149 150 150 151 const play: PlayObjectLifecycleless = { 151 152 data: { 152 - artists: artist !== undefined && artist !== '' ? [artist] : [], 153 + artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), 153 154 album: album !== '' ? album : undefined, 154 155 track, 155 156 // we should treat 0 time as the same as not being provided
+3 -3
src/backend/sources/PlexApiSource.ts
··· 1 1 import EventEmitter from "events"; 2 2 import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; 3 - import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 3 + import { artistNamesToCredits, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 4 4 import { 5 5 asPlayerStateDataMaybePlay, 6 6 FormatPlayObjectOptions, ··· 376 376 377 377 const play: PlayObjectLifecycleless = { 378 378 data: { 379 - artists: realArtists, 380 - albumArtists, 379 + artists: artistNamesToCredits(realArtists), 380 + albumArtists: artistNamesToCredits(albumArtists), 381 381 album, 382 382 track, 383 383 // albumArtists: AlbumArtists !== undefined ? AlbumArtists.map(x => x.Name) : undefined,
+2 -1
src/backend/sources/SonosSource.ts
··· 22 22 import { isDebugMode, playObjDataMatch, sleep } from "../utils.js"; 23 23 import dayjs, { Dayjs } from "dayjs"; 24 24 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 25 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 25 26 26 27 export interface DeviceState { 27 28 device: SonosDevice ··· 377 378 data: { 378 379 track: titleStr, 379 380 album: Album, 380 - artists: Artist === undefined ? undefined : [Artist], 381 + artists: Artist === undefined ? undefined : artistNamesToCredits([Artist]), 381 382 duration: dur, 382 383 }, 383 384 meta: {
+3 -3
src/backend/sources/SpotifySource.ts
··· 3 3 import SpotifyWebApi from "spotify-web-api-node"; 4 4 import request from 'superagent'; 5 5 import { BrainzMeta, PlayObject, PlayObjectLifecycleless, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; 6 - import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 6 + import { artistNamesToCredits, artistNameToCredit, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 7 7 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 8 8 import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; 9 9 import { ··· 214 214 215 215 const play: PlayObjectLifecycleless = { 216 216 data: { 217 - artists: artists.map(x => x.name), 218 - albumArtists: actualAlbumArtists.map(x => x.name), 217 + artists: artists.map(x => artistNameToCredit(x.name)), 218 + albumArtists: actualAlbumArtists.map(x => artistNameToCredit(x.name)), 219 219 album: albumName, 220 220 track: name, 221 221 duration: duration_ms / 1000,
+3 -2
src/backend/sources/WebScrobblerSource.ts
··· 23 23 import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 24 24 import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 25 25 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 26 + import { artistCreditToName, artistNameToCredit } from "../../core/StringUtils.js"; 26 27 27 28 export class WebScrobblerSource extends MemorySource { 28 29 ··· 127 128 const play: PlayObjectLifecycleless = { 128 129 data: { 129 130 track, 130 - artists: [artist], 131 + artists: [artistNameToCredit(artist)], 131 132 album: album === null ? undefined : album, 132 - albumArtists: albumArtist === null ? undefined : [albumArtist], 133 + albumArtists: albumArtist === null ? undefined : [artistNameToCredit(albumArtist)], 133 134 playDate: dayjs.unix(startTimestamp), 134 135 duration: duration === null ? undefined : duration, 135 136 meta: {
+4 -4
src/backend/sources/YTMusicSource.ts
··· 18 18 playsAreSortConsistent 19 19 } from "../utils/PlayComparisonUtils.js"; 20 20 import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; 21 - import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; 21 + import { artistNamesToCredits, buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; 22 22 import { joinedUrl } from "../utils/NetworkUtils.js"; 23 23 import { todayAwareFormat } from "../../core/TimeUtils.js"; 24 24 import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js"; ··· 377 377 duration: dur, // string timestamp 378 378 } = obj; 379 379 380 - let artists = [], 380 + let artists: string[] = [], 381 381 album = undefined, 382 382 duration = undefined; 383 383 if(artistsData !== undefined) { ··· 421 421 } 422 422 const play: PlayObjectLifecycleless = { 423 423 data: { 424 - artists, 425 - albumArtists, 424 + artists: artistNamesToCredits(artists), 425 + albumArtists: artistNamesToCredits(albumArtists), 426 426 album, 427 427 track: title, 428 428 duration,
+2 -1
src/backend/sources/YandexMusicBridgeSource.ts
··· 15 15 import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; 16 16 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 17 17 import { UpstreamError } from "../common/errors/UpstreamError.js"; 18 + import { artistNamesToCredits } from "../../core/StringUtils.js"; 18 19 19 20 interface BridgeTrackData { 20 21 title?: string ··· 437 438 438 439 const play: PlayObjectLifecycleless = { 439 440 data: { 440 - artists, 441 + artists: artistNamesToCredits(artists), 441 442 album: obj.album ?? undefined, 442 443 track: obj.title ?? undefined, 443 444 duration: obj.duration_ms !== undefined && obj.duration_ms !== null
+24 -23
src/backend/tests/component/transformers.test.ts
··· 18 18 import { transientCache } from "../utils/TransientTestUtils.js"; 19 19 import dayjs from "dayjs"; 20 20 import clone from "clone"; 21 + import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.js"; 21 22 22 23 chai.use(asPromised); 23 24 ··· 350 351 } 351 352 component.buildTransformRules(); 352 353 353 - const play = generatePlay({ artists: ['My Artist One / My Artist Two / Another Guy'] }); 354 + const play = generatePlay({ artists: artistNamesToCredits(['My Artist One / My Artist Two / Another Guy']) }); 354 355 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 355 356 expect(transformed.data.artists).length(1) 356 - expect(transformed.data.artists[0]).equal('My Artist One'); 357 + expect(transformed.data.artists[0].name).equal('My Artist One'); 357 358 }); 358 359 359 360 it('Removes title when transform replaces with empty string', async function () { ··· 402 403 } 403 404 component.buildTransformRules(); 404 405 405 - const play = generatePlay({ artists: ['something', 'big'] }); 406 + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']) }); 406 407 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 407 408 expect(transformed.data.artists!.length).is.eq(1) 408 - expect(transformed.data.artists![0]).is.eq('big') 409 + expect(transformed.data.artists![0].name).is.eq('big') 409 410 }); 410 411 411 412 }); ··· 421 422 await t.tryInitialize(); 422 423 423 424 const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); 424 - const play = generatePlay({artists: [str]}); 425 + const play = generatePlay({artists: artistNamesToCredits([str])}); 425 426 426 427 const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); 427 - expect(transformedPlay.data.artists).eql(primaries.concat(secondaries)); 428 + expect(artistCreditsToNames(transformedPlay.data.artists)).eql(primaries.concat(secondaries)); 428 429 }); 429 430 430 431 it('Ignores artists', async function() { ··· 435 436 436 437 await t.tryInitialize(); 437 438 438 - const play = generatePlay({artists: [str], track: 'My Test'}); 439 + const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); 439 440 440 441 const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); 441 - expect(transformedPlay.data.artists).eql([str]); 442 + expect(artistCreditsToNames(transformedPlay.data.artists)).eql([str]); 442 443 }); 443 444 444 445 it('Uses custom delimiters artists', async function() { ··· 456 457 457 458 await t.tryInitialize(); 458 459 459 - const play = generatePlay({artists: [str], track: 'My Test'}); 460 + const play = generatePlay({artists: artistNamesToCredits([str]), track: 'My Test'}); 460 461 461 462 const transformedPlay = await t.handle(t.parseConfig({type: 'native'}), play); 462 - expect(transformedPlay.data.artists).eql(primaries.concat(secondaries)); 463 + expect(artistCreditsToNames(transformedPlay.data.artists)).eql(primaries.concat(secondaries)); 463 464 }); 464 465 465 466 }); ··· 484 485 } 485 486 component.buildTransformRules(); 486 487 487 - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); 488 + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has No Match' }); 488 489 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 489 490 expect(transformed.data.artists!.length).is.eq(2) 490 - expect(transformed.data.artists![0]).is.eq('something') 491 + expect(transformed.data.artists![0].name).is.eq('something') 491 492 }); 492 493 493 494 it('Does run hook if when conditions matches', async function () { ··· 507 508 } 508 509 component.buildTransformRules(); 509 510 510 - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); 511 + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has This Match' }); 511 512 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 512 513 expect(transformed.data.artists!.length).is.eq(1) 513 - expect(transformed.data.artists![0]).is.eq('big') 514 + expect(transformed.data.artists![0].name).is.eq('big') 514 515 }); 515 516 }); 516 517 ··· 537 538 } 538 539 component.buildTransformRules(); 539 540 540 - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has No Match' }); 541 + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has No Match' }); 541 542 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 542 543 expect(transformed.data.artists!.length).is.eq(2) 543 - expect(transformed.data.artists![0]).is.eq('something') 544 + expect(transformed.data.artists![0].name).is.eq('something') 544 545 }); 545 546 546 547 it('Does run hook if when conditions matches', async function () { ··· 565 566 } 566 567 component.buildTransformRules(); 567 568 568 - const play = generatePlay({ artists: ['something', 'big'], album: 'It Has This Match' }); 569 + const play = generatePlay({ artists: artistNamesToCredits(['something', 'big']), album: 'It Has This Match' }); 569 570 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 570 571 expect(transformed.data.artists!.length).is.eq(1) 571 - expect(transformed.data.artists![0]).is.eq('big') 572 + expect(transformed.data.artists![0].name).is.eq('big') 572 573 }); 573 574 }); 574 575 ··· 632 633 const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); 633 634 634 635 component.buildTransformRules(); 635 - const play = generatePlay({ track: 'My cool something track', artists: [str] }); 636 + const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]) }); 636 637 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare); 637 638 expect(transformed.data.track).equal('My cool bar track'); 638 - expect(transformed.data.artists).eql(primaries.concat(secondaries)); 639 + expect(artistCreditsToNames(transformed.data.artists)).eql(primaries.concat(secondaries)); 639 640 }); 640 641 641 642 }); ··· 667 668 const [str, primaries, secondaries] = generateArtistsStr({primary: {max: 3, ambiguousJoinedNames: true, trailingAmpersand: true, finalJoiner: false}}); 668 669 669 670 component.buildTransformRules(); 670 - const play = generatePlay({ track: 'My cool something track', artists: [str], playDate: dayjs().subtract(10, 'm') }); 671 + const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]), playDate: dayjs().subtract(10, 'm') }); 671 672 const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, 'all'); 672 673 expect(transformed.data.track).equal('My cool bar track'); 673 - expect(transformed.data.artists).eql(primaries.concat(secondaries)); 674 + expect(artistCreditsToNames(transformed.data.artists)).eql(primaries.concat(secondaries)); 674 675 675 676 const cachablePlay = clone(play); 676 677 const laterDate = dayjs().subtract(5, 'm'); 677 678 cachablePlay.data.playDate = laterDate; 678 679 const cacheTransformed = await component.transformPlay(cachablePlay, TRANSFORM_HOOK.preCompare, 'all'); 679 680 expect(cacheTransformed.data.track).equal('My cool bar track'); 680 - expect(cacheTransformed.data.artists).eql(primaries.concat(secondaries)); 681 + expect(artistCreditsToNames(cacheTransformed.data.artists)).eql(primaries.concat(secondaries)); 681 682 expect(cacheTransformed.data.playDate.isSame(cachablePlay.data.playDate)); 682 683 }); 683 684
+6 -5
src/backend/tests/lastfm/lastfm.test.ts
··· 9 9 import { loggerDebug } from '@foxxmd/logging'; 10 10 import { configDir, projectDir } from '../../common/index.js'; 11 11 import { LastFMGeo } from 'lastfm-ts-api'; 12 + import { artistNamesToCredits } from '../../../core/StringUtils.js'; 12 13 13 14 chai.use(asPromised); 14 15 15 16 describe('#LFM Scrobble Payload Behavior', function () { 16 17 17 18 it('Should remove VA from album artist', function() { 18 - const play = generatePlay({albumArtists: ['VA']}); 19 + const play = generatePlay({albumArtists: artistNamesToCredits(['VA'])}); 19 20 expect(playToClientPayload(play).albumArtist).to.be.undefined; 20 21 21 - const okPlay = generatePlay({albumArtists: ['My Dude']}); 22 + const okPlay = generatePlay({albumArtists: artistNamesToCredits(['My Dude'])}); 22 23 expect(playToClientPayload(okPlay).albumArtist).eq('My Dude'); 23 24 }); 24 25 }); ··· 55 56 const toArtText = generateLastfmTrackObject(); 56 57 delete toArtText.artist.name; 57 58 expect(toArtText.artist['#text']).to.not.be.undefined; 58 - expect(formatPlayObj(toArtText).data.artists[0]).to.eq(toArtText.artist['#text']); 59 + expect(formatPlayObj(toArtText).data.artists[0].name).to.eq(toArtText.artist['#text']); 59 60 60 61 const toArtTextEmptyNAme = generateLastfmTrackObject(); 61 62 toArtTextEmptyNAme.artist.name = ''; 62 63 expect(toArtTextEmptyNAme.artist['#text']).to.not.be.undefined; 63 - expect(formatPlayObj(toArtTextEmptyNAme).data.artists[0]).to.eq(toArtTextEmptyNAme.artist['#text']); 64 + expect(formatPlayObj(toArtTextEmptyNAme).data.artists[0].name).to.eq(toArtTextEmptyNAme.artist['#text']); 64 65 65 66 const toArtName = generateLastfmTrackObject(); 66 67 delete toArtName.artist['#text']; 67 68 expect(toArtName.artist.name).to.not.be.undefined; 68 - expect(formatPlayObj(toArtName).data.artists[0]).to.eq(toArtName.artist.name); 69 + expect(formatPlayObj(toArtName).data.artists[0].name).to.eq(toArtName.artist.name); 69 70 }); 70 71 71 72 });
+18 -17
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 25 25 import veryWrong from './incorrectlyMapped/veryWrong.json' with { type: "json" }; 26 26 import { generatePlay } from "../../../core/PlayTestUtils.js"; 27 27 import { defaultLifecycle } from "../../utils/PlayTransformUtils.js"; 28 + import { artistCreditsToNames, artistNamesToCredits } from "../../../core/StringUtils.js"; 28 29 29 30 interface LZTestFixture { 30 31 data: ListenResponse ··· 37 38 for(const test of noArtistMapping as unknown as LZTestFixture[]) { 38 39 const play = listenResponseToPlay(test.data); 39 40 assert.equal(play.data.track, test.expected.track); 40 - assert.sameDeepMembers(play.data.artists, test.expected.artists); 41 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 41 42 } 42 43 }); 43 44 ··· 45 46 for(const test of veryWrong as unknown as LZTestFixture[]) { 46 47 const play = listenResponseToPlay(test.data); 47 48 assert.equal(play.data.track, test.expected.track); 48 - assert.sameDeepMembers( play.data.artists, test.expected.artists); 49 + assert.sameDeepMembers( artistCreditsToNames(play.data.artists), test.expected.artists); 49 50 } 50 51 }); 51 52 ··· 53 54 for(const test of incorrectMultiArtistsTrackName as unknown as LZTestFixture[]) { 54 55 const play = listenResponseToPlay(test.data); 55 56 assert.equal(play.data.track, test.expected.track); 56 - assert.sameDeepMembers(play.data.artists, test.expected.artists); 57 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 57 58 } 58 59 }); 59 60 }) ··· 65 66 for(const test of slightlyDifferentNames as unknown as LZTestFixture[]) { 66 67 const play = listenResponseToPlay(test.data); 67 68 assert.equal(play.data.track, test.expected.track); 68 - assert.sameDeepMembers(play.data.artists, test.expected.artists); 69 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 69 70 } 70 71 }); 71 72 ··· 73 74 for(const test of multiMappedArtistsWithSingleUserArtist as unknown as LZTestFixture[]) { 74 75 const play = listenResponseToPlay(test.data); 75 76 assert.equal(play.data.track, test.expected.track); 76 - assert.sameDeepMembers(play.data.artists, test.expected.artists); 77 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 77 78 } 78 79 }); 79 80 ··· 81 82 for(const test of artistWithProperJoiner as unknown as LZTestFixture[]) { 82 83 const play = listenResponseToPlay(test.data); 83 84 assert.equal(play.data.track, test.expected.track); 84 - assert.sameDeepMembers( play.data.artists, test.expected.artists); 85 + assert.sameDeepMembers( artistCreditsToNames(play.data.artists), test.expected.artists); 85 86 } 86 87 }); 87 88 ··· 89 90 for(const test of multiArtistInArtistName as unknown as LZTestFixture[]) { 90 91 const play = listenResponseToPlay(test.data); 91 92 assert.equal(play.data.track, test.expected.track); 92 - assert.sameDeepMembers(play.data.artists, test.expected.artists); 93 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 93 94 } 94 95 }); 95 96 ··· 97 98 for(const test of multiArtistsInTrackName as unknown as LZTestFixture[]) { 98 99 const play = listenResponseToPlay(test.data); 99 100 assert.equal(play.data.track, test.expected.track); 100 - assert.sameDeepMembers( play.data.artists, test.expected.artists); 101 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 101 102 } 102 103 }); 103 104 ··· 105 106 for(const test of normalizedValues as unknown as LZTestFixture[]) { 106 107 const play = listenResponseToPlay(test.data); 107 108 assert.equal(play.data.track, test.expected.track); 108 - assert.sameDeepMembers( play.data.artists, test.expected.artists); 109 + assert.sameDeepMembers(artistCreditsToNames(play.data.artists), test.expected.artists); 109 110 } 110 111 }); 111 112 }); ··· 128 129 async function() { 129 130 const play: PlayObject = { 130 131 data: { 131 - artists: ['Celldweller'], 132 + artists: artistNamesToCredits(['Celldweller']), 132 133 album: 'The Complete Cellout, Volume 01', 133 134 track: 'Frozen', 134 135 duration: 299, ··· 172 173 async function() { 173 174 const play: PlayObject = { 174 175 data: { 175 - artists: ['Celldweller'], 176 + artists: artistNamesToCredits(['Celldweller']), 176 177 album: 'The Complete Cellout, Volume 01', 177 178 track: 'Frozen', 178 179 duration: 299, ··· 202 203 203 204 it('Should combine artist and artist_names', function() { 204 205 205 - const play = generatePlay({artists: ['Artist A'], albumArtists: []}); 206 + const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}); 206 207 const submitPayload = playToListenPayload(play); 207 208 208 209 const additionalArtists = [...submitPayload.track_metadata.additional_info.artist_names, 'Artist B']; ··· 211 212 212 213 const playFromPayload = listenPayloadToPlay(submitPayload); 213 214 214 - expect(playFromPayload.data.artists).to.be.eql(additionalArtists) 215 + expect(artistCreditsToNames(playFromPayload.data.artists)).to.be.eql(additionalArtists) 215 216 216 217 }); 217 218 218 219 it('Should combine artist and artist_names into a unique array', function() { 219 220 220 - const play = generatePlay({artists: ['Artist A'], albumArtists: []}); 221 + const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}); 221 222 const submitPayload = playToListenPayload(play); 222 223 223 224 const additionalArtists = ['Artist A', 'Artist B']; ··· 226 227 227 228 const playFromPayload = listenPayloadToPlay(submitPayload); 228 229 229 - expect(playFromPayload.data.artists).to.be.eql(['Artist A', 'Artist B']) 230 + expect(artistCreditsToNames(playFromPayload.data.artists)).to.be.eql(['Artist A', 'Artist B']) 230 231 231 232 }); 232 233 233 234 it('Should set music_service_name from source', function() { 234 235 235 - const play = generatePlay({artists: ['Artist A'], albumArtists: []}, {source: 'Plex'}); 236 + const play = generatePlay({artists: artistNamesToCredits(['Artist A']), albumArtists: []}, {source: 'Plex'}); 236 237 const submitPayload = playToListenPayload(play); 237 238 238 239 expect(submitPayload.track_metadata.additional_info.music_service_name).to.be.eql('Plex') ··· 243 244 244 245 const playFromPayload = listenPayloadToPlay(submit); 245 246 246 - expect(playFromPayload.data.artists).to.be.eql(submit.track_metadata.additional_info.artist_names); 247 + expect(artistCreditsToNames(playFromPayload.data.artists)).to.be.eql(submit.track_metadata.additional_info.artist_names); 247 248 248 249 }); 249 250
+16 -15
src/backend/tests/musicbrainz/musicbrainz.test.ts
··· 16 16 import { intersect, missingMbidTypes } from '../../utils.js'; 17 17 import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; 18 18 import { CoverArtApiClient, CoverArtApiConfig } from '../../common/vendor/musicbrainz/CoverArtApiClient.js'; 19 + import { artistCreditToName, artistNamesToCredits, artistNameToCredit } from '../../../core/StringUtils.js'; 19 20 20 21 const envPath = path.join(projectDir, '.env'); 21 22 dotenv.config({ path: envPath }); ··· 62 63 const play: PlayObject = { 63 64 data: { 64 65 track: "Little Joe and Mary ii", 65 - artists: ["Khruangbin"], 66 + artists: artistNamesToCredits(["Khruangbin"]), 66 67 album: "The Universe Smiles Upon You ii" 67 68 }, 68 69 meta: { ··· 89 90 track: 'Cyber Space (CrossWorlds Remix): Final Lap (No Chants)', 90 91 album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions", 91 92 artists: [ 92 - "Kanon Oguni" 93 + artistNameToCredit("Kanon Oguni") 93 94 ] 94 95 }, 95 96 meta: { ··· 118 119 const play: PlayObject = { 119 120 data: { 120 121 track: "Fake", 121 - artists: ["Fake"], 122 + artists: artistNamesToCredits(["Fake"]), 122 123 album: "Fake", 123 124 meta: { 124 125 brainz: { ··· 151 152 const play: PlayObject = { 152 153 data: { 153 154 track: "Fake", 154 - artists: ["Fake"], 155 + artists: artistNamesToCredits(["Fake"]), 155 156 album: "Fake", 156 157 isrc: 'GBAHT1600302' 157 158 }, ··· 177 178 const play: PlayObject = { 178 179 data: { 179 180 track: "Berghain", 180 - artists: ["ROSALÍA", "Björk", "Yves Tumor"], 181 - albumArtists: ["ROSALÍA"], 181 + artists: artistNamesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), 182 + albumArtists: artistNamesToCredits(["ROSALÍA"]), 182 183 album: "LUX", 183 184 meta: { 184 185 brainz: { ··· 211 212 const play: PlayObject = { 212 213 data: { 213 214 track: "Berghain", 214 - artists: ["ROSALÍA", "Björk", "Yves Tumor"], 215 - albumArtists: ["ROSALÍA"], 215 + artists: artistNamesToCredits(["ROSALÍA", "Björk", "Yves Tumor"]), 216 + albumArtists: artistNamesToCredits(["ROSALÍA"]), 216 217 album: "LUX", 217 218 meta: { 218 219 brainz: { ··· 246 247 const play: PlayObject = { 247 248 data: { 248 249 track: "Roulette Road (CrossWorlds Remix)", 249 - artists: ["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"], 250 + artists: artistNamesToCredits(["Takahiro Kai, SEGA GAME MUSIC & SEGA SOUND TEAM"]), 250 251 album: "Sonic Racing: CrossWorlds Original Soundtrack - Echoes of Dimensions" 251 252 }, 252 253 meta: { ··· 271 272 const play: PlayObject = { 272 273 data: { 273 274 track: "Undefeatable (feat. Kellin Quinn)", 274 - artists: ["SEGA Sound Team / Tomoya Ohtani"], 275 + artists: artistNamesToCredits(["SEGA Sound Team / Tomoya Ohtani"]), 275 276 }, 276 277 meta: { 277 278 lifecycle: defaultLifecycle() ··· 296 297 const play: PlayObject = { 297 298 data: { 298 299 track: "Bad Apple!! feat.SEKAI", 299 - artists: ["、ナイトコードで。"], 300 + artists: artistNamesToCredits(["、ナイトコードで。"]), 300 301 album: "25時、ナイトコードで。 SEKAI ALBUM Vol.3" 301 302 }, 302 303 meta: { ··· 321 322 const play: PlayObject = { 322 323 data: { 323 324 track: "HIBANA - Reloaded - (feat. 星乃一歌 & Hatsune Miku)", 324 - artists: ["Leo/need"], 325 + artists: artistNamesToCredits(["Leo/need"]), 325 326 album: "Leo / need SEKAI ALBUM Vol.1" 326 327 }, 327 328 meta: { ··· 350 351 const play: PlayObject = { 351 352 data: { 352 353 track: "Price", 353 - artists: ["ATLUS Sound Team"], 354 + artists: artistNamesToCredits(["ATLUS Sound Team"]), 354 355 album: "PERSONA5 ORIGINAL SOUNDTRACK", 355 356 isrc: 'JPK651601515' 356 357 }, ··· 395 396 const play: PlayObject = { 396 397 data: { 397 398 track: "Little Joe and Mary ii", 398 - artists: ["Khruangbin"], 399 + artists: artistNamesToCredits(["Khruangbin"]), 399 400 album: "The Universe Smiles Upon You ii" 400 401 }, 401 402 meta: { ··· 429 430 const play: PlayObject = { 430 431 data: { 431 432 track: "Little Joe and Mary ii", 432 - artists: ["Khruangbin"], 433 + artists: artistNamesToCredits(["Khruangbin"]), 433 434 album: "The Universe Smiles Upon You ii" 434 435 }, 435 436 meta: {
+23 -23
src/backend/tests/plays/mixedDuration.json
··· 2 2 { 3 3 "data": { 4 4 "artists": [ 5 - "Kaidi Tatham" 5 + {"name": "Kaidi Tatham"} 6 6 ], 7 7 "track": "Fricassee", 8 8 "duration": 305, ··· 12 12 { 13 13 "data": { 14 14 "artists": [ 15 - "Kuna Maze" 15 + {"name": "Kuna Maze"} 16 16 ], 17 17 "track": "Jimbó", 18 18 "playDate": "2023-09-20T14:46:00.000Z" ··· 21 21 { 22 22 "data": { 23 23 "artists": [ 24 - "Norman Person", 25 - "Shamek Farrah" 24 + {"name": "Norman Person"}, 25 + {"name": "Shamek Farrah"} 26 26 ], 27 27 "track": "Aisha", 28 28 "duration": 559, ··· 32 32 { 33 33 "data": { 34 34 "artists": [ 35 - "Frédéric Chopin", 36 - "Krystian Zimerman" 35 + {"name": "Frédéric Chopin"}, 36 + {"name": "Krystian Zimerman"} 37 37 ], 38 38 "track": "Ballade No. 4 in F Minor, Op. 52", 39 39 "duration": 575, ··· 43 43 { 44 44 "data": { 45 45 "artists": [ 46 - "Nidia Gongora", 47 - "The Bongo Hop" 46 + {"name": "Nidia Gongora"}, 47 + {"name": "The Bongo Hop"} 48 48 ], 49 49 "track": "Sonora", 50 50 "duration": 327, ··· 55 55 { 56 56 "data": { 57 57 "artists": [ 58 - "Cheo Feliciano" 58 + {"name": "Cheo Feliciano"} 59 59 ], 60 60 "track": "Aprieta (Oye Cómo Va)", 61 61 "playDate": "2023-09-20T15:38:12.000Z" ··· 64 64 { 65 65 "data": { 66 66 "artists": [ 67 - "Pink Floyd" 67 + {"name": "Pink Floyd"} 68 68 ], 69 69 "track": "Another Brick in the Wall, Pt. 1", 70 70 "playDate": "2023-09-20T15:39:12.000Z" ··· 73 73 { 74 74 "data": { 75 75 "artists": [ 76 - "Frédéric Chopin", 77 - "Krystian Zimerman" 76 + {"name": "Frédéric Chopin"}, 77 + {"name": "Krystian Zimerman"} 78 78 ], 79 79 "track": "Ballade No. 1 in G Minor, Op. 23", 80 80 "duration": 575, ··· 85 85 { 86 86 "data": { 87 87 "artists": [ 88 - "A Collection of Boogie", 89 - "Jazz Funk", 90 - "Disco with Mehdi El" 88 + {"name": "A Collection of Boogie"}, 89 + {"name": "Jazz Funk"}, 90 + {"name": "Disco with Mehdi El"} 91 91 ], 92 92 "track": "Aquil", 93 93 "listenedFor": 300, ··· 97 97 { 98 98 "data": { 99 99 "artists": [ 100 - "Outkast" 100 + {"name": "Outkast"} 101 101 ], 102 102 "track": "Da Art of Storytellin' (Pt. 1)", 103 103 "duration": 422, ··· 107 107 { 108 108 "data": { 109 109 "artists": [ 110 - "9th Wonder", 111 - "Cordae", 112 - "Dinner Party", 113 - "Kamasi Washington", 114 - "Phoelix", 115 - "Robert Glasper", 116 - "Terrace Martin" 110 + {"name": "9th Wonder"}, 111 + {"name": "Cordae"}, 112 + {"name": "Dinner Party"}, 113 + {"name": "Kamasi Washington"}, 114 + {"name": "Phoelix"}, 115 + {"name": "Robert Glasper"}, 116 + {"name": "Terrace Martin"} 117 117 ], 118 118 "track": "Freeze Tag", 119 119 "album": "Dinner Party: Dessert",
+16 -16
src/backend/tests/plays/withDuration.json
··· 2 2 { 3 3 "data": { 4 4 "artists": [ 5 - "Kaidi Tatham" 5 + {"name": "Kaidi Tatham"} 6 6 ], 7 7 "track": "Fricassee", 8 8 "duration": 305, ··· 16 16 { 17 17 "data": { 18 18 "artists": [ 19 - "Kuna Maze" 19 + {"name": "Kuna Maze"} 20 20 ], 21 21 "track": "Jimbó", 22 22 "duration": 255, ··· 30 30 { 31 31 "data": { 32 32 "artists": [ 33 - "Norman Person", 34 - "Shamek Farrah" 33 + {"name": "Norman Person"}, 34 + {"name": "Shamek Farrah"} 35 35 ], 36 36 "track": "Aisha", 37 37 "duration": 559, ··· 45 45 { 46 46 "data": { 47 47 "artists": [ 48 - "Frédéric Chopin", 49 - "Krystian Zimerman" 48 + {"name": "Frédéric Chopin"}, 49 + {"name": "Krystian Zimerman"} 50 50 ], 51 51 "track": "Ballade No. 4 in F Minor, Op. 52", 52 52 "duration": 575, ··· 60 60 { 61 61 "data": { 62 62 "artists": [ 63 - "Nidia Gongora", 64 - "The Bongo Hop" 63 + {"name": "Nidia Gongora"}, 64 + {"name": "The Bongo Hop"} 65 65 ], 66 66 "track": "Sonora", 67 67 "duration": 327, ··· 75 75 { 76 76 "data": { 77 77 "artists": [ 78 - "Cheo Feliciano" 78 + {"name": "Cheo Feliciano"} 79 79 ], 80 80 "track": "Aprieta (Oye Cómo Va)", 81 81 "duration": 195, ··· 89 89 { 90 90 "data": { 91 91 "artists": [ 92 - "Pink Floyd" 92 + {"name": "Pink Floyd"} 93 93 ], 94 94 "track": "Another Brick in the Wall, Pt. 1", 95 95 "duration": 192, ··· 103 103 { 104 104 "data": { 105 105 "artists": [ 106 - "Frédéric Chopin", 107 - "Krystian Zimerman" 106 + {"name": "Frédéric Chopin"}, 107 + {"name": "Krystian Zimerman"} 108 108 ], 109 109 "track": "Ballade No. 1 in G Minor, Op. 23", 110 110 "duration": 575, ··· 118 118 { 119 119 "data": { 120 120 "artists": [ 121 - "A Collection of Boogie", 122 - "Jazz Funk", 123 - "Disco with Mehdi El" 121 + {"name": "A Collection of Boogie"}, 122 + {"name": "Jazz Funk"}, 123 + {"name": "Disco with Mehdi El"} 124 124 ], 125 125 "track": "Aquil", 126 126 "duration": 230, ··· 134 134 { 135 135 "data": { 136 136 "artists": [ 137 - "Outkast" 137 + {"name": "Outkast"} 138 138 ], 139 139 "track": "Da Art of Storytellin' (Pt. 1)", 140 140 "duration": 422,
+10 -11
src/backend/tests/scrobbler/scrobblers.test.ts
··· 28 28 import { RepositoryCreatePlayOpts } from '../../common/database/drizzle/repositories/PlayRepository.js'; 29 29 import { fixtureCreatePlay } from '../utils/databaseFixtures.js'; 30 30 import { isAbortError } from 'abort-controller-x'; 31 + import { artistNamesToCredits } from '../../../core/StringUtils.js'; 31 32 32 33 chai.use(asPromised); 33 34 ··· 152 153 testScrobbler.testRecentScrobbles = normalizedWithMixedDur; 153 154 154 155 const uniquePlay = generatePlay({ 155 - artists: [ 156 - "2814" 157 - ], 156 + artists: artistNamesToCredits(["2814"]), 158 157 track: "新宿ゴールデン街", 159 158 duration: 130, 160 159 playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(6, 'minutes') ··· 186 185 187 186 const diffPlay = clone(normalizedWithMixedDur[1]); 188 187 diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's'); 189 - diffPlay.data.artists = ['A Different Artist']; 188 + diffPlay.data.artists = artistNamesToCredits(['A Different Artist']); 190 189 191 190 assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]); 192 191 }); ··· 275 274 it('Is not detected as duplicate when play date matches fuzzy and play source SOT is history', async function () { 276 275 277 276 const play = generatePlay({ 278 - artists: ['Nejad'], 277 + artists: artistNamesToCredits(['Nejad']), 279 278 track: 'CODE', 280 279 album: undefined, 281 280 playDate: dayjs().subtract(179, 's'), ··· 321 320 diffPlay.data.track = ref.data.track.replaceAll(' ', ' '); 322 321 assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); 323 322 324 - diffPlay.data.artists = ref.data.artists.map(x => x.toUpperCase()); 323 + diffPlay.data.artists = ref.data.artists.map(x => ({...x, name: x.name.toUpperCase()})); 325 324 assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); 326 325 327 - diffPlay.data.artists = ref.data.artists.map(x => x.replaceAll(' ', ' ')); 326 + diffPlay.data.artists = ref.data.artists.map(x => ({...x, name: x.name.replaceAll(' ', ' ')})); 328 327 assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]); 329 328 }); 330 329 ··· 411 410 412 411 const spotifyPlay: PlayObject = { 413 412 data: { 414 - artists: [ 413 + artists: artistNamesToCredits([ 415 414 "Terrace Martin", 416 415 "Robert Glasper", 417 416 "9th Wonder", ··· 419 418 "Dinner Party", 420 419 "Cordae", 421 420 "Phoelix" 422 - ], 421 + ]), 423 422 album: "Dinner Party: Dessert", 424 423 track: "Freeze Tag (feat. Cordae & Phoelix)", 425 424 "duration": 191.375, ··· 1060 1059 1061 1060 await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); 1062 1061 1063 - const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); 1062 + const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]); 1064 1063 1065 1064 expect(res).is.not.undefined; 1066 1065 ··· 1068 1067 1069 1068 await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'}); 1070 1069 1071 - const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]); 1070 + const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]); 1072 1071 1073 1072 expect(resUpdate).is.not.undefined; 1074 1073 });
+6 -5
src/backend/tests/source/source.test.ts
··· 19 19 import { sleep } from "../../utils.js"; 20 20 import DeezerInternalSource from "../../sources/DeezerInternalSource.js"; 21 21 import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js"; 22 + import { artistCreditsToNames } from "../../../core/StringUtils.js"; 22 23 23 24 chai.use(asPromised); 24 25 ··· 163 164 const play = SpotifySource.formatPlayObj(noAAPayload as SpotifyApi.CurrentPlaybackResponse); 164 165 expect(play.data.track).eq('The Sandpits Of Zonhoven'); 165 166 expect(play.data.album).eq('Bloodbags And Downtube Shifters'); 166 - expect(play.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); 167 + expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); 167 168 expect(play.data.albumArtists).to.be.empty; 168 169 }); 169 170 ··· 171 172 const play = SpotifySource.formatPlayObj(spotifyPayload as SpotifyApi.CurrentPlaybackResponse); 172 173 expect(play.data.track).eq('The Sandpits Of Zonhoven'); 173 174 expect(play.data.album).eq('Bloodbags And Downtube Shifters'); 174 - expect(play.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); 175 - expect(play.data.albumArtists).eql(['Dubmood']); 175 + expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); 176 + expect(artistCreditsToNames(play.data.albumArtists)).eql(['Dubmood']); 176 177 }); 177 178 178 179 it('Spotify parses payload with identical album artists correctly', function() { ··· 181 182 const identicalArtistsPlay = SpotifySource.formatPlayObj(identicalArtistsPayload as SpotifyApi.CurrentPlaybackResponse); 182 183 expect(identicalArtistsPlay.data.track).eq('The Sandpits Of Zonhoven'); 183 184 expect(identicalArtistsPlay.data.album).eq('Bloodbags And Downtube Shifters'); 184 - expect(identicalArtistsPlay.data.artists).eql(['Dubmood', 'MASTER BOOT RECORD']); 185 - expect(identicalArtistsPlay.data.albumArtists).to.be.empty; 185 + expect(artistCreditsToNames(identicalArtistsPlay.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']); 186 + expect(artistCreditsToNames(identicalArtistsPlay.data.albumArtists)).to.be.empty; 186 187 }); 187 188 }); 188 189
+2 -1
src/backend/tests/tealfm/tealfm.test.ts
··· 4 4 import { generateLastfmTrackObject, generateMbid, generatePlay, generateTealPlayRecord } from "../../../core/PlayTestUtils.js"; 5 5 import { AbstractBlueSkyApiClient, listRecordToPlay } from '../../common/vendor/bluesky/AbstractBlueSkyApiClient.js'; 6 6 import dayjs from 'dayjs'; 7 + import { artistCreditsToNames } from '../../../core/StringUtils.js'; 7 8 8 9 chai.use(asPromised); 9 10 ··· 19 20 expect(play.data.album).eq(rec.value.releaseName); 20 21 expect(play.data.playDate.unix()).eq(dayjs(rec.value.playedTime).unix()); 21 22 expect(play.data.duration).eq(rec.value.duration); 22 - expect(play.data.artists).eql(rec.value.artists.map(x => x.artistName)); 23 + expect(artistCreditsToNames(play.data.artists)).eql(rec.value.artists.map(x => x.artistName)); 23 24 expect(play.meta.user).eq(`did:plc:${did}`); 24 25 expect(play.meta.playId).eq(tid); 25 26 });
+2 -2
src/backend/utils/PlayComparisonUtils.ts
··· 312 312 artists: candidateArtists = [], 313 313 } = {} 314 314 } = candidate; 315 - const normExisting = existingArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); 316 - const candidateExisting = candidateArtists.map(x => normalizeStr(x, {keepSingleWhitespace: true})); 315 + const normExisting = existingArtists.map(x => normalizeStr(x.name, {keepSingleWhitespace: true})); 316 + const candidateExisting = candidateArtists.map(x => normalizeStr(x.name, {keepSingleWhitespace: true})); 317 317 318 318 const wholeMatches = setIntersection(new Set(normExisting), new Set(candidateExisting)).size; 319 319 return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches]
+2 -2
src/backend/utils/PlayTransformUtils.ts
··· 148 148 } 149 149 if(parts.artists !== undefined) { 150 150 // allows user to test if artists are empty 151 - const artists = parts.artists.length === 0 ? [''] : play.data.artists; 152 - if(artists.every(x => !testMaybeRegex(parts.artists, x)[0])) { 151 + const artists = parts.artists.length === 0 ? [{name: ''}] : play.data.artists; 152 + if(artists.every(x => !testMaybeRegex(parts.artists, x.name)[0])) { 153 153 return false; 154 154 } 155 155 }
+1 -1
src/backend/utils/StringUtils.ts
··· 282 282 } 283 283 } = candidate; 284 284 285 - return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr}`, '')).highScore; 285 + return compareNormalizedStrings(existingArtists.reduce((acc, curr) => `${acc} ${curr.name}`, ''), candidateArtists.reduce((acc, curr) => `${acc} ${curr.name}`, '')).highScore; 286 286 } 287 287 288 288 /**
+2 -2
src/client/components/player/Player.tsx
··· 33 33 play: { 34 34 data: { 35 35 track = '???', 36 - artists = ['???'], 36 + artists = [{name: '???'}], 37 37 duration = 0 38 38 } = {}, 39 39 meta: { ··· 95 95 <div className="player__info" style={{position: 'relative', zIndex: 0}}> 96 96 <div className="player-album">{playArt !== undefined ? <img style={{height: '100%', maxWidth: '90px'}} src={playArt}></img> : null}</div> 97 97 <p className="title">{calculated !== 'stopped' ? track : '-'}</p> 98 - <p className="subtitle">{calculated !== 'stopped' ? artists.join(' / ') : '-'}</p> 98 + <p className="subtitle">{calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}</p> 99 99 </div> 100 100 101 101 <PlayerTimestamp duration={duration} indeterminate={(calculated === 'playing' && data.position === undefined) || nowPlayingMode} current={data.position || 0} />
+7 -2
src/core/Atomic.ts
··· 147 147 track?: string 148 148 } 149 149 150 + export interface ArtistCredit { 151 + name: string 152 + mbid?: string 153 + } 154 + 150 155 export interface SpotifyMeta { 151 156 artist?: string[] 152 157 albumArtist?: string[] ··· 160 165 } 161 166 162 167 export interface TrackData { 163 - artists?: string[] 164 - albumArtists?: string[] 168 + artists?: ArtistCredit[] 169 + albumArtists?: ArtistCredit[] 165 170 album?: string 166 171 track?: string 167 172 /**
+2 -2
src/core/PlayTestUtils.ts
··· 9 9 import { genGroupIdStr } from './PlayUtils.js'; 10 10 import { sortByNewestPlayDate } from './PlayUtils.js'; 11 11 import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js'; 12 - import { arrayListAnd } from './StringUtils.js'; 12 + import { arrayListAnd, artistNamesToCredits } from './StringUtils.js'; 13 13 import { findDelimiters } from "./StringUtils.js"; 14 14 import { ListRecord, ScrobbleRecord } from '../backend/common/infrastructure/config/client/tealfm.js'; 15 15 import { nanoid } from 'nanoid'; ··· 175 175 const play: PlayObject = { 176 176 data: { 177 177 track: faker.music.songName(), 178 - artists: faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}}), 178 + artists: artistNamesToCredits(faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}})), 179 179 duration, 180 180 playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})), 181 181 album: faker.music.album(),
+8 -2
src/core/StringUtils.ts
··· 6 6 import utc from "dayjs/plugin/utc.js"; 7 7 import { 8 8 AmbPlayObject, 9 + ArtistCredit, 9 10 PlayData, 10 11 PlayObject, 11 12 SCROBBLE_TS_SOC_END, ··· 108 109 strParts.push(`(${trackId})`); 109 110 } 110 111 if (include.includes('artist')) { 111 - strParts.push(artistsFunc(artists)) 112 + strParts.push(artistsFunc(artistCreditsToNames(artists))) 112 113 } 113 114 if (include.includes('track')) { 114 115 strParts.push(trackFunc(track, playObj, strParts.length > 0)); ··· 306 307 export const containsDelimiters = (str: string) => null !== str.match(/[,&/\\]+/i); 307 308 308 309 const NUMBERS_REGEX = new RegExp(/^\s*\d+\s*$/); 309 - export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str); 310 + export const stringIsOnlyNumbers = (str: string) => NUMBERS_REGEX.test(str); 311 + 312 + export const artistNamesToCredits = (names: string[] | undefined): ArtistCredit[] => names === undefined ? undefined : names.map((x) => ({name: x})); 313 + export const artistNameToCredit = (name: string | undefined): ArtistCredit => name === undefined ? undefined : ({ name }); 314 + export const artistCreditToName = (a: ArtistCredit): string => a.name; 315 + export const artistCreditsToNames = (a: ArtistCredit[]): string[] => a.map((x) => x.name);