mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(mappings): add confidence review workflow
This commit is contained in:
8 files changed
+163
-29
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user