diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index d1eb16c5..c4cdd519 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, + AdminMissingResponse, AdminPlaybackError, AdminQuarantineRow, LidarrConfig, @@ -669,3 +670,29 @@ export async function updateNetworkSettings(hops: number): Promise { + return api.get( + `/api/admin/library/missing?limit=${limit}&offset=${offset}` + ); +} + +// Takes a plain offset rather than a getter: callers wrap the call in +// $derived (as the playback-errors and requests pages do for their tab +// state), so changing the page re-creates the query with a new key. A +// getter would capture the key once and paging would silently not refetch. +// +// staleTime is generous because this list only changes when a scan runs — +// no point re-fetching on every focus like a live triage queue. +export function createMissingFilesQuery(offset: number = 0, limit: number = 50) { + return createQuery({ + queryKey: qk.adminMissingFiles(offset), + queryFn: () => listMissingFiles(offset, limit), + staleTime: 120_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 584c234a..f5586019 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -53,6 +53,8 @@ export const qk = { adminInvites: () => ['adminInvites'] as const, adminDiagnostics: (f: Record) => ['adminDiagnostics', f] as const, + adminMissingFiles: (offset?: number) => + ['adminMissingFiles', { offset: offset ?? 0 }] as const, adminDiagnosticDevices: (userId?: string) => ['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const, smtpConfig: () => ['smtpConfig'] as const, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 34f5d364..a954814c 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -369,3 +369,39 @@ export type HomePayload = { you_might_like_albums: AlbumRef[]; you_might_like_artists: ArtistRef[]; }; + +// Missing files (#2527) ----------------------------------------------------- + +// One track whose file the scan could not find. The text fields come from the +// tracks row rather than the filesystem — the recording is still a known thing +// with a history, only its bytes are absent. last_played_at is null for a file +// that was never played, which is the signal that separates "worth chasing" +// from "let it go". +export type AdminMissingTrack = { + track_id: string; + title: string; + artist_id: string; + artist_name: string; + album_id: string; + album_title: string; + file_path: string; + duration_sec: number; + missing_since: string; + last_played_at: string | null; +}; + +// A directory's worth of missing tracks. The server groups because the unit an +// operator reasons about is a folder: three reorganised albums are three +// decisions, not forty. +export type AdminMissingGroup = { + directory: string; + missing_since: string; + tracks: AdminMissingTrack[]; +}; + +export type AdminMissingResponse = { + total: number; + limit: number; + offset: number; + groups: AdminMissingGroup[]; +}; diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index 08a21264..1be389cf 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -8,6 +8,7 @@ { href: '/admin/integrations', label: 'Integrations' }, { href: '/admin/requests', label: 'Requests' }, { href: '/admin/quarantine', label: 'Quarantine' }, + { href: '/admin/missing-files', label: 'Missing files' }, { href: '/admin/playback-errors', label: 'Playback errors' }, { href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/tuning', label: 'Tuning' }, diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte new file mode 100644 index 00000000..9c552ef7 --- /dev/null +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -0,0 +1,164 @@ + + +{pageTitle('Admin · Missing files')} + +
+
+
+

Missing files

