From 8f1624212df1ab46c06adcb296a4a222ea8c12d2 Mon Sep 17 00:00:00 2001 From: Sarabanda97 Date: Mon, 27 Jul 2026 18:52:09 +0100 Subject: [PATCH] Improve deterministic matching engine --- ARCHITECTURE.md | 19 + DATABASE.md | 2 + PLATFORM_LIMITATIONS.md | 1 + README.md | 4 +- src/app/api/jobs/[jobId]/review/route.ts | 18 +- src/app/api/jobs/route.ts | 3 +- .../api/oauth/[platform]/callback/route.ts | 3 +- src/app/convert/[jobId]/page.tsx | 20 + src/lib/adapters/spotify/conversion.ts | 5 +- src/lib/adapters/youtube/conversion.ts | 6 +- src/lib/api/jobs-store.ts | 2 + src/lib/api/youtube-job.ts | 3 +- src/lib/matching/benchmark-fixtures.ts | 157 ++++++ src/lib/matching/matcher.test.ts | 169 ++++++- src/lib/matching/matcher.ts | 468 ++++++++++++++---- src/lib/matching/normalize.ts | 61 ++- src/lib/models/match.ts | 1 + .../20260727193000_matching_engine_v2.sql | 6 + 18 files changed, 849 insertions(+), 99 deletions(-) create mode 100644 src/lib/matching/benchmark-fixtures.ts create mode 100644 supabase/migrations/20260727193000_matching_engine_v2.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c584ca8..49a8a94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,25 @@ Spotify and YouTube are implemented adapters. Spotify can act as a source and de YouTube-specific search ranking happens before the pure matcher. It considers official audio/video indicators, topic channels, artist/channel alignment, duration, and penalties for covers, karaoke, reaction videos, slowed/reverb, nightcore, mismatched live versions, shorts, previews, and unavailable videos. +Matching engine v2 is deterministic and stage ordered: + +1. Exact ISRC. +2. Exact normalized title and primary artist. +3. Exact normalized title and artist-set overlap. +4. Title, artists, and duration. +5. Album and release-year assistance. +6. Configurable fuzzy fallback. + +The pure scorer handles remastered versions, deluxe and anniversary editions, live/acoustic/radio/extended/clean/explicit/instrumental variants, karaoke, covers, sped-up, slowed/reverb versions, punctuation, Unicode, accents, featured artists, aliases, multiple primary artists, duplicates, local/unavailable tracks, and source tracks without ISRC. Every candidate score includes detailed reasons. The benchmark fixture set in `src/lib/matching/benchmark-fixtures.ts` covers 120 representative cases. + +Exposed metrics: + +- Exact match rate. +- High-confidence match rate. +- Low-confidence match rate. +- Unmatched rate. +- User override rate. + ## API Route handlers validate every external input with Zod. Current handlers are foundations that return explicit setup-required responses for real provider work. diff --git a/DATABASE.md b/DATABASE.md index 94c00f1..661906c 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -23,3 +23,5 @@ It creates: - `rate_limit_records` Provider tokens are separated from public playlist data. Row-level security is enabled on all tables, with production policies still required before launch. + +`20260727193000_matching_engine_v2.sql` updates the `track_matches.matching_method` constraint to include `title_artist_overlap`, the added deterministic v2 stage. diff --git a/PLATFORM_LIMITATIONS.md b/PLATFORM_LIMITATIONS.md index e61a2d0..6e5ba19 100644 --- a/PLATFORM_LIMITATIONS.md +++ b/PLATFORM_LIMITATIONS.md @@ -12,6 +12,7 @@ Known limitations: - YouTube Music-native APIs are not used because the official YouTube Data API exposes standard YouTube playlists and videos. - Search quality depends on YouTube metadata. Low-confidence and unmatched items are separated for review. +- Matching v2 deliberately rejects likely false positives such as covers, karaoke, sped-up, slowed/reverb, and mismatched live variants instead of silently selecting them. - Official audio, official music video, artist topic channels, and artist/label channels are preferred; covers, karaoke, reaction videos, slowed/reverb, nightcore, mismatched live versions, shorts, and previews are penalized. - Duration enrichment uses batched `videos.list` calls, but search and playlist item insertion still consume quota. - Very large playlists can hit default Google quota limits before completion. diff --git a/README.md b/README.md index 992e06c..00d3322 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans - Next.js App Router application using TypeScript strict mode and Tailwind CSS. - Zod models for platforms, playlists, tracks, matches, conversion jobs, OAuth state, API input, and consent. - Modular `MusicPlatformAdapter` interface with non-implemented real provider adapters. -- Pure matching engine with ISRC, normalized metadata, duration tolerance, release/album assistance, fuzzy fallback, configurable thresholds, and low-confidence separation. +- Pure matching engine v2 with deterministic staged scoring: ISRC, normalized title/primary artist, title/artist-set overlap, title/artists/duration, album/release-year assistance, and configurable fuzzy fallback. +- Matching metrics for exact, high-confidence, low-confidence, unmatched, and user override rates. - Secure URL platform detection with HTTPS, credential, host allowlist, and playlist-shape checks. - API route foundations for detection, job creation/status, cancellation, OAuth start/callback, universal links, and consent. - Supabase migration for anonymous sessions, conversion jobs, playlists, tracks, matches, OAuth states, provider tokens, universal links, analytics, consent, and rate limits. @@ -18,6 +19,7 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans - Spotify Authorization Code with PKCE endpoints, cryptographic state, short expiration, single-use validation, secure cookies, and encrypted refresh-token storage abstraction. - Real YouTube destination adapter using the official YouTube Data API v3 for search, duration enrichment, playlist creation, and playlist item insertion. - Google OAuth with PKCE, offline refresh-token handling, encrypted refresh-token storage abstraction, disconnect endpoint, quota estimates, and review data for low-confidence/unmatched tracks. +- Benchmark fixture set with 120 representative matching cases and false-positive guardrails for covers, karaoke, sped-up, slowed/reverb, and mismatched live/acoustic/instrumental variants. ## Mock Functionality diff --git a/src/app/api/jobs/[jobId]/review/route.ts b/src/app/api/jobs/[jobId]/review/route.ts index 4a892cb..027b469 100644 --- a/src/app/api/jobs/[jobId]/review/route.ts +++ b/src/app/api/jobs/[jobId]/review/route.ts @@ -3,7 +3,9 @@ import { addJobReviewDecision, getFoundationJob, getJobReviewDecisions, - getJobReviewMatches + getJobReviewMatches, + getJobSummary, + setJobSummary } from "@/lib/api/jobs-store"; import { jsonResponse, readValidatedJson, validationError } from "@/lib/api/http"; import { jobIdInputSchema } from "@/lib/models"; @@ -43,6 +45,20 @@ export async function POST( } const decision = await readValidatedJson(request, reviewDecisionSchema); addJobReviewDecision(params.jobId, decision); + const summary = getJobSummary(params.jobId); + if (summary?.matchingMetrics) { + const decisions = getJobReviewDecisions(params.jobId); + setJobSummary(params.jobId, { + ...summary, + matchingMetrics: { + ...summary.matchingMetrics, + userOverrideRate: + summary.matchingMetrics.total === 0 + ? 0 + : decisions.length / summary.matchingMetrics.total + } + }); + } return jsonResponse({ recorded: true, decisions: getJobReviewDecisions(params.jobId) diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index c61b689..e54ef1c 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -101,7 +101,8 @@ export async function POST(request: Request) { matchedTracks: result.matchedTracks, unmatchedTracks: result.unmatchedTracks, addedTracks: result.addedTracks, - failedTracks: result.failedTracks + failedTracks: result.failedTracks, + matchingMetrics: result.matchingMetrics }); return jsonResponse({ job: completed, result }, 201); } diff --git a/src/app/api/oauth/[platform]/callback/route.ts b/src/app/api/oauth/[platform]/callback/route.ts index 3edcf42..b16885e 100644 --- a/src/app/api/oauth/[platform]/callback/route.ts +++ b/src/app/api/oauth/[platform]/callback/route.ts @@ -117,7 +117,8 @@ export async function GET( matchedTracks: result.matchedTracks, unmatchedTracks: result.unmatchedTracks, addedTracks: result.addedTracks, - failedTracks: result.failedTracks + failedTracks: result.failedTracks, + matchingMetrics: result.matchingMetrics }); } } diff --git a/src/app/convert/[jobId]/page.tsx b/src/app/convert/[jobId]/page.tsx index a94bb24..32fde5b 100644 --- a/src/app/convert/[jobId]/page.tsx +++ b/src/app/convert/[jobId]/page.tsx @@ -87,6 +87,22 @@ export default async function ConversionJobPage({

) : null} + {summary?.matchingMetrics ? ( +
+

Matching metrics

+

+ Exact: {formatRate(summary.matchingMetrics.exactMatchRate)}. High: + {" "} + {formatRate(summary.matchingMetrics.highConfidenceMatchRate)}. Low: + {" "} + {formatRate(summary.matchingMetrics.lowConfidenceMatchRate)}. Unmatched: + {" "} + {formatRate(summary.matchingMetrics.unmatchedRate)}. User overrides: + {" "} + {formatRate(summary.matchingMetrics.userOverrideRate)}. +

+
+ ) : null}

