export interface Matchable { title: string artist: string } /** * Normalization for matching: lowercase, strip accents, strip punctuation, * strip leading articles (the/a/an anywhere as standalone words), collapse * whitespace. Spec definition of "normalized artist/title equal". */ export function normalize(input: string): string { return input .toLowerCase() .replace(/æ/g, 'ae') .replace(/œ/g, 'oe') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^\p{L}\p{N}\s]/gu, ' ') .replace(/\b(the|a|an)\b/g, ' ') .replace(/\s+/g, ' ') .trim() } export function isConfidentMatch(a: Matchable, b: Matchable): boolean { return normalize(a.title) === normalize(b.title) && normalize(a.artist) === normalize(b.artist) } /** * Albums whose normalized title equals the release's normalized title — * the "ambiguous" candidate list shown when no confident match exists. * Candidates with matching artist sort first. Capped at 20. */ export function candidateAlbums(release: Matchable, albums: T[]): T[] { const title = normalize(release.title) return albums .filter((a) => normalize(a.title) === title) .sort((a, b) => { const am = normalize(a.artist) === normalize(release.artist) ? 0 : 1 const bm = normalize(b.artist) === normalize(release.artist) ? 0 : 1 return am - bm }) .slice(0, 20) }