mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-08-20 13:06:20 -04:00
fix(webui): redesign match events and mappings
This commit is contained in:
@@ -327,18 +327,34 @@ public class AdminUiController : ControllerBase
|
||||
var identity = snapshot?.ProviderTrackIdentityId is { } identityId
|
||||
? providerIdentities.GetValueOrDefault(identityId)
|
||||
: null;
|
||||
var libraryTrack = libraryTracks.GetValueOrDefault(item.LibraryTrackId ?? Guid.Empty);
|
||||
var providerId = identity?.ProviderId ?? snapshot?.ProviderId ?? "matching";
|
||||
var sourceTitle = snapshot == null ? null : AuditDetail(snapshot.PayloadJson, "title");
|
||||
var sourceArtist = snapshot == null ? null : AuditDetail(snapshot.PayloadJson, "artist");
|
||||
var sourceAlbum = snapshot == null ? null : AuditDetail(snapshot.PayloadJson, "album");
|
||||
var artworkUrl = snapshot == null ? null
|
||||
: AuditDetail(snapshot.PayloadJson, "artworkUrl")
|
||||
?? AuditDetail(snapshot.PayloadJson, "coverUrl")
|
||||
?? AuditDetail(snapshot.PayloadJson, "imageUrl");
|
||||
return new AdminUiActivityItem(
|
||||
item.Id.ToString("N"),
|
||||
"matching",
|
||||
providerId,
|
||||
MatchActivityLabel(item.State),
|
||||
item.State.ToString().ToLowerInvariant(),
|
||||
MatchActivityDetail(item, snapshot, identity, libraryTracks.GetValueOrDefault(item.LibraryTrackId ?? Guid.Empty)),
|
||||
MatchActivityDetail(item, snapshot, identity, libraryTrack),
|
||||
item.DecidedAt,
|
||||
item.CorrelationId,
|
||||
SeverityForState(item.State.ToString()),
|
||||
providerId);
|
||||
ProviderId: providerId,
|
||||
ArtworkUrl: artworkUrl,
|
||||
SourceTitle: sourceTitle,
|
||||
SourceArtist: sourceArtist,
|
||||
SourceAlbum: sourceAlbum,
|
||||
TargetProviderId: libraryTrack == null ? null : "library",
|
||||
TargetTitle: libraryTrack?.Title,
|
||||
TargetArtist: libraryTrack?.Artist,
|
||||
ConfidenceLabel: $"{Math.Round(item.Confidence * 100, 1)}%");
|
||||
}));
|
||||
activity.AddRange(audits.Select(AuditActivity));
|
||||
|
||||
@@ -988,4 +1004,12 @@ public sealed record AdminUiActivityItem(
|
||||
string Severity = "info",
|
||||
string? ProviderId = null,
|
||||
string? PlaylistLinkId = null,
|
||||
string? PlaylistName = null);
|
||||
string? PlaylistName = null,
|
||||
string? ArtworkUrl = null,
|
||||
string? SourceTitle = null,
|
||||
string? SourceArtist = null,
|
||||
string? SourceAlbum = null,
|
||||
string? TargetProviderId = null,
|
||||
string? TargetTitle = null,
|
||||
string? TargetArtist = null,
|
||||
string? ConfidenceLabel = null);
|
||||
|
||||
@@ -1845,10 +1845,10 @@
|
||||
|
||||
.event-log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) auto auto minmax(5rem, auto) 1rem;
|
||||
grid-template-columns: 3.5rem minmax(0, 1fr) auto minmax(5rem, auto) 1rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -1876,7 +1876,14 @@
|
||||
.event-log-summary-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.event-log-title-line {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-log-entry small,
|
||||
@@ -1903,23 +1910,35 @@
|
||||
|
||||
.event-log-details {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.8rem 0.8rem 3.8rem;
|
||||
gap: 1rem;
|
||||
padding: 0 1.1rem 1.1rem 5.6rem;
|
||||
}
|
||||
|
||||
.event-log-detail {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
grid-template-columns: 3.5rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-raised, var(--panel));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.event-log-detail > div,
|
||||
.event-log-detail-body,
|
||||
.event-log-detail-body > header,
|
||||
.event-log-detail dl {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.event-log-detail-body > header {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.event-log-detail-body > p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.event-log-detail dl {
|
||||
@@ -1942,10 +1961,375 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.event-log-art {
|
||||
display: grid;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--accent) 18%, var(--surface-raised)), var(--surface-raised));
|
||||
}
|
||||
|
||||
.event-log-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.event-log-art-detail {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.event-match-route {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-route-node {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-route-node > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-route-node small,
|
||||
.event-route-node em {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-route-node strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-route-node.unresolved {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.event-route-arrow {
|
||||
display: grid;
|
||||
color: var(--accent);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.event-route-confidence {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-match-route.detailed {
|
||||
margin: 0.15rem 0;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.event-log-technical {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.event-log-technical summary {
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.event-log-technical code {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.event-log-collapse {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.mapping-review-queue {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.mapping-review-heading {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mapping-metrics {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mapping-filter-bar {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.mapping-filter-bar button {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mapping-filter-bar button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 46%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapping-search-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 1fr) minmax(12rem, 0.55fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.mapping-search-bar label {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.mapping-card-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.mapping-review-card {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.mapping-review-card.needs-attention {
|
||||
border-color: color-mix(in srgb, var(--warning) 35%, var(--border));
|
||||
}
|
||||
|
||||
.mapping-source-mark {
|
||||
display: grid;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--accent) 18%, var(--surface-raised)), var(--surface-raised));
|
||||
}
|
||||
|
||||
.mapping-source-mark > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mapping-track-copy {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-card-title {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mapping-card-title > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-card-title strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.mapping-card-title p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mapping-route {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.75fr) auto minmax(0, 1.25fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.mapping-route-node {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-route-node > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-route-node small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.mapping-route-node strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mapping-route-node.unresolved {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mapping-route-arrow {
|
||||
display: grid;
|
||||
color: var(--accent);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.mapping-notes {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mapping-notes span {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.mapping-card-actions {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.mapping-pagination {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.mapping-review-modal {
|
||||
width: min(52rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.mapping-target-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.mapping-target-tabs button {
|
||||
flex: 1;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mapping-target-tabs button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 46%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.mapping-target-search {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.mapping-target-search label {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.mapping-target-search .grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mapping-target-results {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.mapping-target-result {
|
||||
display: grid;
|
||||
grid-template-columns: 3.25rem minmax(0, 1fr) auto;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mapping-result-art {
|
||||
display: grid;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-page);
|
||||
}
|
||||
|
||||
.mapping-result-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mapping-target-result > span:nth-child(2) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-target-result small {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapping-target-result .choose-label {
|
||||
color: var(--accent);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.event-kind {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -1968,7 +2352,9 @@
|
||||
}
|
||||
|
||||
.event-log-entry {
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) auto 1rem;
|
||||
grid-template-columns: 3rem minmax(0, 1fr) 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.event-log-group-count,
|
||||
@@ -1983,12 +2369,66 @@
|
||||
}
|
||||
|
||||
.event-log-entry > .ui-icon:last-child {
|
||||
grid-column: 4;
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.event-log-details {
|
||||
padding-left: 0.8rem;
|
||||
padding: 0 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
.event-log-art {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.event-match-route,
|
||||
.event-match-route.detailed {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.event-route-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-route-confidence {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.event-log-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.event-log-art-detail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-log-detail-body > header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-search-bar,
|
||||
.mapping-review-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-card-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mapping-route {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-route-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mapping-target-search {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3846,7 +3846,22 @@ class AllstarrApp extends LitElement {
|
||||
const routes = asArray(mapping.providerIdentities).filter((identity) => identity.providerId !== mapping.providerId);
|
||||
const route = mapping.localTrack ? `${display(mapping.localTrack.artist, "Local library")} · ${display(mapping.localTrack.title, mapping.localTrack.backendItemId)}` : routes.map((identity) => `${identity.providerId}:${identity.externalId}`).join(", ");
|
||||
const notes = [...asArray(mapping.reasons), ...asArray(mapping.warnings)].filter(Boolean);
|
||||
return html`<article class="mapping-review-card ${["unresolved", "suggested", "ambiguous", "rejected"].includes(state) ? "needs-attention" : ""}"><div class="mapping-source-mark" aria-hidden="true">${String(mapping.providerId || "?").slice(0, 2).toUpperCase()}</div><div class="mapping-track-copy"><div class="mapping-card-title"><strong>${display(mapping.title, "Unknown track")}</strong><span class="status-pill ${state}">${humanize(state)}</span></div><p>${display(mapping.artist, "Unknown artist")}${mapping.album ? html`<span> · ${mapping.album}</span>` : ""}</p><div class="mapping-route"><span>Source</span><strong>${humanize(mapping.providerId || "provider")}</strong><span>Current match</span><strong>${route || "No playable match"}</strong></div>${notes.length ? html`<div class="mapping-notes">${notes.slice(0, 3).map((note) => html`<span>${note}</span>`)}</div>` : ""}</div><div class="mapping-card-actions"><button class="primary" @click=${() => this.openMappingReview(mapping)}>Review match</button><button @click=${async () => { await API.rematchMapping(mapping.externalSnapshotId); await this.loadMappings(); this.toast("Rematch requested"); }}>Rematch</button><button @click=${async () => { await API.resolveMapping(mapping.externalSnapshotId, { targetType: "reject", reason: "Rejected from the match review queue" }); await this.loadMappings(); this.toast("Track rejected"); }}>Reject</button>${mapping.overrideId ? html`<button class="danger" @click=${async () => { await API.deleteMapping(mapping.overrideId, mapping.overrideRevision ?? 0); await this.loadMappings(); this.toast("Manual review cleared"); }}>Clear review</button>` : ""}</div></article>`;
|
||||
const providerId = mapping.providerId || "provider";
|
||||
const targetProvider = mapping.localTrack ? String(this.status?.backendType || "jellyfin").toLowerCase() : routes[0]?.providerId;
|
||||
const artwork = mapping.artworkUrl || mapping.ArtworkUrl;
|
||||
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-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>
|
||||
<span class="mapping-route-node ${targetProvider ? "" : "unresolved"}">${targetProvider ? this.renderProviderLogo(targetProvider, "tiny") : icon("search", 18)}<span><small>Current match</small><strong>${route || "No playable match"}</strong></span></span>
|
||||
</div>
|
||||
${notes.length ? html`<div class="mapping-notes">${notes.slice(0, 3).map((note) => html`<span>${note}</span>`)}</div>` : ""}
|
||||
</div>
|
||||
<div class="mapping-card-actions"><button class="primary" @click=${() => this.openMappingReview(mapping)}>Review match</button><button @click=${async () => { await API.rematchMapping(mapping.externalSnapshotId); await this.loadMappings(); this.toast("Rematch requested"); }}>Rematch</button><button @click=${async () => { await API.resolveMapping(mapping.externalSnapshotId, { targetType: "reject", reason: "Rejected from the match review queue" }); await this.loadMappings(); this.toast("Track rejected"); }}>Reject</button>${mapping.overrideId ? html`<button class="danger" @click=${async () => { await API.deleteMapping(mapping.overrideId, mapping.overrideRevision ?? 0); await this.loadMappings(); this.toast("Manual review cleared"); }}>Clear review</button>` : ""}</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
mappingReviewProviders(mapping) {
|
||||
@@ -3864,14 +3879,16 @@ class AllstarrApp extends LitElement {
|
||||
if (!editor) return nothing;
|
||||
const mapping = editor.mapping;
|
||||
const local = editor.targetType === "local";
|
||||
return html`<div class="playlist-preview-backdrop mapping-review-backdrop" @click=${(event) => { if (event.target === event.currentTarget) this.mappingEditor = null; }}><section class="playlist-preview-modal mapping-review-modal" role="dialog" aria-modal="true" aria-label="Review track match"><header><div><div class="eyebrow">Review match</div><h2>${display(mapping.title, "Unknown track")}</h2><p>${display(mapping.artist, "Unknown artist")} · source: ${humanize(mapping.providerId)}</p></div><button class="icon-button" aria-label="Close match review" @click=${() => { this.mappingEditor = null; }}>×</button></header><div class="mapping-target-tabs" role="tablist"><button class=${local ? "active" : ""} @click=${() => { editor.targetType = "local"; editor.results = []; this.requestUpdate(); }}>Search local library</button><button class=${!local ? "active" : ""} @click=${() => { editor.targetType = "provider"; editor.results = []; this.requestUpdate(); }}>Search a playback provider</button></div><form class="mapping-target-search" @submit=${(event) => { event.preventDefault(); this.searchMappingTargets(); }}>${!local ? html`<label><span>Provider</span><input list="mapping-provider-options" .value=${editor.provider} @input=${(event) => { editor.provider = event.target.value; }}><datalist id="mapping-provider-options">${this.mappingReviewProviders(mapping).map((provider) => html`<option value=${provider}></option>`)}</datalist></label>` : ""}<label class="grow"><span>Track</span><input .value=${editor.query} @input=${(event) => { editor.query = event.target.value; }} placeholder="Artist and title"></label><button class="primary" type="submit" ?disabled=${editor.busy}>${editor.busy ? "Searching…" : "Search"}</button></form><div class="mapping-target-results">${editor.error ? html`<div class="callout danger">${editor.error}</div>` : ""}${asArray(editor.results).map((result) => this.renderMappingTargetResult(result, local))}${editor.searched && !editor.busy && !editor.results.length ? html`<div class="empty-state compact"><strong>No playable results.</strong><span>Try another provider or a more exact query.</span></div>` : ""}</div></section></div>`;
|
||||
return html`<div class="playlist-preview-backdrop mapping-review-backdrop" @click=${(event) => { if (event.target === event.currentTarget) this.mappingEditor = null; }}><section class="playlist-preview-modal mapping-review-modal" role="dialog" aria-modal="true" aria-label="Review track match"><header><div><div class="eyebrow">Review match</div><h2>${display(mapping.title, "Unknown track")}</h2><p>${display(mapping.artist, "Unknown artist")} · source: ${titleCase(mapping.providerId)}</p></div><button class="icon-button" aria-label="Close match review" @click=${() => { this.mappingEditor = null; }}>×</button></header><div class="mapping-target-tabs" role="tablist"><button class=${local ? "active" : ""} @click=${() => { editor.targetType = "local"; editor.results = []; this.requestUpdate(); }}>Search local library</button><button class=${!local ? "active" : ""} @click=${() => { editor.targetType = "provider"; editor.results = []; this.requestUpdate(); }}>Search a playback provider</button></div><form class="mapping-target-search" @submit=${(event) => { event.preventDefault(); this.searchMappingTargets(); }}>${!local ? html`<label><span>Provider</span><input list="mapping-provider-options" .value=${editor.provider} @input=${(event) => { editor.provider = event.target.value; }}><datalist id="mapping-provider-options">${this.mappingReviewProviders(mapping).map((provider) => html`<option value=${provider}></option>`)}</datalist></label>` : ""}<label class="grow"><span>Track</span><input .value=${editor.query} @input=${(event) => { editor.query = event.target.value; }} placeholder="Artist and title"></label><button class="primary" type="submit" ?disabled=${editor.busy}>${editor.busy ? "Searching…" : "Search"}</button></form><div class="mapping-target-results">${editor.error ? html`<div class="callout danger">${editor.error}</div>` : ""}${asArray(editor.results).map((result) => this.renderMappingTargetResult(result, local))}${editor.searched && !editor.busy && !editor.results.length ? html`<div class="empty-state compact"><strong>No playable results.</strong><span>Try another provider or a more exact query.</span></div>` : ""}</div></section></div>`;
|
||||
}
|
||||
|
||||
renderMappingTargetResult(result, local) {
|
||||
const title = display(result.title ?? result.Title ?? result.name ?? result.Name, "Unknown track");
|
||||
const artist = display(result.artist ?? result.Artist ?? result.artistName ?? result.ArtistName, "Unknown artist");
|
||||
const album = result.album ?? result.Album ?? result.albumTitle ?? result.AlbumTitle;
|
||||
return html`<button class="mapping-target-result" @click=${() => this.applyMappingTarget(result, local)}><span class="mapping-result-art" aria-hidden="true">♫</span><span><strong>${title}</strong><small>${artist}${album ? ` · ${album}` : ""}</small></span><span class="choose-label">Choose</span></button>`;
|
||||
const artwork = result.artworkUrl ?? result.ArtworkUrl ?? result.coverArtUrl ?? result.CoverArtUrl ?? result.imageUrl ?? result.ImageUrl;
|
||||
const provider = local ? String(this.status?.backendType || "jellyfin").toLowerCase() : result.externalProvider ?? result.ExternalProvider ?? this.mappingEditor?.provider;
|
||||
return html`<button class="mapping-target-result" @click=${() => this.applyMappingTarget(result, local)}><span class="mapping-result-art" aria-hidden="true">${artwork ? html`<img src=${artwork} alt="" loading="lazy" decoding="async">` : this.renderProviderLogo(provider || "library", "large")}</span><span><strong>${title}</strong><small>${artist}${album ? ` · ${album}` : ""}</small></span><span class="choose-label">Choose</span></button>`;
|
||||
}
|
||||
|
||||
async searchMappingTargets() {
|
||||
@@ -5507,6 +5524,35 @@ class AllstarrApp extends LitElement {
|
||||
const eventStatusClass = (state) => ["accepted", "delivered", "healthy", "pinned", "succeeded", "success"].includes(String(state).toLowerCase())
|
||||
? "configured"
|
||||
: state;
|
||||
const eventArtwork = (entry, size = "large") => {
|
||||
const artwork = entry.artworkUrl || entry.ArtworkUrl;
|
||||
const provider = entry.providerId || entry.ProviderId || entry.source || entry.Source || "system";
|
||||
return html`<span class=${`event-log-art event-log-art-${size}`}>${artwork ? html`<img src=${artwork} alt="" loading="lazy" decoding="async">` : this.renderProviderLogo(provider, size)}</span>`;
|
||||
};
|
||||
const matchRoute = (entry) => {
|
||||
const sourceProvider = entry.providerId || entry.ProviderId || entry.source || entry.Source || "matching";
|
||||
const rawTargetProvider = entry.targetProviderId || entry.TargetProviderId;
|
||||
const targetProvider = rawTargetProvider === "library" ? String(this.status?.backendType || "jellyfin").toLowerCase() : rawTargetProvider;
|
||||
return {
|
||||
sourceProvider,
|
||||
sourceTitle: entry.sourceTitle || entry.SourceTitle || entry.detail || entry.Detail || "External track",
|
||||
sourceArtist: entry.sourceArtist || entry.SourceArtist || "",
|
||||
sourceAlbum: entry.sourceAlbum || entry.SourceAlbum || "",
|
||||
targetProvider,
|
||||
targetTitle: entry.targetTitle || entry.TargetTitle || "No playable match",
|
||||
targetArtist: entry.targetArtist || entry.TargetArtist || "",
|
||||
confidence: entry.confidenceLabel || entry.ConfidenceLabel || "",
|
||||
};
|
||||
};
|
||||
const renderMatchRoute = (entry, detailed = false) => {
|
||||
const route = matchRoute(entry);
|
||||
return html`<div class=${`event-match-route ${detailed ? "detailed" : ""}`}>
|
||||
<span class="event-route-node">${this.renderProviderLogo(route.sourceProvider, "tiny")}<span><small>${providerDisplayName(route.sourceProvider, this.schema?.providers)}</small><strong>${route.sourceTitle}</strong>${detailed && route.sourceArtist ? html`<em>${route.sourceArtist}${route.sourceAlbum ? ` · ${route.sourceAlbum}` : ""}</em>` : nothing}</span></span>
|
||||
<span class="event-route-arrow" aria-hidden="true">${icon("chevronRight", detailed ? 20 : 16)}</span>
|
||||
<span class=${`event-route-node ${route.targetProvider ? "" : "unresolved"}`}>${route.targetProvider ? this.renderProviderLogo(route.targetProvider, "tiny") : icon("search", 17)}<span><small>${route.targetProvider ? providerDisplayName(route.targetProvider, this.schema?.providers) : "Unmatched"}</small><strong>${route.targetTitle}</strong>${detailed && route.targetArtist ? html`<em>${route.targetArtist}</em>` : nothing}</span></span>
|
||||
${route.confidence ? html`<span class="event-route-confidence">${route.confidence}</span>` : nothing}
|
||||
</div>`;
|
||||
};
|
||||
const groups = [];
|
||||
for (const entry of items) {
|
||||
const key = [entry.kind || entry.Kind, entry.source || entry.Source, entry.label || entry.Label, entry.state || entry.State]
|
||||
@@ -5537,9 +5583,8 @@ class AllstarrApp extends LitElement {
|
||||
const sourceName = providerDisplayName(entry.source || entry.Source || "system", this.schema?.providers);
|
||||
return html`<details class="event-log-group" role="listitem">
|
||||
<summary class="event-log-entry">
|
||||
<span class=${`event-kind event-${kind}`}>${icon(eventIcon(kind), 17)}</span>
|
||||
<span class="event-log-summary-copy"><strong>${titleCase(entry.label || entry.Label || kind)}</strong><small>${sourceName} · ${entry.detail || entry.Detail || "No additional detail"}</small></span>
|
||||
${group.entries.length > 1 ? html`<span class="event-log-group-count">${group.entries.length} events</span>` : nothing}
|
||||
${eventArtwork(entry)}
|
||||
<span class="event-log-summary-copy"><span class="event-log-title-line"><strong>${titleCase(entry.label || entry.Label || kind)}</strong>${group.entries.length > 1 ? html`<span class="event-log-group-count">${group.entries.length} events</span>` : nothing}</span>${kind === "matching" ? renderMatchRoute(entry) : html`<small>${sourceName} · ${entry.detail || entry.Detail || "No additional detail"}</small>`}</span>
|
||||
<span class=${`status-chip ${eventStatusClass(state)}`} title=${`${titleCase(severity)} severity`}>${titleCase(state)}</span>
|
||||
<time datetime=${entry.occurredAt || entry.OccurredAt}>${formatRelativeTime(entry.occurredAt || entry.OccurredAt)}</time>
|
||||
${icon("chevronRight", 16)}
|
||||
@@ -5550,13 +5595,18 @@ class AllstarrApp extends LitElement {
|
||||
const provider = item.providerId || item.ProviderId;
|
||||
const playlist = item.playlistName || item.PlaylistName;
|
||||
return html`<article class="event-log-detail">
|
||||
<div><strong>${item.detail || item.Detail || "No additional detail"}</strong><time datetime=${item.occurredAt || item.OccurredAt}>${formatDate(item.occurredAt || item.OccurredAt)}</time></div>
|
||||
<dl>
|
||||
<div><dt>Source</dt><dd>${providerDisplayName(item.source || item.Source || "system", this.schema?.providers)}</dd></div>
|
||||
${provider ? html`<div><dt>Provider</dt><dd>${providerDisplayName(provider, this.schema?.providers)}</dd></div>` : nothing}
|
||||
${playlist ? html`<div><dt>Playlist</dt><dd>${playlist}</dd></div>` : nothing}
|
||||
${correlation ? html`<div><dt>Correlation ID</dt><dd><code>${correlation}</code></dd></div>` : nothing}
|
||||
</dl>
|
||||
${eventArtwork(item, "detail")}
|
||||
<div class="event-log-detail-body">
|
||||
<header><strong>${titleCase(item.label || item.Label || kind)}</strong><time datetime=${item.occurredAt || item.OccurredAt}>${formatDate(item.occurredAt || item.OccurredAt)}</time></header>
|
||||
${kind === "matching" ? renderMatchRoute(item, true) : html`<p>${item.detail || item.Detail || "No additional detail"}</p>`}
|
||||
<dl>
|
||||
<div><dt>Source</dt><dd>${providerDisplayName(item.source || item.Source || "system", this.schema?.providers)}</dd></div>
|
||||
${provider ? html`<div><dt>Provider</dt><dd>${providerDisplayName(provider, this.schema?.providers)}</dd></div>` : nothing}
|
||||
${playlist ? html`<div><dt>Playlist</dt><dd>${playlist}</dd></div>` : nothing}
|
||||
<div><dt>Outcome</dt><dd><span class=${`status-chip ${eventStatusClass(item.state || item.State || "unknown")}`}>${titleCase(item.state || item.State || "unknown")}</span></dd></div>
|
||||
</dl>
|
||||
${correlation ? html`<details class="event-log-technical"><summary>Technical details</summary><code>${correlation}</code></details>` : nothing}
|
||||
</div>
|
||||
</article>`;
|
||||
})}
|
||||
${group.entries.length > 1 ? html`<button class="event-log-collapse" @click=${(event) => event.currentTarget.closest("details")?.removeAttribute("open")}>Collapse ${group.entries.length} events</button>` : nothing}
|
||||
|
||||
Reference in New Issue
Block a user