feat: one Missing files page under Collection: folders gone, each with its album's want, last search or download, and Search, Releases and Remove; an album held before its files went is no longer shown as held again (M504 #5626)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:44:33 -04:00
co-authored by Claude Opus 5.5
parent 38b2325d2d
commit ead0abf0cf
20 changed files with 702 additions and 390 deletions
+3 -2
View File
@@ -3,7 +3,8 @@ import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
// The queues that wait on an admin (#5605), in the order the Overview lists
// them. The rails and the Overview read the same list, so a queue that
// needs you is counted the same in every place. Requests live under Admin;
// the library's health queues under Collection (M504).
// the library's health queues, Missing files among them, under Collection
// (M504).
export type QueueKey = keyof AdminSummary;
export type QueueDef = {
@@ -21,7 +22,7 @@ export const QUEUES: QueueDef[] = [
{ key: 'reports', href: '/collection/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
{ key: 'duplicates', href: '/collection/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
{ key: 'suspect', href: '/collection/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
{ key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' }
{ key: 'missing', href: '/collection/missing', one: 'missing file', many: 'missing files', none: 'missing files' }
];
export function countOf(summary: AdminSummary | undefined, key: QueueKey): number {
+3
View File
@@ -363,6 +363,9 @@ export type AdminMissingTrack = {
export type AdminReacquisitionState = {
wanted_at: string | null;
held_at: string | null;
// The want itself, as the wanted list sends it (M504), so the Missing page
// shows its search and download and acts on it.
want: Want | null;
};
// A directory's worth of missing tracks. The server groups because the unit an
@@ -99,7 +99,7 @@ describe('NotificationBell', () => {
kind: 'tracks_missing',
title: '4 tracks went missing',
body: 'Their files are no longer where the library expects them.',
link: '/admin/missing-files',
link: '/collection/missing',
read_at: new Date().toISOString()
})
]);
@@ -8,9 +8,9 @@
// second, accented count when downloads are waiting on a person. Unmatched
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
// holds the albums held but wanted for a better copy (#5524). Reports,
// Duplicates and Suspect sources are the library's health queues (M504):
// their counts come from the admin summary and are accented, as they wait
// on a person.
// Duplicates, Suspect sources and Missing files are the library's health
// queues (M504): their counts come from the admin summary and are
// accented, as they wait on a person. Missing files counts tracks.
let { counts, summary }: { counts?: WantCounts; summary?: AdminSummary } = $props();
@@ -18,7 +18,6 @@
const items = $derived([
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/upgrades', label: 'Upgrades', count: counts?.upgrades },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
@@ -26,7 +25,8 @@
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched },
{ href: '/collection/reports', label: 'Reports', attention: countOf(summary, 'reports') },
{ href: '/collection/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
{ href: '/collection/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') }
{ href: '/collection/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
{ href: '/collection/missing', label: 'Missing files', attention: countOf(summary, 'missing') }
]);
</script>
+6 -78
View File
@@ -12,7 +12,8 @@
import QuickFilter from '#lib/components/QuickFilter.svelte';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import WantTracks from './WantTracks.svelte';
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
import type { Want, WantState } from '#lib/api/types.js';
// Albums on the Collection's list (M502 #5520–#5522): the ones still
// wanted, the missing ones wanted back, or, given an artist, every album
@@ -77,25 +78,6 @@
}
}
function searched(w: Want, r: SearchResult): string {
let said: string;
switch (r.outcome) {
case 'grabbed':
said = `Took ${r.grabbed ?? r.best_release}.`;
break;
case 'waiting':
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
break;
case 'nothing':
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
break;
default:
said = r.error ?? 'The search failed.';
}
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
return said;
}
async function onSearch(w: Want) {
searching = w.id;
try {
@@ -112,56 +94,6 @@
}
}
// A download still running, in a line: how far it has got, or what its
// import is doing or waiting on.
function downloading(w: Want): string | null {
const g = w.grab;
if (!g?.live) return null;
switch (g.import_state) {
case 'downloading':
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
case 'importing':
return `Downloaded; checking it is the album and filing it · ${g.name}`;
case 'waiting':
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
case 'review':
return `Needs you: ${g.import_reason}`;
case 'failed':
return `Import failed: ${g.import_reason}`;
}
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
}
// What the schedule last did about a want, in a line.
function status(w: Want): string {
const live = downloading(w);
if (live) return live;
const s = w.search;
let said = 'Not searched yet';
if (s) {
const when = `Searched ${relativeTime(s.searched_at)}`;
switch (s.outcome) {
case 'nothing':
said = s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
break;
case 'waiting':
said = `${when}: ${s.considered} found, nothing taken`;
break;
case 'grabbed':
said = `${when}: took ${s.best_release}`;
break;
default:
said = `${when}: ${s.error}`;
}
}
const g = w.grab;
if (g?.import_state === 'refused') said += `. Last download refused: ${g.import_reason}`;
else if (g && !g.live && ['stalled', 'failed', 'gone'].includes(g.end_reason) && g.end_detail)
said += `. Last download given up: ${g.end_detail}`;
return said;
}
// How a held album arrived, when Minstrel fetched it.
function arrived(w: Want): string | null {
const g = w.grab;
@@ -179,7 +111,6 @@
return w.held_quality ? `You have ${w.held_quality}; looking for better` : 'Looking for a better copy';
}
const attention = (w: Want) => w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
@@ -243,14 +174,11 @@
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">
{#if artist}None of this artist's albums are in the catalogue.
{:else if list === 'missing'}No missing albums are wanted back.
{:else if list === 'upgrade'}No album is wanted for a better copy.
{:else}Nothing is wanted.{/if}
</p>
<p class="mt-1 text-sm text-text-secondary">
{#if list === 'missing'}
An album whose files stay missing past the grace window on the Missing files page lands here.
{:else if list === 'upgrade'}
{#if list === 'upgrade'}
An album you have below the quality set under
<a href="/admin/acquisition" class="text-accent-fg hover:underline">Acquisition</a>, or with a video
rip among its tracks, lands here.
@@ -301,11 +229,11 @@
{/if}
{#if searchable(w)}
<div
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
class="text-xs {needsYou(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
data-testid="want-search"
>
{status(w)}
{#if attention(w)}
{wantStatus(w)}
{#if needsYou(w)}
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
{/if}
</div>
@@ -116,9 +116,9 @@ function setup(
describe('WantList', () => {
test('asks for its own state', () => {
setup([], 'missing');
expect(createWantsQuery).toHaveBeenCalledWith('missing', '');
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
setup([], 'upgrade');
expect(createWantsQuery).toHaveBeenCalledWith('upgrade', '');
expect(screen.getByText(/no album is wanted for a better copy/i)).toBeTruthy();
});
test('a want says why it is wanted', () => {
+79
View File
@@ -0,0 +1,79 @@
import { relativeTime } from '#lib/utils/relativeTime.js';
import type { SearchResult, Want } from '#lib/api/types.js';
// How a want reads in a line, wherever it is shown: the Collection's lists
// and the Missing page's folders (M504).
// What a Search the operator ran found, for its toast.
export function searched(w: Want, r: SearchResult): string {
let said: string;
switch (r.outcome) {
case 'grabbed':
said = `Took ${r.grabbed ?? r.best_release}.`;
break;
case 'waiting':
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
break;
case 'nothing':
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
break;
default:
said = r.error ?? 'The search failed.';
}
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
return said;
}
// A download still running, in a line: how far it has got, or what its
// import is doing or waiting on.
export function downloading(w: Want): string | null {
const g = w.grab;
if (!g?.live) return null;
switch (g.import_state) {
case 'downloading':
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
case 'importing':
return `Downloaded; checking it is the album and filing it · ${g.name}`;
case 'waiting':
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
case 'review':
return `Needs you: ${g.import_reason}`;
case 'failed':
return `Import failed: ${g.import_reason}`;
}
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
}
// A download waiting on a person: an import to review, or one that failed.
export const needsYou = (w: Want) =>
!!w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
// What the schedule last did about a want, in a line.
export function wantStatus(w: Want): string {
const live = downloading(w);
if (live) return live;
const s = w.search;
let said = 'Not searched yet';
if (s) {
const when = `Searched ${relativeTime(s.searched_at)}`;
switch (s.outcome) {
case 'nothing':
said = s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
break;
case 'waiting':
said = `${when}: ${s.considered} found, nothing taken`;
break;
case 'grabbed':
said = `${when}: took ${s.best_release}`;
break;
default:
said = `${when}: ${s.error}`;
}
}
const g = w.grab;
if (g?.import_state === 'refused') said += `. Last download refused: ${g.import_reason}`;
else if (g && !g.live && ['stalled', 'failed', 'gone'].includes(g.end_reason) && g.end_detail)
said += `. Last download given up: ${g.end_detail}`;
return said;
}
-1
View File
@@ -29,7 +29,6 @@
const items: RailItem[] = $derived([
{ href: '/admin', label: 'Overview', exact: true },
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
{ href: '/admin/library', label: 'Library' },
{ href: '/admin/acquisition', label: 'Acquisition' },
{ href: '/admin/metadata', label: 'Metadata sources' },
-1
View File
@@ -168,7 +168,6 @@ describe('admin layout', () => {
expect(links).toEqual([
['/admin', 'Overview'],
['/admin/requests', 'Requests 2'],
['/admin/missing-files', 'Missing files'],
['/admin/library', 'Library'],
['/admin/acquisition', 'Acquisition'],
['/admin/metadata', 'Metadata sources'],
@@ -1,213 +0,0 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { FolderX, Music2 } from 'lucide-svelte';
import {
createMissingFilesQuery,
getReacquisitionSettings,
type ReacquisitionSettings
} from '#lib/api/admin.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import { onMount } from 'svelte';
import type { AdminMissingGroup } from '#lib/api/types.js';
// 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')}`;
}
// What happens to these is set under Acquisition (#5607); the line above
// the list says what it is now, since this is where the question comes up.
let policy = $state<ReacquisitionSettings | null>(null);
onMount(async () => {
try {
policy = await getReacquisitionSettings();
} catch {
policy = null;
}
});
</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-fg"
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>
<p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
{#if policy}
{policy.enabled
? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.`
: 'Missing albums are not wanted back automatically.'}
{/if}
<a href="/admin/acquisition#reacquisition" class="text-accent-fg hover:underline">
{policy ? 'Change' : 'Set what happens to missing files'}
</a>
</p>
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError}
<p class="text-sm text-error-fg">
Couldn't load the missing files.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</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>
<!-- Where this folder's album stands on the wanted list. Without it
the only way to tell "not wanted yet" from "wanted, nothing has
come" is to go and read the wanted list. -->
{#if group.reacquisition}
{@const r = group.reacquisition}
<p
class="border-b border-border px-4 py-2 text-xs text-text-secondary"
data-testid="reacquisition-state"
>
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
On the <a href="/collection/missing" class="text-accent-fg hover:underline"
>wanted list</a
> since {relativeTime(r.wanted_at)}.
{/if}
</p>
{/if}
<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-solid 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-solid 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>
+6 -5
View File
@@ -51,7 +51,6 @@ describe('/collection layout', () => {
expect(links).toEqual([
['/collection', 'Catalogue 7'],
['/collection/wanted', 'Wanted 5'],
['/collection/missing', 'Missing files 1'],
['/collection/upgrades', 'Upgrades 6'],
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
@@ -59,7 +58,8 @@ describe('/collection layout', () => {
['/collection/unmatched', 'Unmatched 3'],
['/collection/reports', 'Reports'],
['/collection/duplicates', 'Duplicates'],
['/collection/suspect-sources', 'Suspect sources']
['/collection/suspect-sources', 'Suspect sources'],
['/collection/missing', 'Missing files']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
@@ -90,7 +90,7 @@ describe('/collection layout', () => {
// The library's health queues moved here from Admin (M504): their counts
// come from the admin summary, accented, and a line names the ones waiting.
test('reports, duplicates and suspect sources carry what waits on you, and a line links to each', () => {
test('the library health queues carry what waits on you, and a line links to each', () => {
setup(counts, '/collection', {
...clear,
reports: { count: 2, since: null },
@@ -102,11 +102,12 @@ describe('/collection layout', () => {
expect(dup.textContent?.replace(/\s+/g, ' ').trim()).toBe('Duplicates 5');
const health = screen.getByTestId('collection-health');
expect(health.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'To look at: 2 broken tracks reported, 5 sets of duplicates to review.'
'To look at: 2 broken tracks reported, 5 sets of duplicates to review, 9 missing files.'
);
expect(Array.from(health.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual([
'/collection/reports',
'/collection/duplicates'
'/collection/duplicates',
'/collection/missing'
]);
});
+302 -2
View File
@@ -1,8 +1,308 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
import { ChevronDown, FolderX, Music2, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createMissingFilesQuery,
getReacquisitionSettings,
type ReacquisitionSettings
} from '#lib/api/admin.js';
import { removeWant, searchWant } from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import { onMount } from 'svelte';
import type { AdminMissingGroup, Want } from '#lib/api/types.js';
// Files the scan looked for and could not find, by folder, with where each
// folder's album stands on the wanted list (M504: this page and the
// Collection's list of missing wants were one subject on two pages). 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 a track; its actions are the wanted list's own.
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')}`;
}
// What happens to these is set under Acquisition (#5607); the line above
// the list says what it is now, since this is where the question comes up.
let policy = $state<ReacquisitionSettings | null>(null);
onMount(async () => {
try {
policy = await getReacquisitionSettings();
} catch {
policy = null;
}
});
// A folder's want, acted on here as on the wanted list.
const client = useQueryClient();
let busy = $state<string | null>(null);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
const refresh = () =>
Promise.all([
client.invalidateQueries({ queryKey: ['adminMissingFiles'] }),
client.invalidateQueries({ queryKey: ['adminWants'] })
]);
async function onSearch(w: Want) {
searching = w.id;
try {
const r = await searchWant(w.id);
pushToast(searched(w, r), r.outcome === 'error' ? 'error' : 'info');
await Promise.all([refresh(), client.invalidateQueries({ queryKey: qk.wantCandidates(w.id) })]);
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
searching = null;
}
}
async function onRemove(w: Want) {
busy = w.id;
try {
await removeWant(w.id);
pushToast(`${w.title} is off the wanted list, and won't be fetched back.`);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
</script>
<svelte:head><title>{pageTitle('Collection · Missing files')}</title></svelte:head>
<WantList list="missing" />
<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-fg"
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>
<p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
{#if policy}
{policy.enabled
? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.`
: 'Missing albums are not wanted back automatically.'}
{/if}
<a href="/admin/acquisition#reacquisition" class="text-accent-fg hover:underline">
{policy ? 'Change' : 'Set what happens to missing files'}
</a>
</p>
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError}
<p class="text-sm text-error-fg">
Couldn't load the missing files.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</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>
<!-- Where this folder's album stands on the wanted list. Without it
the only way to tell "not wanted yet" from "wanted, nothing has
come" is to go and read the wanted list. -->
{#if group.reacquisition}
{@const r = group.reacquisition}
{@const w = r.want}
<div class="space-y-1.5 border-b border-border px-4 py-2" data-testid="reacquisition-state">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="min-w-0 space-y-0.5 text-xs">
<p class="text-text-secondary">
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
Wanted back since {relativeTime(r.wanted_at)}.
{/if}
</p>
{#if w && !r.held_at}
<p
class={needsYou(w) ? 'text-text-primary' : 'text-text-secondary'}
data-testid="missing-want-status"
>
{wantStatus(w)}
{#if needsYou(w)}
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
{/if}
</p>
{/if}
</div>
{#if w && !r.held_at}
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<button
type="button"
onclick={() => onSearch(w)}
disabled={searching !== null || !!w.grab?.live}
aria-label="Search for {w.title} now"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onclick={() => (open = open === w.id ? null : w.id)}
aria-expanded={open === w.id}
aria-label="Releases found for {w.title}"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
>
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
</button>
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<X size={12} strokeWidth={2} /> Remove
</button>
</div>
{/if}
</div>
{#if w && open === w.id}
<WantCandidates wantId={w.id} title={w.title} />
{/if}
</div>
{/if}
<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-solid 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-solid 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>
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, within } from '@testing-library/svelte';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminMissingResponse } from '#lib/api/types.js';
import type { AdminMissingResponse, Want } from '#lib/api/types.js';
// The page reads the re-acquisition policy to say what happens to these;
// the settings themselves are on Admin › Acquisition.
@@ -14,9 +14,23 @@ vi.mock('#lib/api/admin.js', () => ({
unnameable_albums: 0
})
}));
// A folder's want is acted on here as on the wanted list (M504).
vi.mock('#lib/api/wanted.js', () => ({
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import AdminMissingFilesPage from './+page.svelte';
import MissingPage from './+page.svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
import { removeWant, searchWant } from '#lib/api/wanted.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const DAY = 24 * 3_600_000;
@@ -71,10 +85,42 @@ function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
vi.mocked(createMissingFilesQuery).mockReturnValue(
mockQuery({ data, ...extra }) as ReturnType<typeof createMissingFilesQuery>
);
return render(AdminMissingFilesPage);
return render(MissingPage);
}
describe('admin missing files', () => {
function want(extra: Partial<Want> = {}): Want {
return {
id: 'w-1',
release_group_mbid: 'rg-1',
title: 'Minutes to Midnight',
artist_mbid: 'lp',
artist_name: 'Linkin Park',
primary_type: 'Album',
secondary_types: [],
year: '2007',
reason: 'missing',
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
held_at: null,
image_url: '',
search: {
searched_at: new Date(Date.now() - 3_600_000).toISOString(),
outcome: 'nothing',
considered: 0,
best_release: '',
best_verdict: '',
error: ''
},
...extra
};
}
function wantedBack(w: Want | null = want()): AdminMissingResponse {
const withState = structuredClone(response);
withState.groups[0].reacquisition = { wanted_at: new Date(Date.now() - 2 * DAY).toISOString(), held_at: null, want: w };
return withState;
}
describe('/collection/missing', () => {
test('says what happens to missing files, and links to where it is set', async () => {
renderWith(response);
const line = screen.getByTestId('reacquisition-policy');
@@ -133,30 +179,54 @@ describe('admin missing files', () => {
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
});
test('a wanted album says since when, and links the wanted list', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
held_at: null
};
renderWith(withState);
const line = text('reacquisition-state');
expect(line).toMatch(/on the wanted list since 2d ago/i);
expect(
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
).toBe(
'/collection/missing'
test('an album wanted back says since when, and what its last search found', () => {
renderWith(wantedBack());
expect(text('reacquisition-state')).toMatch(/wanted back since 2d ago/i);
expect(text('missing-want-status')).toBe('Searched 1h ago: nothing found');
});
test('its download, when one is running, is the line', () => {
renderWith(
wantedBack(
want({
grab: {
id: 'g', name: 'LP - MtM [FLAC]', grabbed_at: new Date().toISOString(), origin: 'schedule', quality: 'FLAC',
live: true, end_reason: '', progress: 0.42, import_state: 'downloading', import_reason: '', end_detail: '',
imported_at: null, imported_files: 0
}
})
)
);
expect(text('missing-want-status')).toBe('Downloading 42% · LP - MtM [FLAC]');
expect(screen.getByRole('button', { name: /search for minutes to midnight now/i })).toHaveProperty('disabled', true);
});
test('Search runs one now and says what it found', async () => {
renderWith(wantedBack());
await fireEvent.click(screen.getByRole('button', { name: /search for minutes to midnight now/i }));
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w-1'));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Minutes to Midnight.', 'info'));
});
test('Remove takes it off the wanted list', async () => {
renderWith(wantedBack());
await fireEvent.click(screen.getByRole('button', { name: /stop wanting minutes to midnight/i }));
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w-1'));
expect(pushToast).toHaveBeenCalledWith("Minutes to Midnight is off the wanted list, and won't be fetched back.");
});
test('an album held again says so', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
wanted_at: new Date(Date.now() - 5 * DAY).toISOString(),
held_at: new Date(Date.now() - DAY).toISOString()
held_at: new Date(Date.now() - DAY).toISOString(),
want: want({ held_at: new Date(Date.now() - DAY).toISOString() })
};
renderWith(withState);
expect(text('reacquisition-state')).toMatch(/held again 1d ago/i);
// Nothing left to search for.
expect(screen.queryByTestId('missing-want-status')).toBeNull();
expect(screen.queryByRole('button', { name: /search for/i })).toBeNull();
});
// Paging only appears when it can do something: a single page of results