+ {#if total > 0} + + {total} + + {/if} +
+

+ Tracks whose audio file the last scan couldn't find. They keep their play + history and likes, and stop being offered anywhere until the file returns. +

+
+ + {#if query.isPending} +

Checking what's missing…

+ {:else if query.isError} +

Couldn't load the missing-files list.

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

Every track's file is where it should be.

+

+ A track lands here when a library scan can't find its file — moved outside + Minstrel, deleted, or on a drive that didn't mount. It leaves on its own + when the file comes back. +

+
+ {:else} +
    + {#each groups as group (group.directory)} +
  • + +
    +

    + {group.directory} +

    + + {trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)} + +
    + +
      + {#each group.tracks as t (t.track_id)} +
    • + + +
      +
      {t.title}
      +
      + {t.artist_name} · {t.album_title} +
      +
      + + + + {#if t.last_played_at} + last played {relativeTime(t.last_played_at)} + {:else} + never played + {/if} + + {durationLabel(t.duration_sec)} +
    • + {/each} +
    +
  • + {/each} +
+ + {#if hasMore || offset > 0} + + {/if} + {/if} +
diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts new file mode 100644 index 00000000..e1e5c515 --- /dev/null +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { AdminMissingResponse } from '$lib/api/types'; + +vi.mock('$lib/api/admin', () => ({ + createMissingFilesQuery: vi.fn() +})); + +import AdminMissingFilesPage from './+page.svelte'; +import { createMissingFilesQuery } from '$lib/api/admin'; + +const DAY = 24 * 3_600_000; + +function track(title: string, opts: { lastPlayed?: string | null } = {}) { + return { + track_id: `t-${title}`, + title, + artist_id: 'ar-1', + artist_name: 'Linkin Park', + album_id: 'al-1', + album_title: 'Minutes to Midnight', + file_path: `/music/Linkin Park/Minutes to Midnight/${title}.flac`, + duration_sec: 185, + missing_since: new Date(Date.now() - 3 * DAY).toISOString(), + last_played_at: opts.lastPlayed === undefined ? null : opts.lastPlayed + }; +} + +const response: AdminMissingResponse = { + total: 3, + limit: 50, + offset: 0, + groups: [ + { + directory: '/music/Linkin Park/Minutes to Midnight', + missing_since: new Date(Date.now() - 3 * DAY).toISOString(), + tracks: [ + track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }), + track('Bleed It Out') + ] + }, + { + directory: '/music/Boards of Canada/Geogaddi', + missing_since: new Date(Date.now() - 9 * DAY).toISOString(), + tracks: [track('1969')] + } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +function renderWith(data: AdminMissingResponse | undefined, extra = {}) { + vi.mocked(createMissingFilesQuery).mockReturnValue( + mockQuery({ data, ...extra }) as ReturnType + ); + return render(AdminMissingFilesPage); +} + +describe('admin missing files', () => { + test('groups rows under their directory', () => { + renderWith(response); + expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy(); + expect(screen.getByText('/music/Boards of Canada/Geogaddi')).toBeTruthy(); + expect(screen.getAllByTestId('missing-track-row')).toHaveLength(3); + }); + + test('the count pill reports the server total, not the page size', () => { + renderWith(response); + expect(screen.getByTestId('missing-count-pill').textContent?.trim()).toBe('3'); + }); + + test('a group states how many tracks and how long gone', () => { + renderWith(response); + expect(screen.getByText(/2 tracks · gone 3d ago/)).toBeTruthy(); + expect(screen.getByText(/1 track · gone 9d ago/)).toBeTruthy(); + }); + + // The deciding fact for whether a missing file is worth chasing. + test('distinguishes a played track from one never played', () => { + renderWith(response); + expect(screen.getByText(/last played 2d ago/)).toBeTruthy(); + expect(screen.getAllByText('never played')).toHaveLength(2); + }); + + // An operator who has never seen this page should not have to guess what + // would put a row here. + test('the empty state explains what missing means', () => { + renderWith({ total: 0, limit: 50, offset: 0, groups: [] }); + expect(screen.getByText(/every track's file is where it should be/i)).toBeTruthy(); + expect(screen.getByText(/can't find its file/i)).toBeTruthy(); + expect(screen.queryByTestId('missing-count-pill')).toBeNull(); + }); + + test('shows a loading line while pending', () => { + renderWith(undefined, { isPending: true }); + expect(screen.getByText(/checking what's missing/i)).toBeTruthy(); + }); + + test('shows an error line when the query fails', () => { + renderWith(undefined, { isError: true }); + expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy(); + }); + + // Paging only appears when it can do something: a single page of results + // should not render dead Previous/Next buttons. + test('no pager when everything fits on one page', () => { + renderWith(response); + expect(screen.queryByLabelText('Missing files pages')).toBeNull(); + }); + + test('pager appears when the server reports more than this page holds', () => { + renderWith({ ...response, total: 120 }); + expect(screen.getByLabelText('Missing files pages')).toBeTruthy(); + expect(screen.getByText('1–3 of 120')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Previous' })).toHaveProperty('disabled', true); + expect(screen.getByRole('button', { name: 'Next' })).toHaveProperty('disabled', false); + }); +});