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:
@@ -3,6 +3,7 @@ import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type {
|
||||
ActionResult,
|
||||
AdminPlaybackError,
|
||||
AdminQuarantineRow,
|
||||
LidarrConfig,
|
||||
LidarrMetadataProfile,
|
||||
@@ -11,7 +12,8 @@ import type {
|
||||
LidarrRequest,
|
||||
LidarrRequestStatus,
|
||||
LidarrRootFolder,
|
||||
LidarrTestResult
|
||||
LidarrTestResult,
|
||||
PlaybackErrorResolution
|
||||
} from './types';
|
||||
|
||||
// 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 ---------------------------------------------------------
|
||||
|
||||
export type RefetchAlbumCoverResponse = {
|
||||
|
||||
@@ -43,6 +43,8 @@ export const qk = {
|
||||
adminQuarantine: () => ['adminQuarantine'] as const,
|
||||
adminQuarantineActions: (limit?: number) =>
|
||||
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
||||
adminPlaybackErrors: (resolved?: boolean) =>
|
||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||
scanStatus: () => ['scanStatus'] as const,
|
||||
scanSchedule: () => ['scanSchedule'] as const,
|
||||
coverage: () => ['coverage'] as const,
|
||||
|
||||
@@ -264,6 +264,33 @@ export type AdminQuarantineReport = {
|
||||
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 LidarrQuarantineActionRow = {
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
type Item = { href: string; label: string };
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/users', label: 'Users' }
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||
{ href: '/admin/users', label: 'Users' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
|
||||
@@ -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');
|
||||
render(AdminTabs);
|
||||
const links = screen.getAllByRole('link');
|
||||
@@ -61,6 +61,7 @@ describe('AdminTabs', () => {
|
||||
'Integrations',
|
||||
'Requests',
|
||||
'Quarantine',
|
||||
'Playback errors',
|
||||
'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>
|
||||
Reference in New Issue
Block a user