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 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 = {
+2
View File
@@ -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,
+27
View File
@@ -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 = {
+6 -5
View File
@@ -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 {
+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');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -61,6 +61,7 @@ describe('AdminTabs', () => {
'Integrations',
'Requests',
'Quarantine',
'Playback errors',
'Users'
]);
});