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'
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user