refactor: Simplify metadata results

Replace independent metadata *result shapes with extended-Credit shapes

* Removes need for api boundary, and literally everywhere else, conversion between shapes
* Refactor shapes in apifixtures, usage in MetadataResults, and field/form
* Fix album result modifying artists when it should have been albumArtists
* Implement albumArtist form fields while I'm at it
This commit is contained in:
FoxxMD 2026-10-07 15:44:44 +00:00
parent 4cd9ac92c2
commit 6775533197
11 changed files with 213 additions and 210 deletions

View file

@ -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<Exter
}
const recordings = await this.handlePostFetchRecordings(surrogatePlay, res, {type: 'musicbrainz', ...this.defaults});
const results = recordings.slice(0, 5).map((x) => ({
...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<ArtistSearchResult[] | false> {
return [];
@ -622,36 +627,6 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
}
}
const rankedRecordingToTrackSearchResult = (val: RecordingRankedMatched): TrackSearchResult => ({
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 = <T extends IRecordingMatch[]>(list: T, stageConfig: MusicbrainzTransformerDataStage, logger: MaybeLogger = new MaybeLogger()) => {
const {
releaseStatusAllow = [],

View file

@ -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();
});
});

View file

@ -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<AlbumSearchResult> = {
@ -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 = <ArtistCreditTags data={artists.map(artistSearchResultToCredit)} />
artistTags = <ArtistCreditTags data={artists} />
}
return (
@ -34,7 +35,7 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?:
<Stack gap="1" flexGrow="1">
<Text fontWeight="medium">
<HStack gap="1">
{name}{type !== undefined ? <Box>({type})</Box> : undefined}<MusicServiceIndicators services={albumSearchResultToCredit(props.data).metadata ?? []}/>
{name}{albumType !== undefined ? <Box>({albumType})</Box> : undefined}<MusicServiceIndicators services={metadata}/>
<MetadataPickMenu data={props.data} partials={albumPartials} onPick={props.onPick} />
</HStack>
</Text>
@ -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<AlbumSearchResult, 'artists'>) => {
// 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) => <AlbumSearchResultItem data={item} onPick={onPick} />}
/>

View file

@ -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<ArtistSearchResult> = {
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 (
<HStack gap="4" flexGrow="1">
@ -22,7 +22,7 @@ export const ArtistSearchResultItem = (props: { data: ArtistSearchResult, onPick
<Stack gap="1" flexGrow="1">
<Text fontWeight="medium">
<HStack>
{name} <MusicServiceIndicators services={artistSearchResultToCredit(props.data).metadata ?? []}/> <MetadataPickMenu data={props.data} partials={artistPartials} onPick={props.onPick} />
{name} <MusicServiceIndicators services={metadata}/> <MetadataPickMenu data={props.data} partials={artistPartials} onPick={props.onPick} />
</HStack>
</Text>
</Stack>
@ -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) => <ArtistSearchResultItem data={item} onPick={onPick} />}
/>

View file

@ -75,22 +75,21 @@ export interface MetadataSearchComboboxProps<T extends MetadataSearchResult> {
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 = <T extends MetadataSearchResult>(props: MetadataSearchComboboxProps<T>) => {
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<string | undefined>(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 = <T extends MetadataSearchResult>(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);
})}
<Combobox.ItemIndicator />

View file

@ -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) => (
<Fieldset.Root size="lg">
<Fieldset.Legend>{legend}</Fieldset.Legend>
<Fieldset.Content>
<form.ArrayField key={artistsVersion} name={name}>
{(array) => (
<Stack>
{(array.value ?? []).map((artist, i) =>
<form.Field
key={i}
name={`${name}[${i}]`}
// schema issues are reported on descendants (.name), route them to this field
errorBoundary
children={(field) => (
<Field.Root invalid={field.errors.length > 0}>
<Stack width="100%" flexGrow="1">
<HStack width="100%" flexGrow="1">
<ArtistSearch initial={field.value}
onChange={(val) => {
field.handleChange(val);
}} />
<HStack gapX="4">
<ResetButton field={field} onClickAdditional={() => setArtistsVersion(v => v + 1)} />
{i >= minRows ? <TrashIconButton colorPalette="red" onClick={() => array.removeValue(i)} /> : undefined}
</HStack>
</HStack>
{field.errors.map((error) => (
<Field.ErrorText key={error.message}>
{error.message}
</Field.ErrorText>
))}
</Stack>
</Field.Root>
)}
/>)}
<Field.Root invalid={array.errors.length > 0}>
{array.errors.map((error) => (
<Field.ErrorText key={error.message}>
{error.message}
</Field.ErrorText>
))}
</Field.Root>
<Button variant="subtle" maxW="400px" onClick={() => array.pushValue({ name: '' })}>Add {minRows === 0 ? 'Album Artist' : 'Artist'}</Button>
</Stack>
)}
</form.ArrayField>
</Fieldset.Content>
</Fieldset.Root>
);
return (
<Box position="relative">
@ -144,54 +198,7 @@ export const PlayEdit = (props: PlayEditProps) => {
</Field.Root>
)}
/>
<Fieldset.Root size="lg">
<Fieldset.Legend>Artists</Fieldset.Legend>
<Fieldset.Content>
<form.ArrayField key={artistsVersion} name="data.artists">
{(array) => (
<Stack>
{array.value.map((artist, i) =>
<form.Field
key={i}
name={`data.artists[${i}]`}
// schema issues are reported on descendants (.name), route them to this field
errorBoundary
children={(field) => (
<Field.Root invalid={field.errors.length > 0}>
<Stack width="100%" flexGrow="1">
<HStack width="100%" flexGrow="1">
<ArtistSearch initial={field.value}
onChange={(val) => {
field.handleChange(val);
}} />
<HStack gapX="4">
<ResetButton field={field} onClickAdditional={() => setArtistsVersion(v => v + 1)} />
{i !== 0 ? <TrashIconButton colorPalette="red" onClick={() => array.removeValue(i)} /> : undefined}
</HStack>
</HStack>
{field.errors.map((error) => (
<Field.ErrorText key={error.message}>
{error.message}
</Field.ErrorText>
))}
</Stack>
</Field.Root>
)}
/>)}
<Field.Root invalid={array.errors.length > 0}>
{array.errors.map((error) => (
<Field.ErrorText key={error.message}>
{error.message}
</Field.ErrorText>
))}
</Field.Root>
<Button variant="subtle" maxW="400px" onClick={() => array.pushValue({ name: '' })}>Add Artist</Button>
</Stack>
)}
</form.ArrayField>
</Fieldset.Content>
</Fieldset.Root>
{artistsField('data.artists', 'Artists', 1)}
<form.Field
name="data.album"
errorBoundary
@ -218,6 +225,7 @@ export const PlayEdit = (props: PlayEditProps) => {
</Field.Root>
)}
/>
{artistsField('data.albumArtists', 'Album Artists', 0)}
<HStack wrap="wrap" gap="5">
<form.Field
name="data.duration"

View file

@ -2,17 +2,18 @@ import { useListCollection, Stack, Text, HStack, Span } from "@chakra-ui/react"
import { useDebouncedState } from '@tanstack/react-pacer'
import { useQuery } from '@tanstack/react-query';
import { tanQueries } from "../../queries/index.js";
import { albumSearchResultToCredit, artistSearchResultToCredit, trackSearchResultToCredit, type TrackSearchResult } from "../../../core/Api.js";
import { type TrackSearchResult } from "../../../core/Api.js";
import React, { useCallback, useEffect, useState } from "react";
import { ArtistCreditTags } from "../ArtistCreditDisplay.js";
import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.js";
import type { Credit } from "../../../core/Atomic.js";
import { playTrackDataSchema, type Credit, type TrackData } from "../../../core/Atomic.js";
import { playImage } from "../../../core/MusicMetadata.js";
import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.js";
import { timeToHumanTimestamp } from "../../../core/TimeUtils.js";
const trackPartials: MetadataPartials<TrackSearchResult> = {
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 = (<Text color="fg.muted" textStyle="sm">
<HStack>
{album.name} {album.type !== undefined ? `(${album.type})` : ''}<MusicServiceIndicators services={albumSearchResultToCredit(album).metadata ?? []}/> {andCount}
{album.name} {albumType !== undefined ? `(${albumType})` : ''}<MusicServiceIndicators services={album.metadata ?? []}/> {andCount}
</HStack>
</Text>)
}
let artistTags: React.JSX.Element | undefined = undefined;
if (artists.length > 0) {
artistTags = <ArtistCreditTags data={artists.map((x) => artistSearchResultToCredit(x))} />
artistTags = <ArtistCreditTags data={artists} />
}
return (
<HStack gap="4" flexGrow="1">
<LeftSideMetadataResultContent {...props.data} />
<LeftSideMetadataResultContent {...props.data} image={playImage(props.data)} />
<Stack gap="1" flexGrow="1">
<Text fontWeight="medium" mb="1">
<HStack>
{name}
<MusicServiceIndicators services={trackSearchResultToCredit(props.data).metadata ?? []}/>
{track?.name}
<MusicServiceIndicators services={track?.metadata ?? []}/>
{duration !== undefined ? <Span>({timeToHumanTimestamp(duration * 1000)})</Span> : undefined}
<MetadataPickMenu data={props.data} partials={trackPartials} onPick={onPick} />
</HStack>
@ -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<TrackData, 'track' | 'artists' | 'album' | 'albumArtists' | 'duration' | 'isrc'>;
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<TrackSearchResult>({
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) => <TrackSearchResultItem data={item} onPick={onPick} />}
/>

View file

@ -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<typeof playStateBodySchema>;
export const metadataResultBaseSchema = z.object({
id: z.string(),
name: z.string()
id: z.string()
});
export type MetadataResultBase = z.infer<typeof metadataResultBaseSchema>;
@ -260,12 +257,16 @@ export const metadataResultImageSchema = z.object({
})
export type MetadataResultImage = z.infer<typeof metadataResultImageSchema>;
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<typeof artistSearchResultSchema>;
@ -274,15 +275,13 @@ export const artistSearchResultResponseSchema = z.object({
});
export type ArtistSearchResultResponse = z.infer<typeof artistSearchResultResponseSchema>;
/** 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<typeof albumSearchResultSchema>;
@ -291,18 +290,14 @@ export const albumSearchResultResponseSchema = z.object({
});
export type AlbumSearchResultResponse = z.infer<typeof albumSearchResultResponseSchema>;
/** 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<typeof trackSearchResultSchema>;
@ -310,12 +305,3 @@ export const trackSearchResultResponseSchema = z.object({
data: trackSearchResultSchema.array()
});
export type TrackSearchResultResponse = z.infer<typeof trackSearchResultResponseSchema>;
export const artistSearchResultToCredit = (val: Pick<ArtistSearchResult, 'name' | 'mbid' | 'spotifyId' | 'image'>): Credit =>
withImage(nameToCredit(val.name, mbMeta(val.mbid, 'artist'), spotifyMeta(val.spotifyId, 'artist')), val.image);
export const albumSearchResultToCredit = (val: Pick<AlbumSearchResult, 'name' | 'mbidRelease' | 'mbidReleaseGroup' | 'spotifyId' | 'image'>): 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<TrackSearchResult, 'name' | 'mbidTrack' | 'mbidRecording' | 'spotifyId' | 'image'>): Credit =>
withImage(nameToCredit(val.name, mbMeta(val.mbidTrack, 'track'), mbMeta(val.mbidRecording, 'recording'), spotifyMeta(val.spotifyId, 'track')), val.image);

View file

@ -230,6 +230,17 @@ export const playImage = (data: Pick<TrackData, 'track' | 'album' | 'artists'>,
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 = <T extends Pick<TrackData, 'artists' | 'albumArtists'>>(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 });

View file

@ -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<ArtistSearchResult>
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> = {}): 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<TrackSearchResult> =
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())

View file

@ -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<typeof TrackSearch>;
@ -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
});