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 ? (
+
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'));