diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 20ca2da3..96e621dc 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -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 { + return api.get( + `/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 = { diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 418ffaa6..37dafb5c 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 667d4b49..26796f06 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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 = { diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index 07429f1c..78ad4cc6 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -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 { diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts index d76ddbdf..73bd812f 100644 --- a/web/src/lib/components/AdminTabs.test.ts +++ b/web/src/lib/components/AdminTabs.test.ts @@ -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' ]); }); diff --git a/web/src/routes/admin/playback-errors/+page.svelte b/web/src/routes/admin/playback-errors/+page.svelte new file mode 100644 index 00000000..86fdf9a0 --- /dev/null +++ b/web/src/routes/admin/playback-errors/+page.svelte @@ -0,0 +1,316 @@ + + +{pageTitle('Admin · Playback errors')} + +
+
+
+

Playback errors

+

+ 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. +

+
+ {#if !query.isPending && !query.isError && !resolved} + + {rows.length} unresolved + + {/if} +
+ + +
+ + +
+ + {#if query.isError} +

Couldn't load: {errMessage(query.error)}

+ {:else if query.isPending} +

Loading…

+ {:else if rows.length === 0} +

+ {resolved ? 'No resolved errors yet.' : 'No unresolved errors.'} +

+ {:else} +
    + {#each rows as r (r.id)} +
  • +
    +
    + + {r.track_title} + + + {r.artist_name} · {r.album_title} + + + {kindLabel(r.kind)} + + {#if resolved && r.resolution} + + {resolutionLabel(r.resolution)} + + {/if} +
    +
    + by {r.username} · {relativeTime(r.occurred_at)} + {#if r.detail} · {r.detail}{/if} +
    +
    + {r.track_file_path} +
    +
    + {#if !resolved} + {@const a = actionsFor(r)} + + {/if} +
  • + {/each} +
+ {/if} +
+ + { deleteFileOpen = null; }} +> + {#if deleteFileOpen} +

+ This removes {deleteFileOpen.track_title} + from the library and deletes the underlying file. The error row + will be marked resolved. +

+

{deleteFileOpen.track_file_path}

+
+ + +
+ {/if} +
+ + { resolveOpen = null; }} +> + {#if resolveOpen} +

+ Marking {resolveOpen.track_title} resolved. + Pick how you handled it: +

+ +
+ + +
+ {/if} +