feat: title/artist normalization and matching
This commit is contained in:
41
server/src/matcher.ts
Normal file
41
server/src/matcher.ts
Normal file
@@ -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<T extends Matchable>(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)
|
||||
}
|
||||
59
server/test/matcher.test.ts
Normal file
59
server/test/matcher.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user