feat: show what re-acquisition has done, per folder — #2527
test-web / test (push) Failing after 42s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m3s

Completes milestone #290. The sweeper has been running and the settings
have been editable, but the list itself said nothing about either, so
the only way to tell "not tried yet" from "asked twice and nothing came
back" was to go and read the Requests queue.

Each folder now carries its album's attempt record: how many times, when
last, when next -- or that it gave up, with the reassurance that a file
coming back and going missing later starts the process over. Null when
nothing has been attempted, which is the common case for a folder that
just went missing and would be noise on every row.

next_attempt_at is computed, not stored. The schedule is a function of
the attempt count and the current settings, so persisting it would go
stale the moment an operator edited the backoff -- and the card lets
them do exactly that.

Needed a forward-looking formatter. relativeTime deliberately collapses
a future timestamp to "just now" (pinned by its own test) because that
is the right answer for a clock-skewed past event; it is the wrong one
for a scheduled future attempt, which would have rendered "next just
now". timeUntil is its companion rather than a sign-aware rewrite: the
two read differently in the same sentence -- "last tried 3d ago, next in
4h" -- and a test asserts they disagree about the future on purpose, so
nobody later "fixes" the divergence.

The state lookup is one batched query for the whole page and best-effort:
this is context on a list whose real job is showing what is missing, so
a failure leaves the groups bare rather than failing the page. The
settings service is read with a nil guard falling back to the shipped
defaults, since contexts that wire routing without services exist and a
backoff projection is not worth a nil-pointer panic (rule #48).
This commit is contained in:
2026-08-17 00:19:48 -04:00
parent 952132714e
commit 414dfb23b6
6 changed files with 264 additions and 3 deletions
+13
View File
@@ -390,6 +390,18 @@ export type AdminMissingTrack = {
last_played_at: string | null;
};
// What the re-acquisition sweeper has done about this group's album (#290).
// null when nothing has been attempted yet — the common case for a folder
// that just went missing, and distinct from attempts: 0, which cannot occur.
export type AdminReacquisitionState = {
attempts: number;
last_attempt_at: string | null;
// Projected from the last attempt plus the configured backoff, so it moves
// when the operator edits the schedule. Null once the album has given up.
next_attempt_at: string | null;
gave_up_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.
@@ -397,6 +409,7 @@ export type AdminMissingGroup = {
directory: string;
missing_since: string;
tracks: AdminMissingTrack[];
reacquisition: AdminReacquisitionState | null;
};
export type AdminMissingResponse = {
+40 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { relativeTime } from './relativeTime';
import { relativeTime, timeUntil } from './relativeTime';
// Fixed "now" so the thresholds are exercised deterministically rather than
// against the wall clock, which would make the minute boundary flaky.
@@ -43,3 +43,42 @@ describe('relativeTime', () => {
expect(relativeTime(new Date(NOW.getTime() + 60_000).toISOString())).toBe('just now');
});
});
describe('timeUntil', () => {
test.each([
['minutes out', 42 * 60 * 1_000, 'in 42m'],
['exactly an hour', 3_600_000, 'in 1h'],
['hours out', 5 * 3_600_000, 'in 5h'],
['exactly a day', 24 * 3_600_000, 'in 1d'],
['days out', 6 * 24 * 3_600_000, 'in 6d']
])('%s', (_label, delta, expected) => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
expect(timeUntil(new Date(NOW.getTime() + delta).toISOString())).toBe(expected);
});
// A due-or-overdue attempt is the sweeper's next tick away, not "3h ago" —
// the operator wants to know it is imminent, not how late it is.
test('a moment already passed reads as imminent', () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
expect(timeUntil(ago(3 * 3_600_000))).toBe('any moment');
});
test('under a minute reads as imminent', () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
expect(timeUntil(new Date(NOW.getTime() + 30_000).toISOString())).toBe('any moment');
});
// The pair must not converge: relativeTime collapses a future timestamp to
// "just now", which is right for clock skew on a past event and wrong for a
// scheduled one. That difference is why both exist.
test('the two formatters disagree about the future, deliberately', () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const soon = new Date(NOW.getTime() + 4 * 3_600_000).toISOString();
expect(relativeTime(soon)).toBe('just now');
expect(timeUntil(soon)).toBe('in 4h');
});
});
+22
View File
@@ -25,3 +25,25 @@ export function relativeTime(iso: string): string {
if (minutes >= 1) return `${minutes}m ago`;
return 'just now';
}
/**
* Forward-looking companion to [relativeTime]: "in 4h", "in 2d", or "any
* moment" once the moment has passed.
*
* Separate function rather than a sign-aware relativeTime, because the two
* read differently in a sentence ("last tried 3d ago, next in 4h") and
* because relativeTime deliberately collapses future timestamps to "just
* now" — that is the right answer for a clock-skewed past event and the
* wrong one for a scheduled future one.
*/
export function timeUntil(iso: string): string {
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return 'any moment';
const days = Math.floor(ms / (24 * 3_600_000));
if (days >= 1) return `in ${days}d`;
const hours = Math.floor(ms / 3_600_000);
if (hours >= 1) return `in ${hours}h`;
const minutes = Math.floor(ms / 60_000);
if (minutes >= 1) return `in ${minutes}m`;
return 'any moment';
}
@@ -2,7 +2,7 @@
import { pageTitle } from '$lib/branding';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '$lib/api/admin';
import { relativeTime } from '$lib/utils/relativeTime';
import { relativeTime, timeUntil } from '$lib/utils/relativeTime';
import { coverUrl } from '$lib/media/covers';
import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
import type { AdminMissingGroup } from '$lib/api/types';
@@ -26,6 +26,14 @@
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
const hasMore = $derived(offset + shown < total);
// "once" / "twice" reads far better than "1 times" in the sentence these
// land in, and the count is almost always small.
function attemptLabel(n: number): string {
if (n === 1) return 'once';
if (n === 2) return 'twice';
return `${n} times`;
}
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
@@ -95,6 +103,28 @@
</span>
</div>
<!-- What the sweeper has done about this folder. Without it the only
way to tell "not tried yet" from "asked twice, nothing came
back" is to go and read the Requests queue. -->
{#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.gave_up_at}
<span class="text-action-destructive">Gave up</span>
after {attemptLabel(r.attempts)} — last tried
{relativeTime(r.last_attempt_at ?? r.gave_up_at)}. It'll be tried again
if the files come back and go missing later.
{:else if r.last_attempt_at}
Asked Lidarr {attemptLabel(r.attempts)}, last
{relativeTime(r.last_attempt_at)}{#if r.next_attempt_at}, next
{timeUntil(r.next_attempt_at)}{/if}.
{/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">
@@ -49,6 +49,7 @@ const response: AdminMissingResponse = {
{
directory: '/music/Linkin Park/Minutes to Midnight',
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
reacquisition: null,
tracks: [
track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }),
track('Bleed It Out')
@@ -57,6 +58,7 @@ const response: AdminMissingResponse = {
{
directory: '/music/Boards of Canada/Geogaddi',
missing_since: new Date(Date.now() - 9 * DAY).toISOString(),
reacquisition: null,
tracks: [track('1969')]
}
]
@@ -116,6 +118,45 @@ describe('admin missing files', () => {
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
});
// Nothing attempted yet is the common case for a folder that just went
// missing; a line saying so would be noise on every row.
test('no re-acquisition line before anything has been attempted', () => {
renderWith(response);
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
});
test('an in-flight re-acquisition says how often and when next', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 2,
last_attempt_at: new Date(Date.now() - 2 * DAY).toISOString(),
next_attempt_at: new Date(Date.now() + 4 * 3_600_000).toISOString(),
gave_up_at: null
};
renderWith(withState);
const line = screen.getByTestId('reacquisition-state');
expect(line.textContent).toMatch(/asked lidarr twice/i);
expect(line.textContent).toMatch(/last 2d ago/i);
// Forward-looking, not relativeTime — which would say "just now" for a
// future timestamp and read as nonsense.
expect(line.textContent).toMatch(/next in 4h/i);
});
test('a given-up album says so and says it can come back', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 3,
last_attempt_at: new Date(Date.now() - 5 * DAY).toISOString(),
next_attempt_at: null,
gave_up_at: new Date(Date.now() - 5 * DAY).toISOString()
};
renderWith(withState);
const line = screen.getByTestId('reacquisition-state');
expect(line.textContent).toMatch(/gave up/i);
expect(line.textContent).toMatch(/after 3 times/i);
expect(line.textContent).toMatch(/tried again/i);
});
// Paging only appears when it can do something: a single page of results
// should not render dead Previous/Next buttons.
test('no pager when everything fits on one page', () => {