From 175495a32cbfb59ae13e2c2b71fba95534847b62 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 23 Jul 2026 00:17:29 -0400 Subject: [PATCH] fix(library): show mapping art and admin sources --- .../PlaylistLinksControllerContractTests.cs | 3 +- .../Controllers/PlaylistLinksController.cs | 19 ++++++-- .../Controllers/TrackMatchesController.cs | 20 ++++++-- allstarr/wwwroot/css/workspaces.css | 47 ++++++++++++++++++- allstarr/wwwroot/js/webui.js | 6 ++- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/allstarr.Tests/PlaylistLinksControllerContractTests.cs b/allstarr.Tests/PlaylistLinksControllerContractTests.cs index b6db517c..f8e0e203 100644 --- a/allstarr.Tests/PlaylistLinksControllerContractTests.cs +++ b/allstarr.Tests/PlaylistLinksControllerContractTests.cs @@ -81,13 +81,14 @@ public sealed class PlaylistLinksControllerContractTests } [Fact] - public void SourceDiscovery_ExcludesNonOperationalAndUnapprovedGlobalPersonalAccounts() + public void SourceDiscovery_ExcludesNonOperationalProvidersAndAllowsAdministratorsToManageGlobalAccounts() { var source = File.ReadAllText(FindRepositoryFile( "allstarr", "Controllers", "PlaylistLinksController.cs")); Assert.Contains("includeNonOperational: false", source, StringComparison.Ordinal); Assert.Contains("providerPolicy.AllowGlobalPersonalAccounts", source, StringComparison.Ordinal); + Assert.Contains("session.IsAdministrator", source, StringComparison.Ordinal); } [Fact] diff --git a/allstarr/Controllers/PlaylistLinksController.cs b/allstarr/Controllers/PlaylistLinksController.cs index 5133ba39..692d6ae7 100644 --- a/allstarr/Controllers/PlaylistLinksController.cs +++ b/allstarr/Controllers/PlaylistLinksController.cs @@ -58,12 +58,20 @@ public sealed class PlaylistLinksController( return capability.AllowedAccountScopes.Contains(item.Scope); }).ToArray(); var availableAccounts = capableAccounts - .Where(item => item.Scope != ProviderAccountScope.Global || providerPolicy.AllowGlobalPersonalAccounts) + .Where(item => item.Scope != ProviderAccountScope.Global || + providerPolicy.AllowGlobalPersonalAccounts || + session.IsAdministrator) .ToArray(); var blockedAccounts = capableAccounts.Except(availableAccounts).ToArray(); return Ok(new { - accounts = availableAccounts.Select(item => ToPlaylistSourceAccountDto(item, true, null)), + accounts = availableAccounts.Select(item => ToPlaylistSourceAccountDto( + item, + true, + null, + item.Scope == ProviderAccountScope.Global && + session.IsAdministrator && + !providerPolicy.AllowGlobalPersonalAccounts)), blockedAccounts = blockedAccounts.Select(item => ToPlaylistSourceAccountDto( item, false, @@ -81,7 +89,8 @@ public sealed class PlaylistLinksController( }), policy = new { - allowSharedPlaylistCredentials = providerPolicy.AllowGlobalPersonalAccounts + allowSharedPlaylistCredentials = providerPolicy.AllowGlobalPersonalAccounts, + administratorCanUseSharedPlaylistCredentials = session.IsAdministrator } }); }); @@ -663,7 +672,8 @@ public sealed class PlaylistLinksController( private static object ToPlaylistSourceAccountDto( ProviderAccountRecord account, bool available, - string? reasonCode) => new + string? reasonCode, + bool administratorAccess = false) => new { id = account.Id, providerId = account.ProviderId, @@ -674,6 +684,7 @@ public sealed class PlaylistLinksController( { ProviderAccountScope.User => "Personal account", ProviderAccountScope.Library => "Library-shared account", + ProviderAccountScope.Global when administratorAccess => "Administrator account", _ => "Deployment-shared account" }, revision = account.Revision, diff --git a/allstarr/Controllers/TrackMatchesController.cs b/allstarr/Controllers/TrackMatchesController.cs index c2af13f6..d5cdac24 100644 --- a/allstarr/Controllers/TrackMatchesController.cs +++ b/allstarr/Controllers/TrackMatchesController.cs @@ -572,11 +572,13 @@ public sealed class TrackMatchesController( private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); - private static TrackMetadata ToTrackMetadata((string? Title, string? Artist, string? Album) value) => new() + private static TrackMetadata ToTrackMetadata( + (string? Title, string? Artist, string? Album, string? ArtworkUrl, string? Isrc) value) => new() { Title = value.Title, Artist = value.Artist, - Album = value.Album + Album = value.Album, + ArtworkUrl = value.ArtworkUrl }; private static MatchRow Row(ExternalMetadataSnapshotRecord snapshot, TrackMatchRecord? decision, @@ -609,6 +611,8 @@ public sealed class TrackMatchesController( title = metadata.Title, artist = metadata.Artist, album = metadata.Album, + artworkUrl = metadata.ArtworkUrl, + isrc = metadata.Isrc, localTrack = track == null ? null : new { track.Id, track.BackendItemId, track.Title, track.Artist, track.Album }, providerIdentities, candidates = ParseCandidates(decision?.CandidateResultsJson), @@ -620,7 +624,7 @@ public sealed class TrackMatchesController( return new(state, $"{metadata.Title} {metadata.Artist} {metadata.Album} {snapshot.ProviderId} {track?.Title} {track?.Artist}", value); } - private static (string? Title, string? Artist, string? Album) Metadata(string json) + private static (string? Title, string? Artist, string? Album, string? ArtworkUrl, string? Isrc) Metadata(string json) { try { @@ -630,9 +634,15 @@ public sealed class TrackMatchesController( var artist = Text(root, "artist") ?? Text(root, "Artist"); if (artist == null && (root.TryGetProperty("artists", out var artists) || root.TryGetProperty("Artists", out artists)) && artists.ValueKind == JsonValueKind.Array) artist = string.Join(", ", artists.EnumerateArray().Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : Text(item, "name")).Where(item => item != null)); - return (title, artist, Text(root, "album") ?? Text(root, "Album") ?? Text(root, "albumTitle") ?? Text(root, "AlbumTitle")); + return ( + title, + artist, + Text(root, "album") ?? Text(root, "Album") ?? Text(root, "albumTitle") ?? Text(root, "AlbumTitle"), + Text(root, "artworkUrl") ?? Text(root, "ArtworkUrl") ?? Text(root, "coverUrl") ?? Text(root, "CoverUrl") ?? + Text(root, "imageUrl") ?? Text(root, "ImageUrl"), + Text(root, "isrc") ?? Text(root, "Isrc") ?? Text(root, "ISRC")); } - catch (JsonException) { return (null, null, null); } + catch (JsonException) { return (null, null, null, null, null); } } private static string? Text(JsonElement root, string name) => root.ValueKind == JsonValueKind.Object && diff --git a/allstarr/wwwroot/css/workspaces.css b/allstarr/wwwroot/css/workspaces.css index 7f211ca8..4b0cbb0e 100644 --- a/allstarr/wwwroot/css/workspaces.css +++ b/allstarr/wwwroot/css/workspaces.css @@ -2165,18 +2165,61 @@ } .mapping-card-title > div { + display: grid; + gap: 0.22rem; min-width: 0; } .mapping-card-title strong { - font-size: 1.05rem; + font-size: 1.12rem; + line-height: 1.25; } .mapping-card-title p { - margin: 0.2rem 0 0; + margin: 0; color: var(--text-secondary); } +.mapping-source-label { + display: flex; + gap: 0.4rem; + align-items: center; + margin-bottom: 0.2rem; + color: var(--text-secondary); + font-size: 0.74rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.mapping-track-artist { + font-weight: 650; +} + +.mapping-track-album { + font-size: 0.86rem; +} + +.mapping-card-meta { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.mapping-card-meta > span { + display: inline-flex; + gap: 0.32rem; + align-items: center; + padding: 0.28rem 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-page); + color: var(--text-secondary); + font-size: 0.74rem; + font-weight: 650; +} + .mapping-route { display: grid; grid-template-columns: minmax(0, 0.75fr) auto minmax(0, 1.25fr); diff --git a/allstarr/wwwroot/js/webui.js b/allstarr/wwwroot/js/webui.js index af4f2720..fbd2d8b3 100644 --- a/allstarr/wwwroot/js/webui.js +++ b/allstarr/wwwroot/js/webui.js @@ -3849,10 +3849,14 @@ class AllstarrApp extends LitElement { const providerId = mapping.providerId || "provider"; const targetProvider = mapping.localTrack ? String(this.status?.backendType || "jellyfin").toLowerCase() : routes[0]?.providerId; const artwork = mapping.artworkUrl || mapping.ArtworkUrl; + const confidence = Number(mapping.confidence ?? mapping.Confidence); + const confidenceLabel = Number.isFinite(confidence) ? `${Math.round(confidence * 1000) / 10}% confidence` : ""; + const isrc = mapping.isrc || mapping.Isrc; return html`
-
${display(mapping.title, "Unknown track")}

${display(mapping.artist, "Unknown artist")}${mapping.album ? html` ยท ${mapping.album}` : ""}

${titleCase(state)}
+
${this.renderProviderLogo(providerId, "tiny")}${providerDisplayName(providerId, this.schema?.providers)} source${display(mapping.title, "Unknown track")}

${display(mapping.artist, "Unknown artist")}

${mapping.album ? html`

${mapping.album}

` : nothing}
${titleCase(state)}
+
${confidenceLabel ? html`${icon("activity", 14)} ${confidenceLabel}` : nothing}${isrc ? html`ISRC ${isrc}` : nothing}${mapping.decidedAt ? html`${icon("clock", 14)} ${formatRelativeTime(mapping.decidedAt)}` : nothing}
${this.renderProviderLogo(providerId, "tiny")}Source${providerDisplayName(providerId, this.schema?.providers)}