diff --git a/package-lock.json b/package-lock.json index ede65493..a6db443b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 681b1785..93adfc48 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/common/metadataProviders/MetadataProviderUtils.ts b/src/backend/common/metadataProviders/MetadataProviderUtils.ts index 8c90b557..74bab34d 100644 --- a/src/backend/common/metadataProviders/MetadataProviderUtils.ts +++ b/src/backend/common/metadataProviders/MetadataProviderUtils.ts @@ -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 `getTrackResults` in val && typeof val.getTrackResults === 'function' && `getArtistResults` in val && typeof val.getArtistResults === 'function' - && `getAlbumResults` in val && typeof val.getAlbumResults === 'function' \ No newline at end of file + && `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, + album: Omit, keyof ArtistSearchResult> & Partial> = {} +): 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 } +}); diff --git a/src/backend/common/transforms/MusicbrainzTransformer.ts b/src/backend/common/transforms/MusicbrainzTransformer.ts index 3a1f1726..f25aa63e 100644 --- a/src/backend/common/transforms/MusicbrainzTransformer.ts +++ b/src/backend/common/transforms/MusicbrainzTransformer.ts @@ -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 { - 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 { @@ -662,7 +666,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer 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; }); diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts index e70b2cb4..23309ed0 100644 --- a/src/backend/common/transforms/SpotifyTransformer.ts +++ b/src/backend/common/transforms/SpotifyTransformer.ts @@ -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 ({ + 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 { - 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 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 diff --git a/src/backend/common/transforms/rocksky/RockskyTransformer.ts b/src/backend/common/transforms/rocksky/RockskyTransformer.ts index eda83298..ec3c4f1d 100644 --- a/src/backend/common/transforms/rocksky/RockskyTransformer.ts +++ b/src/backend/common/transforms/rocksky/RockskyTransformer.ts @@ -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 ({ ...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 diff --git a/src/backend/tests/utils/invariants.test.ts b/src/backend/tests/utils/invariants.test.ts index d4443671..f6572484 100644 --- a/src/backend/tests/utils/invariants.test.ts +++ b/src/backend/tests/utils/invariants.test.ts @@ -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(); + }); }); diff --git a/src/client/components/Country.tsx b/src/client/components/Country.tsx new file mode 100644 index 00000000..3e1ffb90 --- /dev/null +++ b/src/client/components/Country.tsx @@ -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 ; +} \ No newline at end of file diff --git a/src/client/components/play/AlbumSearch.tsx b/src/client/components/play/AlbumSearch.tsx index 026ee687..ec5bc86d 100644 --- a/src/client/components/play/AlbumSearch.tsx +++ b/src/client/components/play/AlbumSearch.tsx @@ -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 = { 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 = } + const countryElm: React.JSX.Element | undefined = country !== undefined ? `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 = {countryElm}{dayjs(date).format('YYYY')} + } + return ( @@ -43,7 +55,7 @@ export const AlbumSearchResultItem = (props: { data: AlbumSearchResult, onPick?: - {name}{albumHints.length > 0? ({albumHints.join(' -- ')}) : undefined} + {name}{albumHints.length > 0? ({albumHints.join(' -- ')}) : undefined}{locationDateInfo} @@ -91,11 +103,11 @@ export const AlbumSearch = (props: AlbumSearchProps) => { } }, [query, set]) - const doChange = useCallback((val: Credit & Pick) => { + 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 ?? []; diff --git a/src/client/components/play/TrackSearch.tsx b/src/client/components/play/TrackSearch.tsx index ef1e1ec7..e6a6682f 100644 --- a/src/client/components/play/TrackSearch.tsx +++ b/src/client/components/play/TrackSearch.tsx @@ -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 = { - 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 ? `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 = {countryElm}{dayjs(date).format('YYYY')} + } 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 ? ( - and {albumCount} more... + and {albumCount} more albums... ) : undefined; albumContent = ( - {album.name} {albumHints.length > 0 ? `(${albumHints.join(' -- ')})` : ''} {andCount} + {albumName} {albumHints.length > 0 ? `(${albumHints.join(' -- ')})` : ''}{locationDateInfo} {andCount} ) } @@ -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; diff --git a/src/core/Api.ts b/src/core/Api.ts index 79c6f3f7..b2c56667 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -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; @@ -293,12 +293,14 @@ export const albumSearchResultResponseSchema = z.object({ }); export type AlbumSearchResultResponse = z.infer; -/** 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(), }); diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 8a0305d5..da87308e 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -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 => ({ - 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 => { + 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 => ({ - 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 => { + 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[] = [];