feat(web): re-acquisition settings card on the missing-files page — #2527
test-web / test (push) Successful in 34s
test-web / test (push) Successful in 34s
Rule #27: the sweeper has been running since bab9b168 with no way to see
or change what it does. This is the half that makes it a feature.
Placed above the list it governs rather than under Integrations. An
operator looking at missing files is exactly the person deciding what
should happen to them; Lidarr is the mechanism, not the subject, and
separating the policy from the problem would mean finding one to
understand the other.
The card states the retry schedule the numbers add up to -- "6h -> 12h
-> 24h" -- because the fields are meaningless individually. "First retry
gap: 6" tells you nothing until you know it doubles and where it stops,
and an operator should not have to simulate the algorithm to predict it.
It recomputes as they type, including the clamp.
It also states the unnameable-album count with its reason. Those albums
will never produce a request no matter how long they sit in the list
below, because Lidarr cannot be asked for a release MusicBrainz cannot
name. Watching rows never move with no explanation is how a working
feature gets reported as broken.
Save errors surface the server's own message. The Go layer validates the
same ranges the CHECKs enforce and names the field, so the operator
reads "grace_hours must be 1-720" rather than a generic failure.
The dirty check compares only the stored fields: unnameable_albums is
server-computed, and including it would make the form look edited
whenever the library changed underneath.
Nine tests, including the schedule clamp, the disabled-until-dirty Save,
the surfaced validation message, and a failed load offering a retry
instead of an empty card. The existing missing-files page suite gains a
stub for the card's own settings fetch -- it mocks the whole admin API
module, so the card's imports would otherwise be undefined at mount.
This commit is contained in:
@@ -696,3 +696,29 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
|
||||
staleTime: 120_000
|
||||
});
|
||||
}
|
||||
|
||||
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
|
||||
|
||||
export type ReacquisitionSettings = {
|
||||
enabled: boolean;
|
||||
grace_hours: number;
|
||||
backoff_base_hours: number;
|
||||
backoff_max_hours: number;
|
||||
max_attempts: number;
|
||||
max_per_pass: number;
|
||||
auto_approve: boolean;
|
||||
// Albums with missing files that can never be auto-requested because
|
||||
// neither they nor their artist carries an MBID. Read-only; the server
|
||||
// computes it, and the card states it so the gap isn't a mystery.
|
||||
unnameable_albums: number;
|
||||
};
|
||||
|
||||
export async function getReacquisitionSettings(): Promise<ReacquisitionSettings> {
|
||||
return api.get<ReacquisitionSettings>('/api/admin/library/reacquisition');
|
||||
}
|
||||
|
||||
export async function updateReacquisitionSettings(
|
||||
s: ReacquisitionSettings
|
||||
): Promise<ReacquisitionSettings> {
|
||||
return api.put<ReacquisitionSettings>('/api/admin/library/reacquisition', s);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
getReacquisitionSettings,
|
||||
updateReacquisitionSettings,
|
||||
type ReacquisitionSettings
|
||||
} from '$lib/api/admin';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Policy for turning a missing file back into a Lidarr request
|
||||
// (milestone #290). Lives on the missing-files page rather than under
|
||||
// Integrations because this is where the operator meets the problem it
|
||||
// solves; Lidarr is the mechanism, not the subject.
|
||||
|
||||
let saved = $state<ReacquisitionSettings | null>(null);
|
||||
let form = $state<ReacquisitionSettings | null>(null);
|
||||
let saving = $state(false);
|
||||
let loadError = $state(false);
|
||||
|
||||
const dirty = $derived(
|
||||
!!saved && !!form && JSON.stringify(stripDerived(saved)) !== JSON.stringify(stripDerived(form))
|
||||
);
|
||||
|
||||
// unnameable_albums is server-computed and read-only; comparing it would
|
||||
// make the form look dirty whenever the library changed underneath. Listed
|
||||
// explicitly rather than destructured-and-spread so no lint rule has to be
|
||||
// argued with about an intentionally unused binding.
|
||||
function stripDerived(s: ReacquisitionSettings) {
|
||||
return {
|
||||
enabled: s.enabled,
|
||||
grace_hours: s.grace_hours,
|
||||
backoff_base_hours: s.backoff_base_hours,
|
||||
backoff_max_hours: s.backoff_max_hours,
|
||||
max_attempts: s.max_attempts,
|
||||
max_per_pass: s.max_per_pass,
|
||||
auto_approve: s.auto_approve
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
saved = await getReacquisitionSettings();
|
||||
form = { ...saved };
|
||||
loadError = false;
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function save() {
|
||||
if (!form) return;
|
||||
saving = true;
|
||||
try {
|
||||
saved = await updateReacquisitionSettings(form);
|
||||
form = { ...saved };
|
||||
pushToast('Re-acquisition settings saved.');
|
||||
} catch (e) {
|
||||
// The server validates the same ranges the database CHECKs enforce and
|
||||
// names the offending field, so surface its message rather than a
|
||||
// generic failure.
|
||||
pushToast(e instanceof Error ? e.message : "Couldn't save settings.", 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-language echo of what the numbers add up to. The individual fields
|
||||
// are meaningless on their own — "6 hours" says nothing until you know it
|
||||
// doubles — so the card states the resulting schedule.
|
||||
const schedule = $derived.by(() => {
|
||||
if (!form) return '';
|
||||
const steps: string[] = [];
|
||||
let hours = form.backoff_base_hours;
|
||||
for (let i = 0; i < form.max_attempts; i++) {
|
||||
steps.push(`${hours}h`);
|
||||
hours = Math.min(hours * 2, form.backoff_max_hours);
|
||||
}
|
||||
return steps.join(' → ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
When a file has been missing for a while, Minstrel can ask Lidarr for the album
|
||||
again by itself. Requests are made per album, not per track — a whole folder
|
||||
going missing is one request, not forty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
Couldn't load re-acquisition settings.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if form === null}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Ask Lidarr for missing albums automatically</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off leaves missing files listed here and does nothing about them.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Wait before the first attempt</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Hours a file must be missing before anything is requested. A drive that
|
||||
didn't mount, or a container that started before its media, resolves
|
||||
itself well inside a day — this is what stops those becoming requests.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="720"
|
||||
bind:value={form.grace_hours}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Attempts before giving up</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
After this many tries the album is left alone. It's picked up again if the
|
||||
file comes back and goes missing later.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={form.max_attempts}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">First retry gap (hours)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
bind:value={form.backoff_base_hours}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Longest gap (hours)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="720"
|
||||
bind:value={form.backoff_max_hours}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Albums per sweep</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Ceiling on requests made each hour, so a large loss trickles.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
bind:value={form.max_per_pass}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
|
||||
Retry schedule: <span class="font-mono text-text-primary">{schedule}</span> after the
|
||||
first attempt.
|
||||
</p>
|
||||
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" bind:checked={form.auto_approve} class="mt-1" />
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Send requests to Lidarr without approval</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off means requests appear in the Requests queue for you to approve, and
|
||||
nothing is downloaded until you do.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if saved && saved.unnameable_albums > 0}
|
||||
<!-- Stated rather than left to be inferred: without this, an operator
|
||||
watching those rows never get a request would reasonably conclude
|
||||
the feature is broken. -->
|
||||
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
|
||||
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
|
||||
<span>
|
||||
{saved.unnameable_albums}
|
||||
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
|
||||
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be requested —
|
||||
Lidarr has no way to identify the release. A re-scan after tagging fixes it.
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving}
|
||||
onclick={save}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import type { ReacquisitionSettings } from '$lib/api/admin';
|
||||
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
getReacquisitionSettings: vi.fn(),
|
||||
updateReacquisitionSettings: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte';
|
||||
import { getReacquisitionSettings, updateReacquisitionSettings } from '$lib/api/admin';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
const base: ReacquisitionSettings = {
|
||||
enabled: true,
|
||||
grace_hours: 24,
|
||||
backoff_base_hours: 6,
|
||||
backoff_max_hours: 168,
|
||||
max_attempts: 3,
|
||||
max_per_pass: 20,
|
||||
auto_approve: true,
|
||||
unnameable_albums: 0
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
async function renderCard(over: Partial<ReacquisitionSettings> = {}) {
|
||||
vi.mocked(getReacquisitionSettings).mockResolvedValue({ ...base, ...over });
|
||||
const r = render(ReacquisitionSettingsCard);
|
||||
await waitFor(() => expect(getReacquisitionSettings).toHaveBeenCalled());
|
||||
return r;
|
||||
}
|
||||
|
||||
describe('ReacquisitionSettingsCard', () => {
|
||||
// The individual numbers say nothing on their own — "6 hours" is meaningless
|
||||
// until you know it doubles — so the card spells the schedule out.
|
||||
test('states the retry schedule the numbers add up to', async () => {
|
||||
await renderCard();
|
||||
await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
|
||||
});
|
||||
|
||||
test('the schedule clamps at the longest gap', async () => {
|
||||
await renderCard({ backoff_base_hours: 6, backoff_max_hours: 12, max_attempts: 4 });
|
||||
await waitFor(() => expect(screen.getByText('6h → 12h → 12h → 12h')).toBeTruthy());
|
||||
});
|
||||
|
||||
test('the schedule follows the attempt count', async () => {
|
||||
await renderCard({ max_attempts: 1 });
|
||||
await waitFor(() => expect(screen.getByText('6h')).toBeTruthy());
|
||||
});
|
||||
|
||||
// Saving an unchanged form would be a pointless round-trip, and a live Save
|
||||
// button invites the operator to wonder whether anything happened.
|
||||
test('save is disabled until something changes', async () => {
|
||||
await renderCard();
|
||||
const save = await screen.findByRole('button', { name: /save/i });
|
||||
expect(save).toHaveProperty('disabled', true);
|
||||
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
|
||||
await fireEvent.input(grace, { target: { value: '48' } });
|
||||
await waitFor(() => expect(save).toHaveProperty('disabled', false));
|
||||
});
|
||||
|
||||
test('saving sends the edited values', async () => {
|
||||
vi.mocked(updateReacquisitionSettings).mockResolvedValue({ ...base, grace_hours: 48 });
|
||||
await renderCard();
|
||||
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
|
||||
await fireEvent.input(grace, { target: { value: '48' } });
|
||||
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateReacquisitionSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ grace_hours: 48 })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// The server names the offending field ("grace_hours must be 1-720"); a
|
||||
// generic "couldn't save" would throw that away.
|
||||
test('a rejected save surfaces the server message', async () => {
|
||||
vi.mocked(updateReacquisitionSettings).mockRejectedValue(
|
||||
new Error('grace_hours must be 1-720')
|
||||
);
|
||||
await renderCard();
|
||||
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
|
||||
await fireEvent.input(grace, { target: { value: '900' } });
|
||||
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith('grace_hours must be 1-720', 'error')
|
||||
);
|
||||
});
|
||||
|
||||
// Without this the operator watches those albums never get a request and
|
||||
// reasonably concludes the feature is broken.
|
||||
test('unnameable albums are called out with the reason', async () => {
|
||||
await renderCard({ unnameable_albums: 3 });
|
||||
await waitFor(() => expect(screen.getByText(/no\s+MusicBrainz ID/i)).toBeTruthy());
|
||||
expect(screen.getByText(/3\s+albums have/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('no warning when every missing album is identifiable', async () => {
|
||||
await renderCard({ unnameable_albums: 0 });
|
||||
await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
|
||||
expect(screen.queryByText(/MusicBrainz ID/i)).toBeNull();
|
||||
});
|
||||
|
||||
test('a failed load offers a retry rather than an empty card', async () => {
|
||||
vi.mocked(getReacquisitionSettings).mockRejectedValue(new Error('nope'));
|
||||
render(ReacquisitionSettingsCard);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/couldn't load re-acquisition settings/i)).toBeTruthy()
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
import { createMissingFilesQuery } from '$lib/api/admin';
|
||||
import { relativeTime } from '$lib/utils/relativeTime';
|
||||
import { coverUrl } from '$lib/media/covers';
|
||||
import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
|
||||
import type { AdminMissingGroup } from '$lib/api/types';
|
||||
|
||||
// Files the scan looked for and could not find. Read-only on purpose:
|
||||
@@ -57,6 +58,11 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- The policy sits above the list it governs: an operator looking at
|
||||
missing files is exactly the person deciding what should happen to
|
||||
them, and burying this under Integrations would separate the two. -->
|
||||
<ReacquisitionSettingsCard />
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Checking what's missing…</p>
|
||||
{:else if query.isError}
|
||||
|
||||
@@ -3,8 +3,22 @@ import { render, screen } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminMissingResponse } from '$lib/api/types';
|
||||
|
||||
// The page now embeds ReacquisitionSettingsCard, which loads its own settings
|
||||
// from this same module on mount. Stubbing both keeps these tests about the
|
||||
// missing-files list — the card has its own suite.
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
createMissingFilesQuery: vi.fn()
|
||||
createMissingFilesQuery: vi.fn(),
|
||||
getReacquisitionSettings: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
grace_hours: 24,
|
||||
backoff_base_hours: 6,
|
||||
backoff_max_hours: 168,
|
||||
max_attempts: 3,
|
||||
max_per_pass: 20,
|
||||
auto_approve: true,
|
||||
unnameable_albums: 0
|
||||
}),
|
||||
updateReacquisitionSettings: vi.fn()
|
||||
}));
|
||||
|
||||
import AdminMissingFilesPage from './+page.svelte';
|
||||
|
||||
Reference in New Issue
Block a user