111 lines
4.6 KiB
Svelte
111 lines
4.6 KiB
Svelte
<script lang="ts">
|
||
import { plural } from '#lib/utils/plural.js';
|
||
import { onMount } from 'svelte';
|
||
import {
|
||
getReacquisitionSettings,
|
||
updateReacquisitionSettings,
|
||
type ReacquisitionSettings
|
||
} from '#lib/api/admin.js';
|
||
import { errMessage } from '#lib/api/errors.js';
|
||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||
|
||
// Policy for putting an album whose files went missing back on the wanted
|
||
// list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
|
||
// of how music is got (#5607); Missing files says what it is set to and
|
||
// links here. Searching for a want, and retrying, is the search
|
||
// schedule's job, so there is no retry policy here.
|
||
|
||
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-24 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
|
||
|
||
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,
|
||
max_per_pass: s.max_per_pass
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
</script>
|
||
|
||
<SettingsGroup title="Missing files" id="reacquisition" hint="Put an album back on the wanted list when its files go missing.">
|
||
{#if loadError}
|
||
<p class="px-4 py-3 text-sm text-error-fg">
|
||
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="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||
{:else}
|
||
<SettingRow label="Want missing albums back" for="reacq-enabled" hint="Per album, not per track: a lost folder is one want.">
|
||
<input id="reacq-enabled" type="checkbox" role="switch" aria-checked={form.enabled} bind:checked={form.enabled} class="h-4 w-4 cursor-pointer accent-accent" />
|
||
</SettingRow>
|
||
<SettingRow label="Wait before wanting it back" for="reacq-grace" hint="Rides out a drive that didn't mount in time.">
|
||
<input id="reacq-grace" type="number" min="1" max="720" bind:value={form.grace_hours} class={field} />
|
||
<span class="text-sm text-text-secondary">hours</span>
|
||
</SettingRow>
|
||
<SettingRow label="Albums per sweep" for="reacq-max" hint="Each hour, so a large loss trickles in.">
|
||
<input id="reacq-max" type="number" min="1" max="200" bind:value={form.max_per_pass} class={field} />
|
||
</SettingRow>
|
||
{#if saved && saved.unnameable_albums > 0}
|
||
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
|
||
<p class="px-4 py-3 text-sm text-text-primary">
|
||
{plural(saved.unnameable_albums, 'album has', 'albums have')} missing files but no MusicBrainz ID,
|
||
so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted. Tag and re-scan to fix.
|
||
</p>
|
||
{/if}
|
||
<div class="flex justify-end px-4 py-3">
|
||
<button type="button" class={button} disabled={!dirty || saving} onclick={save}>
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</SettingsGroup>
|