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
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user