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
+27
View File
@@ -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<NetworkSettin
trusted_proxy_hops: hops
});
}
// Missing files (#2527) -----------------------------------------------------
export async function listMissingFiles(
offset: number = 0,
limit: number = 50
): Promise<AdminMissingResponse> {
return api.get<AdminMissingResponse>(
`/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
});
}
+2
View File
@@ -53,6 +53,8 @@ export const qk = {
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
['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,
+36
View File
@@ -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[];
};
+1
View File
@@ -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' },
@@ -0,0 +1,164 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '$lib/api/admin';
import { relativeTime } from '$lib/utils/relativeTime';
import { coverUrl } from '$lib/media/covers';
import type { AdminMissingGroup } from '$lib/api/types';
// Files the scan looked for and could not find. Read-only on purpose:
// a missing file keeps its track row, its play history and its likes,
// because the file may come back — the scanner clears the mark by
// itself, and adopts the row if it returns under a new name (#2528).
// Nothing on this page deletes anything.
const PAGE_SIZE = 50;
let offset = $state(0);
// $derived so paging re-creates the query with a new key — same shape the
// playback-errors page uses for its tab state.
const queryStore = $derived(createMissingFilesQuery(offset, PAGE_SIZE));
const query = $derived($queryStore);
const groups = $derived((query.data?.groups ?? []) as AdminMissingGroup[]);
const total = $derived(query.data?.total ?? 0);
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
const hasMore = $derived(offset + shown < total);
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
function durationLabel(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">
<div class="flex items-center gap-2">
<h2 class="font-display text-2xl font-medium text-text-primary">Missing files</h2>
{#if total > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
data-testid="missing-count-pill"
>
{total}
</span>
{/if}
</div>
<p class="text-text-secondary">
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.
</p>
</header>
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError}
<p class="text-error">Couldn't load the missing-files list.</p>
{:else if groups.length === 0}
<!-- Empty state explains the feature, not just the emptiness: an operator
who has never seen this page shouldn't have to guess what would put a
row here or when it would appear. -->
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<FolderX size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">Every track's file is where it should be.</p>
<p class="mt-1 text-sm text-text-secondary">
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.
</p>
</div>
{:else}
<ul class="space-y-4">
{#each groups as group (group.directory)}
<li class="overflow-hidden rounded-lg border border-border bg-surface">
<!-- The folder is the unit of decision. Three reorganised albums are
three things to think about, not forty. -->
<div class="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3">
<h3 class="truncate font-mono text-sm text-text-primary" title={group.directory}>
{group.directory}
</h3>
<span class="shrink-0 text-xs text-text-secondary">
{trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)}
</span>
</div>
<ul class="divide-y divide-border">
{#each group.tracks as t (t.track_id)}
<li class="flex items-center gap-3 px-4 py-2" data-testid="missing-track-row">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-surface-hover"
aria-hidden="true"
>
{#if t.album_id}
<img
src={coverUrl(t.album_id)}
alt=""
class="h-full w-full rounded object-cover"
loading="lazy"
/>
{:else}
<Music2 size={18} strokeWidth={1} class="text-text-muted" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-text-primary">{t.title}</div>
<div class="truncate text-xs text-text-secondary">
{t.artist_name} · {t.album_title}
</div>
</div>
<!-- Last played is the deciding fact: a file gone six months
that nobody ever played is not the same problem as one
played two hundred times last week. -->
<span class="shrink-0 text-xs text-text-muted">
{#if t.last_played_at}
last played {relativeTime(t.last_played_at)}
{:else}
never played
{/if}
</span>
<span class="shrink-0 text-xs text-text-muted">{durationLabel(t.duration_sec)}</span>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{#if hasMore || offset > 0}
<nav class="flex items-center justify-between" aria-label="Missing files pages">
<button
type="button"
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
hover:bg-surface-hover focus-visible:outline focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
disabled={offset === 0}
onclick={() => (offset = Math.max(0, offset - PAGE_SIZE))}
>
Previous
</button>
<span class="text-xs text-text-secondary">
{offset + 1}{offset + shown} of {total}
</span>
<button
type="button"
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
hover:bg-surface-hover focus-visible:outline focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
disabled={!hasMore}
onclick={() => (offset += PAGE_SIZE)}
>
Next
</button>
</nav>
{/if}
{/if}
</div>
@@ -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('13 of 120')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Previous' })).toHaveProperty('disabled', true);
expect(screen.getByRole('button', { name: 'Next' })).toHaveProperty('disabled', false);
});
});