diff --git a/src/backend/common/transforms/MusicbrainzTransformer.ts b/src/backend/common/transforms/MusicbrainzTransformer.ts index 7c5a11df..6c9a7f83 100644 --- a/src/backend/common/transforms/MusicbrainzTransformer.ts +++ b/src/backend/common/transforms/MusicbrainzTransformer.ts @@ -16,7 +16,7 @@ import { DELIMITERS } from '../../../core/Atomic.ts'; import { MaybeLogger } from '../MaybeLogger.ts'; import { childLogger } from "@foxxmd/logging"; import { MusicbrainzApiClientPool, recordingToPlay, type UsingTypes } from "../vendor/musicbrainz/MusicbrainzApiClientPool.ts"; -import type {IArtist, IRecordingList, IRecordingMatch, IRelease} from "musicbrainz-api"; +import type {IRecordingList, IRecordingMatch} from "musicbrainz-api"; import { intersect, missingMbidTypes } from "../../utils.ts"; import { removeUndefinedKeys } from '../../../core/DataUtils.ts'; import { SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../errors/MSErrors.ts"; @@ -606,13 +606,18 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer ({ - ...rankedRecordingToTrackSearchResult(x), - albumCount: (x.releases ??[]).length - })); - - return results; - + return recordings.slice(0, 5).map((x): TrackSearchResult => { + const releaseGroup = x.releases?.[0]?.["release-group"]; + return { + // same credits a transform using this recording would produce + ...recordingToPlay(x).data, + service: 'musicbrainz', + id: x.id, + score: x.score, + albumType: releaseGroup?.["primary-type"] ?? releaseGroup?.["secondary-types"]?.[0], + albumCount: (x.releases ?? []).length + }; + }); } async getArtistResults(query: string): Promise { return []; @@ -622,36 +627,6 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer ({ - service: 'musicbrainz', - name: val.title, - mbidRecording: val.id, - isrc: val.isrcs !== undefined ? val.isrcs[0] : undefined, - id: val.id, - score: val.score, - album: val.releases !== undefined ? releaseToAlbumSearchResult(val.releases[0]) : undefined, - artists: val["artist-credit"] !== undefined ? val["artist-credit"].map((x) => artistToArtistSearchResult(x.artist)) : undefined, - duration: val.length -}); - -const releaseToAlbumSearchResult = (val: IRelease): AlbumSearchResult => ({ - service: 'musicbrainz', - name: val.title, - id: val.id, - mbidRelease: val.id, - mbidReleaseGroup: val["release-group"]?.id, - type: val["release-group"]?.["primary-type"] ?? val["release-group"]?.["secondary-types"]?.[0], - artists: val["release-group"]?.["artist-credit"] !== undefined ? val["release-group"]?.["artist-credit"].map((x) => artistToArtistSearchResult(x.artist)) : undefined -}); - -const artistToArtistSearchResult = (val: IArtist): ArtistSearchResult => ({ - service: 'musicbrainz', - name: val.name, - id: val.id, - mbid: val.id -}); - - export const filterByValidReleaseStatus = (list: T, stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => { const { releaseStatusAllow = [], diff --git a/src/backend/tests/utils/invariants.test.ts b/src/backend/tests/utils/invariants.test.ts index d79f3bd0..d4443671 100644 --- a/src/backend/tests/utils/invariants.test.ts +++ b/src/backend/tests/utils/invariants.test.ts @@ -3,7 +3,9 @@ import { expect } from 'chai'; import clone from 'clone'; import type { PlayObject } from '../../../core/Atomic.ts'; import { generateArtistCredits, generatePlay, withBrainz } from '../../../core/tests/utils/PlayTestUtils.ts'; -import { spotifyMeta, stripCredits, withImage, withMetadata } from '../../../core/MusicMetadata.ts'; +import { dedupAlbumArtists, spotifyMeta, stripCredits, withImage, withMetadata } from '../../../core/MusicMetadata.ts'; +import { albumSearchResultSchema, artistSearchResultSchema, trackSearchResultSchema } from '../../../core/Api.ts'; +import { generateAlbumSearchResult, generateArtistSearchResult, generateTrackSearchResult } from '../../../core/tests/utils/apiFixtures.ts'; import { nameToCredit } from "../../../core/MusicMetadata.ts"; import { metaInvariantTransform, @@ -93,3 +95,32 @@ describe('#Play Invariant Transforms', function () { }); }); }); + +describe('#Album Artists Dedup', function () { + + const artists = [nameToCredit('Artist A'), nameToCredit('Artist B')]; + + it('removes album artists that are all track artists', function () { + expect(dedupAlbumArtists({ artists, albumArtists: [nameToCredit(' artist a')] })).to.not.have.property('albumArtists'); + expect(dedupAlbumArtists({ artists, albumArtists: [...artists] })).to.not.have.property('albumArtists'); + }); + + it('removes empty album artists', function () { + expect(dedupAlbumArtists({ artists, albumArtists: [] })).to.not.have.property('albumArtists'); + }); + + it('keeps album artists when any is not a track artist', function () { + const albumArtists = [nameToCredit('Artist A'), nameToCredit('Various Artists')]; + expect(dedupAlbumArtists({ artists, albumArtists }).albumArtists).to.eql(albumArtists); + expect(dedupAlbumArtists({ albumArtists }).albumArtists).to.eql(albumArtists); + }); +}); + +describe('#Metadata Search Results', function () { + + it('are Credit and TrackData shapes', function () { + expect(() => trackSearchResultSchema.parse(generateTrackSearchResult())).to.not.throw(); + expect(() => albumSearchResultSchema.parse(generateAlbumSearchResult())).to.not.throw(); + expect(() => artistSearchResultSchema.parse(generateArtistSearchResult())).to.not.throw(); + }); +}); diff --git a/src/client/components/play/AlbumSearch.tsx b/src/client/components/play/AlbumSearch.tsx index 827ce2c6..6449b6df 100644 --- a/src/client/components/play/AlbumSearch.tsx +++ b/src/client/components/play/AlbumSearch.tsx @@ -2,11 +2,11 @@ import { Box, useListCollection, Stack, Text, HStack } from "@chakra-ui/react" import { useDebouncedState } from '@tanstack/react-pacer' import { useQuery } from '@tanstack/react-query'; import { tanQueries } from "../../queries/index.ts"; -import { albumSearchResultToCredit, artistSearchResultToCredit, type AlbumSearchResult } from "../../../core/Api.ts"; +import { type AlbumSearchResult } from "../../../core/Api.ts"; import React, { useCallback, useEffect, useState } from "react"; import { ArtistCreditTags } from "../ArtistCreditDisplay.tsx"; import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.tsx"; -import type { Credit } from "../../../core/Atomic.ts"; +import { creditSchema, type Credit } from "../../../core/Atomic.ts"; import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.tsx"; const albumPartials: MetadataPartials = { @@ -17,13 +17,14 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: const { name, - type, + albumType, + metadata = [], artists = [], } = props.data; let artistTags: React.JSX.Element | undefined = undefined; if(artists.length > 0) { - artistTags = + artistTags = } return ( @@ -34,7 +35,7 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: - {name}{type !== undefined ? ({type}) : undefined} + {name}{albumType !== undefined ? ({albumType}) : undefined} @@ -44,18 +45,10 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: ) } -/** Play data to change when an album is selected. Artists are only included if the selected album has them. */ +/** Play data to change when an album is selected. Album artists are always replaced, even when the selected album has none, so artists from a previous album are not kept. */ export interface AlbumOnChange { album: Credit - artists?: Credit[] -} - -const albumSearchResultToOnChange = (val: AlbumSearchResult): AlbumOnChange => { - const change: AlbumOnChange = { album: albumSearchResultToCredit(val) }; - if (val.artists !== undefined && val.artists.length > 0) { - change.artists = val.artists.map(artistSearchResultToCredit); - } - return change; + albumArtists: Credit[] | undefined } export interface AlbumSearchProps { @@ -90,9 +83,11 @@ export const AlbumSearch = (props: AlbumSearchProps) => { } }, [query, set]) - const doChange = useCallback((val: AlbumSearchResult) => { - setSelectedItem(albumSearchResultToCredit(val)); - onChange(albumSearchResultToOnChange(val)); + const doChange = useCallback((val: Credit & Pick) => { + // drops the search-only properties of a result + const album = creditSchema.parse(val); + setSelectedItem(album); + onChange({ album, albumArtists: val.artists }); },[setSelectedItem, onChange]); const services = selectedItem.metadata ?? []; @@ -111,6 +106,7 @@ export const AlbumSearch = (props: AlbumSearchProps) => { initialInput={selectedItem?.name} isError={query.isError} onChange={doChange} + onFreetext={(name) => doChange({ name })} onQueryChange={setDebouncedQuery} renderItem={(item, onPick) => } /> diff --git a/src/client/components/play/ArtistSearch.tsx b/src/client/components/play/ArtistSearch.tsx index 09066044..5ee39f5a 100644 --- a/src/client/components/play/ArtistSearch.tsx +++ b/src/client/components/play/ArtistSearch.tsx @@ -2,19 +2,19 @@ import { useListCollection, Stack, Text, HStack } from "@chakra-ui/react" import { useDebouncedState } from '@tanstack/react-pacer' import { useQuery } from '@tanstack/react-query'; import { tanQueries } from "../../queries/index.js"; -import { artistSearchResultToCredit, type ArtistSearchResult } from "../../../core/Api.js"; +import { type ArtistSearchResult } from "../../../core/Api.js"; import { useCallback, useEffect, useState } from "react"; import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.js"; -import { type Credit } from "../../../core/Atomic.js"; +import { creditSchema, type Credit } from "../../../core/Atomic.js"; import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.js"; const artistPartials: MetadataPartials = { - name: { label: 'Name only', pick: ({ mbid, ...rest }) => rest }, + name: { label: 'Name only', pick: ({ image, metadata, ...rest }) => rest }, }; export const ArtistSearchResultItem = (props: { data: ArtistSearchResult, onPick?: (val: ArtistSearchResult) => void }) => { - const { name } = props.data; + const { name, metadata = [] } = props.data; return ( @@ -22,7 +22,7 @@ export const ArtistSearchResultItem = (props: { data: ArtistSearchResult, onPick - {name} + {name} @@ -62,8 +62,9 @@ export const ArtistSearch = (props: ArtistSearchProps) => { } }, [query, set]) - const doChange = useCallback((val: ArtistSearchResult) => { - const credit = artistSearchResultToCredit(val); + const doChange = useCallback((val: Credit) => { + // drops the search-only properties of a result + const credit = creditSchema.parse(val); setSelectedItem(credit); onChange(credit); },[setSelectedItem, onChange]); @@ -83,6 +84,7 @@ export const ArtistSearch = (props: ArtistSearchProps) => { isLoading={query.isLoading} isError={query.isError} onChange={doChange} + onFreetext={(name) => doChange({ name })} onQueryChange={setDebouncedQuery} renderItem={(item, onPick) => } /> diff --git a/src/client/components/play/MetadataResults.tsx b/src/client/components/play/MetadataResults.tsx index 5487d395..908a40c8 100644 --- a/src/client/components/play/MetadataResults.tsx +++ b/src/client/components/play/MetadataResults.tsx @@ -75,22 +75,21 @@ export interface MetadataSearchComboboxProps { inputGroupContent?: React.JSX.Element initialInput?: string onChange: (val: T) => void + /** Called with the typed text when the user commits it without selecting a result */ + onFreetext: (name: string) => void onQueryChange: (query: string) => void renderItem: (item: T, onPick: (val: T) => void) => React.JSX.Element } export const MetadataSearchCombobox = (props: MetadataSearchComboboxProps) => { - const { placeholder, collection, isLoading, isError, onChange, onQueryChange, renderItem, initialInput = '' } = props; + const { placeholder, collection, isLoading, isError, onChange, onFreetext, onQueryChange, renderItem, initialInput = '' } = props; // a ref, not state: combobox captures onInteractOutside when the popup opens so state read there is stale const rawInput = useRef(initialInput === '' ? undefined : initialInput); - // every other property on a search result is optional - const freetext = (name: string) => ({ id: 'nonce', service: 'user', name }) as T; - const commitFreetext = () => { if (rawInput.current !== undefined && rawInput.current !== initialInput) { - onChange(freetext(rawInput.current)); + onFreetext(rawInput.current); } }; @@ -162,7 +161,7 @@ export const MetadataSearchCombobox = (props: Me {renderItem(item, (val) => { onChange(val); rawInput.current = undefined; - combobox.setInputValue(val.name, 'item-select'); + combobox.setInputValue(collection.stringifyItem(val) ?? '', 'item-select'); combobox.setOpen(false); })} diff --git a/src/client/components/play/PlayEdit.tsx b/src/client/components/play/PlayEdit.tsx index acc76bad..9e71b757 100644 --- a/src/client/components/play/PlayEdit.tsx +++ b/src/client/components/play/PlayEdit.tsx @@ -10,6 +10,7 @@ import { AlbumSearch } from "./AlbumSearch.js"; import { DurationSepEditable } from "./DurationEditable.js"; import { Tooltip } from "../ToggleTip.js"; import { hashObject } from "../../../core/StringUtils.js"; +import { dedupAlbumArtists } from "../../../core/MusicMetadata.js"; import { LuCalendar } from "react-icons/lu"; import { parseAbsoluteToLocal, today, getLocalTimeZone, toZoned, DateFormatter } from '@internationalized/date'; @@ -80,18 +81,71 @@ export const PlayEdit = (props: PlayEditProps) => { }], }); - // ArrayField only re-renders on length/_arrayVersion change, so remount artists when TrackSearch replaces them + // ArrayField only re-renders on length/_arrayVersion change, so remount artists and album artists when a search replaces them const [artistsVersion, setArtistsVersion] = useState(0); const form = useForm({ ...opts, onSubmit: ({ schemaOutputs }) => { - onSubmit(schemaOutputs[0]); + const [play] = schemaOutputs; + onSubmit({ ...play, data: dedupAlbumArtists(play.data) }); //props.setOpen(false); } }); + // track artists and album artists are edited the same way, album artists can be empty + const artistsField = (name: 'data.artists' | 'data.albumArtists', legend: string, minRows: number) => ( + + {legend} + + + {(array) => ( + + + {(array.value ?? []).map((artist, i) => + ( + 0}> + + + { + field.handleChange(val); + }} /> + + setArtistsVersion(v => v + 1)} /> + {i >= minRows ? array.removeValue(i)} /> : undefined} + + + {field.errors.map((error) => ( + + {error.message} + + ))} + + + )} + />)} + 0}> + {array.errors.map((error) => ( + + {error.message} + + ))} + + + + )} + + + + ); + return ( @@ -144,54 +198,7 @@ export const PlayEdit = (props: PlayEditProps) => { )} /> - - Artists - - - {(array) => ( - - - {array.value.map((artist, i) => - ( - 0}> - - - { - field.handleChange(val); - }} /> - - setArtistsVersion(v => v + 1)} /> - {i !== 0 ? array.removeValue(i)} /> : undefined} - - - {field.errors.map((error) => ( - - {error.message} - - ))} - - - )} - />)} - 0}> - {array.errors.map((error) => ( - - {error.message} - - ))} - - - - )} - - - + {artistsField('data.artists', 'Artists', 1)} { )} /> + {artistsField('data.albumArtists', 'Album Artists', 0)} = { - track: { label: 'Track + duration only', pick: ({ artists, album, albumCount, ...rest }) => rest }, - artists: { label: 'Track + duration + artists', pick: ({ album, albumCount, ...rest }) => rest }, + track: { label: 'Track + duration only', pick: ({ artists, album, albumArtists, albumType, albumCount, ...rest }) => rest }, + artists: { label: 'Track + duration + artists', pick: ({ album, albumArtists, albumType, albumCount, ...rest }) => rest }, album: { label: 'Track + duration + album', pick: ({ artists, ...rest }) => rest }, }; @@ -21,9 +22,10 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: const { onPick } = props; const { + track, album, + albumType, albumCount, - name, duration, artists = [] } = props.data; @@ -37,24 +39,24 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: ) : undefined; albumContent = ( - {album.name} {album.type !== undefined ? `(${album.type})` : ''} {andCount} + {album.name} {albumType !== undefined ? `(${albumType})` : ''} {andCount} ) } let artistTags: React.JSX.Element | undefined = undefined; if (artists.length > 0) { - artistTags = artistSearchResultToCredit(x))} /> + artistTags = } return ( - + - {name} - + {track?.name} + {duration !== undefined ? ({timeToHumanTimestamp(duration * 1000)}) : undefined} @@ -67,30 +69,18 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?: } /** Play data to change when a track is selected. Artists and album are only included if the selected track has them. */ -export interface TrackOnChange { - track: Credit - artists?: Credit[] - album?: Credit - duration?: number -} +export type TrackOnChange = Pick; export interface TrackSearchProps { initial?: Credit onChange: (val: TrackOnChange) => void } -const trackSearchResultToOnChange = (val: TrackSearchResult): TrackOnChange => { - const change: TrackOnChange = { track: trackSearchResultToCredit(val) }; - if (val.artists !== undefined && val.artists.length > 0) { - change.artists = val.artists.map((x) => artistSearchResultToCredit(x)); - } - if (val.album !== undefined) { - change.album = albumSearchResultToCredit(val.album); - } - if(val.duration !== undefined) { - change.duration = val.duration; - } - return change; +const trackSearchResultToOnChange = (val: TrackOnChange): TrackOnChange => { + // drops the search-only properties of a result + const { meta, ...change } = playTrackDataSchema.parse(val); + // an album always replaces album artists, even when it has none, so artists from a previous album are not kept + return change.album !== undefined ? { ...change, albumArtists: change.albumArtists } : change; } export const TrackSearch = (props: TrackSearchProps) => { @@ -110,7 +100,7 @@ export const TrackSearch = (props: TrackSearchProps) => { const { collection, set } = useListCollection({ initialItems: query.data?.data ?? [], - itemToString: (item) => item.name, + itemToString: (item) => item.track?.name ?? '', itemToValue: (item) => item.id, }); @@ -120,9 +110,10 @@ export const TrackSearch = (props: TrackSearchProps) => { } }, [query, set]) - const doChange = useCallback((val: TrackSearchResult) => { - setSelectedItem(trackSearchResultToCredit(val)); - onChange(trackSearchResultToOnChange(val)); + const doChange = useCallback((val: TrackOnChange) => { + const change = trackSearchResultToOnChange(val); + setSelectedItem(change.track ?? {name: ''}); + onChange(change); },[setSelectedItem, onChange]); const services = selectedItem.metadata ?? []; @@ -140,6 +131,7 @@ export const TrackSearch = (props: TrackSearchProps) => { isError={query.isError} initialInput={selectedItem?.name} onChange={doChange} + onFreetext={(name) => doChange({ track: { name } })} onQueryChange={setDebouncedQuery} renderItem={(item, onPick) => } /> diff --git a/src/core/Api.ts b/src/core/Api.ts index fc13e0e2..a34109fc 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -1,13 +1,11 @@ import type { CompareOpKey, ComponentMinimalSelect } from "../backend/common/database/drizzle/drizzleTypes.ts" -import { mbidSchema, type Credit, type ClientType, type ComponentAuthType, type DeepReplaceValue, type MonitoringStatus, type QueueContext } from "./Atomic.ts" +import { creditSchema, playTrackDataSchema, type ClientType, type ComponentAuthType, type DeepReplaceValue, type MonitoringStatus, type QueueContext } from "./Atomic.ts" import type { SourceType } from "./Atomic.ts" import type { ComponentType, DateLike, ErrorLike, JsonPlayObject, PlayState, QueueName, SOURCE_SOT_TYPES, SourcePlayerJson } from "./Atomic.ts" import type { Dayjs } from "dayjs" import type { ErrorIsh } from "./ErrorUtils.ts" import type { PlayEvent } from "./PlayEvent.ts" import * as z from "zod" -import { mbMeta, spotifyMeta, withImage } from "./MusicMetadata.ts" -import { nameToCredit } from "./MusicMetadata.ts" export interface PlayApiCommon { uid: string @@ -244,8 +242,7 @@ export const playStateBodySchema = z.object({ export type PlayStateBody = z.infer; export const metadataResultBaseSchema = z.object({ - id: z.string(), - name: z.string() + id: z.string() }); export type MetadataResultBase = z.infer; @@ -260,12 +257,16 @@ export const metadataResultImageSchema = z.object({ }) export type MetadataResultImage = z.infer; +export const metadataResultAlbumTypeSchema = z.object({ + /** The kind of release the album is (album, single, ep...) */ + albumType: z.string().optional() +}) + +/** A Credit for an artist with the service it was found on */ export const artistSearchResultSchema = z.object({ - mbid: mbidSchema.optional(), - spotifyId: z.string().optional(), - ...metadataResultServiceScoreSchema.shape, + ...creditSchema.shape, ...metadataResultBaseSchema.shape, - ...metadataResultImageSchema.shape, + ...metadataResultServiceScoreSchema.shape, }); export type ArtistSearchResult = z.infer; @@ -274,15 +275,13 @@ export const artistSearchResultResponseSchema = z.object({ }); export type ArtistSearchResultResponse = z.infer; +/** A Credit for an album with the service it was found on */ export const albumSearchResultSchema = z.object({ - ...metadataResultServiceScoreSchema.shape, + ...creditSchema.shape, ...metadataResultBaseSchema.shape, - ...metadataResultImageSchema.shape, - mbidRelease: z.string().optional(), - mbidReleaseGroup: z.string().optional(), - spotifyId: z.string().optional(), - type: z.string().optional(), - artists: artistSearchResultSchema.array().optional(), + ...metadataResultServiceScoreSchema.shape, + ...metadataResultAlbumTypeSchema.shape, + artists: creditSchema.array().optional(), }); export type AlbumSearchResult = z.infer; @@ -291,18 +290,14 @@ export const albumSearchResultResponseSchema = z.object({ }); export type AlbumSearchResultResponse = z.infer; +/** TrackData with the service it was found on */ export const trackSearchResultSchema = z.object({ - ...metadataResultServiceScoreSchema.shape, + ...playTrackDataSchema.shape, ...metadataResultBaseSchema.shape, - ...metadataResultImageSchema.shape, - mbidRecording: z.string().optional(), - mbidTrack: z.string().optional(), - isrc: z.string().optional(), - spotifyId: z.string().optional(), - artists: artistSearchResultSchema.array().optional(), - album: albumSearchResultSchema.optional(), - albumCount: z.int().positive().optional(), - duration: z.int().positive().optional() + ...metadataResultServiceScoreSchema.shape, + ...metadataResultAlbumTypeSchema.shape, + /** Number of albums the track was found on */ + albumCount: z.int().nonnegative().optional(), }); export type TrackSearchResult = z.infer; @@ -310,12 +305,3 @@ export const trackSearchResultResponseSchema = z.object({ data: trackSearchResultSchema.array() }); export type TrackSearchResultResponse = z.infer; - -export const artistSearchResultToCredit = (val: Pick): Credit => - withImage(nameToCredit(val.name, mbMeta(val.mbid, 'artist'), spotifyMeta(val.spotifyId, 'artist')), val.image); - -export const albumSearchResultToCredit = (val: Pick): Credit => - withImage(nameToCredit(val.name, mbMeta(val.mbidRelease, 'release'), mbMeta(val.mbidReleaseGroup, 'release-group'), spotifyMeta(val.spotifyId, 'album')), val.image); - -export const trackSearchResultToCredit = (val: Pick): Credit => - withImage(nameToCredit(val.name, mbMeta(val.mbidTrack, 'track'), mbMeta(val.mbidRecording, 'recording'), spotifyMeta(val.spotifyId, 'track')), val.image); diff --git a/src/core/MusicMetadata.ts b/src/core/MusicMetadata.ts index b7564b0c..ee1e7270 100644 --- a/src/core/MusicMetadata.ts +++ b/src/core/MusicMetadata.ts @@ -230,6 +230,17 @@ export const playImage = (data: Pick, return order.map(x => images[x]).find(x => x !== undefined); } +/** Remove albumArtists when every album artist is also a track artist (by name) so identical credits are not kept in both lists */ +export const dedupAlbumArtists = >(data: T): T => { + const { albumArtists, ...rest } = data; + if (albumArtists === undefined) { + return data; + } + const clean = (x: Credit) => x.name.trim().toLocaleLowerCase(); + const names = (data.artists ?? []).map(clean); + return albumArtists.every(x => names.includes(clean(x))) ? rest as T : data; +} + /** Credit with only the name -- no image or metadata */ export const stripCredit = (credit: Credit): Credit => ({ name: credit.name }); diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 3c381c93..4c12f9df 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -1,11 +1,11 @@ import { faker } from "@faker-js/faker"; import type {AlbumSearchResult, ArtistSearchResult, ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentHistoricalApi, ComponentSourceApi, ComponentSourceApiJson, ComponentState, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi, TrackSearchResult} from "../../Api.ts"; -import { INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes, type MBID } from "../../Atomic.ts"; -import { generateArtist, generateMbid, generatePlay, normalizePlays } from "./PlayTestUtils.ts"; +import { INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes, type MBID, type Credit } from "../../Atomic.ts"; +import { generateArtist, generateArtistCredits, generateMbid, generatePlay, normalizePlays } from "./PlayTestUtils.ts"; import { generatePlayInput, generatePlayWithLifecycle, playWithLifecycleScrobble, randomPlayState } from "./fixtures.ts"; import { asJsonPlayObject } from "../../PlayMarshalUtils.ts"; import { generatePlayUid } from "../../StringUtils.ts"; -import { withAlbumArt } from "../../MusicMetadata.ts"; +import { mbMeta, nameToCredit, spotifyMeta, withAlbumArt, withImage } from "../../MusicMetadata.ts"; import dayjs, { type Dayjs } from "dayjs"; import { isSourceType } from "../../Atomic.ts"; import { sourceTypes } from "../../Atomic.ts"; @@ -514,9 +514,7 @@ export const generateArtistSearchResult = (partial: Partial id: faker.string.alphanumeric(7), score: faker.number.int({min: 10, max: 100}), service: faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']), - image: placeholderImage([300]), - name: faker.music.artist(), - mbid: opts.mbidVal === false ? undefined : (typeof opts.mbidVal === 'string' ? opts.mbidVal : generateMbid()), + ...withImage(nameToCredit(faker.music.artist(), mbMeta(opts.mbidVal === false ? undefined : (typeof opts.mbidVal === 'string' ? opts.mbidVal : generateMbid()), 'artist')), placeholderImage([300])), ...partial }) @@ -536,17 +534,20 @@ export const generateArtistSearchResults = (opts: {query?: string, count?: numbe return results; } +const generateAlbumCredit = (): Credit => withImage(nameToCredit( + faker.music.album(), + mbMeta(faker.helpers.arrayElement([generateMbid(), undefined]), 'release'), + mbMeta(faker.helpers.arrayElement([generateMbid(), undefined]), 'release-group'), + spotifyMeta(faker.helpers.arrayElement([faker.string.alphanumeric(4), undefined]), 'album') +), placeholderImage([300])); + export const generateAlbumSearchResult = (partial: Partial = {}): AlbumSearchResult => ({ id: faker.string.alphanumeric(7), score: faker.number.int({min: 10, max: 100}), service: faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']), - image: placeholderImage([300]), - name: faker.music.album(), - mbidRelease: faker.helpers.arrayElement([generateMbid(), undefined]), - mbidReleaseGroup: faker.helpers.arrayElement([generateMbid(), undefined]), - spotifyId: faker.helpers.arrayElement([faker.string.alphanumeric(4), undefined]), - type: faker.helpers.arrayElement(['single','album','live','compilation',undefined]), - artists: generateArtistSearchResults({count: faker.number.int({min: 1, max: 3})}), + ...generateAlbumCredit(), + albumType: faker.helpers.arrayElement(['single','album','live','compilation',undefined]), + artists: generateArtistCredits(undefined, 3, {mbidVal: true}), ...partial }); @@ -570,15 +571,18 @@ export const generateTrackSearchResult = (partial: Partial = id: faker.string.alphanumeric(7), score: faker.number.int({min: 10, max: 100}), service: faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']), - image: placeholderImage([300]), - name: faker.music.album(), - mbidRecording: faker.helpers.arrayElement([generateMbid(), undefined]), - mbidTrack: faker.helpers.arrayElement([generateMbid(), undefined]), - spotifyId: faker.helpers.arrayElement([faker.string.alphanumeric(4), undefined]), - album: generateAlbumSearchResult(), + track: nameToCredit( + faker.music.songName(), + mbMeta(faker.helpers.arrayElement([generateMbid(), undefined]), 'recording'), + mbMeta(faker.helpers.arrayElement([generateMbid(), undefined]), 'track'), + spotifyMeta(faker.helpers.arrayElement([faker.string.alphanumeric(4), undefined]), 'track') + ), + album: generateAlbumCredit(), + albumType: faker.helpers.arrayElement(['single','album','live','compilation',undefined]), + albumArtists: faker.helpers.arrayElement([generateArtistCredits(1, 1, {mbidVal: true}), undefined]), duration: faker.number.int({min: 10, max: 305}), albumCount: faker.number.int({min: 1, max: 15}), - artists: generateArtistSearchResults({count: faker.number.int({min: 1, max: 3})}), + artists: generateArtistCredits(undefined, 3, {mbidVal: true}), ...partial }); @@ -589,7 +593,7 @@ export const generateTrackSearchResults = (opts: {query?: string, count?: number } const c = opts.count ?? faker.number.int({min: 2, max: 5}); if(opts.query !== undefined) { - results.push(generateTrackSearchResult({name: opts.query})) + results.push(generateTrackSearchResult({track: {name: opts.query}})) } while(results.length !== c) { results.push(generateTrackSearchResult()) diff --git a/src/stories/plays/TrackSearch.stories.tsx b/src/stories/plays/TrackSearch.stories.tsx index cf32bfd8..1696a124 100644 --- a/src/stories/plays/TrackSearch.stories.tsx +++ b/src/stories/plays/TrackSearch.stories.tsx @@ -6,7 +6,6 @@ import { Container } from '@chakra-ui/react'; import { http, HttpResponse } from 'msw'; import { generateTrackSearchResults } from "../../core/tests/utils/apiFixtures.js"; import { TrackSearch } from "../../client/components/play/TrackSearch.js"; -import type { TrackSearchResult } from "../../core/Api.js"; type PropsAndCustomArgs = React.ComponentProps; @@ -32,7 +31,7 @@ const meta = preview.type<{ args: PropsAndCustomArgs }>().meta({ ], args: { initial: undefined, - onSubmit: (val: TrackSearchResult) => console.log(val) + onChange: (val) => console.log(val) }, // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args });