Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions PLATFORM_LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
18 changes: 17 additions & 1 deletion src/app/api/jobs/[jobId]/review/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/jobs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/oauth/[platform]/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/app/convert/[jobId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ export default async function ConversionJobPage({
</p>
</div>
) : null}
{summary?.matchingMetrics ? (
<div className="mt-6 rounded-md border border-white/10 p-4 text-sm text-white/65">
<h3 className="font-medium text-white">Matching metrics</h3>
<p className="mt-2">
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)}.
</p>
</div>
) : null}
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-md border border-white/10 p-4">
<h3 className="font-medium">Low-confidence tracks</h3>
Expand Down Expand Up @@ -130,3 +146,7 @@ export default async function ConversionJobPage({
</main>
);
}

function formatRate(value: number): string {
return `${Math.round(value * 100)}%`;
}
5 changes: 4 additions & 1 deletion src/lib/adapters/spotify/conversion.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,6 +14,7 @@ export interface SpotifyToSpotifyConversionResult {
failedTracks: number;
lowConfidenceMatches: TrackMatch[];
failedTrackIds: string[];
matchingMetrics: MatchingMetrics;
}

export async function convertSpotifyToSpotify(input: {
Expand Down Expand Up @@ -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 })
};
}
6 changes: 4 additions & 2 deletions src/lib/adapters/youtube/conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +18,7 @@ export interface SpotifyToYouTubeConversionResult {
lowConfidenceMatches: TrackMatch[];
unmatchedMatches: TrackMatch[];
quota: YouTubeQuotaMetrics;
matchingMetrics: MatchingMetrics;
}

export async function convertSpotifyToYouTube(input: {
Expand Down Expand Up @@ -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 })
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib/api/jobs-store.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,6 +21,7 @@ export interface ConversionJobSummary {
failedTracks: number;
lowConfidenceTracks?: number;
quota?: YouTubeQuotaMetrics;
matchingMetrics?: MatchingMetrics;
}

export interface ReviewDecision {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/youtube-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
157 changes: 157 additions & 0 deletions src/lib/matching/benchmark-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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
};
}
Loading
Loading