diff --git a/src/backend/common/transforms/SpotifyTransformer.ts b/src/backend/common/transforms/SpotifyTransformer.ts index f3da9c8c..2ca8887c 100644 --- a/src/backend/common/transforms/SpotifyTransformer.ts +++ b/src/backend/common/transforms/SpotifyTransformer.ts @@ -11,13 +11,13 @@ import { removeUndefinedKeys } from '../../../core/DataUtils.ts'; import type { ExternalMetadataTerm, PlayTransformMetadataStage } from "../../../core/Transform.ts"; import { isWhenCondition } from "../../utils/PlayTransformUtils.ts"; import { parseArrayFromMaybeString } from "../../utils/StringUtils.ts"; -import { scorePlaySameness } from "../../utils/PlayComparisonUtils.ts"; +import { compareArtistCreditsNormalized, scorePlaySameness, type ScoreParts } from "../../utils/PlayComparisonUtils.ts"; import { intersect } from "../../utils.ts"; -import { isCompilation, SpotifyApiClient, trackToPlay } from "../vendor/spotify/SpotifyApiClient.ts"; +import { chooseImageByResolution, isCompilation, SpotifyApiClient, trackToPlay } from "../vendor/spotify/SpotifyApiClient.ts"; import { MaybeLogger } from '../MaybeLogger.ts'; import { SkipTransformStageError, StagePrerequisiteError, StageTransformError } from "../errors/MSErrors.ts"; import AtomicPartsTransformer from "./AtomicPartsTransformer.ts"; -import { creditId, creditIds, type CreditRules, resolveCredit, resolveCredits } from "../../../core/MusicMetadata.ts"; +import { creditId, creditIds, type CreditRules, nameToCredit, resolveCredit, resolveCredits, spotifyMeta, withImage } from "../../../core/MusicMetadata.ts"; import type { TransformerOptions } from "./AbstractTransformer.ts"; import { SearchPrerequisiteError } from "./MusicbrainzTransformer.ts"; import { @@ -29,6 +29,9 @@ import { type SpotifySearchType, type SpotifyTransformerConfig, type SpotifyTransformerData } from "./spotify/SpotifyTransformerUtil.ts"; +import type { AlbumSearchResult, ArtistSearchResult, TrackSearchResult } from "../../../core/Api.ts"; +import { x } from "tinyexec"; +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 */ export const COMPILATION_PENALTY = 0.15; @@ -158,7 +161,7 @@ export const missingSpotifyTypes = (play: PlayObject): SpotifyMissingType[] => { * used to both disambiguate results (EX an ISRC present on more than one album) and to determine whether a match * is confident enough to use at all. */ -export const rankTracksBySimilarity = (tracks: SpotifyApi.TrackObjectFull[], play: PlayObject, stageConfig: SpotifyTransformerDataStage): RankedSpotifyTrack[] => { +export const rankTracksBySimilarity = (tracks: SpotifyApi.TrackObjectFull[], play: PlayObject, stageConfig: SpotifyTransformerDataStage & {parts?: ScoreParts[]}): RankedSpotifyTrack[] => { const { titleWeight = TITLE_WEIGHT, artistWeight = ARTIST_WEIGHT, @@ -167,12 +170,13 @@ export const rankTracksBySimilarity = (tracks: SpotifyApi.TrackObjectFull[], pla const ranked = tracks.map((track) => { const candidate = trackToPlay(track); - let matchScore = scorePlaySameness(play, candidate, { + let [matchScore] = scorePlaySameness(play, candidate, { weights: { track: titleWeight, artist: artistWeight, album: albumWeight - } + }, + parts: stageConfig.parts }); if (deprioritizeCompilations && isCompilation(track)) { @@ -318,7 +322,7 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { @@ -330,7 +334,68 @@ export default class SpotifyTransformer extends AtomicPartsTransformer { + const surrogatePlay: PlayObject = {data: {track: {name: query}}, meta:{}}; + const res = await this.searchByBasicFields(surrogatePlay, {type: 'spotify', ...this.defaults}); + const filtered = this.rankTrackMatches(surrogatePlay, {tracks: res, searchType: 'basic', requestQueries: []}, { + type: 'spotify', + ...this.defaults, + parts: ['track'], + titleWeight: 1 // only comparing track at this point (only thing being queried for) so it should be out of 100 + }); + + if(filtered.length === 0) { + return [] + } + const results = filtered.slice(0, 10).map((x) => ({ + service: 'spotify', + score: x.matchScore, + albumCount: 1, + albumType: x.track.album.album_type, + ...trackToPlay(x.track).data, + id: x.track.id.toString(), + })); + results.sort((a, b) => b.score - a.score); + return results; + } + + async getArtistResults(query: string): Promise { + const surrogateArtist: Credit = {name: query}; + const scoreThreshold = this.defaults.score ?? 0.6; + const res = await this.api.searchArtists({name: query}, this.defaults); + const ranked = res.map((x) => ({...x, score: compareArtistCreditsNormalized([surrogateArtist], [{name: x.name}])[0]})) + .sort((a, b) => b.score - a.score) + .filter(x => x.score >= scoreThreshold); + const results: ArtistSearchResult[] = ranked.slice(0, 10).map((x) => ({ + ...withImage(nameToCredit(x.name, spotifyMeta(x.id, 'artist')), chooseImageByResolution(x.images, {fallbackBest: true}).url), + score: x.score, + service: 'spotify', + id: x.id + })); + return results; + } + async getAlbumResults(query: string): Promise { + 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) => { + const sameness = compareNormalizedStrings(query, x.name); + return {...x, score: Math.min(sameness.highScore, 100)} + }) + .sort((a, b) => b.score - a.score) + .filter(x => x.score >= scoreThreshold); + const results: AlbumSearchResult[] = ranked.slice(0, 10).map((x) => ({ + ...withImage(nameToCredit(x.name, spotifyMeta(x.id, 'album')), chooseImageByResolution(x.images, {fallbackBest: true}).url), + albumType: x.album_type, + artists: (x.artists ?? []).length === 0 ? undefined : x.artists.map((y) => nameToCredit(y.name, spotifyMeta(y.id, 'artist'))), + score: x.score, + service: 'spotify', + id: x.id + })); + return results; } public async handlePostFetch(play: PlayObject, transformData: SpotifyTrackSearchResult, stageConfig: SpotifyTransformerDataStage): Promise { @@ -353,6 +418,37 @@ export default class SpotifyTransformer extends AtomicPartsTransformer x.matchScore >= score); + if (filtered.length === 0) { + throw new StagePrerequisiteError(`All ${tracks.length} fetched matches had a score < ${score}, best match was ${ranked[0]?.matchScore.toFixed(3)}`, { shortStack: true, inputs: requestQueries }); + } + this.logger.debug(`${filtered.length} of ${tracks.length} fetched matches were valid. Using match with best score of ${filtered[0].matchScore.toFixed(3)}`); + } + const spotifyPlay = trackToPlay(filtered[0].track); + spotifyPlay.meta.lifecycleInputs = [...(spotifyPlay.meta.lifecycleInputs ?? []), ...requestQueries, { type: 'spotifyTrack', input: filtered[0].track.id }]; + return spotifyPlay; + } + + protected rankTrackMatches(play: PlayObject,transformData: SpotifyTrackSearchResult, stageConfig: SpotifyTransformerDataStage & {parts?: ScoreParts[]}): RankedSpotifyTrack[] { + const { + tracks = [], + searchType + } = transformData ?? {}; + + if (tracks.length === 0) { + return []; + } + + const { + score = this.defaults.score ?? 0.6 + } = stageConfig; + + const mergedConfig = Object.assign({}, removeUndefinedKeys({ ...this.defaults }), removeUndefinedKeys({ ...stageConfig })); + + const ranked = rankTracksBySimilarity(tracks, play, {...mergedConfig, parts: stageConfig.parts}); + let filtered: RankedSpotifyTrack[]; if (searchType === 'isrc') { // an ISRC match already identifies the exact recording -- fuzzy scoring here is only used to pick @@ -363,15 +459,9 @@ export default class SpotifyTransformer extends AtomicPartsTransformer x.matchScore >= score); - if (filtered.length === 0) { - throw new StagePrerequisiteError(`All ${tracks.length} fetched matches had a score < ${score}, best match was ${ranked[0]?.matchScore.toFixed(3)}`, { shortStack: true, inputs: requestQueries }); - } - this.logger.debug(`${filtered.length} of ${tracks.length} fetched matches were valid. Using match with best score of ${filtered[0].matchScore.toFixed(3)}`); } - const spotifyPlay = trackToPlay(filtered[0].track); - spotifyPlay.meta.lifecycleInputs = [...(spotifyPlay.meta.lifecycleInputs ?? []), ...requestQueries, { type: 'spotifyTrack', input: filtered[0].track.id }]; - return spotifyPlay; + return filtered; } protected override readonly hydratesCredits = true; diff --git a/src/backend/common/transforms/TransformerManager.ts b/src/backend/common/transforms/TransformerManager.ts index bb27b402..96e7a5fe 100644 --- a/src/backend/common/transforms/TransformerManager.ts +++ b/src/backend/common/transforms/TransformerManager.ts @@ -397,16 +397,58 @@ export default class TransformerManager { return res; } public async getArtistResults(query: string): Promise> { - return { + const readyMps: Record>>> = {}; + for(const [type, name] of Object.entries(this.transformMetadataProviders)) { + const t = this.transformers.get(type)?.get(name.toLocaleLowerCase()) as unknown as MetadataProvider & AbstractTransformer; + if(t !== undefined && t.isReady()) { + readyMps[type] = t.getArtistResults(query); + } + } + const all = await pPropsAllSettled(readyMps); + const res: AggregateMetadataResponse = { data: [], errors: [] } + for(const [name, r] of Object.entries(all)) { + if(r.status === 'fulfilled') { + if(r.value === false) { + res.errors.push({service: name, error: {message: 'Not Ready'}}); + } else { + res.data = res.data.concat(r.value) + } + } else { + res.errors.push({service: name, error: r.reason}); + } + } + res.data.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return res; } public async getAlbumResults(query: string): Promise> { - return { + const readyMps: Record>>> = {}; + for(const [type, name] of Object.entries(this.transformMetadataProviders)) { + const t = this.transformers.get(type)?.get(name.toLocaleLowerCase()) as unknown as MetadataProvider & AbstractTransformer; + if(t !== undefined && t.isReady()) { + readyMps[type] = t.getAlbumResults(query); + } + } + const all = await pPropsAllSettled(readyMps); + const res: AggregateMetadataResponse = { data: [], errors: [] } + for(const [name, r] of Object.entries(all)) { + if(r.status === 'fulfilled') { + if(r.value === false) { + res.errors.push({service: name, error: {message: 'Not Ready'}}); + } else { + res.data = res.data.concat(r.value) + } + } else { + res.errors.push({service: name, error: r.reason}); + } + } + res.data.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return res; } } diff --git a/src/backend/common/vendor/spotify/SpotifyApiClient.ts b/src/backend/common/vendor/spotify/SpotifyApiClient.ts index 4a2f2677..dbdcdaa8 100644 --- a/src/backend/common/vendor/spotify/SpotifyApiClient.ts +++ b/src/backend/common/vendor/spotify/SpotifyApiClient.ts @@ -1,7 +1,7 @@ import SpotifyWebApi from "spotify-web-api-node"; import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible'; import type { Cacheable } from "cacheable"; -import type { PlayObject, PlayObjectMinimal } from "../../../../core/Atomic.ts"; +import type { Credit, PlayObject, PlayObjectMinimal } from "../../../../core/Atomic.ts"; import { nameToCredit } from "../../../../core/MusicMetadata.ts"; import { spotifyMeta, withImage } from "../../../../core/MusicMetadata.ts"; import { isrcNoHyphens } from "../../../../core/PlayUtils.ts"; @@ -91,16 +91,16 @@ export class SpotifyApiClient extends AbstractApiClient { } } - searchByIsrc = async (isrc: string, opts: SpotifySearchOptions = {}): Promise => { + searchTracksByIsrc = async (isrc: string, opts: SpotifySearchOptions = {}): Promise => { const { limit = 50, market, locale, useCachedResult } = opts; const q = `isrc:${isrcNoHyphens(isrc)}`; const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`; - this.logger.debug({ labels: ['ISRC Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`); + this.logger.trace({ labels: ['ISRC Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`); const res = await this.callApi((api) => api.searchTracks(q, removeUndefinedKeys({ limit, market })), { cacheKey, useCachedResult }); return res.body.tracks?.items ?? []; } - searchByFields = async (play: PlayObject, opts: SpotifySearchOptions = {}): Promise => { + searchTracksByFields = async (play: PlayObject, opts: SpotifySearchOptions = {}): Promise => { const { limit = 50, market, locale, useCachedResult } = opts; const parts: string[] = []; @@ -118,12 +118,36 @@ export class SpotifyApiClient extends AbstractApiClient { // happens afterwards via fuzzy ranking instead. const q = parts.join(' '); - const cacheKey = `spotify-search-${hashObject({ q, limit, market, locale })}`; - this.logger.debug({ labels: ['Basic Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`); + const cacheKey = `spotify-search-track-${hashObject({ q, limit, market, locale })}`; + this.logger.trace({ labels: ['Basic Search'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`); const res = await this.callApi((api) => api.searchTracks(q, removeUndefinedKeys({ limit, market })), { cacheKey, useCachedResult }); return res.body.tracks?.items ?? []; } + searchArtists = async (credit: Credit, opts: SpotifySearchOptions = {}): Promise => { + const { limit = 50, market, locale, useCachedResult } = opts; + + const q = `artist:${luceneQuoteIfNeeded(credit.name)}`; + const cacheKey = `spotify-search-artist-${hashObject({ q, limit, market, locale })}`; + this.logger.trace({ labels: ['Basic Search Artist'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`); + const res = await this.callApi((api) => api.searchArtists(q, removeUndefinedKeys({ limit, market })), { cacheKey, useCachedResult }); + return res.body.artists?.items ?? []; + } + + searchAlbums = async (data: {album: Credit, artists?: Credit[]}, opts: SpotifySearchOptions = {}): Promise => { + const { limit = 50, market, locale, useCachedResult } = opts; + + let q = `album:${luceneQuoteIfNeeded(data.album.name)}`; + const artists = data.artists ?? []; + if(artists.length > 0) { + q += ` artist:${luceneQuoteIfNeeded(artists[0].name)}` + } + const cacheKey = `spotify-search-album-${hashObject({ q, limit, market, locale })}`; + this.logger.trace({ labels: ['Basic Search Album'] }, `Search Query => ${q} | market: ${market ?? '(none)'} | locale: ${locale ?? '(none)'}`); + const res = await this.callApi((api) => api.searchAlbums(q, removeUndefinedKeys({ limit, market })), { cacheKey, useCachedResult }); + return res.body.albums?.items ?? []; + } + static formatPlayObj(obj: SpotifyApi.TrackObjectFull, options: FormatPlayObjectOptions = {}): PlayObject { return trackToPlay(obj); } diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index 8c256655..3e80b828 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -388,9 +388,12 @@ export interface SamenessScoreOptions { exact?: number naive?: number } - } + }, + parts?: ScoreParts[] } -export const scorePlaySameness = (ref: PlayObject, candidate: PlayObject, options: SamenessScoreOptions = {}) => { +export type ScoreParts = 'track' | 'artist' | 'album'; +export type ScoreBreakdown = {type: ScoreParts, score: number}; +export const scorePlaySameness = (ref: PlayObject, candidate: PlayObject, options: SamenessScoreOptions = {}): [number, ScoreBreakdown[]] => { const { weights: { @@ -407,34 +410,52 @@ export const scorePlaySameness = (ref: PlayObject, candidate: PlayObject, option albumBonuses: { exact: alExact = 0.05, } = {} - } = {} + } = {}, + parts = ['album','track','artist'] } = options; - const [trackHigh, trackRes] = comparePlayTracksNormalized(ref, candidate); - const [artistHigh, artistRes] = comparePlayArtistsNormalized(ref, candidate); - const [albumHigh, albumRes] = comparePlayAlbumNormalized(ref, candidate); - - let trackBonus = 0; - if(trackRes.exact) { - trackBonus = tExact; - } else if(trackRes.naive.highScore > trackRes.cleaned.highScore) { - trackBonus = tNaive; - } - const trackScore = trackHigh * (trackWeight + trackBonus); - - let artistBonus = 0; - if(artistRes > 0) { - artistBonus = arExact; + const scores: ScoreBreakdown[] = []; + + for(const partType of parts) { + switch(partType.toLocaleLowerCase()) { + case 'track':{ + const [trackHigh, trackRes] = comparePlayTracksNormalized(ref, candidate); + let trackBonus = 0; + if(trackRes.exact) { + trackBonus = tExact; + } else if(trackRes.naive.highScore > trackRes.cleaned.highScore) { + trackBonus = tNaive; + } + const trackScore = trackHigh * (trackWeight + trackBonus); + scores.push({type: 'track', score: trackScore}); + } break; + case 'artist': { + const [artistHigh, artistRes] = comparePlayArtistsNormalized(ref, candidate); + let artistBonus = 0; + if(artistRes > 0) { + artistBonus = arExact; + } + const artistScore = artistHigh * (artistWeight + artistBonus); + scores.push({type: 'artist', score: artistScore}); + } break; + case 'album': { + const [albumHigh, albumRes] = comparePlayAlbumNormalized(ref, candidate); + let albumBonus = 0; + if(albumRes.exact) { + albumBonus = alExact; + } + const albumScore = albumHigh * (albumWeight + albumBonus); + scores.push({type: 'album', score: albumScore}); + } + } } - const artistScore = artistHigh * (artistWeight + artistBonus); - let albumBonus = 0; - if(albumRes.exact) { - albumBonus = alExact; + + if(scores.length === 0) { + return [0, []]; } - const albumScore = albumHigh * (albumWeight + albumBonus); - - return trackScore + artistScore + albumScore; + const total = scores.reduce((acc, curr) => acc + curr.score, 0); + return [total, scores]; } export const playDateWithinDurationOfAny = (play: PlayObject, plays: PlayObject[], dur: Duration): PlayObject | undefined => {