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
#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
240 lines
9.0 KiB
Svelte
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>
|