feat(webui): migrate Library playlists

This commit is contained in:
joshpatra committed 2026-07-27 00:22:01 -04:00
1 parent 9ca6111a97
commit 0414d933bb
11 files changed
+1372 -3

No files matched your search

@@ -18,6 +18,7 @@ public sealed class PlaylistLinksControllerContractTests
var expected = new Dictionary<string, string?>
{
[nameof(PlaylistLinksController.List)] = null,
[nameof(PlaylistLinksController.Details)] = "{id:guid}",
[nameof(PlaylistLinksController.Create)] = null,
[nameof(PlaylistLinksController.Update)] = "{id:guid}",
[nameof(PlaylistLinksController.Refresh)] = "{id:guid}/refresh",
@@ -115,7 +116,7 @@ public sealed class PlaylistLinksControllerContractTests
private static PlaylistLinksController Controller()
{
var controller = new PlaylistLinksController(
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
return controller;
}
@@ -512,10 +512,14 @@ public sealed class PlaylistOrchestrationIntegrationTests : IAsyncLifetime
Entry(1, "entry-projection-2", "source-2", "Two"));
await _service.RunAsync(Context(), new(_link, 73));
var projection = await new DurablePlaylistProjectionReader(_factory)
.ReadByNameAsync(_tenant, _user, "Provider Mix");
var reader = new DurablePlaylistProjectionReader(_factory);
var projection = await reader.ReadByNameAsync(_tenant, _user, "Provider Mix");
var projectionByLink = await reader.ReadByLinkIdAsync(_tenant, null, _link);
Assert.NotNull(projection);
Assert.NotNull(projectionByLink);
Assert.Equal(projection.SnapshotId, projectionByLink.SnapshotId);
Assert.Equal(2, projectionByLink.LocalCount);
Assert.Equal(2, projection.Entries.Count);
Assert.Equal(2, projection.LocalCount);
Assert.Equal(0, projection.MissingCount);
@@ -25,6 +25,7 @@ namespace allstarr.Controllers;
public sealed class PlaylistLinksController(
IDbContextFactory<AllstarrDbContext> contextFactory,
IPlaylistPersistenceService playlists,
DurablePlaylistProjectionReader projections,
ITrackMatchRepository matches,
PlaylistOrchestrationService orchestration,
DurableJobQueue jobs,
@@ -519,6 +520,21 @@ public sealed class PlaylistLinksController(
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Details(Guid id, CancellationToken cancellationToken)
{
return await Execute(async session =>
{
await LoadScopedLink(session, id, cancellationToken);
var projection = await projections.ReadByLinkIdAsync(
session.TenantId!.Value,
session.IsAdministrator ? null : session.AllstarrUserId,
id,
cancellationToken);
return projection == null ? NotFound() : Ok(ToProjectionDto(projection));
});
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreatePlaylistLinkRequest request, CancellationToken cancellationToken)
{
@@ -1103,6 +1119,63 @@ public sealed class PlaylistLinksController(
},
virtualPlaylistId = PlaylistVirtualizationService.CreateProtocolId(value.Id)
};
private static object ToProjectionDto(DurablePlaylistProjection value) => new
{
id = value.LinkId,
snapshotId = value.SnapshotId,
snapshotVersion = value.SnapshotVersion,
name = value.Name,
sourceProviderId = value.SourceProviderId,
targetProtocol = value.TargetProtocol,
targetPlaylistId = value.TargetPlaylistId,
artworkUrl = value.ArtworkReferenceKey == null ? null :
$"/api/admin/playlist-sources/{value.ProviderAccountId}/playlists/{Uri.EscapeDataString(value.SourcePlaylistId)}/artwork",
retrievedAt = value.RetrievedAt,
completedAt = value.CompletedAt,
syncState = value.SyncState?.ToString().ToLowerInvariant(),
trackCount = value.TotalCount,
localCount = value.LocalCount,
externalCount = value.ExternalCount,
unresolvedCount = value.MissingCount,
durationMs = value.DurationMilliseconds,
unknownDurationCount = value.UnknownDurationCount,
materializationVerification = value.VerificationCode == null ? null : new
{
code = value.VerificationCode,
plannedTrackCount = value.PlannedTargetTrackCount,
plannedDurationMs = value.PlannedTargetDurationMilliseconds,
reportedTrackCount = value.VerifiedTargetTrackCount,
reportedDurationMs = value.VerifiedTargetDurationMilliseconds,
verifiedAt = value.VerifiedAt
},
tracks = value.Entries.Select(item => new
{
position = item.Position,
externalSnapshotId = item.ExternalSnapshotId,
title = item.Title,
artists = item.Artists,
album = item.Album,
isrc = item.Isrc,
durationMs = item.DurationMilliseconds,
durationProvenance = item.DurationProvenance,
durationRetrievedAt = item.DurationRetrievedAt,
artworkUrl = item.BackendItemId != null
? $"/api/admin/downloads/artwork/{Uri.EscapeDataString(item.BackendItemId)}"
: item.RouteKind == "external" && item.RouteProviderId != null
? $"/api/admin/downloads/artwork/{Uri.EscapeDataString($"ext-{item.RouteProviderId}-song-{item.ExternalId}")}"
: null,
backendItemId = item.BackendItemId,
routeKind = item.RouteKind,
routeProviderId = item.RouteProviderId,
matchState = item.MatchState?.ToString().ToLowerInvariant(),
providerRoutes = item.ProviderRoutes.Select(route => new
{
providerId = route.ProviderId,
externalId = route.ExternalId,
pinned = route.IsManual
})
})
};
private sealed record PlaylistListMetrics(
int Total,
int Matched,
@@ -80,6 +80,38 @@ public sealed class DurablePlaylistProjectionReader(
.FirstOrDefaultAsync(cancellationToken);
if (snapshot == null) return null;
return await ProjectAsync(database, snapshot, tenantId, cancellationToken);
}
public async Task<DurablePlaylistProjection?> ReadByLinkIdAsync(
Guid tenantId,
Guid? ownerUserId,
Guid playlistLinkId,
CancellationToken cancellationToken = default)
{
await using var database = await factory.CreateDbContextAsync(cancellationToken);
var snapshots = database.PlaylistSourceSnapshots.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.PlaylistLinkId == playlistLinkId &&
item.PublishedAt.HasValue);
if (ownerUserId.HasValue)
snapshots = snapshots.Where(item => item.OwnerUserId == ownerUserId.Value);
var snapshot = await snapshots
.OrderByDescending(item => item.SnapshotVersion)
.ThenByDescending(item => item.RetrievedAt)
.FirstOrDefaultAsync(cancellationToken);
if (snapshot == null) return null;
return await ProjectAsync(database, snapshot, tenantId, cancellationToken);
}
private async Task<DurablePlaylistProjection> ProjectAsync(
AllstarrDbContext database,
PlaylistSourceSnapshotRecord snapshot,
Guid tenantId,
CancellationToken cancellationToken)
{
var ownerUserId = snapshot.OwnerUserId;
var link = await database.PlaylistLinks.AsNoTracking()
.SingleAsync(item => item.Id == snapshot.PlaylistLinkId, cancellationToken);
var entries = await database.PlaylistSourceEntries.AsNoTracking()
+559
View File
@@ -764,3 +764,562 @@
transition-duration: 0.01ms !important;
}
}
@layer components {
.playlist-layout {
display: grid;
min-height: 0;
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
gap: 1rem;
}
.playlist-list,
.playlist-detail {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.playlist-toolbar,
.playlist-hero {
display: flex;
align-items: center;
gap: 0.85rem;
border-bottom: 1px solid var(--color-edge);
padding: 1rem;
}
.playlist-toolbar {
justify-content: space-between;
}
.playlist-toolbar h2,
.playlist-hero h2 {
margin: 0.15rem 0 0;
font-family: var(--font-display);
font-size: 1.15rem;
}
.playlist-filters,
.track-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.45rem;
border-bottom: 1px solid var(--color-edge);
padding: 0.7rem;
}
.playlist-filters input,
.playlist-filters select,
.track-toolbar input,
.track-toolbar select {
width: 100%;
min-height: 2.35rem;
border: 1px solid var(--color-edge);
border-radius: 0.65rem;
background: var(--color-panel-raised);
color: var(--color-ink);
padding: 0 0.65rem;
font-size: 0.72rem;
}
.playlist-rows {
max-height: calc(100vh - 14.5rem);
overflow: auto;
}
.playlist-row {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
border: 0;
border-bottom: 1px solid var(--color-edge);
background: transparent;
color: inherit;
cursor: pointer;
padding: 0.7rem;
text-align: left;
}
.playlist-row:hover,
.playlist-row.active {
background: rgb(255 255 255 / 4%);
}
.playlist-row.active {
box-shadow: inset 3px 0 var(--color-signal);
}
.media-art {
display: grid;
flex: none;
place-items: center;
overflow: hidden;
border: 1px solid var(--color-edge);
border-radius: 0.7rem;
background: var(--color-panel-raised);
color: var(--color-ink-muted);
}
.media-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-art {
width: 3rem;
height: 3rem;
}
.playlist-copy,
.playlist-numbers,
.track-identity > span:last-child,
.route-cell > span {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.playlist-copy strong,
.track-identity strong {
overflow: hidden;
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-copy small,
.playlist-numbers small,
.track-identity small,
.route-cell small {
overflow: hidden;
color: var(--color-ink-muted);
font-size: 0.63rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-pair,
.hero-route {
display: flex !important;
min-width: 0;
align-items: center;
gap: 0.3rem !important;
}
.route-pair > span:not(.provider-mark) {
overflow: hidden;
text-overflow: ellipsis;
}
.provider-mark {
display: inline-grid;
width: 1.15rem;
height: 1.15rem;
flex: none;
place-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--route-color) 55%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--route-color) 15%, var(--color-panel-raised));
color: var(--route-color);
font-size: 0.53rem;
font-style: normal;
font-weight: 850;
}
.provider-mark img {
width: 72%;
height: 72%;
object-fit: contain;
}
.playlist-numbers {
justify-items: end;
font-size: 0.72rem;
}
.attention {
color: var(--color-danger) !important;
}
.coverage-track {
height: 0.22rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 8%);
}
.coverage-track > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--route-color);
}
.playlist-hero {
align-items: flex-start;
}
.hero-art {
width: 4.5rem;
height: 4.5rem;
border-radius: 0.9rem;
}
.playlist-hero-copy {
min-width: 0;
flex: 1;
}
.playlist-hero-copy h2 {
overflow: hidden;
font-size: 1.35rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-hero-copy > p:not(.eyebrow) {
margin: 0.35rem 0 0;
color: var(--color-ink-muted);
font-size: 0.7rem;
}
.hero-route {
margin-top: 0.55rem;
color: var(--color-ink-muted);
font-size: 0.65rem;
}
.coverage-stack {
display: flex;
height: 0.28rem;
overflow: hidden;
margin-top: 0.6rem;
border-radius: 999px;
background: rgb(255 255 255 / 8%);
}
.coverage-stack > span {
min-width: 0.15rem;
background: var(--route-color);
}
.playlist-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.playlist-actions > button,
.playlist-actions .menu-trigger {
min-height: 2.35rem;
border: 1px solid var(--color-edge);
border-radius: 0.65rem;
background: var(--color-panel-raised);
color: var(--color-ink);
cursor: pointer;
padding: 0 0.7rem;
font-size: 0.7rem;
font-weight: 700;
}
.playlist-actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.playlist-actions .menu-trigger {
display: grid;
min-width: 2.35rem;
place-items: center;
}
.playlist-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-bottom: 1px solid var(--color-edge);
}
.playlist-stat-grid > div {
display: grid;
gap: 0.15rem;
border-right: 1px solid var(--color-edge);
padding: 0.7rem 1rem;
}
.playlist-stat-grid > div:last-child {
border-right: 0;
}
.playlist-stat-grid strong {
overflow: hidden;
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-stat-grid small {
color: var(--color-ink-muted);
font-size: 0.62rem;
}
.action-feedback {
margin: 0;
border-bottom: 1px solid var(--color-edge);
background: rgb(168 255 62 / 7%);
color: var(--color-signal);
padding: 0.55rem 1rem;
font-size: 0.7rem;
}
.track-table {
min-width: 0;
}
.track-head,
.track-row {
display: grid;
grid-template-columns: 2.5rem minmax(12rem, 1fr) minmax(7rem, 0.55fr) 3.5rem 2.75rem;
align-items: center;
}
.track-head {
min-height: 2rem;
border-bottom: 1px solid var(--color-edge);
color: var(--color-ink-muted);
font-size: 0.6rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.track-head > span:first-child,
.track-head > span:nth-last-child(-n + 2) {
text-align: center;
}
.track-scroll {
max-height: min(55vh, 40rem);
overflow: auto;
}
.track-row {
min-height: 3.7rem;
border-bottom: 1px solid var(--color-edge);
padding: 0.35rem 0;
}
.track-row:hover {
background: rgb(255 255 255 / 3%);
}
.track-index,
.track-duration {
color: var(--color-ink-muted);
font-variant-numeric: tabular-nums;
font-size: 0.68rem;
text-align: center;
}
.track-identity,
.route-cell {
display: flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
padding-right: 0.55rem;
}
.track-art {
width: 2.65rem;
height: 2.65rem;
border-radius: 0.55rem;
}
.route-cell i {
width: 0.48rem;
height: 0.48rem;
flex: none;
border-radius: 50%;
background: var(--route-color);
}
.route-cell strong {
overflow: hidden;
font-size: 0.67rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-menu {
display: grid;
place-items: center;
}
.track-menu-trigger {
display: grid;
width: 2rem;
height: 2rem;
border: 0;
place-items: center;
border-radius: 0.55rem;
background: transparent;
color: var(--color-ink);
cursor: pointer;
}
.track-menu-trigger[aria-expanded="true"],
.track-menu-trigger:hover {
background: var(--color-panel-raised);
}
.bits-menu {
z-index: 30;
display: grid;
gap: 0.35rem;
border: 1px solid var(--color-edge);
border-radius: 0.75rem;
background: var(--color-panel-raised);
box-shadow: 0 1rem 2rem rgb(0 0 0 / 40%);
padding: 0.75rem;
font-size: 0.65rem;
}
.bits-menu-item {
min-height: 2.4rem;
border-radius: 0.5rem;
cursor: pointer;
padding: 0 0.65rem;
outline: none;
font-size: 0.7rem;
font-weight: 700;
}
.bits-menu-item[data-highlighted] {
background: rgb(255 255 255 / 7%);
}
.bits-menu-item[data-disabled] {
cursor: not-allowed;
opacity: 0.45;
}
.track-details-menu {
width: min(22rem, 80vw);
}
.track-technical {
display: grid;
gap: 0.35rem;
}
.track-technical small {
overflow-wrap: anywhere;
color: var(--color-ink-muted);
}
.detail-loading {
display: grid;
min-height: 25rem;
place-items: center;
color: var(--color-ink-muted);
font-size: 0.8rem;
}
.playlist-pagination {
display: flex;
min-height: 2.8rem;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-top: 1px solid var(--color-edge);
padding: 0.45rem 0.7rem;
color: var(--color-ink-muted);
font-size: 0.65rem;
}
.playlist-pagination button {
min-height: 2rem;
border: 1px solid var(--color-edge);
border-radius: 0.55rem;
background: var(--color-panel-raised);
color: var(--color-ink);
cursor: pointer;
padding: 0 0.6rem;
font-size: 0.65rem;
}
.playlist-pagination button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.empty-action {
display: grid;
width: max-content;
min-width: 9rem;
place-items: center;
padding: 0 1rem;
text-decoration: none;
}
}
@media (max-width: 1050px) {
.playlist-layout {
grid-template-columns: 1fr;
}
.playlist-rows {
max-height: 20rem;
}
}
@media (max-width: 650px) {
.playlist-filters,
.track-toolbar {
grid-template-columns: 1fr 1fr;
}
.playlist-filters label:first-child,
.track-toolbar label:first-child {
grid-column: 1 / -1;
}
.playlist-hero {
flex-wrap: wrap;
}
.playlist-actions {
width: 100%;
}
.playlist-actions > button {
flex: 1;
}
.playlist-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.playlist-stat-grid > div:nth-child(2) {
border-right: 0;
}
.playlist-stat-grid > div:nth-child(-n + 2) {
border-bottom: 1px solid var(--color-edge);
}
.track-head,
.track-row {
grid-template-columns: 2.1rem minmax(0, 1fr) 3.4rem 2.5rem;
}
.track-head > span:nth-child(3),
.route-cell {
display: none;
}
}
+94
View File
@@ -71,6 +71,76 @@ export type ProviderDefinition = {
logoUrl?: string | null;
};
export type PlaylistLinkMetrics = {
total: number;
matched: number;
unresolved: number;
review: number;
rejected: number;
playable: number;
materialized: number;
snapshotId?: string | null;
snapshotVersion?: number | null;
};
export type PlaylistLink = {
id: string;
enabled: boolean;
name: string;
description?: string | null;
artworkUrl?: string | null;
sourceProviderId: string;
targetProtocol: string;
materializationMode: string;
revision: number;
lastRunAt?: string | null;
lastRunState?: string | null;
trackCount: number;
matchedCount: number;
unmatchedCount: number;
playableCount: number;
materializedCount: number;
metrics: PlaylistLinkMetrics;
};
export type PlaylistTrack = {
position: number;
externalSnapshotId: string;
title: string;
artists: string[];
album?: string | null;
isrc?: string | null;
durationMs?: number | null;
durationProvenance?: string | null;
artworkUrl?: string | null;
backendItemId?: string | null;
routeKind: "local" | "external" | "unmatched";
routeProviderId?: string | null;
matchState?: string | null;
providerRoutes: Array<{ providerId: string; externalId: string; pinned: boolean }>;
};
export type PlaylistDetails = {
id: string;
snapshotId: string;
snapshotVersion: number;
name: string;
sourceProviderId: string;
targetProtocol: string;
targetPlaylistId?: string | null;
artworkUrl?: string | null;
retrievedAt: string;
completedAt?: string | null;
syncState?: string | null;
trackCount: number;
localCount: number;
externalCount: number;
unresolvedCount: number;
durationMs?: number | null;
unknownDurationCount: number;
tracks: PlaylistTrack[];
};
async function json<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
cache: "no-store",
@@ -105,3 +175,27 @@ export const home = {
activity: () => json<{ items: ActivityItem[] }>("/api/admin/ui/activity?limit=8"),
providers: () => json<{ providers: ProviderSummary[] }>("/api/admin/ui/provider-summaries"),
};
export const playlistLinks = {
list: () => json<{ playlistLinks: PlaylistLink[] }>("/api/admin/playlist-links"),
details: (id: string) => json<PlaylistDetails>(`/api/admin/playlist-links/${encodeURIComponent(id)}`),
sync: (id: string, snapshotId: string) =>
json<{ jobId: string; created: boolean }>(`/api/admin/playlist-links/${encodeURIComponent(id)}/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ snapshotId }),
}),
rematch: (id: string) =>
json<{ preview: unknown }>(`/api/admin/playlist-links/${encodeURIComponent(id)}/refresh`, {
method: "POST",
}),
setEnabled: (id: string, expectedRevision: number, enabled: boolean) =>
json<{ id: string; enabled: boolean; revision: number }>(
`/api/admin/playlist-links/${encodeURIComponent(id)}/state`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision, enabled }),
},
),
};
@@ -0,0 +1,426 @@
<script lang="ts">
import { onMount } from "svelte";
import { DropdownMenu } from "bits-ui";
import ProviderMark from "$lib/components/ProviderMark.svelte";
import {
home,
playlistLinks,
type PlaylistDetails,
type PlaylistLink,
type ProviderDefinition,
} from "$lib/api";
import {
filterPlaylists,
filterTracks,
formatDuration,
providerColor,
summarizeRoutes,
type PlaylistSort,
type TrackSort,
} from "$lib/playlists";
import { liveUpdates } from "$lib/live-updates.svelte";
let playlists = $state<PlaylistLink[]>([]);
let providers = $state<ProviderDefinition[]>([]);
let details = $state<PlaylistDetails | null>(null);
let selectedId = $state("");
let query = $state("");
let stateFilter = $state<"all" | "ready" | "attention" | "paused">("all");
let sort = $state<PlaylistSort>("name");
let trackQuery = $state("");
let routeFilter = $state<"all" | "local" | "external" | "unmatched">("all");
let trackSort = $state<TrackSort>("position");
let loading = $state(true);
let detailLoading = $state(false);
let refreshing = $state(false);
let error = $state("");
let degraded = $state("");
let feedback = $state("");
let action = $state("");
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let detailRequest = 0;
let page = $state(1);
const visiblePlaylists = $derived(filterPlaylists(playlists, query, stateFilter, sort));
const pageCount = $derived(Math.max(1, Math.ceil(visiblePlaylists.length / 20)));
const currentPage = $derived(Math.min(page, pageCount));
const pagePlaylists = $derived(
visiblePlaylists.slice((currentPage - 1) * 20, currentPage * 20),
);
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) {
return providers.find((item) => item.id.toLowerCase() === providerId.toLowerCase());
}
function providerName(providerId?: string | null) {
return providerId ? (provider(providerId)?.name ?? providerId) : "Unresolved";
}
function percent(part: number, total: number) {
return total ? Math.round((part / total) * 100) : 0;
}
function relativeTime(value?: string | null) {
if (!value) return "Never";
const seconds = Math.round((new Date(value).getTime() - Date.now()) / 1_000);
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
if (Math.abs(seconds) < 60) return formatter.format(seconds, "second");
const minutes = Math.round(seconds / 60);
if (Math.abs(minutes) < 60) return formatter.format(minutes, "minute");
const hours = Math.round(minutes / 60);
if (Math.abs(hours) < 24) return formatter.format(hours, "hour");
return formatter.format(Math.round(hours / 24), "day");
}
async function loadDetails(id: string) {
selectedId = id;
details = null;
detailLoading = true;
const request = ++detailRequest;
try {
const next = await playlistLinks.details(id);
if (request === detailRequest) details = next;
} catch (cause) {
if (request === detailRequest)
degraded = cause instanceof Error ? cause.message : "Playlist details are unavailable.";
} finally {
if (request === detailRequest) detailLoading = false;
}
}
async function refresh() {
if (refreshing) return;
refreshing = true;
error = "";
degraded = "";
const [linksResult, schemaResult] = await Promise.allSettled([
playlistLinks.list(),
home.schema(),
]);
if (linksResult.status === "rejected") {
error =
linksResult.reason instanceof Error
? linksResult.reason.message
: "Playlists are unavailable.";
} else {
playlists = linksResult.value.playlistLinks;
const nextId = playlists.some((playlist) => playlist.id === selectedId)
? selectedId
: (playlists[0]?.id ?? "");
if (nextId) await loadDetails(nextId);
else {
selectedId = "";
details = null;
}
}
if (schemaResult.status === "fulfilled") providers = schemaResult.value.providers;
else degraded = "Provider names and artwork are temporarily unavailable.";
loading = false;
refreshing = false;
}
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
void refresh();
}, 250);
}
async function run(name: "sync" | "rematch" | "toggle") {
if (!selected || action) return;
action = name;
feedback = "";
try {
if (name === "sync") {
if (!details) return;
const result = await playlistLinks.sync(selected.id, details.snapshotId);
feedback = result.created ? "Sync queued." : "Sync is already queued.";
} else if (name === "rematch") {
await playlistLinks.rematch(selected.id);
feedback = "Source refreshed and tracks rematched.";
} else {
await playlistLinks.setEnabled(selected.id, selected.revision, !selected.enabled);
feedback = selected.enabled ? "Playlist paused." : "Playlist resumed.";
}
await refresh();
} catch (cause) {
feedback = cause instanceof Error ? cause.message : "The action failed.";
} finally {
action = "";
}
}
onMount(() => {
void refresh();
const unsubscribe = liveUpdates.subscribe(scheduleRefresh);
return () => {
unsubscribe();
if (refreshTimer) clearTimeout(refreshTimer);
};
});
</script>
{#if loading}
<section class="playlist-layout" aria-label="Loading playlists" aria-busy="true">
<div class="panel playlist-list skeleton-panel"></div>
<div class="panel playlist-detail skeleton-panel"></div>
</section>
{:else if error}
<section class="panel route-error" role="alert">
<span aria-hidden="true">!</span>
<div>
<p class="eyebrow">Playlists unavailable</p>
<h2>Allstarr could not load the canonical playlist list.</h2>
<p>{error}</p>
</div>
<button class="button-secondary" type="button" onclick={() => void refresh()}>Try again</button>
</section>
{:else if !playlists.length}
<section class="panel empty-state">
<span class="empty-orbit" aria-hidden="true">♫</span>
<p class="eyebrow">Library playlists</p>
<h2>No managed playlists yet.</h2>
<p>Add a playlist from an installed Source. Its matches, routes, and sync state will appear here.</p>
<a class="button-primary empty-action" href="#/sources">Open Sources</a>
</section>
{:else}
{#if degraded}
<div class="degraded-banner" role="status">
<span aria-hidden="true">!</span>
<p><strong>Some playlist data is unavailable.</strong> {degraded}</p>
<button type="button" onclick={() => void refresh()}>Retry</button>
</div>
{/if}
<section class="playlist-layout" aria-busy={refreshing}>
<article class="panel playlist-list">
<header class="playlist-toolbar">
<div>
<p class="eyebrow">Managed playlists</p>
<h2>{playlists.length} linked</h2>
</div>
<button class="icon-button" type="button" onclick={() => void refresh()} aria-label="Refresh playlists">↻</button>
</header>
<div class="playlist-filters">
<label>
<span class="sr-only">Filter playlists</span>
<input bind:value={query} type="search" placeholder="Filter playlists" />
</label>
<label>
<span class="sr-only">Playlist status</span>
<select bind:value={stateFilter}>
<option value="all">All states</option>
<option value="ready">Ready</option>
<option value="attention">Needs attention</option>
<option value="paused">Paused</option>
</select>
</label>
<label>
<span class="sr-only">Sort playlists</span>
<select bind:value={sort}>
<option value="name">Name</option>
<option value="tracks">Tracks</option>
<option value="coverage">Coverage</option>
<option value="updated">Updated</option>
</select>
</label>
</div>
<div class="playlist-rows" aria-label="Playlists">
{#each pagePlaylists as playlist}
<button
class:active={playlist.id === selectedId}
class="playlist-row"
type="button"
onclick={() => void loadDetails(playlist.id)}
>
<span class="media-art playlist-art">
{#if playlist.artworkUrl}
<img src={playlist.artworkUrl} alt="" loading="lazy" />
{:else}
<span aria-hidden="true">♫</span>
{/if}
</span>
<span class="playlist-copy">
<strong>{playlist.name}</strong>
<small class="route-pair">
<ProviderMark id={playlist.sourceProviderId} definition={provider(playlist.sourceProviderId)} />
<span>{providerName(playlist.sourceProviderId)}</span>
<span aria-hidden="true">→</span>
<ProviderMark id={playlist.targetProtocol} definition={provider(playlist.targetProtocol)} />
<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>
</span>
</span>
<span class="playlist-numbers">
<strong>{playlist.playableCount}/{playlist.trackCount}</strong>
<small class:attention={playlist.unmatchedCount > 0}>
{playlist.enabled ? `${playlist.unmatchedCount} unresolved` : "Paused"}
</small>
</span>
</button>
{:else}
<div class="compact-empty">
<strong>No playlists match these filters</strong>
<button type="button" onclick={() => { query = ""; stateFilter = "all"; }}>Clear filters</button>
</div>
{/each}
</div>
{#if pageCount > 1}
<nav class="playlist-pagination" aria-label="Playlist pages">
<button type="button" disabled={currentPage === 1} onclick={() => { page = Math.max(1, currentPage - 1); }}>Previous</button>
<span>Page {currentPage} of {pageCount}</span>
<button type="button" disabled={currentPage === pageCount} onclick={() => { page = Math.min(pageCount, currentPage + 1); }}>Next</button>
</nav>
{/if}
</article>
<article class="panel playlist-detail">
{#if detailLoading}
<div class="detail-loading" aria-busy="true">Loading playlist tracks…</div>
{:else if details && selected}
<header class="playlist-hero">
<span class="media-art hero-art">
{#if details.artworkUrl}<img src={details.artworkUrl} alt="" />{:else}<span aria-hidden="true">♫</span>{/if}
</span>
<div class="playlist-hero-copy">
<p class="eyebrow">{providerName(details.sourceProviderId)} playlist</p>
<h2>{details.name}</h2>
<p>
{details.trackCount} tracks · {formatDuration(details.durationMs)}
{#if details.unknownDurationCount} · {details.unknownDurationCount} unknown duration{/if}
</p>
<div class="hero-route">
<ProviderMark id={details.sourceProviderId} definition={provider(details.sourceProviderId)} />
<span>{providerName(details.sourceProviderId)}</span>
<span aria-hidden="true">→</span>
<ProviderMark id={details.targetProtocol} definition={provider(details.targetProtocol)} />
<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}
<span
title={`${providerName(route.providerId)}: ${route.count}`}
style={`width:${percent(route.count, details.trackCount)}%;--route-color:${providerColor(route.providerId)}`}
></span>
{/each}
</div>
</div>
<div class="playlist-actions" aria-label="Playlist actions">
<button disabled={Boolean(action) || !selected.enabled} type="button" onclick={() => void run("sync")}>Sync</button>
<button disabled={Boolean(action) || !selected.enabled} type="button" onclick={() => void run("rematch")}>Rematch</button>
<button disabled={Boolean(action)} type="button" onclick={() => void refresh()}>Refresh</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="menu-trigger" aria-label="Additional playlist operations">•••</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="bits-menu" sideOffset={6} align="end">
<DropdownMenu.Item
class="bits-menu-item"
disabled={Boolean(action)}
onSelect={() => void run("toggle")}
>
{selected.enabled ? "Pause playlist" : "Resume playlist"}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</header>
<div class="playlist-stat-grid">
<div><strong>{details.localCount}</strong><small>{providerName(details.targetProtocol)}</small></div>
<div><strong>{details.externalCount}</strong><small>External routes</small></div>
<div class:attention={details.unresolvedCount > 0}><strong>{details.unresolvedCount}</strong><small>Unresolved</small></div>
<div><strong>{relativeTime(details.completedAt)}</strong><small>Last sync</small></div>
</div>
{#if feedback}<p class="action-feedback" role="status">{feedback}</p>{/if}
<div class="track-toolbar">
<label>
<span class="sr-only">Filter tracks</span>
<input bind:value={trackQuery} type="search" placeholder="Filter tracks" />
</label>
<label>
<span class="sr-only">Track route</span>
<select bind:value={routeFilter}>
<option value="all">All routes</option>
<option value="local">{providerName(details.targetProtocol)}</option>
<option value="external">External</option>
<option value="unmatched">Unresolved</option>
</select>
</label>
<label>
<span class="sr-only">Sort tracks</span>
<select bind:value={trackSort}>
<option value="position">Playlist order</option>
<option value="title">Title</option>
<option value="duration">Duration</option>
<option value="route">Route</option>
</select>
</label>
</div>
<div class="track-table" role="table" aria-label={`${details.name} tracks`}>
<div class="track-head" role="row">
<span role="columnheader">#</span><span role="columnheader">Track</span>
<span role="columnheader">Route</span><span role="columnheader">Time</span>
<span role="columnheader"><span class="sr-only">Details</span></span>
</div>
<div class="track-scroll">
{#each visibleTracks as track}
<div class="track-row" role="row">
<span class="track-index" role="cell">{track.position}</span>
<span class="track-identity" role="cell">
<span class="media-art track-art">
{#if track.artworkUrl}<img src={track.artworkUrl} alt="" loading="lazy" />{:else}<span aria-hidden="true">♪</span>{/if}
</span>
<span><strong>{track.title}</strong><small>{track.artists.join(", ") || "Unknown artist"}{track.album ? ` · ${track.album}` : ""}</small></span>
</span>
<span class="route-cell" role="cell">
<i style={`--route-color:${providerColor(track.routeProviderId ?? track.routeKind)}`}></i>
<span><strong>{providerName(track.routeProviderId ?? (track.routeKind === "local" ? details.targetProtocol : null))}</strong><small>{track.routeKind}</small></span>
</span>
<span class="track-duration" role="cell">{formatDuration(track.durationMs)}</span>
<span class="track-menu" role="cell">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="track-menu-trigger" aria-label={`Technical details for ${track.title}`}>•••</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="bits-menu track-details-menu" sideOffset={4} align="end">
<div class="track-technical">
<strong>{track.matchState ?? "unmatched"}</strong>
{#if track.isrc}<small>ISRC {track.isrc}</small>{/if}
{#if track.backendItemId}<small>Backend {track.backendItemId}</small>{/if}
{#each track.providerRoutes as route}
<small>{providerName(route.providerId)} · {route.externalId}{route.pinned ? " · pinned" : ""}</small>
{/each}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</span>
</div>
{:else}
<div class="compact-empty"><strong>No tracks match these filters</strong></div>
{/each}
</div>
</div>
{:else}
<div class="compact-empty"><strong>Select a playlist to inspect its tracks</strong></div>
{/if}
</article>
</section>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { ProviderDefinition } from "$lib/api";
import { providerColor } from "$lib/playlists";
let {
id,
definition,
label = definition?.name ?? id,
}: { id: string; definition?: ProviderDefinition; label?: string } = $props();
let failed = $state(false);
</script>
<span class="provider-mark" style={`--route-color:${providerColor(id)}`} title={label}>
{#if definition?.logoUrl && !failed}
<img src={definition.logoUrl} alt="" onerror={() => { failed = true; }} />
{:else}
<span aria-hidden="true">{label[0]?.toUpperCase() ?? "?"}</span>
{/if}
<span class="sr-only">{label}</span>
</span>
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { PlaylistLink, PlaylistTrack } from "./api";
import {
filterPlaylists,
filterTracks,
formatDuration,
providerColor,
summarizeRoutes,
} from "./playlists";
const playlist = (values: Partial<PlaylistLink>): PlaylistLink => ({
id: "one",
enabled: true,
name: "One",
sourceProviderId: "source",
targetProtocol: "target",
materializationMode: "reconcile",
revision: 1,
trackCount: 10,
matchedCount: 9,
unmatchedCount: 1,
playableCount: 9,
materializedCount: 8,
metrics: { total: 10, matched: 9, unresolved: 1, review: 0, rejected: 0, playable: 9, materialized: 8 },
...values,
});
describe("playlist presentation", () => {
it("filters and orders summaries by canonical coverage", () => {
const result = filterPlaylists(
[
playlist({ id: "partial", name: "Partial", playableCount: 8, unmatchedCount: 2 }),
playlist({ id: "ready", name: "Ready", playableCount: 10, unmatchedCount: 0 }),
playlist({ id: "paused", name: "Paused", enabled: false, playableCount: 2 }),
],
"",
"all",
"coverage",
);
expect(result.map((item) => item.id)).toEqual(["ready", "partial", "paused"]);
expect(filterPlaylists(result, "source", "attention", "name").map((item) => item.id)).toEqual([
"partial",
]);
});
it("keeps source order by default and formats durations", () => {
const tracks = [
{ position: 2, externalSnapshotId: "b", title: "B", artists: [], routeKind: "external", providerRoutes: [] },
{ position: 1, externalSnapshotId: "a", title: "A", artists: [], routeKind: "local", providerRoutes: [] },
] satisfies PlaylistTrack[];
expect(filterTracks(tracks, "", "all", "position").map((item) => item.position)).toEqual([1, 2]);
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 },
]);
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { PlaylistLink, PlaylistTrack } from "./api";
export type PlaylistSort = "name" | "tracks" | "coverage" | "updated";
export type TrackSort = "position" | "title" | "duration" | "route";
export function coverage(playlist: Pick<PlaylistLink, "trackCount" | "playableCount">) {
return playlist.trackCount ? playlist.playableCount / playlist.trackCount : 0;
}
export function filterPlaylists(
playlists: PlaylistLink[],
query: string,
state: "all" | "ready" | "attention" | "paused",
sort: PlaylistSort,
) {
const needle = query.trim().toLocaleLowerCase();
return playlists
.filter((playlist) => {
const matchesQuery =
!needle ||
`${playlist.name} ${playlist.description ?? ""} ${playlist.sourceProviderId} ${playlist.targetProtocol}`
.toLocaleLowerCase()
.includes(needle);
const matchesState =
state === "all" ||
(state === "paused" && !playlist.enabled) ||
(state === "ready" && playlist.enabled && playlist.unmatchedCount === 0) ||
(state === "attention" && playlist.enabled && playlist.unmatchedCount > 0);
return matchesQuery && matchesState;
})
.toSorted((left, right) => {
if (sort === "tracks") return right.trackCount - left.trackCount;
if (sort === "coverage") return coverage(right) - coverage(left);
if (sort === "updated")
return Date.parse(right.lastRunAt ?? "") - Date.parse(left.lastRunAt ?? "");
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
}
export function filterTracks(
tracks: PlaylistTrack[],
query: string,
route: "all" | PlaylistTrack["routeKind"],
sort: TrackSort,
) {
const needle = query.trim().toLocaleLowerCase();
return tracks
.filter(
(track) =>
(route === "all" || track.routeKind === route) &&
(!needle ||
`${track.title} ${track.artists.join(" ")} ${track.album ?? ""} ${track.routeProviderId ?? ""}`
.toLocaleLowerCase()
.includes(needle)),
)
.toSorted((left, right) => {
if (sort === "title") return left.title.localeCompare(right.title, undefined, { sensitivity: "base" });
if (sort === "duration") return (right.durationMs ?? -1) - (left.durationMs ?? -1);
if (sort === "route")
return (left.routeProviderId ?? left.routeKind).localeCompare(
right.routeProviderId ?? right.routeKind,
);
return left.position - right.position;
});
}
export function formatDuration(milliseconds?: number | null) {
if (milliseconds == null) return "—";
const totalSeconds = Math.max(0, Math.round(milliseconds / 1_000));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
return hours
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
: `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function providerColor(providerId: string) {
let hash = 0;
for (const character of providerId) hash = (hash * 31 + character.codePointAt(0)!) >>> 0;
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 }));
}
+3
View File
@@ -3,6 +3,7 @@
import { page } from "$app/state";
import { auth, type Session } from "$lib/api";
import HomeView from "$lib/components/HomeView.svelte";
import PlaylistsView from "$lib/components/PlaylistsView.svelte";
import { liveUpdates } from "$lib/live-updates.svelte";
const destinations = [
@@ -173,6 +174,8 @@
{#if route === "/"}
<HomeView administrator={session.user?.isAdministrator ?? false} />
{:else if route === "/library/playlists"}
<PlaylistsView />
{:else}
<section class="panel empty-state">
<span class="empty-orbit" aria-hidden="true">✦</span>