feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s

Renders GET /api/admin/library/missing under Admin -> Missing files.
Folder-grouped, because that is the unit an operator decides about: the
case behind #2523 was three reorganised albums, and forty individual
rows hides that it is really three decisions.

Each row leads with the fact that settles whether a missing file is
worth chasing -- "last played 2d ago" against "never played". The group
header carries how many tracks and how long they have been gone.

Read-only. No remove button anywhere: the row, its play history and its
likes survive a file going missing, and the scanner clears the mark by
itself when the file returns (or adopts the row if it returns renamed,
#2528). The page says so in its own copy rather than leaving the
operator to infer it.

Empty state explains the feature instead of the emptiness -- what puts a
row here (moved outside Minstrel, deleted, a drive that didn't mount)
and that rows leave on their own. Someone who has never seen this page
should not have to guess.

Paging follows the house pattern -- plain offset into the factory,
wrapped in $derived so a page change re-creates the query with a new
key. Passing a getter instead would capture the key once and paging
would silently not refetch. The pager only renders when it can do
something.
This commit is contained in:
2026-08-16 12:03:59 -04:00
parent 845f45fb0b
commit 8d1f2674fd
6 changed files with 349 additions and 0 deletions
@@ -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<typeof createMissingFilesQuery>
);
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);
});
});