Files
minstrel/web/src/lib/components/SearchSettingsCard.svelte
T
bvandeusenandClaude Opus 5.5 7f913e3e70
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m27s
release / go (push) Successful in 1m44s
release / android (push) Successful in 5m13s
release / integration (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: the admin settings cards read as menus: each a titled group of rows with one short line, status first, failures in words (M503 #5607)
Indexers and clients, searching, missing files, Lidarr's neighbours, AcoustID, fingerprinting, loudness, outgoing email's schedule, public address and proxies move from paragraph cards to SettingsGroup and SettingRow. Switches are role="switch". Also fixes the two type errors run 9023 found: the Lidarr test result is narrowed before its lists are read, and the tag-source save mock carries version_bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:25:46 -04:00

219 lines
8.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSearchSettingsQuery, putSearchSettings } from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import type { SearchSettings } from '#lib/api/types.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Searching and grabbing (M502 #5521): how often each want is searched
// for, whether the best release is taken without asking, and which
// qualities are taken at all. The schedule wakes every ten minutes and
// keeps to each indexer's searches-per-hour. A finished download is
// checked and filed into one of the library's folders (#5522); it then
// seeds until the ratio or the days below, and a download nobody shares
// is given up after the hours below (#5523).
const client = useQueryClient();
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const field = 'w-20 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
const store = createSearchSettingsQuery();
const query = $derived($store);
type Form = {
auto_grab: boolean;
search_interval_hours: number;
allowed: string[];
import_root: string;
seed_ratio: number;
seed_days: number;
stall_hours: number;
};
// The form is the server's settings until edited; saved holds what a save
// returned until the query catches up.
let saved = $state<SearchSettings | null>(null);
let edited = $state<Form | null>(null);
let saving = $state(false);
const base = $derived(saved ?? query.data ?? null);
const form = $derived<Form | null>(edited ?? (base ? fromSettings(base) : null));
function fromSettings(s: SearchSettings): Form {
return {
auto_grab: s.auto_grab,
search_interval_hours: s.search_interval_hours,
allowed: [...s.allowed_qualities],
import_root: s.import_root,
seed_ratio: s.seed_ratio,
seed_days: s.seed_days,
stall_hours: s.stall_hours
};
}
const dirty = $derived(!!edited && !!base && JSON.stringify(edited) !== JSON.stringify(fromSettings(base)));
function edit(change: Partial<Form>) {
if (form) edited = { ...form, ...change };
}
function toggle(tier: string) {
if (!form) return;
edit({ allowed: form.allowed.includes(tier) ? form.allowed.filter((t) => t !== tier) : [...form.allowed, tier] });
}
async function save() {
if (!form) return;
saving = true;
try {
saved = await putSearchSettings({
auto_grab: form.auto_grab,
search_interval_hours: Number(form.search_interval_hours),
allowed_qualities: form.allowed,
import_root: form.import_root,
seed_ratio: Number(form.seed_ratio),
seed_days: Number(form.seed_days),
stall_hours: Number(form.stall_hours)
});
edited = null;
await client.invalidateQueries({ queryKey: qk.searchSettings() });
pushToast('Search settings saved. The next pass uses them.');
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
function lastPass(s: SearchSettings): string {
const p = s.last_pass;
if (!s.last_pass_at) return 'The schedule hasn’t run yet.';
const when = `Last pass ${relativeTime(s.last_pass_at)}`;
if (p.off) return `${when}: the schedule is off.`;
if (p.unconfigured) return `${when}: no indexer is set up, so nothing was searched.`;
if (p.error) return `${when} failed: ${p.error}`;
if (!p.looked) return `${when}: nothing was due.`;
let said = `${when}: searched ${p.looked}, took ${p.grabbed ?? 0}, ${p.waiting ?? 0} waiting for you, ${p.nothing ?? 0} with nothing found`;
if (p.failed) said += `, ${p.failed} failed`;
said += '.';
if (p.paced) said += ' It stopped to keep to your indexers’ searches per hour; the rest wait for the next pass.';
return said;
}
</script>
<SettingsGroup title="Searching" id="searching" hint="Each wanted album is searched for, judged, and the best release downloaded.">
{#if query.isPending}
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else if query.isError || !form || !query.data}
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load the search settings.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else}
{@const data = query.data}
<SettingRow label="Last pass">
{#snippet hint()}<span data-testid="last-pass">{lastPass(data)}</span>{/snippet}
<a href="/collection/wanted" class="text-sm text-accent-fg hover:underline">Wanted</a>
</SettingRow>
<SettingRow label="Take the best release without asking" for="search-auto" hint="Off, you choose from the Wanted page.">
<input
id="search-auto"
type="checkbox"
role="switch"
aria-checked={form.auto_grab}
checked={form.auto_grab}
onchange={(e) => edit({ auto_grab: e.currentTarget.checked })}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
<SettingRow label="Search again after" for="search-interval" hint="For an album still wanted. 0 turns the schedule off.">
<input
id="search-interval"
type="number"
min="0"
max="720"
value={form.search_interval_hours}
oninput={(e) => edit({ search_interval_hours: Number(e.currentTarget.value) })}
class={field}
/>
<span class="text-sm text-text-secondary">hours</span>
</SettingRow>
<SettingRow label="Qualities to take" hint="The best of these is taken first." />
{#each data.tiers as t (t.tier)}
<div class="pl-4">
<SettingRow label={t.label} for="tier-{t.tier}" hint={t.what}>
<input
id="tier-{t.tier}"
type="checkbox"
checked={form.allowed.includes(t.tier)}
onchange={() => toggle(t.tier)}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
</div>
{/each}
{#if data.scan_paths.length > 1}
<SettingRow label="File new music into" for="search-root" hint="As Artist/Album (Year), tagged with its MusicBrainz ids.">
<select
id="search-root"
value={form.import_root || data.scan_paths[0]}
onchange={(e) => edit({ import_root: e.currentTarget.value })}
class="rounded-md border border-border bg-background px-2 py-1.5 font-mono text-sm"
>
{#each data.scan_paths as p (p)}
<option value={p}>{p}</option>
{/each}
</select>
</SettingRow>
{:else if data.scan_paths.length === 1}
<SettingRow label="New music goes into" hint="As Artist/Album (Year), tagged with its MusicBrainz ids.">
<span class="font-mono text-sm text-text-secondary">{data.scan_paths[0]}</span>
</SettingRow>
{/if}
<SettingRow label="Seed ratio" for="seed-ratio" hint="Share a finished download to this ratio. 0 stops once it's filed.">
<input
id="seed-ratio"
type="number"
min="0"
max="100"
step="0.1"
value={form.seed_ratio}
oninput={(e) => edit({ seed_ratio: Number(e.currentTarget.value) })}
class={field}
/>
</SettingRow>
<SettingRow label="Seed days" for="seed-days" hint="Or for this long, whichever comes first. 0 is no limit.">
<input
id="seed-days"
type="number"
min="0"
max="365"
value={form.seed_days}
oninput={(e) => edit({ seed_days: Number(e.currentTarget.value) })}
class={field}
/>
<span class="text-sm text-text-secondary">days</span>
</SettingRow>
<SettingRow label="Give up on a stalled download after" for="stall-hours" hint="When nobody shares it. 0 never gives up.">
<input
id="stall-hours"
type="number"
min="0"
max="720"
value={form.stall_hours}
oninput={(e) => edit({ stall_hours: Number(e.currentTarget.value) })}
class={field}
/>
<span class="text-sm text-text-secondary">hours</span>
</SettingRow>
<div class="flex justify-end px-4 py-3">
<button type="button" class={button} onclick={save} disabled={!dirty || saving || form.allowed.length === 0}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</SettingsGroup>