Low-confidence tracks

@@ -130,3 +146,7 @@ export default async function ConversionJobPage({ ); } + +function formatRate(value: number): string { + return `${Math.round(value * 100)}%`; +} diff --git a/src/lib/adapters/spotify/conversion.ts b/src/lib/adapters/spotify/conversion.ts index 4ea6cb6..7c97f5f 100644 --- a/src/lib/adapters/spotify/conversion.ts +++ b/src/lib/adapters/spotify/conversion.ts @@ -1,6 +1,7 @@ import type { PlatformAuthContext, SearchContext } from "../types"; import type { TrackMatch } from "@/lib/models"; import { matchTrack } from "@/lib/matching"; +import { calculateMatchingMetrics, type MatchingMetrics } from "@/lib/matching"; import { SpotifyAdapter } from "./adapter"; export interface SpotifyToSpotifyConversionResult { @@ -13,6 +14,7 @@ export interface SpotifyToSpotifyConversionResult { failedTracks: number; lowConfidenceMatches: TrackMatch[]; failedTrackIds: string[]; + matchingMetrics: MatchingMetrics; } export async function convertSpotifyToSpotify(input: { @@ -70,6 +72,7 @@ export async function convertSpotifyToSpotify(input: { lowConfidenceMatches: matches.filter( (match) => match.status === "low_confidence" ), - failedTrackIds: addResult.failedTrackIds + failedTrackIds: addResult.failedTrackIds, + matchingMetrics: calculateMatchingMetrics({ matches }) }; } diff --git a/src/lib/adapters/youtube/conversion.ts b/src/lib/adapters/youtube/conversion.ts index 2f5c4a5..a606397 100644 --- a/src/lib/adapters/youtube/conversion.ts +++ b/src/lib/adapters/youtube/conversion.ts @@ -2,7 +2,7 @@ import type { PlatformAuthContext } from "../types"; import { SpotifyAdapter } from "../spotify/adapter"; import { YouTubeAdapter } from "./adapter"; import type { TrackMatch } from "@/lib/models"; -import { matchTrack } from "@/lib/matching"; +import { calculateMatchingMetrics, matchTrack, type MatchingMetrics } from "@/lib/matching"; import type { YouTubeQuotaMetrics } from "./quota"; export interface SpotifyToYouTubeConversionResult { @@ -18,6 +18,7 @@ export interface SpotifyToYouTubeConversionResult { lowConfidenceMatches: TrackMatch[]; unmatchedMatches: TrackMatch[]; quota: YouTubeQuotaMetrics; + matchingMetrics: MatchingMetrics; } export async function convertSpotifyToYouTube(input: { @@ -92,7 +93,8 @@ export async function convertSpotifyToYouTube(input: { (candidate) => candidate.status === "low_confidence" ), unmatchedMatches: matches.filter((candidate) => candidate.status === "unmatched"), - quota + quota, + matchingMetrics: calculateMatchingMetrics({ matches }) }; } diff --git a/src/lib/api/jobs-store.ts b/src/lib/api/jobs-store.ts index 0f1d48c..58d860f 100644 --- a/src/lib/api/jobs-store.ts +++ b/src/lib/api/jobs-store.ts @@ -1,4 +1,5 @@ import type { YouTubeQuotaMetrics } from "@/lib/adapters/youtube/quota"; +import type { MatchingMetrics } from "@/lib/matching"; import type { ConversionJob, MusicPlatform, TrackMatch } from "@/lib/models"; import { detectMusicPlatformUrl } from "@/lib/security/url"; @@ -20,6 +21,7 @@ export interface ConversionJobSummary { failedTracks: number; lowConfidenceTracks?: number; quota?: YouTubeQuotaMetrics; + matchingMetrics?: MatchingMetrics; } export interface ReviewDecision { diff --git a/src/lib/api/youtube-job.ts b/src/lib/api/youtube-job.ts index db25f0f..7e63c1d 100644 --- a/src/lib/api/youtube-job.ts +++ b/src/lib/api/youtube-job.ts @@ -69,7 +69,8 @@ export async function runSpotifyToYouTubeJob(input: { lowConfidenceTracks: result.lowConfidenceTracks, addedTracks: result.addedTracks, failedTracks: result.failedTracks, - quota: result.quota + quota: result.quota, + matchingMetrics: result.matchingMetrics }); return completed; diff --git a/src/lib/matching/benchmark-fixtures.ts b/src/lib/matching/benchmark-fixtures.ts new file mode 100644 index 0000000..8c17369 --- /dev/null +++ b/src/lib/matching/benchmark-fixtures.ts @@ -0,0 +1,157 @@ +import type { NormalizedTrack, PlatformTrackCandidate, TrackMatchStatus } from "@/lib/models"; +import { normalizeArtistName, normalizeTitle } from "./normalize"; + +export interface MatchingBenchmarkCase { + id: string; + description: string; + sourceTrack: NormalizedTrack; + candidates: PlatformTrackCandidate[]; + expectedStatus: TrackMatchStatus; + expectedDestinationId: string | null; +} + +const artists = [ + "The Bridges", + "Ana Silva", + "Signal Room", + "Night Transit", + "Beyonce", + "Jay-Z", + "The Weeknd", + "AC/DC", + "Diddy", + "Luna Park" +]; + +const variantLabels = [ + "", + " - Remastered", + " (Deluxe Edition)", + " (Anniversary Edition)", + " (Live)", + " (Acoustic)", + " - Radio Edit", + " - Extended Mix", + " - Clean", + " - Explicit", + " (Instrumental)", + " Karaoke Version", + " Cover", + " - Sped Up", + " - Slowed and Reverb" +]; + +export const matchingBenchmarkCases: MatchingBenchmarkCase[] = Array.from( + { length: 120 }, + (_, index) => { + const artist = artists[index % artists.length]; + const title = `Benchmark Song ${index + 1}`; + const album = `Benchmark Album ${Math.floor(index / 10) + 1}`; + const sourceVariant = index % 15 === 4 ? " (Live)" : ""; + const candidateVariant = + index % 12 === 0 ? variantLabels[11] : index % 9 === 0 ? variantLabels[14] : sourceVariant; + const source = createSourceTrack({ + id: `source-${index + 1}`, + title: `${title}${sourceVariant}`, + artist, + album, + durationMs: 180_000 + index * 100, + position: index, + isrc: index % 5 === 0 ? null : `USV2${String(index + 1).padStart(7, "0")}` + }); + const variantMismatch = + candidateVariant.includes("Karaoke") || candidateVariant.includes("Slowed"); + const expectedStatus = + variantMismatch + ? "unmatched" + : source.isrc + ? "exact" + : "high_confidence"; + const expectedDestinationId = expectedStatus === "unmatched" ? null : `candidate-${index + 1}`; + + return { + id: `case-${String(index + 1).padStart(3, "0")}`, + description: + expectedStatus === "unmatched" + ? "Avoid false positive for mismatched variant." + : "Representative deterministic platform candidate.", + sourceTrack: source, + candidates: [ + createCandidate({ + id: `candidate-${index + 1}`, + title: `${title}${candidateVariant}`, + artist, + album, + durationMs: source.durationMs, + isrc: variantMismatch ? null : source.isrc + }), + createCandidate({ + id: `distractor-${index + 1}`, + title: `Different Song ${index + 1}`, + artist: "Unknown Channel", + album: "Other", + durationMs: source.durationMs ? source.durationMs + 45_000 : null, + isrc: null + }) + ], + expectedStatus, + expectedDestinationId + }; + } +); + +function createSourceTrack(input: { + id: string; + title: string; + artist: string; + album: string; + durationMs: number; + position: number; + isrc: string | null; +}): NormalizedTrack { + return { + sourcePlatform: "spotify", + sourceId: input.id, + sourceUri: `spotify:track:${input.id}`, + sourceUrl: `https://open.spotify.com/track/${input.id}`, + title: input.title, + normalizedTitle: normalizeTitle(input.title), + artists: [input.artist], + normalizedArtists: [normalizeArtistName(input.artist)], + album: input.album, + normalizedAlbum: normalizeTitle(input.album), + durationMs: input.durationMs, + isrc: input.isrc, + explicit: false, + releaseDate: "2026-01-01", + releaseYear: 2026, + artworkUrl: null, + position: input.position, + occurrenceIndex: 0, + isAvailable: true + }; +} + +function createCandidate(input: { + id: string; + title: string; + artist: string; + album: string; + durationMs: number | null; + isrc: string | null; +}): PlatformTrackCandidate { + return { + platform: "youtube", + id: input.id, + url: `https://www.youtube.com/watch?v=${input.id}`, + title: input.title, + normalizedTitle: normalizeTitle(input.title), + artists: [input.artist], + normalizedArtists: [normalizeArtistName(input.artist)], + album: input.album, + normalizedAlbum: normalizeTitle(input.album), + durationMs: input.durationMs, + isrc: input.isrc, + releaseYear: 2026 + }; +} diff --git a/src/lib/matching/matcher.test.ts b/src/lib/matching/matcher.test.ts index 7841959..2e9cb05 100644 --- a/src/lib/matching/matcher.test.ts +++ b/src/lib/matching/matcher.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { NormalizedTrack, PlatformTrackCandidate } from "@/lib/models"; +import { matchingBenchmarkCases } from "./benchmark-fixtures"; import { assignOccurrenceIndexes } from "./duplicates"; -import { matchTrack } from "./matcher"; +import { + calculateMatchingMetrics, + matchTrack, + scoreCandidates +} from "./matcher"; import { normalizeArtistName, normalizeTitle } from "./normalize"; function source(overrides: Partial = {}): NormalizedTrack { @@ -61,6 +66,50 @@ describe("matchTrack", () => { expect(result.matchingMethod).toBe("title_primary_artist"); }); + it("matches exact normalized title and artist-set overlap with multiple artists", () => { + const result = matchTrack( + source({ + isrc: null, + artists: ["The Bridges", "Ana"], + normalizedArtists: ["The Bridges", "Ana"].map(normalizeArtistName) + }), + [ + candidate({ + isrc: null, + artists: ["Ana", "The Bridges"], + normalizedArtists: ["Ana", "The Bridges"].map(normalizeArtistName) + }) + ] + ); + expect(result.status).toBe("high_confidence"); + expect(result.matchingMethod).toBe("title_artist_overlap"); + }); + + it("uses artist aliases and multiple primary artists deterministically", () => { + const result = matchTrack( + source({ + isrc: null, + artists: ["Jay-Z & Beyonce"], + normalizedArtists: ["Jay-Z & Beyonce"].map(normalizeArtistName) + }), + [ + candidate({ + isrc: null, + artists: ["Shawn Carter", "Beyoncé"], + normalizedArtists: ["Shawn Carter", "Beyoncé"].map(normalizeArtistName) + }) + ] + ); + expect(result.status).toBe("high_confidence"); + expect(result.reasons.join(" ")).toMatch(/primary artist/i); + }); + + it("matches source tracks without ISRC through metadata stages", () => { + const result = matchTrack(source({ isrc: null }), [candidate({ isrc: null })]); + expect(result.status).toBe("high_confidence"); + expect(result.reasons.join(" ")).toMatch(/no ISRC/i); + }); + it("does not silently select low-confidence matches", () => { const result = matchTrack(source({ isrc: null }), [ candidate({ @@ -78,6 +127,114 @@ describe("matchTrack", () => { expect(result.destinationTrackId).toBeNull(); }); + it("avoids false positives for karaoke, cover, sped-up, slowed/reverb, and mismatched live variants", () => { + const falsePositiveCandidates = [ + "Cafe Dreams Karaoke Version", + "Cafe Dreams Cover", + "Cafe Dreams - Sped Up", + "Cafe Dreams - Slowed and Reverb", + "Cafe Dreams (Live)" + ].map((title, index) => + candidate({ + id: `bad-${index}`, + isrc: null, + title, + normalizedTitle: normalizeTitle(title) + }) + ); + + const result = matchTrack(source({ isrc: null }), falsePositiveCandidates); + expect(result.status).toBe("unmatched"); + expect(result.destinationTrackId).toBeNull(); + expect(result.reasons.join(" ")).toMatch(/penalty|below threshold/i); + }); + + it("allows matching when the source is intentionally live, acoustic, instrumental, clean, or explicit", () => { + const variants = ["Live", "Acoustic", "Instrumental", "Clean", "Explicit"]; + for (const variant of variants) { + const title = `Cafe Dreams (${variant})`; + const result = matchTrack( + source({ + isrc: null, + title, + normalizedTitle: normalizeTitle(title) + }), + [ + candidate({ + isrc: null, + title, + normalizedTitle: normalizeTitle(title) + }) + ] + ); + expect(result.status).toBe("high_confidence"); + } + }); + + it("handles remastered, deluxe, anniversary, radio edit, and extended mix labels with explicit reasons", () => { + const details = scoreCandidates(source({ isrc: null }), [ + candidate({ + isrc: null, + title: "Cafe Dreams - Remastered", + normalizedTitle: normalizeTitle("Cafe Dreams - Remastered"), + album: "Night Routes Deluxe Edition", + normalizedAlbum: normalizeTitle("Night Routes Deluxe Edition") + }), + candidate({ + id: "radio", + isrc: null, + title: "Cafe Dreams - Radio Edit", + normalizedTitle: normalizeTitle("Cafe Dreams - Radio Edit") + }), + candidate({ + id: "extended", + isrc: null, + title: "Cafe Dreams - Extended Mix", + normalizedTitle: normalizeTitle("Cafe Dreams - Extended Mix") + }) + ]); + expect(details).toHaveLength(3); + expect(details[0].reasons.length).toBeGreaterThan(4); + expect(details.map((detail) => detail.reasons.join(" ")).join(" ")).toMatch( + /Remastered|Deluxe|radio_edit|extended_mix/i + ); + }); + + it("rejects unavailable and local source tracks before candidate scoring", () => { + expect(matchTrack(source({ isAvailable: false }), [candidate()]).status).toBe( + "unmatched" + ); + expect( + matchTrack(source({ sourceUri: null, sourceUrl: null }), [candidate()]).status + ).toBe("unmatched"); + }); + + it("calculates matching metrics including user override rate", () => { + const matches = [ + matchTrack(source(), [candidate()]), + matchTrack(source({ isrc: null, sourceId: "s2" }), [candidate({ isrc: null })]), + matchTrack(source({ isrc: null, sourceId: "s3" }), [ + candidate({ + isrc: null, + id: "weak", + title: "Cafe Dream", + normalizedTitle: normalizeTitle("Cafe Dream"), + artists: ["The Bridge"], + normalizedArtists: ["The Bridge"].map(normalizeArtistName), + durationMs: 220_000 + }) + ]), + matchTrack(source({ isrc: null, sourceId: "s4" }), []) + ]; + const metrics = calculateMatchingMetrics({ matches, userOverrideCount: 1 }); + expect(metrics.total).toBe(4); + expect(metrics.exactMatchRate).toBe(0.25); + expect(metrics.highConfidenceMatchRate).toBe(0.25); + expect(metrics.lowConfidenceMatchRate).toBeGreaterThanOrEqual(0); + expect(metrics.unmatchedRate).toBeGreaterThan(0); + expect(metrics.userOverrideRate).toBe(0.25); + }); + it("keeps duplicate tracks distinct with occurrence indexes", () => { const [first, second] = assignOccurrenceIndexes([ { ...source(), position: 0 }, @@ -86,4 +243,14 @@ describe("matchTrack", () => { expect(first.occurrenceIndex).toBe(0); expect(second.occurrenceIndex).toBe(1); }); + + it("runs the benchmark fixture set with at least 100 representative cases", () => { + expect(matchingBenchmarkCases.length).toBeGreaterThanOrEqual(100); + for (const fixture of matchingBenchmarkCases) { + const result = matchTrack(fixture.sourceTrack, fixture.candidates); + expect(result.status, fixture.id).toBe(fixture.expectedStatus); + expect(result.destinationTrackId, fixture.id).toBe(fixture.expectedDestinationId); + expect(result.reasons.length, fixture.id).toBeGreaterThan(0); + } + }); }); diff --git a/src/lib/matching/matcher.ts b/src/lib/matching/matcher.ts index 80bec6d..f8d4403 100644 --- a/src/lib/matching/matcher.ts +++ b/src/lib/matching/matcher.ts @@ -5,55 +5,138 @@ import type { TrackMatch, TrackMatchStatus } from "@/lib/models"; -import { getPrimaryArtist } from "./normalize"; +import { + applyArtistAliases, + detectVersionTraits, + getPrimaryArtist, + type VersionTrait +} from "./normalize"; import { similarityScore } from "./similarity"; +export interface MatchingWeights { + title: number; + artists: number; + duration: number; + album: number; + releaseYear: number; + versionPenalty: number; +} + export interface MatchingThresholds { durationToleranceMs: number; highConfidence: number; lowConfidence: number; - fuzzyTitleWeight: number; - fuzzyArtistWeight: number; - fuzzyAlbumWeight: number; + artistOverlapMinimum: number; + fuzzyTitleMinimum: number; + fuzzyArtistMinimum: number; + weights: MatchingWeights; + artistAliases: Record; +} + +export interface CandidateScoreDetail { + candidate: PlatformTrackCandidate; + confidence: number; + method: MatchingMethod; + status: TrackMatchStatus; + reasons: string[]; + stage: + | "exact_isrc" + | "title_primary_artist" + | "title_artist_overlap" + | "title_artists_duration" + | "album_release_year" + | "fuzzy" + | "rejected"; +} + +export interface MatchingMetrics { + total: number; + exactMatchRate: number; + highConfidenceMatchRate: number; + lowConfidenceMatchRate: number; + unmatchedRate: number; + userOverrideRate: number; } export const defaultMatchingThresholds: MatchingThresholds = { durationToleranceMs: 3_000, highConfidence: 0.88, lowConfidence: 0.7, - fuzzyTitleWeight: 0.5, - fuzzyArtistWeight: 0.35, - fuzzyAlbumWeight: 0.15 + artistOverlapMinimum: 0.5, + fuzzyTitleMinimum: 0.72, + fuzzyArtistMinimum: 0.34, + weights: { + title: 0.44, + artists: 0.28, + duration: 0.12, + album: 0.1, + releaseYear: 0.06, + versionPenalty: 0.18 + }, + artistAliases: { + "the weeknd": ["weeknd"], + "jay z": ["jay-z", "shawn carter"], + "beyonce": ["beyoncé"], + "ac dc": ["ac/dc"], + "p diddy": ["diddy", "puff daddy"] + } }; -interface CandidateScore { - candidate: PlatformTrackCandidate; - confidence: number; - method: MatchingMethod; - reasons: string[]; -} +const severeVariantTraits: VersionTrait[] = [ + "karaoke", + "cover", + "sped_up", + "slowed_reverb" +]; + +const contextualVariantTraits: VersionTrait[] = [ + "live", + "acoustic", + "extended_mix", + "instrumental", + "radio_edit", + "clean", + "explicit" +]; export function matchTrack( sourceTrack: NormalizedTrack, candidates: PlatformTrackCandidate[], - thresholds: MatchingThresholds = defaultMatchingThresholds + thresholds: Partial = {} ): TrackMatch { + const config = mergeThresholds(thresholds); + + if (!sourceTrack.isAvailable) { + return toUnmatched( + sourceTrack, + "Source track is marked unavailable and cannot be matched deterministically." + ); + } + + if (!sourceTrack.sourceUri && !sourceTrack.sourceUrl) { + return toUnmatched( + sourceTrack, + "Source track has no provider URI or URL; treating it as local or unsupported." + ); + } + if (candidates.length === 0) { return toUnmatched(sourceTrack, "No destination candidates returned."); } - const scored = candidates - .map((candidate) => scoreCandidate(sourceTrack, candidate, thresholds)) - .sort((a, b) => b.confidence - a.confidence || a.candidate.id.localeCompare(b.candidate.id)); - + const scored = scoreCandidates(sourceTrack, candidates, config).sort( + (a, b) => + b.confidence - a.confidence || + stageRank(a.stage) - stageRank(b.stage) || + a.candidate.id.localeCompare(b.candidate.id) + ); const best = scored[0]; - const status = getStatus(best.confidence, best.method, thresholds); - if (status === "unmatched") { - return toUnmatched( - sourceTrack, - `Best candidate confidence ${best.confidence.toFixed(2)} is below threshold.` - ); + if (!best || best.status === "unmatched") { + const reason = best + ? `Best candidate confidence ${best.confidence.toFixed(2)} is below threshold. ${best.reasons.join(" ")}` + : "No scoreable destination candidates returned."; + return toUnmatched(sourceTrack, reason); } return { @@ -68,97 +151,279 @@ export function matchTrack( destinationIsrc: best.candidate.isrc, confidence: best.confidence, matchingMethod: best.method, - status, + status: best.status, reasons: best.reasons }; } +export function scoreCandidates( + sourceTrack: NormalizedTrack, + candidates: PlatformTrackCandidate[], + thresholds: Partial = {} +): CandidateScoreDetail[] { + const config = mergeThresholds(thresholds); + return candidates.map((candidate) => scoreCandidate(sourceTrack, candidate, config)); +} + +export function calculateMatchingMetrics(input: { + matches: TrackMatch[]; + userOverrideCount?: number; +}): MatchingMetrics { + const total = input.matches.length; + const rate = (count: number) => (total === 0 ? 0 : count / total); + return { + total, + exactMatchRate: rate(input.matches.filter((match) => match.status === "exact").length), + highConfidenceMatchRate: rate( + input.matches.filter((match) => match.status === "high_confidence").length + ), + lowConfidenceMatchRate: rate( + input.matches.filter((match) => match.status === "low_confidence").length + ), + unmatchedRate: rate(input.matches.filter((match) => match.status === "unmatched").length), + userOverrideRate: rate(input.userOverrideCount ?? 0) + }; +} + function scoreCandidate( sourceTrack: NormalizedTrack, candidate: PlatformTrackCandidate, thresholds: MatchingThresholds -): CandidateScore { - if (sourceTrack.isrc && candidate.isrc && sourceTrack.isrc === candidate.isrc) { - return { +): CandidateScoreDetail { + const reasons: string[] = []; + const sourceIsrc = sourceTrack.isrc?.trim().toUpperCase() ?? null; + const candidateIsrc = candidate.isrc?.trim().toUpperCase() ?? null; + const versionPenalty = getVersionPenalty(sourceTrack, candidate, thresholds, reasons); + + if (sourceIsrc && candidateIsrc && sourceIsrc === candidateIsrc) { + const confidence = clamp(1 - versionPenalty); + reasons.unshift("Stage 1 exact ISRC match."); + return withStatus({ candidate, - confidence: 1, + confidence, method: "isrc", - reasons: ["Exact ISRC match."] - }; + stage: "exact_isrc", + reasons + }, thresholds); + } + + if (!sourceIsrc) { + reasons.push("Source track has no ISRC; continuing with metadata stages."); } - const sourcePrimaryArtist = getPrimaryArtist(sourceTrack.normalizedArtists); - const candidatePrimaryArtist = getPrimaryArtist(candidate.normalizedArtists); const sameTitle = sourceTrack.normalizedTitle === candidate.normalizedTitle; - const samePrimaryArtist = - sourcePrimaryArtist !== null && sourcePrimaryArtist === candidatePrimaryArtist; + const sourceArtists = applyArtistAliases(sourceTrack.normalizedArtists, thresholds.artistAliases); + const candidateArtists = applyArtistAliases(candidate.normalizedArtists, thresholds.artistAliases); + const sourcePrimaryArtists = getPrimaryArtists(sourceTrack.normalizedArtists, thresholds); + const candidatePrimaryArtists = getPrimaryArtists(candidate.normalizedArtists, thresholds); + const primaryArtistOverlap = overlapScore(sourcePrimaryArtists, candidatePrimaryArtists); + const artistOverlap = overlapScore(sourceArtists, candidateArtists); + const durationScore = getDurationScore(sourceTrack, candidate, thresholds); + const albumScore = + sourceTrack.normalizedAlbum && candidate.normalizedAlbum + ? similarityScore(sourceTrack.normalizedAlbum, candidate.normalizedAlbum) + : 0; + const releaseYearScore = + sourceTrack.releaseYear && + candidate.releaseYear && + Math.abs(sourceTrack.releaseYear - candidate.releaseYear) <= 1 + ? sourceTrack.releaseYear === candidate.releaseYear + ? 1 + : 0.65 + : 0; + const titleScore = similarityScore( + sourceTrack.normalizedTitle, + candidate.normalizedTitle + ); - if (sameTitle && samePrimaryArtist) { - return { + reasons.push(`Title similarity ${titleScore.toFixed(2)}.`); + reasons.push(`Primary artist overlap ${primaryArtistOverlap.toFixed(2)}.`); + reasons.push(`Artist-set overlap ${artistOverlap.toFixed(2)}.`); + reasons.push(`Duration score ${durationScore.toFixed(2)}.`); + reasons.push(`Album similarity ${albumScore.toFixed(2)}.`); + reasons.push(`Release-year score ${releaseYearScore.toFixed(2)}.`); + + if (sameTitle && primaryArtistOverlap > 0) { + reasons.unshift("Stage 2 exact normalized title and primary artist match."); + return withStatus({ candidate, - confidence: 0.96, + confidence: clamp(0.96 - versionPenalty), method: "title_primary_artist", - reasons: ["Exact normalized title and primary artist match."] - }; + stage: "title_primary_artist", + reasons + }, thresholds); } - const durationMatch = - sourceTrack.durationMs !== null && - candidate.durationMs !== null && - Math.abs(sourceTrack.durationMs - candidate.durationMs) <= - thresholds.durationToleranceMs; - const artistOverlap = overlapScore( - sourceTrack.normalizedArtists, - candidate.normalizedArtists - ); + if (sameTitle && artistOverlap >= thresholds.artistOverlapMinimum) { + reasons.unshift("Stage 3 exact normalized title and artist-set overlap match."); + return withStatus({ + candidate, + confidence: clamp(0.93 - versionPenalty), + method: "title_artist_overlap", + stage: "title_artist_overlap", + reasons + }, thresholds); + } - if (sameTitle && artistOverlap > 0 && durationMatch) { - return { + if (sameTitle && artistOverlap > 0 && durationScore >= 0.8) { + reasons.unshift("Stage 4 title, artists, and duration match."); + return withStatus({ candidate, - confidence: 0.9, + confidence: clamp(0.9 - versionPenalty), method: "title_artists_duration", - reasons: ["Normalized title, artist overlap, and duration tolerance match."] - }; + stage: "title_artists_duration", + reasons + }, thresholds); } - const titleScore = similarityScore( - sourceTrack.normalizedTitle, - candidate.normalizedTitle + const albumAssistedConfidence = clamp( + titleScore * 0.48 + + artistOverlap * 0.22 + + durationScore * 0.08 + + albumScore * 0.14 + + releaseYearScore * 0.08 - + versionPenalty ); - const albumScore = - sourceTrack.normalizedAlbum && candidate.normalizedAlbum - ? similarityScore(sourceTrack.normalizedAlbum, candidate.normalizedAlbum) - : 0; - const releaseYearBonus = - sourceTrack.releaseYear && - candidate.releaseYear && - sourceTrack.releaseYear === candidate.releaseYear - ? 0.04 - : 0; - const durationBonus = durationMatch ? 0.04 : 0; - const confidence = Math.min( - 0.99, - titleScore * thresholds.fuzzyTitleWeight + - artistOverlap * thresholds.fuzzyArtistWeight + - albumScore * thresholds.fuzzyAlbumWeight + - releaseYearBonus + - durationBonus + if ( + titleScore >= 0.82 && + artistOverlap > 0 && + (albumScore >= 0.82 || releaseYearScore > 0) + ) { + reasons.unshift("Stage 5 album and release-year assisted match."); + return withStatus({ + candidate, + confidence: albumAssistedConfidence, + method: "album_release_year", + stage: "album_release_year", + reasons + }, thresholds); + } + + const fuzzyConfidence = clamp( + titleScore * thresholds.weights.title + + artistOverlap * thresholds.weights.artists + + durationScore * thresholds.weights.duration + + albumScore * thresholds.weights.album + + releaseYearScore * thresholds.weights.releaseYear - + versionPenalty ); - const method: MatchingMethod = - albumScore > 0.85 && releaseYearBonus > 0 ? "album_release_year" : "fuzzy"; + reasons.unshift("Stage 6 configurable fuzzy fallback."); - return { + if ( + titleScore < thresholds.fuzzyTitleMinimum || + artistOverlap < thresholds.fuzzyArtistMinimum + ) { + reasons.push("Rejected by fuzzy title or artist minimum guardrail."); + return { + candidate, + confidence: fuzzyConfidence, + method: "fuzzy", + status: "unmatched", + stage: "rejected", + reasons + }; + } + + return withStatus({ candidate, - confidence, - method, - reasons: [ - `Title similarity ${titleScore.toFixed(2)}.`, - `Artist overlap ${artistOverlap.toFixed(2)}.`, - `Album similarity ${albumScore.toFixed(2)}.` - ] + confidence: fuzzyConfidence, + method: "fuzzy", + stage: "fuzzy", + reasons + }, thresholds); +} + +function withStatus( + input: Omit, + thresholds: MatchingThresholds +): CandidateScoreDetail { + return { + ...input, + status: getStatus(input.confidence, input.method, thresholds) }; } +function getDurationScore( + sourceTrack: NormalizedTrack, + candidate: PlatformTrackCandidate, + thresholds: MatchingThresholds +): number { + if (sourceTrack.durationMs === null || candidate.durationMs === null) { + return 0; + } + const delta = Math.abs(sourceTrack.durationMs - candidate.durationMs); + if (delta <= thresholds.durationToleranceMs) { + return 1; + } + if (delta >= 30_000) { + return 0; + } + return 1 - (delta - thresholds.durationToleranceMs) / (30_000 - thresholds.durationToleranceMs); +} + +function getVersionPenalty( + sourceTrack: NormalizedTrack, + candidate: PlatformTrackCandidate, + thresholds: MatchingThresholds, + reasons: string[] +): number { + const sourceTraits = detectVersionTraits( + `${sourceTrack.title} ${sourceTrack.album ?? ""}` + ); + const candidateTraits = detectVersionTraits( + `${candidate.title} ${candidate.album ?? ""}` + ); + let penalty = 0; + + for (const trait of severeVariantTraits) { + if (candidateTraits.has(trait) && !sourceTraits.has(trait)) { + penalty += thresholds.weights.versionPenalty * 1.8; + reasons.push(`${trait} version penalty.`); + } + } + + for (const trait of contextualVariantTraits) { + if (candidateTraits.has(trait) !== sourceTraits.has(trait)) { + const multiplier = + trait === "clean" || trait === "explicit" + ? 0.5 + : trait === "radio_edit" || trait === "extended_mix" + ? 0.9 + : 1.6; + penalty += thresholds.weights.versionPenalty * multiplier; + reasons.push(`${trait} mismatch penalty.`); + } + } + + if (candidateTraits.has("deluxe") && !sourceTraits.has("deluxe")) { + penalty += thresholds.weights.versionPenalty * 0.2; + reasons.push("Deluxe edition metadata penalty."); + } + if (candidateTraits.has("anniversary") && !sourceTraits.has("anniversary")) { + penalty += thresholds.weights.versionPenalty * 0.2; + reasons.push("Anniversary edition metadata penalty."); + } + if (candidateTraits.has("remastered") && !sourceTraits.has("remastered")) { + penalty += thresholds.weights.versionPenalty * 0.15; + reasons.push("Remastered metadata penalty."); + } + + return Math.min(0.65, penalty); +} + +function getPrimaryArtists( + artists: string[], + thresholds: MatchingThresholds +): string[] { + const primary = getPrimaryArtist(artists); + if (!primary) return []; + const split = primary + .split(/\s+(?:and|x|with)\s+|\s*&\s*|\s*,\s*/i) + .map((artist) => artist.trim()) + .filter(Boolean); + return applyArtistAliases(split.length > 0 ? split : [primary], thresholds.artistAliases); +} + function overlapScore(source: string[], candidate: string[]): number { const sourceSet = new Set(source); const candidateSet = new Set(candidate); @@ -172,7 +437,7 @@ function getStatus( method: MatchingMethod, thresholds: MatchingThresholds ): TrackMatchStatus { - if (method === "isrc") { + if (method === "isrc" && confidence >= thresholds.highConfidence) { return "exact"; } @@ -187,6 +452,18 @@ function getStatus( return "unmatched"; } +function stageRank(stage: CandidateScoreDetail["stage"]): number { + return [ + "exact_isrc", + "title_primary_artist", + "title_artist_overlap", + "title_artists_duration", + "album_release_year", + "fuzzy", + "rejected" + ].indexOf(stage); +} + function toUnmatched(sourceTrack: NormalizedTrack, reason: string): TrackMatch { return { sourceTrack, @@ -204,3 +481,22 @@ function toUnmatched(sourceTrack: NormalizedTrack, reason: string): TrackMatch { reasons: [reason] }; } + +function mergeThresholds(input: Partial): MatchingThresholds { + return { + ...defaultMatchingThresholds, + ...input, + weights: { + ...defaultMatchingThresholds.weights, + ...input.weights + }, + artistAliases: { + ...defaultMatchingThresholds.artistAliases, + ...input.artistAliases + } + }; +} + +function clamp(value: number): number { + return Math.max(0, Math.min(1, value)); +} diff --git a/src/lib/matching/normalize.ts b/src/lib/matching/normalize.ts index 0a4b7e2..5b1567c 100644 --- a/src/lib/matching/normalize.ts +++ b/src/lib/matching/normalize.ts @@ -1,12 +1,28 @@ const featurePattern = /\s*(\(|\[)?\s*(feat\.?|ft\.?|featuring)\s+[^)\]]+(\)|\])?/gi; const versionPatterns = [ - /\s*-\s*(remaster(ed)?|[0-9]{4}\s+remaster(ed)?).*$/gi, - /\s*-\s*(live|radio edit|edit|explicit|clean|album version|single version).*$/gi, - /\s*\((remaster(ed)?|[0-9]{4}\s+remaster(ed)?|live|radio edit|edit|explicit|clean|album version|single version)\)\s*/gi, - /\s*\[(remaster(ed)?|[0-9]{4}\s+remaster(ed)?|live|radio edit|edit|explicit|clean|album version|single version)\]\s*/gi + /\s*-\s*(remaster(ed)?|[0-9]{4}\s+remaster(ed)?|deluxe( edition)?|anniversary( edition)?).*$/gi, + /\s*-\s*(live|acoustic|radio edit|extended mix|extended|edit|explicit|clean|album version|single version|instrumental|karaoke|cover|sped up|sped-up|slowed( and | & )reverb).*$/gi, + /\s*\((remaster(ed)?|[0-9]{4}\s+remaster(ed)?|deluxe( edition)?|anniversary( edition)?|live|acoustic|radio edit|extended mix|extended|edit|explicit|clean|album version|single version|instrumental|karaoke|cover|sped up|sped-up|slowed( and | & )reverb)\)\s*/gi, + /\s*\[(remaster(ed)?|[0-9]{4}\s+remaster(ed)?|deluxe( edition)?|anniversary( edition)?|live|acoustic|radio edit|extended mix|extended|edit|explicit|clean|album version|single version|instrumental|karaoke|cover|sped up|sped-up|slowed( and | & )reverb)\]\s*/gi ]; +export type VersionTrait = + | "remastered" + | "deluxe" + | "anniversary" + | "live" + | "acoustic" + | "radio_edit" + | "extended_mix" + | "clean" + | "explicit" + | "instrumental" + | "karaoke" + | "cover" + | "sped_up" + | "slowed_reverb"; + export function normalizeText(input: string): string { return input .normalize("NFKD") @@ -43,3 +59,40 @@ export function normalizeDurationBucket(durationMs: number | null): number | nul export function getPrimaryArtist(artists: string[]): string | null { return artists.length > 0 ? artists[0] : null; } + +export function detectVersionTraits(input: string): Set { + const normalized = normalizeText(input); + const traits = new Set(); + if (/\b(remaster|remastered)\b/.test(normalized)) traits.add("remastered"); + if (/\bdeluxe\b/.test(normalized)) traits.add("deluxe"); + if (/\banniversary\b/.test(normalized)) traits.add("anniversary"); + if (/\blive\b/.test(normalized)) traits.add("live"); + if (/\bacoustic\b/.test(normalized)) traits.add("acoustic"); + if (/\bradio edit\b|\bradio\b/.test(normalized)) traits.add("radio_edit"); + if (/\bextended mix\b|\bextended\b/.test(normalized)) traits.add("extended_mix"); + if (/\bclean\b/.test(normalized)) traits.add("clean"); + if (/\bexplicit\b/.test(normalized)) traits.add("explicit"); + if (/\binstrumental\b/.test(normalized)) traits.add("instrumental"); + if (/\bkaraoke\b/.test(normalized)) traits.add("karaoke"); + if (/\bcover\b|\bcovered by\b/.test(normalized)) traits.add("cover"); + if (/\bsped up\b|\bspedup\b/.test(normalized)) traits.add("sped_up"); + if (/\bslowed\b|\breverb\b/.test(normalized)) traits.add("slowed_reverb"); + return traits; +} + +export function applyArtistAliases( + artists: string[], + aliases: Record = {} +): string[] { + const expanded = new Set(); + for (const artist of artists) { + const normalized = normalizeArtistName(artist); + if (!normalized) continue; + expanded.add(normalized); + for (const alias of aliases[normalized] ?? []) { + const normalizedAlias = normalizeArtistName(alias); + if (normalizedAlias) expanded.add(normalizedAlias); + } + } + return [...expanded]; +} diff --git a/src/lib/models/match.ts b/src/lib/models/match.ts index e1b697b..9d2bf5d 100644 --- a/src/lib/models/match.ts +++ b/src/lib/models/match.ts @@ -15,6 +15,7 @@ export type TrackMatchStatus = z.infer; export const matchingMethodSchema = z.enum([ "isrc", "title_primary_artist", + "title_artist_overlap", "title_artists_duration", "album_release_year", "fuzzy", diff --git a/supabase/migrations/20260727193000_matching_engine_v2.sql b/supabase/migrations/20260727193000_matching_engine_v2.sql new file mode 100644 index 0000000..68f3f27 --- /dev/null +++ b/supabase/migrations/20260727193000_matching_engine_v2.sql @@ -0,0 +1,6 @@ +alter table public.track_matches + drop constraint if exists track_matches_matching_method_check; + +alter table public.track_matches + add constraint track_matches_matching_method_check + check (matching_method in ('isrc', 'title_primary_artist', 'title_artist_overlap', 'title_artists_duration', 'album_release_year', 'fuzzy', 'none'));