fix(webui): redesign match events and mappings

This commit is contained in:
2026-07-23 00:07:36 -04:00
parent c79059b143
commit 384c21b99f
3 changed files with 543 additions and 29 deletions
+27 -3
View File
@@ -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);
+453 -13
View File
@@ -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;
}
}
+63 -13
View File
@@ -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}