feat: refactor nested album/art entities and add country/date to search rendering

This commit is contained in:
FoxxMD 2026-10-08 01:06:50 +00:00
parent 6dc0a5d7ab
commit ae8604ebba
12 changed files with 195 additions and 69 deletions

12
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

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

View 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}/>;
}

View file

@ -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 ?? [];

View file

@ -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;

View file

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

View file

@ -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[] = [];