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
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>
138 lines
5.4 KiB
Svelte
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>
|