refactor(playlists): share provider route coverage

This commit is contained in:
joshpatra committed 2026-07-27 03:36:00 -04:00
1 parent 2ad77c5298
commit 0a7932f205
9 files changed
+34 -30

No files matched your search

@@ -77,6 +77,9 @@ public sealed class PlaylistCoverageMathTests
Assert.Equal(1, projection.MatchedCount);
Assert.Equal(1, projection.ReviewCount);
Assert.Equal(1, projection.RejectedCount);
Assert.Equal(1, projection.RouteCounts["jellyfin"]);
Assert.Equal(1, projection.RouteCounts["provider"]);
Assert.Equal(1, projection.RouteCounts["unresolved"]);
}
private static DurablePlaylistEntryProjection Entry(
@@ -994,6 +994,11 @@ public sealed class PlaylistLinksController(
unmatchedCount = projection?.MissingCount ?? 0,
playableCount = projection?.PlayableCount ?? 0,
materializedCount = projection?.MaterializedCount ?? 0,
routeCoverage = projection?.RouteCounts.Select(item => new
{
providerId = item.Key,
count = item.Value
}).ToArray() ?? [],
metrics = new
{
total = projection?.TotalCount ?? 0,
@@ -1028,6 +1033,11 @@ public sealed class PlaylistLinksController(
localCount = value.LocalCount,
externalCount = value.ExternalCount,
unresolvedCount = value.MissingCount,
routeCoverage = value.RouteCounts.Select(item => new
{
providerId = item.Key,
count = item.Value
}),
durationMs = value.DurationMilliseconds,
unknownDurationCount = value.UnknownDurationCount,
materializationVerification = value.VerificationCode == null ? null : new
@@ -60,6 +60,12 @@ public sealed record DurablePlaylistProjection(
item.MatchState is TrackMatchState.Suggested or TrackMatchState.Ambiguous);
public int RejectedCount => Entries.Count(item => item.MatchState == TrackMatchState.Rejected);
public int PlayableCount => LocalCount + ExternalCount;
public IReadOnlyDictionary<string, int> RouteCounts => Entries
.GroupBy(item => item.RouteProviderId ??
(item.RouteKind == "local" ? TargetProtocol :
item.RouteKind == "unmatched" ? "unresolved" : item.RouteKind),
StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
public int UnknownDurationCount => Entries.Count(item => !item.DurationMilliseconds.HasValue);
public long? DurationMilliseconds => Entries.Any(item => item.DurationMilliseconds.HasValue)
? Entries.Sum(item => item.DurationMilliseconds ?? 0)
+1 -2
View File
@@ -1104,6 +1104,7 @@
}
.coverage-track {
display: flex;
height: 0.22rem;
overflow: hidden;
border-radius: 999px;
@@ -1111,9 +1112,7 @@
}
.coverage-track > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--route-color);
}
+2
View File
@@ -295,6 +295,7 @@ export type PlaylistLink = {
unmatchedCount: number;
playableCount: number;
materializedCount: number;
routeCoverage: Array<{ providerId: string; count: number }>;
metrics: PlaylistLinkMetrics;
};
@@ -331,6 +332,7 @@ export type PlaylistDetails = {
localCount: number;
externalCount: number;
unresolvedCount: number;
routeCoverage: Array<{ providerId: string; count: number }>;
durationMs?: number | null;
unknownDurationCount: number;
tracks: PlaylistTrack[];
@@ -16,7 +16,6 @@
filterTracks,
formatDuration,
providerColor,
summarizeRoutes,
type PlaylistSort,
type TrackSort,
} from "$lib/playlists";
@@ -57,9 +56,6 @@
const visibleTracks = $derived(
details ? filterTracks(details.tracks, trackQuery, routeFilter, trackSort) : [],
);
const routeCoverage = $derived(
details ? summarizeRoutes(details.tracks, details.targetProtocol) : [],
);
const selected = $derived(playlists.find((playlist) => playlist.id === selectedId));
function provider(providerId: string) {
@@ -293,9 +289,12 @@
<span>{providerName(playlist.targetProtocol)}</span>
</small>
<span class="coverage-track" aria-label={`${percent(playlist.playableCount, playlist.trackCount)} percent playable`}>
<span
style={`width:${percent(playlist.playableCount, playlist.trackCount)}%;--route-color:${providerColor(playlist.sourceProviderId)}`}
></span>
{#each playlist.routeCoverage as route}
<span
title={`${providerName(route.providerId)}: ${route.count}`}
style={`width:${percent(route.count, playlist.trackCount)}%;--route-color:${providerColor(route.providerId)}`}
></span>
{/each}
</span>
</span>
<span class="playlist-numbers">
@@ -347,7 +346,7 @@
<span>{providerName(details.targetProtocol)}</span>
</div>
<div class="coverage-stack" aria-label={`${percent(details.trackCount - details.unresolvedCount, details.trackCount)} percent playable`}>
{#each routeCoverage as route}
{#each details.routeCoverage as route}
<span
title={`${providerName(route.providerId)}: ${route.count}`}
style={`width:${percent(route.count, details.trackCount)}%;--route-color:${providerColor(route.providerId)}`}
+1 -5
View File
@@ -6,7 +6,6 @@ import {
formatDuration,
orderPlaylistSources,
providerColor,
summarizeRoutes,
} from "./playlists";
const playlist = (values: Partial<PlaylistLink>): PlaylistLink => ({
@@ -22,6 +21,7 @@ const playlist = (values: Partial<PlaylistLink>): PlaylistLink => ({
unmatchedCount: 1,
playableCount: 9,
materializedCount: 8,
routeCoverage: [{ providerId: "target", count: 9 }, { providerId: "unresolved", count: 1 }],
metrics: { total: 10, matched: 9, unresolved: 1, review: 0, rejected: 0, playable: 9, materialized: 8 },
...values,
});
@@ -53,10 +53,6 @@ describe("playlist presentation", () => {
expect(formatDuration(3_723_000)).toBe("1:02:03");
expect(formatDuration(null)).toBe("—");
expect(providerColor("any-extension")).toMatch(/^hsl\(\d+ 72% 58%\)$/);
expect(summarizeRoutes(tracks, "local-provider")).toEqual([
{ providerId: "external", count: 1 },
{ providerId: "local-provider", count: 1 },
]);
});
it("orders local targets, Spotify, then configured playlist Sources", () => {
-15
View File
@@ -81,21 +81,6 @@ export function providerColor(providerId: string) {
return `hsl(${hash % 360} 72% 58%)`;
}
export function summarizeRoutes(tracks: PlaylistTrack[], targetProtocol: string) {
const counts = new Map<string, number>();
for (const track of tracks) {
const providerId =
track.routeProviderId ??
(track.routeKind === "local"
? targetProtocol
: track.routeKind === "external"
? "external"
: "unresolved");
counts.set(providerId, (counts.get(providerId) ?? 0) + 1);
}
return [...counts].map(([providerId, count]) => ({ providerId, count }));
}
export function orderPlaylistSources(
accounts: PlaylistSourceAccount[],
providerOrder: string[],
+4
View File
@@ -70,6 +70,7 @@ const responses: Record<string, unknown> = {
sourceProviderId: "lumen-audio", targetProtocol: "jellyfin",
materializationMode: "reconcile", revision: 1, trackCount: 1,
matchedCount: 0, unmatchedCount: 0, playableCount: 1, materializedCount: 1,
routeCoverage: [{ providerId: "lumen-audio", count: 1 }],
metrics: { total: 1, matched: 0, unresolved: 0, review: 1, rejected: 0, playable: 1, materialized: 1 },
}],
},
@@ -191,6 +192,7 @@ async function mockApi(page: Page, options: { delay?: string; fail?: string[] }
name: "Test playlist", sourceProviderId: "lumen-audio", targetProtocol: "jellyfin",
retrievedAt: "2026-01-01", completedAt: "2026-01-01", trackCount: 1,
localCount: 0, externalCount: 1, unresolvedCount: 0, durationMs: 180_000,
routeCoverage: [{ providerId: "lumen-audio", count: 1 }],
unknownDurationCount: 0, tracks: [{
position: 1, externalSnapshotId: "snapshot", title: "Test song",
artists: ["Artist"], album: "Album", isrc: "US-AAA-26-00001",
@@ -566,9 +568,11 @@ test("Playlist details use a responsive dialog and track rows open mapping revie
await page.setViewportSize({ width: 390, height: 844 });
await mockApi(page);
await page.goto("#/library/playlists");
await expect(page.locator('.playlist-row [title="Lumen Audio: 1"]')).toBeVisible();
await page.getByRole("button", { name: /Test playlist/ }).click();
const dialog = page.getByRole("dialog", { name: "Test playlist" });
await expect(dialog).toBeVisible();
await expect(dialog.locator('[title="Lumen Audio: 1"]')).toBeVisible();
await expect(dialog.getByRole("button", { name: "Sync" })).toBeInViewport();
await expect(dialog.getByRole("button", { name: "Rematch" })).toBeInViewport();
await expect(dialog.getByRole("button", { name: "Refresh" })).toBeInViewport();