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;
}