feat(mappings): add confidence review workflow

This commit is contained in:
joshpatra committed 2026-07-27 02:40:28 -04:00
1 parent dd755f75aa
commit 7ed09f2f21
8 files changed
+163 -29

No files matched your search

+13 -3
View File
@@ -198,6 +198,7 @@ public sealed class TrackMatchesController(
[FromQuery] string? libraryScopeId = null,
[FromQuery] string? state = null,
[FromQuery] string? search = null,
[FromQuery] string? sort = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default)
@@ -209,6 +210,8 @@ public sealed class TrackMatchesController(
!state.Equals("matched", StringComparison.OrdinalIgnoreCase) &&
!Enum.TryParse<TrackMatchState>(state, true, out _))
return BadRequest(new { error = "State is not a valid match state" });
if (sort is not (null or "" or "confidence_desc" or "confidence_asc"))
return BadRequest(new { error = "Sort is not supported" });
var tenantId = session!.TenantId!.Value;
var userId = session.AllstarrUserId!.Value;
@@ -260,7 +263,13 @@ public sealed class TrackMatchesController(
return Row(snapshot, decision, manual, sourceIdentity, library, libraryByCanonical, identities);
})
.ToArray();
var rows = allRows.Where(row => MatchesStateFilter(row.State, state)).ToArray();
var filteredRows = allRows.Where(row => MatchesStateFilter(row.State, state));
var rows = sort switch
{
"confidence_desc" => filteredRows.OrderByDescending(row => row.Confidence).ToArray(),
"confidence_asc" => filteredRows.OrderBy(row => row.Confidence).ToArray(),
_ => filteredRows.ToArray()
};
var total = rows.Length;
var items = rows.Skip((page - 1) * pageSize).Take(pageSize).Select(row => row.Value).ToArray();
return Ok(new
@@ -272,6 +281,7 @@ public sealed class TrackMatchesController(
matched = allRows.Count(item => item.State is TrackMatchState.Accepted or TrackMatchState.Pinned),
accepted = allRows.Count(item => item.State is TrackMatchState.Accepted or TrackMatchState.Pinned),
unresolved = allRows.Count(item => item.State == TrackMatchState.Unresolved),
suggested = allRows.Count(item => item.State == TrackMatchState.Suggested),
review = allRows.Count(item => item.State is TrackMatchState.Suggested or TrackMatchState.Ambiguous),
rejected = allRows.Count(item => item.State == TrackMatchState.Rejected),
attention = allRows.Count(item => item.State is TrackMatchState.Unresolved or
@@ -570,7 +580,7 @@ public sealed class TrackMatchesController(
decidedAt = decision?.DecidedAt,
reviewedAt = manual?.CreatedAt
};
return new(state, $"{metadata.Title} {metadata.Artist} {metadata.Album} {snapshot.ProviderId} {track?.Title} {track?.Artist}", value);
return new(state, decision?.Confidence, $"{metadata.Title} {metadata.Artist} {metadata.Album} {snapshot.ProviderId} {track?.Title} {track?.Artist}", value);
}
private static (string? Title, string? Artist, string? Album, string? ArtworkUrl, string? Isrc, long? DurationMilliseconds) Metadata(string json)
@@ -692,5 +702,5 @@ public sealed class TrackMatchesController(
session = found; return true;
}
private sealed record MatchRow(TrackMatchState State, string SearchText, object Value);
private sealed record MatchRow(TrackMatchState State, double? Confidence, string SearchText, object Value);
}
+25 -11
View File
@@ -1417,7 +1417,7 @@
.mapping-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--color-edge);
}
@@ -1479,7 +1479,7 @@
}
.mapping-filters {
grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 0.55fr) auto auto;
grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 0.55fr) auto auto auto;
align-items: end;
}
@@ -1540,7 +1540,8 @@
gap: 0.65rem;
}
.mapping-source > span:last-child {
.mapping-source > span:last-child,
.mapping-source > div {
display: grid;
min-width: 0;
gap: 0.18rem;
@@ -1561,6 +1562,11 @@
white-space: nowrap;
}
.mapping-source > div > span {
color: var(--color-ink-muted);
font-size: 0.63rem;
}
.mapping-art {
width: 2.6rem;
height: 2.6rem;
@@ -1850,6 +1856,11 @@
padding: 0.9rem 1rem;
}
.match-dialog > .mapping-source {
border-bottom: 1px solid var(--color-edge);
padding: 0.9rem 1rem;
}
.dialog-section-heading {
display: flex;
align-items: center;
@@ -1889,7 +1900,7 @@
.candidate-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.65rem;
border: 1px solid var(--color-edge);
@@ -1898,24 +1909,24 @@
padding: 0.65rem;
}
.candidate-card > div:first-child {
.candidate-copy {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.candidate-card > div:first-child strong,
.candidate-card > div:first-child small {
.candidate-copy strong,
.candidate-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.candidate-card > div:first-child strong {
.candidate-copy strong {
font-size: 0.72rem;
}
.candidate-card > div:first-child small {
.candidate-copy small {
color: var(--color-ink-muted);
font-size: 0.6rem;
}
@@ -1927,6 +1938,8 @@
}
.candidate-card > button {
grid-column: 4;
grid-row: 1;
min-height: 2.1rem;
border: 1px solid var(--color-edge);
border-radius: 0.55rem;
@@ -2161,7 +2174,7 @@
border-bottom: 1px solid var(--color-edge);
}
.mapping-metrics .metric-card:nth-child(2) {
.mapping-metrics .metric-card:nth-child(even) {
border-right: 0;
}
@@ -2225,11 +2238,12 @@
}
.candidate-card {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto;
}
.candidate-card > button {
grid-column: 1 / -1;
grid-row: auto;
}
.target-results > button {
+2
View File
@@ -420,6 +420,7 @@ export type MatchReviewResponse = {
matched: number;
accepted: number;
unresolved: number;
suggested: number;
review: number;
rejected: number;
attention: number;
@@ -836,6 +837,7 @@ export const matchReview = {
pageSize?: number;
search?: string;
state?: string;
sort?: string;
libraryScopeId?: string;
}) => {
const query = new URLSearchParams();
+63 -8
View File
@@ -14,12 +14,14 @@
currentTarget,
isAttention,
percent,
scoreComponents,
} from "$lib/mappings";
import { formatDuration } from "$lib/playlists";
import { liveUpdates } from "$lib/live-updates.svelte";
type DestructiveAction = { kind: "reject" | "clear"; match: MatchReviewItem };
let { initialSearch = "" }: { initialSearch?: string } = $props();
let { initialSearch = "", initialReview = "" }: { initialSearch?: string; initialReview?: string } = $props();
let data = $state<MatchReviewResponse | null>(null);
let providers = $state<ProviderDefinition[]>([]);
@@ -28,6 +30,7 @@
let searchInput = $state("");
let search = $state("");
let libraryScopeId = $state("");
let sort = $state("");
let page = $state(1);
let loading = $state(true);
let refreshing = $state(false);
@@ -38,6 +41,7 @@
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let dialogOpen = $state(false);
let initialReviewOpened = $state(false);
let selected = $state<MatchReviewItem | null>(null);
let destructiveOpen = $state(false);
let destructive = $state<DestructiveAction | null>(null);
@@ -74,8 +78,16 @@
pageSize: 50,
search,
state: stateFilter,
sort,
libraryScopeId,
});
if (initialReview && !initialReviewOpened) {
const requested = data.matches.find((item) => item.externalSnapshotId === initialReview);
if (requested) {
initialReviewOpened = true;
openMatch(requested);
}
}
} catch (cause) {
error = cause instanceof Error ? cause.message : "Match review is unavailable.";
} finally {
@@ -139,6 +151,25 @@
}
}
async function accept(match: MatchReviewItem) {
const candidate = match.candidates.find((item) => item.libraryTrackId);
if (!candidate?.libraryTrackId || action) return openMatch(match);
action = match.externalSnapshotId;
try {
await matchReview.resolve(match.externalSnapshotId, {
targetType: "local",
libraryTrackId: candidate.libraryTrackId,
reason: "Accepted highest-confidence automatic candidate",
});
feedback = "Highest-confidence candidate accepted.";
await load();
} catch (cause) {
feedback = cause instanceof Error ? cause.message : "The candidate could not be accepted.";
} finally {
action = "";
}
}
function confirm(kind: DestructiveAction["kind"], match: MatchReviewItem) {
destructive = { kind, match };
destructiveOpen = true;
@@ -226,6 +257,9 @@
<button class="metric-card attention-card" aria-pressed={stateFilter === "attention"} onclick={() => setState("attention")}>
<span>Needs attention</span><strong>{data.stats.attention}</strong>
</button>
<button class="metric-card" aria-pressed={stateFilter === "suggested"} onclick={() => setState("suggested")}>
<span>Suggested / High likelihood</span><strong>{data.stats.suggested}</strong>
</button>
<button class="metric-card" aria-pressed={stateFilter === "matched"} onclick={() => setState("matched")}>
<span>Matched</span><strong>{data.stats.matched}</strong>
</button>
@@ -249,11 +283,19 @@
<option value="attention">Needs attention</option>
<option value="">All tracks</option>
<option value="matched">Matched</option>
<option value="suggested">Suggested</option>
<option value="suggested">Suggested / High likelihood</option>
<option value="ambiguous">Ambiguous</option>
<option value="rejected">Rejected</option>
</select>
</label>
<label>
<span>Confidence</span>
<select bind:value={sort} onchange={() => { page = 1; void load(); }}>
<option value="">Default order</option>
<option value="confidence_desc">Highest first</option>
<option value="confidence_asc">Lowest first</option>
</select>
</label>
<button class="button-primary" type="submit">Apply</button>
</form>
@@ -262,6 +304,7 @@
<div class="mapping-rows">
{#each data.matches as match}
{@const target = currentTarget(match)}
{@const candidate = match.candidates[0]}
<article class:needs-attention={isAttention(match.state)} class="mapping-row">
<div class="mapping-track-copy">
<div class="mapping-source">
@@ -274,7 +317,7 @@
</span>
<span>
<strong>{match.title || "Unknown track"}</strong>
<small>{match.artist || "Unknown artist"}{match.album ? ` · ${match.album}` : ""}</small>
<small>{match.artist || "Unknown artist"}{match.album ? ` · ${match.album}` : ""} · {formatDuration(match.durationMilliseconds)}{match.isrc ? ` · ${match.isrc}` : ""}</small>
</span>
</div>
@@ -286,11 +329,15 @@
<span class="mapping-arrow" aria-hidden="true">→</span>
<span class:unresolved={!target} class="mapping-route-node">
{#if target}
<ProviderMark
id={target.providerId === "local" ? backend.toLowerCase() : target.providerId}
definition={provider(target.providerId)}
label={providerName(target.providerId)}
/>
{#if match.candidateArtworkUrl}
<span class="media-art mapping-art"><img src={match.candidateArtworkUrl} alt="" loading="lazy" /></span>
{:else}
<ProviderMark
id={target.providerId === "local" ? backend.toLowerCase() : target.providerId}
definition={provider(target.providerId)}
label={providerName(target.providerId)}
/>
{/if}
{:else}
<span class="mapping-route-missing" aria-hidden="true">?</span>
{/if}
@@ -310,6 +357,11 @@
{#each [...match.reasons, ...match.warnings].slice(0, 3) as reason}
<span>{reason.replaceAll("_", " ")}</span>
{/each}
{#if candidate}
{#each scoreComponents(candidate) as [name, value]}
<span>{name.replaceAll("_", " ")} {percent(value)}</span>
{/each}
{/if}
<details>
<summary>Technical details</summary>
<dl>
@@ -327,6 +379,9 @@
</div>
<div class="mapping-row-actions">
{#if match.candidates.some((candidate) => candidate.libraryTrackId)}
<button class="button-primary" type="button" disabled={action === match.externalSnapshotId} onclick={() => void accept(match)}>Accept</button>
{/if}
<button class="button-primary" type="button" onclick={() => openMatch(match)}>Review match</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="track-menu-trigger" aria-label={`More actions for ${match.title || "track"}`}>•••</DropdownMenu.Trigger>
+26 -1
View File
@@ -62,6 +62,12 @@
return provider(providerId)?.name ?? providerId;
}
function candidateArtwork(backendItemId?: string | null) {
return backendItemId
? `/api/admin/downloads/artwork/${encodeURIComponent(backendItemId)}`
: "";
}
function switchMode(mode: "local" | "provider") {
targetMode = mode;
results = [];
@@ -144,6 +150,18 @@
<Dialog.Close class="icon-button" aria-label="Close match dialog">×</Dialog.Close>
</header>
<section class="mapping-source">
<span class="media-art mapping-art">
{#if match.sourceArtworkUrl}<img src={match.sourceArtworkUrl} alt="" />{:else}<ProviderMark id={match.providerId} definition={provider(match.providerId)} />{/if}
</span>
<div>
<small>Source track</small>
<strong>{match.title || "Unknown track"}</strong>
<span>{match.artist || "Unknown artist"}{match.album ? ` · ${match.album}` : ""}</span>
<span>{formatDuration(match.durationMilliseconds)}{match.isrc ? ` · ISRC ${match.isrc}` : ""}</span>
</div>
</section>
<section class="automatic-candidates">
<div class="dialog-section-heading">
<div><strong>Automatic candidates</strong><small>Same scores used by automatic matching</small></div>
@@ -153,9 +171,16 @@
<div class="candidate-list">
{#each match.candidates.slice(0, 5) as candidate}
<article class="candidate-card">
<div>
<span class="media-art mapping-art">
{#if candidateArtwork(candidate.backendItemId)}<img src={candidateArtwork(candidate.backendItemId)} alt="" loading="lazy" />{:else}<span aria-hidden="true">♪</span>{/if}
</span>
<div class="candidate-copy">
<strong>{candidate.title || candidate.backendItemId || "Indexed track"}</strong>
<small>{candidate.artist || "Unknown artist"}{candidate.album ? ` · ${candidate.album}` : ""}</small>
<small>{formatDuration(candidate.durationMilliseconds)}{candidate.candidateIsrc ? ` · ISRC ${candidate.candidateIsrc}` : ""}</small>
{#each Object.entries(candidate.providerTrackIds ?? {}) as [providerId, externalId]}
<small>{providerName(providerId)} · {externalId}</small>
{/each}
</div>
<span class="candidate-confidence">{percent(candidate.confidence)}</span>
<div class="score-components">
@@ -420,6 +420,10 @@
{#each track.providerRoutes as route}
<small>{providerName(route.providerId)} · {route.externalId}{route.pinned ? " · pinned" : ""}</small>
{/each}
<a
class="button-secondary"
href={`#/library/mappings?search=${encodeURIComponent(track.title)}&review=${encodeURIComponent(track.externalSnapshotId)}`}
>Review match</a>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
+4 -1
View File
@@ -45,7 +45,10 @@
: route === "/library/playlists"
? { initialId: routeQuery.get("playlist") ?? "" }
: route === "/library/mappings"
? { initialSearch: routeQuery.get("search") ?? "" }
? {
initialSearch: routeQuery.get("search") ?? "",
initialReview: routeQuery.get("review") ?? "",
}
: route === "/library/cached"
? { storage: "cache" }
: route === "/library/kept"
+26 -5
View File
@@ -145,14 +145,17 @@ async function mockApi(page: Page, options: { delay?: string; fail?: string[] }
body = {
matches: [{
externalSnapshotId: "snapshot", providerId: "lumen-audio", libraryScopeId: "library",
state: "review", decisionSource: "automatic", confidence: 0.82, threshold: 0.9,
title: "Test song", artist: "Artist", album: "Album", durationMilliseconds: 180_000,
state: "suggested", decisionSource: "automatic", confidence: 0.82, threshold: 0.9,
title: "Test song", artist: "Artist", album: "Album", isrc: "US-AAA-26-00001",
durationMilliseconds: 180_000,
providerIdentities: [], reasons: ["title_match"], warnings: [], candidates: [{
libraryTrackId: "local-track", title: "Test song", artist: "Artist", album: "Album",
confidence: 0.82, durationMilliseconds: 180_000,
libraryTrackId: "local-track", backendItemId: "backend-track", title: "Test song",
artist: "Artist", album: "Album", candidateIsrc: "US-AAA-26-00001",
providerTrackIds: { "lumen-audio": "provider-track" },
confidence: 0.82, durationMilliseconds: 180_000, components: { title: 1 },
}],
}],
stats: { total: 1, matched: 0, accepted: 0, unresolved: 0, review: 1, rejected: 0, attention: 1 },
stats: { total: 1, matched: 0, accepted: 0, unresolved: 0, suggested: 1, review: 1, rejected: 0, attention: 1 },
pagination: { page: 1, pageSize: 50, total: 1, totalPages: 1 },
};
if (url.pathname.includes("/api/admin/playlist-sources/") && url.pathname.endsWith("/playlists"))
@@ -423,3 +426,21 @@ test("Add playlist prioritizes local and configured Sources on mobile", async ({
await dialog.getByRole("button", { name: "Add playlist" }).click();
await expect(dialog).toBeHidden();
});
test("Suggested mappings sort by confidence and deep links open review", async ({ page }) => {
await mockApi(page);
await page.goto("#/library/mappings?search=Test%20song&review=snapshot");
const dialog = page.getByRole("dialog", { name: "Test song" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("ISRC US-AAA-26-00001")).toHaveCount(2);
await expect(dialog.getByText("Lumen Audio · provider-track")).toBeVisible();
await dialog.getByRole("button", { name: "Close match dialog" }).click();
const request = page.waitForRequest((item) =>
item.url().includes("/api/admin/track-matches") &&
new URL(item.url()).searchParams.get("sort") === "confidence_desc");
await page.getByLabel("Confidence").selectOption("confidence_desc");
await request;
await page.getByRole("button", { name: /Suggested.*High likelihood/ }).click();
await expect(page.getByRole("button", { name: "Accept" })).toBeVisible();
});