mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-10-19 15:26:33 +00:00
feat: refactor nested album/art entities and add country/date to search rendering
This commit is contained in:
parent
6dc0a5d7ab
commit
ae8604ebba
12 changed files with 195 additions and 69 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -200,6 +200,7 @@
|
|||
"supertest": "^7.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
"ts-react-emoji-flag": "^1.1.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
|
|
@ -19504,6 +19505,17 @@
|
|||
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-react-emoji-flag": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-react-emoji-flag/-/ts-react-emoji-flag-1.1.0.tgz",
|
||||
"integrity": "sha512-8Me/38Zw2WnJzKDF3HoTFEbGp/JM41Hgc9nC9WMuGlfWAODZyT7iMLSjJmHQOsTIPgap3FW+znFQXzUTzzTQLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"supertest": "^7.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
"ts-react-emoji-flag": "^1.1.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AlbumSearchResult, ArtistSearchResult, TrackSearchResult } from "../../../core/Api.ts";
|
||||
import type { AlbumSearchResult, ArtistSearchResult, MetadataResultServiceScore, TrackSearchResult } from "../../../core/Api.ts";
|
||||
import type { Credit, TrackData } from "../../../core/Atomic.ts";
|
||||
import type { ErrorIsh } from "../../../core/ErrorUtils.ts";
|
||||
|
||||
export interface MetadataProvider {
|
||||
|
|
@ -15,4 +16,24 @@ export interface AggregateMetadataResponse<T extends (TrackSearchResult | Artist
|
|||
export const asMetadataProvider = (val: object): val is MetadataProvider =>
|
||||
`getTrackResults` in val && typeof val.getTrackResults === 'function'
|
||||
&& `getArtistResults` in val && typeof val.getArtistResults === 'function'
|
||||
&& `getAlbumResults` in val && typeof val.getAlbumResults === 'function'
|
||||
&& `getAlbumResults` in val && typeof val.getAlbumResults === 'function'
|
||||
|
||||
/** A Credit as a result nested in another result: found on the same service as its parent and identified by the first service id it has, or its name if it has none */
|
||||
export const creditToResult = (credit: Credit, service: string): ArtistSearchResult => ({
|
||||
...credit,
|
||||
service,
|
||||
id: credit.metadata?.[0]?.id ?? credit.name
|
||||
});
|
||||
|
||||
/** Build a track result from TrackData, nesting its artists and album as results from the same service */
|
||||
export const trackDataToResult = (
|
||||
data: TrackData,
|
||||
result: Pick<TrackSearchResult, 'id' | 'service' | 'score' | 'albumCount'>,
|
||||
album: Omit<Partial<AlbumSearchResult>, keyof ArtistSearchResult> & Partial<Pick<MetadataResultServiceScore, 'score'>> = {}
|
||||
): TrackSearchResult => ({
|
||||
...data,
|
||||
...result,
|
||||
artists: data.artists?.map(x => creditToResult(x, result.service)),
|
||||
albumArtists: data.albumArtists?.map(x => creditToResult(x, result.service)),
|
||||
album: data.album === undefined ? undefined : { ...creditToResult(data.album, result.service), ...album }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { nativeParse } from "./NativeTransformer.ts";
|
|||
import { comparePlayArtistsNormalized, scoreTrackWeightedAndNormalized } from "../../utils/PlayComparisonUtils.ts";
|
||||
import type { MusicbrainzTransformerConfig, MusicbrainzTransformerData, SearchType } from "./musicbrainz/MusicbrainzTransformerUtil.ts";
|
||||
import { maybeStringLowerArrayFromString } from "../../utils/ZodUtils.ts";
|
||||
import type { MetadataProvider } from "../metadataProviders/MetadataProviderUtils.ts";
|
||||
import { creditToResult, trackDataToResult, type MetadataProvider } from "../metadataProviders/MetadataProviderUtils.ts";
|
||||
import type { TrackSearchResult, ArtistSearchResult, AlbumSearchResult } from "../../../core/Api.ts";
|
||||
|
||||
export const asMissingMbid = (str: string): MissingMbidType => {
|
||||
|
|
@ -607,17 +607,21 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
|||
const recordings = await this.handlePostFetchRecordings(surrogatePlay, res, {type: 'musicbrainz', ...this.defaults});
|
||||
|
||||
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,
|
||||
const release = x.releases?.[0];
|
||||
const releaseGroup = release?.["release-group"];
|
||||
// same credits a transform using this recording would produce
|
||||
return trackDataToResult(recordingToPlay(x).data, {
|
||||
service: 'musicbrainz',
|
||||
id: x.id,
|
||||
score: x.score,
|
||||
albumCount: (x.releases ?? []).length
|
||||
}, {
|
||||
albumType: releaseGroup?.["primary-type"] ?? releaseGroup?.["secondary-types"]?.[0],
|
||||
albumTypeHint: x.disambiguation,
|
||||
albumCount: (x.releases ?? []).length
|
||||
};
|
||||
country: release?.country,
|
||||
date: release?.date,
|
||||
score: x.rankScore
|
||||
});
|
||||
});
|
||||
}
|
||||
async getArtistResults(query: string): Promise<ArtistSearchResult[] | false> {
|
||||
|
|
@ -662,7 +666,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
|
|||
if(x["artist-credit"] !== undefined) {
|
||||
result.artists = x["artist-credit"]
|
||||
.filter(y => this.defaults.ignoreVA === false || y.name !== 'Various Artists')
|
||||
.map((y) => nameToCredit(y.name, mbMeta(y.artist.id, 'artist')))
|
||||
.map((y) => creditToResult(nameToCredit(y.name, mbMeta(y.artist.id, 'artist')), 'musicbrainz'))
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
type SpotifyTransformerConfig,
|
||||
type SpotifyTransformerData } from "./spotify/SpotifyTransformerUtil.ts";
|
||||
import type { AlbumSearchResult, ArtistSearchResult, TrackSearchResult } from "../../../core/Api.ts";
|
||||
import { x } from "tinyexec";
|
||||
import { creditToResult, trackDataToResult } from "../metadataProviders/MetadataProviderUtils.ts";
|
||||
import { compareNormalizedStrings } from "../../../core/StringUtils.ts";
|
||||
|
||||
/** How much to subtract from a candidate's match score when it belongs to a compilation album and deprioritizeCompilations is enabled */
|
||||
|
|
@ -350,15 +350,16 @@ export default class SpotifyTransformer extends AtomicPartsTransformer<ExternalM
|
|||
if(filtered.length === 0) {
|
||||
return []
|
||||
}
|
||||
const results = filtered.slice(0, 5).map((x) => ({
|
||||
const results = filtered.slice(0, 5).map((x) => trackDataToResult(trackToPlay(x.track).data, {
|
||||
service: 'spotify',
|
||||
score: x.matchScore * 100,
|
||||
albumCount: 1,
|
||||
albumType: x.track.album.album_type,
|
||||
...trackToPlay(x.track).data,
|
||||
id: x.track.id.toString(),
|
||||
}, {
|
||||
albumType: x.track.album.album_type,
|
||||
date: x.track.album.release_date
|
||||
}));
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
results.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
return results;
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +379,6 @@ export default class SpotifyTransformer extends AtomicPartsTransformer<ExternalM
|
|||
return results;
|
||||
}
|
||||
async getAlbumResults(query: string): Promise<AlbumSearchResult[] | false> {
|
||||
const surrogateArtist: Credit = {name: query};
|
||||
const scoreThreshold = this.defaults.score ?? 0.6;
|
||||
const res = await this.api.searchAlbums({album: {name: query}}, this.defaults);
|
||||
const ranked = res.map((x) => {
|
||||
|
|
@ -391,7 +391,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer<ExternalM
|
|||
...withImage(nameToCredit(x.name, spotifyMeta(x.id, 'album')), chooseImageByResolution(x.images, {fallbackBest: true}).url),
|
||||
albumType: x.album_type,
|
||||
date: x.release_date,
|
||||
artists: (x.artists ?? []).length === 0 ? undefined : x.artists.map((y) => nameToCredit(y.name, spotifyMeta(y.id, 'artist'))),
|
||||
artists: (x.artists ?? []).length === 0 ? undefined : x.artists.map((y) => creditToResult(nameToCredit(y.name, spotifyMeta(y.id, 'artist')), 'spotify')),
|
||||
score: x.score,
|
||||
service: 'spotify',
|
||||
id: x.id
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { type AlbumViewBasic, type ArtistViewBasic, RockskyError, type SongMatch
|
|||
import { RockskyClientPool } from "../../vendor/rocksky/RockskyClientWrapped.ts";
|
||||
import type { RockskyTransformerConfig, RockskyTransformerData } from "../../vendor/rocksky/interfaces.ts";
|
||||
import { DEFAULT_ROCKSKY_SEARCH_ORDER, type SearchType, searchType } from "./RockskyTransformerUtil.ts";
|
||||
import type { MetadataProvider } from "../../metadataProviders/MetadataProviderUtils.ts";
|
||||
import { creditToResult, trackDataToResult, type MetadataProvider } from "../../metadataProviders/MetadataProviderUtils.ts";
|
||||
import type { AlbumSearchResult, ArtistSearchResult, TrackSearchResult } from "../../../../core/Api.ts";
|
||||
|
||||
export const DEFAULT_SEARCHTYPE_ORDER: SearchType[] = ['isrc','basic'];
|
||||
|
|
@ -469,12 +469,11 @@ export default class RockskyTransformer extends AtomicPartsTransformer<ExternalM
|
|||
const view: SongViewDetailedMS = x === res.matches?.[0]
|
||||
? mergeSongMatch(res, x)
|
||||
: {requestQuery: query, title: x.title, artist: x.artist, album: x.album, albumArt: x.albumArt, isrc: x.isrc, duration: x.durationMs};
|
||||
return {
|
||||
...songViewToPlay(view).data,
|
||||
return trackDataToResult(songViewToPlay(view).data, {
|
||||
service: 'rocksky',
|
||||
id: String(x.id ?? index),
|
||||
score: Math.round(x.score ?? 0)
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -493,7 +492,7 @@ export default class RockskyTransformer extends AtomicPartsTransformer<ExternalM
|
|||
return hits.map((x): AlbumSearchResult => ({
|
||||
...withImage(nameToCredit(x.title as string, serviceMeta('rocksky', x.id, 'album')), x.albumArt),
|
||||
date: x.releaseDate ?? x.year?.toString(),
|
||||
artists: x.artist === undefined ? undefined : [nameToCredit(x.artist)],
|
||||
artists: x.artist === undefined ? undefined : [creditToResult(nameToCredit(x.artist), 'rocksky')],
|
||||
score: x.score,
|
||||
service: 'rocksky',
|
||||
id: x.id as string
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import clone from 'clone';
|
|||
import type { PlayObject } from '../../../core/Atomic.ts';
|
||||
import { generateArtistCredits, generatePlay, withBrainz } from '../../../core/tests/utils/PlayTestUtils.ts';
|
||||
import { dedupAlbumArtists, spotifyMeta, stripCredits, withImage, withMetadata } from '../../../core/MusicMetadata.ts';
|
||||
import { trackDataToResult } from '../../common/metadataProviders/MetadataProviderUtils.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";
|
||||
|
|
@ -123,4 +124,17 @@ describe('#Metadata Search Results', function () {
|
|||
expect(() => albumSearchResultSchema.parse(generateAlbumSearchResult())).to.not.throw();
|
||||
expect(() => artistSearchResultSchema.parse(generateArtistSearchResult())).to.not.throw();
|
||||
});
|
||||
|
||||
it('nests artists and album as results from the same service', function () {
|
||||
const artist = nameToCredit('Artist A', spotifyMeta('ar1', 'artist'));
|
||||
const result = trackDataToResult(
|
||||
{ track: nameToCredit('Track'), artists: [artist, nameToCredit('Artist B')], album: nameToCredit('Album', spotifyMeta('al1', 'album')) },
|
||||
{ id: 't1', service: 'spotify', score: 90, albumCount: 1 },
|
||||
{ albumType: 'single' }
|
||||
);
|
||||
expect(result.artists).to.eql([{ ...artist, service: 'spotify', id: 'ar1' }, { name: 'Artist B', service: 'spotify', id: 'Artist B' }]);
|
||||
expect(result.album).to.include({ name: 'Album', service: 'spotify', id: 'al1', albumType: 'single' });
|
||||
expect(result).to.not.have.property('albumType');
|
||||
expect(() => trackSearchResultSchema.parse(result)).to.not.throw();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
36
src/client/components/Country.tsx
Normal file
36
src/client/components/Country.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type React from "react";
|
||||
import TsCountryFlag from "ts-react-emoji-flag";
|
||||
|
||||
const userLocale =
|
||||
navigator.languages && navigator.languages.length
|
||||
? navigator.languages[0]
|
||||
: navigator.language;
|
||||
|
||||
const getCountryNamesIntl = new Intl.DisplayNames([userLocale ?? 'en'], { type: 'region' });
|
||||
|
||||
export const getCountryName = (iso2: string) => {
|
||||
const name = getCountryNamesIntl.of(iso2);
|
||||
if (name !== undefined) {
|
||||
return name;
|
||||
}
|
||||
return iso2;
|
||||
}
|
||||
|
||||
export const CountryFlag = (props: {
|
||||
iso: string,
|
||||
tooltip?: true | string | ((country: string) => string)
|
||||
}) => {
|
||||
const isoToUse = props.iso === 'XW' ? 'UN' : props.iso;
|
||||
|
||||
let content: string | undefined = undefined;;
|
||||
if (props.tooltip !== undefined) {
|
||||
if (props.tooltip === true) {
|
||||
content = getCountryName(props.iso);
|
||||
} else if (typeof props.tooltip === 'function') {
|
||||
content = props.tooltip(getCountryName(props.iso))
|
||||
} else {
|
||||
content = props.tooltip;
|
||||
}
|
||||
}
|
||||
return <TsCountryFlag countryCode={isoToUse} title={content}/>;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Box, useListCollection, Stack, Text, HStack } from "@chakra-ui/react"
|
||||
import { Box, useListCollection, Stack, Text, HStack, Badge, Span } from "@chakra-ui/react"
|
||||
import { useDebouncedState } from '@tanstack/react-pacer'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { tanQueries } from "../../queries/index.ts";
|
||||
|
|
@ -8,6 +8,8 @@ import { ArtistCreditTags } from "../ArtistCreditDisplay.tsx";
|
|||
import { LeftSideMetadataResultContent, MetadataPickMenu, MetadataSearchCombobox, type MetadataPartials } from "./MetadataResults.tsx";
|
||||
import { creditSchema, type Credit } from "../../../core/Atomic.ts";
|
||||
import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.tsx";
|
||||
import { CountryFlag } from "../Country.tsx";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const albumPartials: MetadataPartials<AlbumSearchResult> = {
|
||||
album: { label: 'Album only', pick: ({ artists, ...rest }) => rest },
|
||||
|
|
@ -21,6 +23,8 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?:
|
|||
albumTypeHint,
|
||||
metadata = [],
|
||||
artists = [],
|
||||
country,
|
||||
date,
|
||||
} = props.data;
|
||||
const albumHints: string[] = [];
|
||||
if(albumType !== undefined) {
|
||||
|
|
@ -35,6 +39,14 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?:
|
|||
artistTags = <ArtistCreditTags data={artists} />
|
||||
}
|
||||
|
||||
const countryElm: React.JSX.Element | undefined = country !== undefined ? <CountryFlag iso={country} tooltip={(val) => `Released in ${val}`}/> : undefined;
|
||||
let locationDateInfo: React.JSX.Element | undefined = undefined;
|
||||
if(countryElm !== undefined && date === undefined) {
|
||||
locationDateInfo = countryElm;
|
||||
} else if(countryElm !== undefined && date !== undefined) {
|
||||
locationDateInfo = <Badge variant="outline">{countryElm}<Span>{dayjs(date).format('YYYY')}</Span></Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap="4" flexGrow="1">
|
||||
<Stack>
|
||||
|
|
@ -43,7 +55,7 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?:
|
|||
<Stack gap="1" flexGrow="1">
|
||||
<Text fontWeight="medium">
|
||||
<HStack gap="1">
|
||||
{name}{albumHints.length > 0? <Box>({albumHints.join(' -- ')})</Box> : undefined}<MusicServiceIndicators services={metadata}/>
|
||||
{name}{albumHints.length > 0? <Box>({albumHints.join(' -- ')})</Box> : undefined}{locationDateInfo}<MusicServiceIndicators services={metadata}/>
|
||||
<MetadataPickMenu data={props.data} partials={albumPartials} onPick={props.onPick} />
|
||||
</HStack>
|
||||
</Text>
|
||||
|
|
@ -91,11 +103,11 @@ export const AlbumSearch = (props: AlbumSearchProps) => {
|
|||
}
|
||||
}, [query, set])
|
||||
|
||||
const doChange = useCallback((val: Credit & Pick<AlbumSearchResult, 'artists'>) => {
|
||||
const doChange = useCallback((val: Credit & { artists?: Credit[] }) => {
|
||||
// drops the search-only properties of a result
|
||||
const album = creditSchema.parse(val);
|
||||
setSelectedItem(album);
|
||||
onChange({ album, albumArtists: val.artists });
|
||||
onChange({ album, albumArtists: creditSchema.array().optional().parse(val.artists) });
|
||||
},[setSelectedItem, onChange]);
|
||||
|
||||
const services = selectedItem.metadata ?? [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useListCollection, Stack, Text, HStack, Span } from "@chakra-ui/react"
|
||||
import { useListCollection, Stack, Text, HStack, Span, Badge } from "@chakra-ui/react"
|
||||
import { useDebouncedState } from '@tanstack/react-pacer'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { tanQueries } from "../../queries/index.js";
|
||||
|
|
@ -10,10 +10,12 @@ import { playTrackDataSchema, type Credit, type TrackData } from "../../../core/
|
|||
import { playImage } from "../../../core/MusicMetadata.js";
|
||||
import { MusicServiceIndicators } from "../musicServices/MusicServiceIndicators.js";
|
||||
import { timeToHumanTimestamp } from "../../../core/TimeUtils.js";
|
||||
import { CountryFlag } from "../Country.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const trackPartials: MetadataPartials<TrackSearchResult> = {
|
||||
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 },
|
||||
track: { label: 'Track + duration only', pick: ({ artists, album, albumArtists, albumCount, ...rest }) => rest },
|
||||
artists: { label: 'Track + duration + artists', pick: ({ album, albumArtists, albumCount, ...rest }) => rest },
|
||||
album: { label: 'Track + duration + album', pick: ({ artists, ...rest }) => rest },
|
||||
};
|
||||
|
||||
|
|
@ -24,8 +26,6 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?:
|
|||
const {
|
||||
track,
|
||||
album,
|
||||
albumType,
|
||||
albumTypeHint,
|
||||
albumCount,
|
||||
duration,
|
||||
artists = []
|
||||
|
|
@ -33,6 +33,22 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?:
|
|||
|
||||
let albumContent: React.JSX.Element | undefined = undefined;
|
||||
if (album !== undefined) {
|
||||
const {
|
||||
albumType,
|
||||
albumTypeHint,
|
||||
country,
|
||||
date,
|
||||
name: albumName,
|
||||
metadata: albumMetadata
|
||||
} = album;
|
||||
|
||||
const countryElm: React.JSX.Element | undefined = country !== undefined ? <CountryFlag iso={country} tooltip={(val) => `Released in ${val}`}/> : undefined;
|
||||
let locationDateInfo: React.JSX.Element | undefined = undefined;
|
||||
if(countryElm !== undefined && date === undefined) {
|
||||
locationDateInfo = countryElm;
|
||||
} else if(countryElm !== undefined && date !== undefined) {
|
||||
locationDateInfo = <Badge variant="outline">{countryElm}<Span>{dayjs(date).format('YYYY')}</Span></Badge>
|
||||
}
|
||||
const albumHints: string[] = [];
|
||||
if(albumType !== undefined) {
|
||||
albumHints.push(albumType);
|
||||
|
|
@ -42,12 +58,12 @@ export const TrackSearchResultItem = (props: { data: TrackSearchResult, onPick?:
|
|||
}
|
||||
const andCount = albumCount !== undefined && albumCount > 1 ? (
|
||||
<Text color="fg.subtle" textStyle="sm">
|
||||
and {albumCount} more...
|
||||
and {albumCount} more albums...
|
||||
</Text>
|
||||
) : undefined;
|
||||
albumContent = (<Text color="fg.muted" textStyle="sm">
|
||||
<HStack>
|
||||
{album.name} {albumHints.length > 0 ? `(${albumHints.join(' -- ')})` : ''}<MusicServiceIndicators services={album.metadata ?? []}/> {andCount}
|
||||
{albumName} {albumHints.length > 0 ? `(${albumHints.join(' -- ')})` : ''}{locationDateInfo}<MusicServiceIndicators services={album.metadata ?? []}/> {andCount}
|
||||
</HStack>
|
||||
</Text>)
|
||||
}
|
||||
|
|
@ -85,7 +101,7 @@ export interface TrackSearchProps {
|
|||
}
|
||||
|
||||
const trackSearchResultToOnChange = (val: TrackOnChange): TrackOnChange => {
|
||||
// drops the search-only properties of a result
|
||||
// drops the search-only properties of a result and of the results nested in it
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ export const albumSearchResultSchema = z.object({
|
|||
...metadataResultAlbumTypeSchema.shape,
|
||||
date: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
artists: creditSchema.array().optional(),
|
||||
artists: artistSearchResultSchema.array().optional(),
|
||||
});
|
||||
export type AlbumSearchResult = z.infer<typeof albumSearchResultSchema>;
|
||||
|
||||
|
|
@ -293,12 +293,14 @@ export const albumSearchResultResponseSchema = z.object({
|
|||
});
|
||||
export type AlbumSearchResultResponse = z.infer<typeof albumSearchResultResponseSchema>;
|
||||
|
||||
/** TrackData with the service it was found on */
|
||||
/** TrackData with the service it was found on. Artists and album are results from the same service. */
|
||||
export const trackSearchResultSchema = z.object({
|
||||
...playTrackDataSchema.shape,
|
||||
...metadataResultBaseSchema.shape,
|
||||
...metadataResultServiceScoreSchema.shape,
|
||||
...metadataResultAlbumTypeSchema.shape,
|
||||
artists: artistSearchResultSchema.array().optional(),
|
||||
albumArtists: artistSearchResultSchema.array().optional(),
|
||||
album: albumSearchResultSchema.optional(),
|
||||
/** Number of albums the track was found on */
|
||||
albumCount: z.int().nonnegative().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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, type Credit } from "../../Atomic.ts";
|
||||
import { generateArtist, generateArtistCredits, generateMbid, generatePlay, normalizePlays } from "./PlayTestUtils.ts";
|
||||
import { generateArtist, generateMbid, generatePlay, normalizePlays } from "./PlayTestUtils.ts";
|
||||
import { generatePlayInput, generatePlayWithLifecycle, playWithLifecycleScrobble, randomPlayState } from "./fixtures.ts";
|
||||
import { asJsonPlayObject } from "../../PlayMarshalUtils.ts";
|
||||
import { generatePlayUid } from "../../StringUtils.ts";
|
||||
|
|
@ -534,6 +534,10 @@ export const generateArtistSearchResults = (opts: {query?: string, count?: numbe
|
|||
return results;
|
||||
}
|
||||
|
||||
/** Artists as they appear nested in an album or track result, without a score */
|
||||
const generateNestedArtistResults = (max: number, service: string): ArtistSearchResult[] =>
|
||||
generateArtistSearchResults({count: faker.number.int({min: 1, max})}).map((x) => ({...x, service, score: undefined}));
|
||||
|
||||
const generateAlbumCredit = (): Credit => withImage(nameToCredit(
|
||||
faker.music.album(),
|
||||
mbMeta(faker.helpers.arrayElement([generateMbid(), undefined]), 'release'),
|
||||
|
|
@ -541,18 +545,21 @@ const generateAlbumCredit = (): Credit => withImage(nameToCredit(
|
|||
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']),
|
||||
...generateAlbumCredit(),
|
||||
albumType: faker.helpers.arrayElement(['single','album','live','compilation',undefined]),
|
||||
albumTypeHint: faker.helpers.arrayElement(['remastered', undefined]),
|
||||
artists: generateArtistCredits(undefined, 3, {mbidVal: true}),
|
||||
country: faker.location.countryCode(),
|
||||
date: dayjs(faker.date.past({years: 10})).toISOString(),
|
||||
...partial
|
||||
});
|
||||
export const generateAlbumSearchResult = (partial: Partial<AlbumSearchResult> = {}): AlbumSearchResult => {
|
||||
const service = partial.service ?? faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']);
|
||||
return {
|
||||
id: faker.string.alphanumeric(7),
|
||||
score: faker.number.int({min: 10, max: 100}),
|
||||
service,
|
||||
...generateAlbumCredit(),
|
||||
albumType: faker.helpers.arrayElement(['single','album','live','compilation',undefined]),
|
||||
albumTypeHint: faker.helpers.arrayElement(['remastered', undefined]),
|
||||
artists: generateNestedArtistResults(3, service),
|
||||
country: faker.location.countryCode(),
|
||||
date: dayjs(faker.date.past({years: 10})).toISOString(),
|
||||
...partial
|
||||
};
|
||||
};
|
||||
|
||||
export const generateAlbumSearchResults = (opts: {query?: string, count?: number} = {}): AlbumSearchResult[] => {
|
||||
const results: AlbumSearchResult[] = [];
|
||||
|
|
@ -570,25 +577,27 @@ export const generateAlbumSearchResults = (opts: {query?: string, count?: number
|
|||
return results;
|
||||
}
|
||||
|
||||
export const generateTrackSearchResult = (partial: Partial<TrackSearchResult> = {}): TrackSearchResult => ({
|
||||
id: faker.string.alphanumeric(7),
|
||||
score: faker.number.int({min: 10, max: 100}),
|
||||
service: faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']),
|
||||
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]),
|
||||
albumTypeHint: faker.helpers.arrayElement(['remastered', undefined]),
|
||||
duration: faker.number.int({min: 10, max: 305}),
|
||||
albumCount: faker.number.int({min: 1, max: 15}),
|
||||
artists: generateArtistCredits(undefined, 3, {mbidVal: true}),
|
||||
...partial
|
||||
});
|
||||
export const generateTrackSearchResult = (partial: Partial<TrackSearchResult> = {}): TrackSearchResult => {
|
||||
const service = partial.service ?? faker.helpers.arrayElement(['spotify','musicbrainz','rocksky']);
|
||||
return {
|
||||
id: faker.string.alphanumeric(7),
|
||||
score: faker.number.int({min: 10, max: 100}),
|
||||
service,
|
||||
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')
|
||||
),
|
||||
// nested results have no score, a nested album has no artists of its own
|
||||
album: generateAlbumSearchResult({service, score: undefined, artists: undefined}),
|
||||
albumArtists: faker.helpers.arrayElement([generateNestedArtistResults(1, service), undefined]),
|
||||
duration: faker.number.int({min: 10, max: 305}),
|
||||
albumCount: faker.number.int({min: 1, max: 15}),
|
||||
artists: generateNestedArtistResults(3, service),
|
||||
...partial
|
||||
};
|
||||
};
|
||||
|
||||
export const generateTrackSearchResults = (opts: {query?: string, count?: number} = {}): TrackSearchResult[] => {
|
||||
const results: TrackSearchResult[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue