mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(webui): migrate Library playlists
This commit is contained in:
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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,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>
|
||||
|
||||
Reference in new issue
Block a user