mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
refactor(playlists): share provider route coverage
This commit is contained in:
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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user