diff --git a/server/src/matcher.ts b/server/src/matcher.ts new file mode 100644 index 0000000..a0beb14 --- /dev/null +++ b/server/src/matcher.ts @@ -0,0 +1,41 @@ +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() + .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) +} diff --git a/server/test/matcher.test.ts b/server/test/matcher.test.ts new file mode 100644 index 0000000..ef51db7 --- /dev/null +++ b/server/test/matcher.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { normalize, isConfidentMatch, candidateAlbums } from '../src/matcher.js' + +describe('normalize', () => { + it('lowercases, strips punctuation, articles, accents, extra whitespace', () => { + expect(normalize('The Cinematic Orchestra!')).toBe('cinematic orchestra') + expect(normalize(' Blur: The Best of… ')).toBe('blur best of') + expect(normalize('Björk — Début')).toBe('bjork debut') + expect(normalize('A Tribe Called Quest')).toBe('tribe called quest') + }) +}) + +describe('isConfidentMatch', () => { + it('matches when normalized artist AND title are equal', () => { + expect( + isConfidentMatch( + { title: 'Motion!', artist: 'The Cinematic Orchestra' }, + { title: 'Motion', artist: 'Cinematic Orchestra' } + ) + ).toBe(true) + expect( + isConfidentMatch( + { title: 'Motion', artist: 'Massive Attack' }, + { title: 'Motion', artist: 'The Cinematic Orchestra' } + ) + ).toBe(false) + expect( + isConfidentMatch( + { title: 'Blue Lines', artist: 'Massive Attack' }, + { title: 'Motion', artist: 'Massive Attack' } + ) + ).toBe(false) + }) +}) + +describe('candidateAlbums', () => { + const albums = [ + { id: 1, title: 'Motion', artist: 'Someone Else' }, + { id: 2, title: 'Motion', artist: 'The Cinematic Orchestra' }, + { id: 3, title: 'Other Album', artist: 'The Cinematic Orchestra' }, + ] + + it('returns same-title albums with artist matches first', () => { + const candidates = candidateAlbums( + { title: 'Motion', artist: 'Cinematic Orchestra' }, + albums + ) + expect(candidates.map((c) => c.id)).toEqual([2, 1]) + }) + + it('caps candidates at 20', () => { + const many = Array.from({ length: 50 }, (_, i) => ({ + id: i, + title: 'Motion', + artist: `Artist ${i}`, + })) + expect(candidateAlbums({ title: 'Motion', artist: 'zzz' }, many)).toHaveLength(20) + }) +})