feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s
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:
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user