Files
minstrel/web/src/lib/components/LoudnessSettingsCard.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

138 lines
5.4 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import {
getLoudnessCoverage,
getLoudnessSettings,
updateLoudnessSettings,
type LoudnessCoverage,
type LoudnessSettings
} 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';
// Loudness analysis (#4995): the background measurement that loudness
// normalization levels playback from, its progress, and its knobs. The
// cache size bounds the leveled copies rendered for speakers (#5001).
let saved = $state<LoudnessSettings | null>(null);
let form = $state<LoudnessSettings | null>(null);
let coverage = $state<LoudnessCoverage | null>(null);
let saving = $state(false);
let loadError = $state(false);
const dirty = $derived(!!saved && !!form && JSON.stringify(saved) !== JSON.stringify(form));
const concurrencyOk = $derived(
!!form &&
Number.isInteger(form.backfill_concurrency) &&
form.backfill_concurrency >= 1 &&
form.backfill_concurrency <= 8
);
const cacheOk = $derived(
!!form &&
Number.isInteger(form.leveled_cache_mb) &&
form.leveled_cache_mb >= 256 &&
form.leveled_cache_mb <= 65536
);
const valid = $derived(concurrencyOk && cacheOk);
async function load() {
try {
saved = await getLoudnessSettings();
form = { ...saved };
loadError = false;
} catch {
loadError = true;
}
await refreshCoverage();
}
// The gauge moves a few tracks a minute while the backfill runs, so a slow
// poll is enough; a failed read keeps the last figures rather than blanking.
async function refreshCoverage() {
try {
coverage = await getLoudnessCoverage();
} catch {
// Keep what we had.
}
}
onMount(() => {
load();
const timer = setInterval(refreshCoverage, 30_000);
return () => clearInterval(timer);
});
async function save() {
if (!form || !valid) return;
saving = true;
try {
saved = await updateLoudnessSettings(form);
form = { ...saved };
pushToast('Loudness analysis settings saved.');
await refreshCoverage();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
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';
</script>
<SettingsGroup title="Loudness" id="loudness" hint="Measures each track so playback can even out the volume.">
{#if coverage && coverage.total > 0}
{@const c = coverage}
<div data-testid="loudness-coverage">
<SettingRow label="Progress">
{#snippet hint()}
{c.measured.toLocaleString()} of {c.total.toLocaleString()} tracks measured
{#if c.pending > 0}{` · ${c.pending.toLocaleString()} pending`}{/if}
{#if !c.enabled && c.pending > 0}{' · paused while analysis is off'}{/if}
{#if c.silent > 0}
<span title="Silent or too short to measure. They play without adjustment.">{` · ${c.silent.toLocaleString()} silent`}</span>
{/if}
{#if c.unreadable > 0}
<span title="ffmpeg couldn't decode these files. Each is tried again when its file changes.">{` · ${c.unreadable.toLocaleString()} unreadable`}</span>
{/if}
{/snippet}
</SettingRow>
</div>
{/if}
{#if loadError}
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load loudness analysis 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="Analyze tracks" for="loudness-enabled" hint="Off stops it; tracks already measured stay leveled.">
<input id="loudness-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="Files analyzed at once" for="loudness-concurrency" hint="Lower it if playback stutters while it runs.">
<input id="loudness-concurrency" type="number" min="1" max="8" bind:value={form.backfill_concurrency} class={field} />
</SettingRow>
<SettingRow label="Speaker cache" for="loudness-cache" hint="Leveled copies for Sonos and UPnP speakers.">
<input id="loudness-cache" type="number" min="256" max="65536" step="256" bind:value={form.leveled_cache_mb} class={field} />
<span class="text-sm text-text-secondary">MB</span>
</SettingRow>
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3">
{#if !valid}
<div class="mr-auto text-xs text-error-fg" data-testid="settings-problems">
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
</div>
{/if}
<button type="button" class={button} disabled={!dirty || saving || !valid} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</SettingsGroup>