import type { MusicPlatformAdapter } from "@/lib/adapters/types"; import { getMatchCacheEntry, setMatchCacheEntry } from "@/lib/db/repositories/match-cache"; import { artistSimilarity, titleSimilarity } from "@/lib/matching/scoring"; import type { CanonicalAlbum, CanonicalArtist, TokenSet } from "@/lib/types/model"; // Artists/albums have no ISRC-equivalent identifier, so thresholds are stricter // than track matching (which can fall back to duration as a third signal). const ARTIST_AUTO_THRESHOLD = 0.85; const ALBUM_AUTO_THRESHOLD = 0.85; export type EntityMatchOutcome = | { kind: "auto"; entity: T } | { kind: "review"; candidates: T[] }; export async function matchArtist(params: { sourceArtist: CanonicalArtist; destAdapter: MusicPlatformAdapter; destTokens: TokenSet; destAccountId: string; }): Promise> { const { sourceArtist, destAdapter, destTokens, destAccountId } = params; const cached = getMatchCacheEntry(sourceArtist.canonicalId, destAccountId); if (cached?.destPlatformTrackId) { return { kind: "auto", entity: { ...sourceArtist, platformIds: { ...sourceArtist.platformIds, [destAdapter.platform]: cached.destPlatformTrackId } }, }; } if (!destAdapter.searchArtist) { return { kind: "review", candidates: [] }; } const candidates = await destAdapter.searchArtist(destTokens, sourceArtist.name); const scored = candidates .map((candidate) => ({ candidate, s: titleSimilarity(sourceArtist.name, candidate.name) })) .sort((a, b) => b.s - a.s); const best = scored[0]; if (best && best.s >= ARTIST_AUTO_THRESHOLD) { setMatchCacheEntry({ canonicalId: sourceArtist.canonicalId, destAccountId, destPlatformTrackId: best.candidate.platformIds[destAdapter.platform] ?? null, confidence: "high", }); return { kind: "auto", entity: best.candidate }; } return { kind: "review", candidates: scored.slice(0, 3).map((s) => s.candidate) }; } export async function matchAlbum(params: { sourceAlbum: CanonicalAlbum; destAdapter: MusicPlatformAdapter; destTokens: TokenSet; destAccountId: string; }): Promise> { const { sourceAlbum, destAdapter, destTokens, destAccountId } = params; const cached = getMatchCacheEntry(sourceAlbum.canonicalId, destAccountId); if (cached?.destPlatformTrackId) { return { kind: "auto", entity: { ...sourceAlbum, platformIds: { ...sourceAlbum.platformIds, [destAdapter.platform]: cached.destPlatformTrackId } }, }; } if (!destAdapter.searchAlbum) { return { kind: "review", candidates: [] }; } const candidates = await destAdapter.searchAlbum(destTokens, { title: sourceAlbum.title, artist: sourceAlbum.artists[0] ?? "", }); const scored = candidates .map((candidate) => ({ candidate, s: 0.6 * titleSimilarity(sourceAlbum.title, candidate.title) + 0.4 * artistSimilarity(sourceAlbum.artists, candidate.artists), })) .sort((a, b) => b.s - a.s); const best = scored[0]; if (best && best.s >= ALBUM_AUTO_THRESHOLD) { setMatchCacheEntry({ canonicalId: sourceAlbum.canonicalId, destAccountId, destPlatformTrackId: best.candidate.platformIds[destAdapter.platform] ?? null, confidence: "high", }); return { kind: "auto", entity: best.candidate }; } return { kind: "review", candidates: scored.slice(0, 3).map((s) => s.candidate) }; }