Files
minstrel/web/src/lib/components/ReacquisitionSettingsCard.svelte
T
bvandeusenandClaude Opus 5 516413f4ca
test-web / test (push) Successful in 1m9s
test-go / test (push) Successful in 1m28s
test-go / integration (push) Successful in 3m57s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(admin): re-acquisition settings take effect without a restart, and say why a save was refused (#3936, #3937)
#3936: Router() built a reacquisition.SettingsService of its own, so a save
from the admin card refreshed that instance's cache while the sweeper in
main.go kept serving what it loaded at boot. The card showed the new
policy, the feature ran the old one, and only a restart reconciled them.
main.go now hands its instance to the server (srv.ReacqSettings), as it
already did for RecSettings, TagSettings and FingerprintSettings, and
Router() constructs one only when that field is nil. The regression test
saves through the router and reads the sweeper's instance.

#3937: the card's catch tested `e instanceof Error`, but api.put throws a
plain {code, message, status} object, so every reason the server gave was
discarded in favour of "Couldn't save settings." It now uses errMessage,
which appends the server's message for invalid_setting. Its test rejected
with an Error no code path produces, so it passed throughout; it now
rejects with what the client actually throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 20:15:35 -04:00

240 lines
9.0 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { TriangleAlert } from 'lucide-svelte';
import {
getReacquisitionSettings,
updateReacquisitionSettings,
type ReacquisitionSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
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. errMessage, not `e.message`: the API client throws a
// plain {code, message} object, never an Error, so an instanceof check
// here silently discarded every reason the server gave (#3937).
pushToast(errMessage(e), '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>