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' },