fix(library): show mapping art and admin sources
CI / build-and-test (push) Canceled after 0s
CI / provider-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s

This commit is contained in:
2026-07-23 00:17:29 -04:00
parent 384c21b99f
commit 175495a32c
5 changed files with 82 additions and 13 deletions
@@ -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]
@@ -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,
+15 -5
View File
@@ -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 &&
+45 -2
View File
@@ -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);
+5 -1
View File
@@ -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`<article class="mapping-review-card ${["unresolved", "suggested", "ambiguous", "rejected"].includes(state) ? "needs-attention" : ""}">
<div class="mapping-source-mark" aria-hidden="true">${artwork ? html`<img src=${artwork} alt="" loading="lazy" decoding="async">` : this.renderProviderLogo(providerId, "large")}</div>
<div class="mapping-track-copy">
<div class="mapping-card-title"><div><strong>${display(mapping.title, "Unknown track")}</strong><p>${display(mapping.artist, "Unknown artist")}${mapping.album ? html`<span> · ${mapping.album}</span>` : ""}</p></div><span class="status-pill ${state}">${titleCase(state)}</span></div>
<div class="mapping-card-title"><div><span class="mapping-source-label">${this.renderProviderLogo(providerId, "tiny")}${providerDisplayName(providerId, this.schema?.providers)} source</span><strong>${display(mapping.title, "Unknown track")}</strong><p class="mapping-track-artist">${display(mapping.artist, "Unknown artist")}</p>${mapping.album ? html`<p class="mapping-track-album">${mapping.album}</p>` : nothing}</div><span class="status-pill ${state}">${titleCase(state)}</span></div>
<div class="mapping-card-meta">${confidenceLabel ? html`<span>${icon("activity", 14)} ${confidenceLabel}</span>` : nothing}${isrc ? html`<span class="mono">ISRC ${isrc}</span>` : nothing}${mapping.decidedAt ? html`<span>${icon("clock", 14)} ${formatRelativeTime(mapping.decidedAt)}</span>` : nothing}</div>
<div class="mapping-route">
<span class="mapping-route-node">${this.renderProviderLogo(providerId, "tiny")}<span><small>Source</small><strong>${providerDisplayName(providerId, this.schema?.providers)}</strong></span></span>
<span class="mapping-route-arrow" aria-hidden="true">${icon("chevronRight", 18)}</span>