feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s

Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.

Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
  "no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
  / detail / reporter / client_id / occurred_at. Matches the
  operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
  /api/admin/quarantine/{track_id}/delete-file endpoint AND
  auto-stamps resolution='deleted' so a single click closes both
  the file and the inbox row.

Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).

Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
This commit is contained in:
2026-06-02 11:34:46 -04:00
parent 99e1df4920
commit de61305fde
6 changed files with 384 additions and 7 deletions
+31 -1
View File
@@ -3,6 +3,7 @@ import { api } from './client';
import { qk } from './queries'; import { qk } from './queries';
import type { import type {
ActionResult, ActionResult,
AdminPlaybackError,
AdminQuarantineRow, AdminQuarantineRow,
LidarrConfig, LidarrConfig,
LidarrMetadataProfile, LidarrMetadataProfile,
@@ -11,7 +12,8 @@ import type {
LidarrRequest, LidarrRequest,
LidarrRequestStatus, LidarrRequestStatus,
LidarrRootFolder, LidarrRootFolder,
LidarrTestResult LidarrTestResult,
PlaybackErrorResolution
} from './types'; } from './types';
// Admin Lidarr config ----------------------------------------------------- // Admin Lidarr config -----------------------------------------------------
@@ -174,6 +176,34 @@ export function createQuarantineActionsQuery(limit: number = 50) {
}); });
} }
// Admin playback errors ---------------------------------------------------
export async function listAdminPlaybackErrors(
resolved: boolean = false
): Promise<AdminPlaybackError[]> {
return api.get<AdminPlaybackError[]>(
`/api/admin/playback-errors?resolved=${resolved}`
);
}
export async function resolvePlaybackError(
id: string,
resolution: PlaybackErrorResolution
): Promise<{ id: string }> {
return api.post<{ id: string }>(
`/api/admin/playback-errors/${id}/resolve`,
{ resolution }
);
}
export function createAdminPlaybackErrorsQuery(resolved: boolean = false) {
return createQuery({
queryKey: qk.adminPlaybackErrors(resolved),
queryFn: () => listAdminPlaybackErrors(resolved),
staleTime: 30_000
});
}
// Admin cover art --------------------------------------------------------- // Admin cover art ---------------------------------------------------------
export type RefetchAlbumCoverResponse = { export type RefetchAlbumCoverResponse = {
+2
View File
@@ -43,6 +43,8 @@ export const qk = {
adminQuarantine: () => ['adminQuarantine'] as const, adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) => adminQuarantineActions: (limit?: number) =>
['adminQuarantineActions', { limit: limit ?? 50 }] as const, ['adminQuarantineActions', { limit: limit ?? 50 }] as const,
adminPlaybackErrors: (resolved?: boolean) =>
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const, scanStatus: () => ['scanStatus'] as const,
scanSchedule: () => ['scanSchedule'] as const, scanSchedule: () => ['scanSchedule'] as const,
coverage: () => ['coverage'] as const, coverage: () => ['coverage'] as const,
+27
View File
@@ -264,6 +264,33 @@ export type AdminQuarantineReport = {
created_at: string; created_at: string;
}; };
// What GET /api/admin/playback-errors returns per row
export type PlaybackErrorKind = 'zero_duration' | 'load_failed' | 'stalled';
export type PlaybackErrorResolution =
| 'hidden'
| 'deleted'
| 'requested'
| 'fixed'
| 'ignored';
export type AdminPlaybackError = {
id: string;
track_id: string;
user_id: string;
username: string;
client_id: string;
kind: PlaybackErrorKind;
detail?: string | null;
occurred_at: string;
resolved_at?: string | null;
resolution?: PlaybackErrorResolution | null;
track_title: string;
track_file_path: string;
artist_name: string;
album_title: string;
album_id: string;
};
export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr';
export type LidarrQuarantineActionRow = { export type LidarrQuarantineActionRow = {
+6 -5
View File
@@ -4,11 +4,12 @@
type Item = { href: string; label: string }; type Item = { href: string; label: string };
const items: Item[] = [ const items: Item[] = [
{ href: '/admin', label: 'Overview' }, { href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' }, { href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' }, { href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' }, { href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/users', label: 'Users' } { href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/users', label: 'Users' }
]; ];
function isActive(href: string): boolean { function isActive(href: string): boolean {
+2 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
); );
}); });
test('renders exactly five tabs', () => { test('renders all six tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin'); state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs); render(AdminTabs);
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
@@ -61,6 +61,7 @@ describe('AdminTabs', () => {
'Integrations', 'Integrations',
'Requests', 'Requests',
'Quarantine', 'Quarantine',
'Playback errors',
'Users' 'Users'
]); ]);
}); });
@@ -0,0 +1,316 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Copy, Trash2, CheckCircle2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
import {
createAdminPlaybackErrorsQuery,
resolvePlaybackError,
deleteQuarantineFile
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/Modal.svelte';
import type { AdminPlaybackError, PlaybackErrorResolution } from '$lib/api/types';
// Client-reported playback errors inbox. Two tabs — Unresolved
// (default) / Resolved. Per-row: copy details to clipboard, delete
// the source file (auto-resolves as 'deleted'), or mark resolved
// manually with a fixed/ignored reason. Other resolutions (hidden,
// requested) get stamped by their respective workflows in follow-up
// slices.
const client = useQueryClient();
// Tab state — observed by the query factory so swapping tabs
// re-fetches the corresponding list.
let resolved = $state(false);
const queryStore = $derived(createAdminPlaybackErrorsQuery(resolved));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as AdminPlaybackError[]);
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const days = Math.floor(ms / (24 * 3_600_000));
if (days >= 1) return `${days}d ago`;
const hours = Math.floor(ms / 3_600_000);
if (hours >= 1) return `${hours}h ago`;
const minutes = Math.floor(ms / 60_000);
if (minutes >= 1) return `${minutes}m ago`;
return 'just now';
}
// Maps the kind enum to a short readable badge label.
function kindLabel(kind: string): string {
switch (kind) {
case 'zero_duration': return 'Zero duration';
case 'load_failed': return 'Load failed';
case 'stalled': return 'Stalled';
default: return kind;
}
}
// Maps the resolution enum to a short label for the Resolved tab.
function resolutionLabel(r: string | null | undefined): string {
if (!r) return '';
switch (r) {
case 'hidden': return 'Hidden';
case 'deleted': return 'Deleted';
case 'requested': return 'Re-requested';
case 'fixed': return 'Fixed';
case 'ignored': return 'Ignored';
default: return r;
}
}
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminPlaybackErrors(resolved) });
}
// Copy a single-row JSON payload to the clipboard. Includes the
// server-side file_path so the operator can grep their library
// mount without round-tripping back through the UI.
async function onCopy(r: AdminPlaybackError) {
const payload = {
id: r.id,
track_id: r.track_id,
track_title: r.track_title,
artist_name: r.artist_name,
album_title: r.album_title,
file_path: r.track_file_path,
kind: r.kind,
detail: r.detail,
reported_by: r.username,
client_id: r.client_id,
occurred_at: r.occurred_at
};
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
pushToast('Copied error details to clipboard');
} catch (e: unknown) {
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
}
}
// Delete the source file via the existing quarantine admin endpoint;
// on success stamp this row as resolved='deleted' so the inbox
// reflects the action immediately.
let deleteFileOpen = $state<AdminPlaybackError | null>(null);
async function confirmDeleteFile() {
const row = deleteFileOpen;
if (!row) return;
deleteFileOpen = null;
try {
await deleteQuarantineFile(row.track_id);
await resolvePlaybackError(row.id, 'deleted');
pushToast(`Deleted "${row.track_title}"`);
await invalidate();
} catch (e: unknown) {
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
}
}
// Manual mark-resolved modal. Resolution dropdown limited to the two
// "no further action taken" cases (fixed / ignored). Delete already
// stamps 'deleted'; hide/request land in follow-up slices.
let resolveOpen = $state<AdminPlaybackError | null>(null);
let resolveChoice = $state<PlaybackErrorResolution>('fixed');
function openResolve(row: AdminPlaybackError) {
resolveOpen = row;
resolveChoice = 'fixed';
}
async function confirmResolve() {
const row = resolveOpen;
if (!row) return;
resolveOpen = null;
try {
await resolvePlaybackError(row.id, resolveChoice);
pushToast(`Marked "${row.track_title}" ${resolutionLabel(resolveChoice).toLowerCase()}`);
await invalidate();
} catch (e: unknown) {
pushToast(`Resolve failed: ${errMessage(e)}`, 'error');
}
}
function actionsFor(r: AdminPlaybackError): { primary: RowAction; secondary: RowAction[] } {
return {
primary: {
icon: CheckCircle2,
label: 'Resolve',
ariaLabel: `Resolve ${r.track_title}`,
onclick: () => openResolve(r)
},
secondary: [
{
icon: Copy,
label: 'Copy',
ariaLabel: `Copy details for ${r.track_title}`,
onclick: () => onCopy(r)
},
{
icon: Trash2,
label: 'Delete file',
ariaLabel: `Delete file for ${r.track_title}`,
danger: true,
onclick: () => { deleteFileOpen = r; }
}
]
};
}
</script>
<svelte:head><title>{pageTitle('Admin · Playback errors')}</title></svelte:head>
<div class="space-y-4">
<header class="flex items-end justify-between gap-3">
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">Playback errors</h1>
<p class="text-sm text-text-secondary">
Tracks that failed to play on a client. Reported automatically by
the Android player when a track loads with zero duration or
decode-fails — the player skips fast and logs here for triage.
</p>
</div>
{#if !query.isPending && !query.isError && !resolved}
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent">
{rows.length} unresolved
</span>
{/if}
</header>
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
the cache holds both lists without re-fetching when you flip. -->
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border">
<button
type="button"
role="tab"
aria-selected={!resolved}
onclick={() => { resolved = false; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Unresolved</button>
<button
type="button"
role="tab"
aria-selected={resolved}
onclick={() => { resolved = true; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Resolved</button>
</div>
{#if query.isError}
<p class="text-error">Couldn't load: {errMessage(query.error)}</p>
{:else if query.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
<p class="text-text-secondary">
{resolved ? 'No resolved errors yet.' : 'No unresolved errors.'}
</p>
{:else}
<ul class="divide-y divide-border rounded-md border border-border">
{#each rows as r (r.id)}
<li class="flex items-start gap-3 px-3 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="truncate text-sm font-medium text-text-primary">
{r.track_title}
</span>
<span class="truncate text-xs text-text-secondary">
{r.artist_name} · {r.album_title}
</span>
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
{kindLabel(r.kind)}
</span>
{#if resolved && r.resolution}
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent">
{resolutionLabel(r.resolution)}
</span>
{/if}
</div>
<div class="mt-0.5 text-xs text-text-muted">
by {r.username} · {relativeTime(r.occurred_at)}
{#if r.detail}<span class="text-text-secondary"> · {r.detail}</span>{/if}
</div>
<div class="mt-1 truncate text-[11px] font-mono text-text-muted" title={r.track_file_path}>
{r.track_file_path}
</div>
</div>
{#if !resolved}
{@const a = actionsFor(r)}
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<Modal
title="Delete file?"
open={deleteFileOpen !== null}
onClose={() => { deleteFileOpen = null; }}
>
{#if deleteFileOpen}
<p class="text-sm text-text-secondary">
This removes <span class="font-medium text-text-primary">{deleteFileOpen.track_title}</span>
from the library and deletes the underlying file. The error row
will be marked resolved.
</p>
<p class="mt-2 text-xs text-text-muted font-mono">{deleteFileOpen.track_file_path}</p>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => { deleteFileOpen = null; }}
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
>Cancel</button>
<button
type="button"
onclick={confirmDeleteFile}
class="rounded bg-action-danger px-3 py-2 text-sm text-action-fg hover:opacity-90"
>Delete file</button>
</div>
{/if}
</Modal>
<Modal
title="Mark resolved"
open={resolveOpen !== null}
onClose={() => { resolveOpen = null; }}
>
{#if resolveOpen}
<p class="text-sm text-text-secondary">
Marking <span class="font-medium text-text-primary">{resolveOpen.track_title}</span> resolved.
Pick how you handled it:
</p>
<label class="mt-3 block text-xs text-text-secondary">
Resolution
<select
bind:value={resolveChoice}
class="mt-1 block w-full rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="fixed">Fixed — track now plays</option>
<option value="ignored">Ignored — no action needed</option>
</select>
</label>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => { resolveOpen = null; }}
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
>Cancel</button>
<button
type="button"
onclick={confirmResolve}
class="rounded bg-action-primary px-3 py-2 text-sm text-action-fg hover:opacity-90"
>Mark resolved</button>
</div>
{/if}
</Modal>