mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-10-19 15:26:33 +00:00
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:
parent
4cd9ac92c2
commit
6775533197
11 changed files with 213 additions and 210 deletions
|
|
@ -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 = [],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue