feat: show what re-acquisition has done, per folder — #2527
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user