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)
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
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>
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
} 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';
|
||||
|
||||
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
|
||||
// untagged tracks lack, so the similarity arms can see them. Off until the
|
||||
@@ -122,194 +124,93 @@
|
||||
}
|
||||
}
|
||||
|
||||
const rowClass = 'flex items-center justify-between gap-4 py-2';
|
||||
const inputClass =
|
||||
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
|
||||
'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
|
||||
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 = 'rounded-md border border-border bg-background px-3 py-1.5 text-sm';
|
||||
</script>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">AcoustID</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Finds MusicBrainz ids for untagged tracks, so Songs-like and radio can place them.
|
||||
</p>
|
||||
</div>
|
||||
{#if overview}
|
||||
{#if overview.ready && !overview.status.problem}
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-md bg-accent-tint px-3 py-1 text-xs font-medium text-action-primary"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-action-primary"></span>
|
||||
{overview.status.running ? 'Looking up' : 'On'}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
|
||||
data-testid="acoustid-state"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
|
||||
{#if overview.ready}Needs attention{:else if !overview.settings.api_key_set}Needs a key{:else}Off{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="AcoustID" id="acoustid" hint="Names untagged tracks from their sound, so radio and Songs-like can place them.">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
<p class="px-4 py-3 text-sm text-error-fg">
|
||||
Couldn't load AcoustID settings.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if overview === null}
|
||||
<p class="text-sm text-text-secondary">Loading AcoustID settings…</p>
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading AcoustID settings…</p>
|
||||
{:else}
|
||||
<SettingRow label="Status">
|
||||
{#if overview.ready && !overview.status.problem}
|
||||
<span class="text-sm text-text-secondary">{overview.status.running ? 'Looking up' : 'On'}</span>
|
||||
{:else}
|
||||
<span class="text-sm text-text-secondary" data-testid="acoustid-state">
|
||||
{#if overview.ready}Needs attention{:else if !overview.settings.api_key_set}Needs a key{:else}Off{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
{#if overview.status.problem}
|
||||
<p class="text-sm text-action-destructive" data-testid="acoustid-problem">
|
||||
{overview.status.problem}
|
||||
</p>
|
||||
<p class="px-4 py-3 text-sm text-error-fg" data-testid="acoustid-problem">{overview.status.problem}</p>
|
||||
{/if}
|
||||
|
||||
<div class="divide-y divide-border">
|
||||
<label class={rowClass}>
|
||||
<span>
|
||||
<span class="block text-sm text-text-primary">Look up untagged tracks</span>
|
||||
<span class="block text-xs text-text-secondary">Sends audio fingerprints to AcoustID.</span>
|
||||
</span>
|
||||
<input type="checkbox" bind:checked={enabled} />
|
||||
</label>
|
||||
|
||||
<div class={rowClass}>
|
||||
<label for="acoustid-key">
|
||||
<span class="block text-sm text-text-primary">API key</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Free for non-commercial use.
|
||||
<a
|
||||
href="https://acoustid.org/new-application"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:no-underline">Register an application</a
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="acoustid-key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
bind:value={keyInput}
|
||||
placeholder={overview.settings.api_key_set ? 'Stored' : 'Paste key'}
|
||||
class="{inputClass} w-40 font-mono"
|
||||
/>
|
||||
{#if overview.settings.api_key_set}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-text-secondary underline hover:no-underline"
|
||||
disabled={saving}
|
||||
onclick={forgetKey}>Remove</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class={rowClass}>
|
||||
<span>
|
||||
<span class="block text-sm text-text-primary">Minimum score</span>
|
||||
<span class="block text-xs text-text-secondary">How sure AcoustID must be, 0.5 to 1.</span>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="1"
|
||||
step="0.01"
|
||||
bind:value={minScore}
|
||||
class="{inputClass} w-24 text-right"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !scoreOk}
|
||||
<p class="text-xs text-action-destructive" data-testid="acoustid-problems">
|
||||
Minimum score must be from 0.5 to 1.
|
||||
</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-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving || !scoreOk}
|
||||
onclick={save}
|
||||
>
|
||||
<SettingRow label="Look up untagged tracks" for="acoustid-enabled" hint="Sends audio fingerprints to AcoustID.">
|
||||
<input id="acoustid-enabled" type="checkbox" role="switch" aria-checked={enabled} bind:checked={enabled} class="h-4 w-4 cursor-pointer accent-accent" />
|
||||
</SettingRow>
|
||||
<SettingRow label="API key" for="acoustid-key">
|
||||
{#snippet hint()}
|
||||
Free for non-commercial use.
|
||||
<a href="https://acoustid.org/new-application" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline"
|
||||
>Register an application</a
|
||||
>
|
||||
{/snippet}
|
||||
<input
|
||||
id="acoustid-key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
bind:value={keyInput}
|
||||
placeholder={overview.settings.api_key_set ? 'Key saved' : 'Paste key'}
|
||||
class="{field} w-40 font-mono"
|
||||
/>
|
||||
{#if overview.settings.api_key_set}
|
||||
<button type="button" class={button} disabled={saving} onclick={forgetKey}>Remove</button>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
<SettingRow label="Minimum score" for="acoustid-score" hint="How sure AcoustID must be, 0.5 to 1.">
|
||||
<input id="acoustid-score" type="number" min="0.5" max="1" step="0.01" bind:value={minScore} class="{field} w-24" />
|
||||
</SettingRow>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3">
|
||||
{#if !scoreOk}
|
||||
<p class="mr-auto text-xs text-error-fg" data-testid="acoustid-problems">Minimum score must be from 0.5 to 1.</p>
|
||||
{/if}
|
||||
<button type="button" class={button} disabled={!dirty || saving || !scoreOk} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if overview.coverage.total > 0}
|
||||
<div class="border-t border-border pt-3" data-testid="acoustid-coverage">
|
||||
<h4 class="text-sm font-medium text-text-primary">Recording ids</h4>
|
||||
<dl class="mt-1 divide-y divide-border text-sm">
|
||||
<div class={rowClass}>
|
||||
<dt class="text-text-secondary">From tags</dt>
|
||||
<dd class="tabular-nums">{overview.coverage.from_tag.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div class={rowClass}>
|
||||
<dt class="text-text-secondary">Looked up</dt>
|
||||
<dd class="tabular-nums">{overview.coverage.from_acoustid.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div class={rowClass}>
|
||||
<dt class="text-text-secondary">None</dt>
|
||||
<dd class="tabular-nums">{withoutId.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div class={rowClass}>
|
||||
<dt class="pl-4 text-text-secondary">Waiting to be looked up</dt>
|
||||
<dd class="tabular-nums">{overview.coverage.pending.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div class={rowClass}>
|
||||
<dt class="pl-4 text-text-secondary" title="AcoustID knows no recording for these">
|
||||
No match
|
||||
</dt>
|
||||
<dd class="tabular-nums">{overview.coverage.no_match.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div class={rowClass}>
|
||||
<dt
|
||||
class="pl-4 text-text-secondary"
|
||||
title="Several recordings fit and nothing told them apart, so none was written"
|
||||
>
|
||||
Ambiguous
|
||||
</dt>
|
||||
<dd class="tabular-nums">{overview.coverage.ambiguous.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div class={rowClass}>
|
||||
<dt class="pl-4 text-text-secondary" title="The file couldn't be fingerprinted">
|
||||
Failed
|
||||
</dt>
|
||||
<dd class="tabular-nums">{overview.coverage.failed.toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-2 flex justify-end">
|
||||
{@const c = overview.coverage}
|
||||
<div data-testid="acoustid-coverage">
|
||||
<SettingRow
|
||||
label="Recording ids"
|
||||
hint="{c.from_tag.toLocaleString()} from tags · {c.from_acoustid.toLocaleString()} looked up · {withoutId.toLocaleString()} none"
|
||||
/>
|
||||
<SettingRow
|
||||
label="Without one"
|
||||
hint="{c.pending.toLocaleString()} waiting · {c.no_match.toLocaleString()} no match · {c.ambiguous.toLocaleString()} ambiguous · {c.failed.toLocaleString()} failed"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class={button}
|
||||
disabled={!overview.ready || overview.status.running || starting}
|
||||
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
|
||||
onclick={runNow}
|
||||
>
|
||||
{overview.status.running ? 'Looking up…' : 'Look up now'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{#if overview.coverage.ambiguous + overview.coverage.failed > 0}
|
||||
<details class="border-t border-border pt-3" ontoggle={toggleUnsettled}>
|
||||
{#if c.ambiguous + c.failed > 0}
|
||||
<details class="px-4 py-3" ontoggle={toggleUnsettled}>
|
||||
<summary class="cursor-pointer text-sm text-text-primary">
|
||||
Tracks the lookup couldn't settle ({(
|
||||
overview.coverage.ambiguous + overview.coverage.failed
|
||||
).toLocaleString()})
|
||||
Tracks the lookup couldn't settle ({(c.ambiguous + c.failed).toLocaleString()})
|
||||
</summary>
|
||||
{#if unsettled === null}
|
||||
<p class="mt-2 text-sm text-text-secondary">Loading tracks…</p>
|
||||
@@ -319,12 +220,10 @@
|
||||
<li class="flex items-center justify-between gap-4 py-2">
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-text-primary">{t.title}</span>
|
||||
<span class="block truncate text-xs text-text-secondary"
|
||||
>{t.artist} · {t.album}</span
|
||||
>
|
||||
<span class="block truncate text-xs text-text-secondary">{t.artist} · {t.album}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-text-muted" title={t.detail ?? undefined}>
|
||||
{t.state === 'ambiguous' ? `${t.candidates} recordings` : 'failed'}
|
||||
{t.state === 'ambiguous' ? `${t.candidates} recordings` : "couldn't fingerprint"}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -334,4 +233,4 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -43,7 +43,7 @@ afterEach(() => vi.clearAllMocks());
|
||||
async function renderCard(o: AcoustIDOverview = base) {
|
||||
vi.mocked(getAcoustID).mockResolvedValue(o);
|
||||
render(AcoustIDCard);
|
||||
await screen.findByRole("checkbox", { name: /look up untagged tracks/i });
|
||||
await screen.findByRole("switch", { name: /look up untagged tracks/i });
|
||||
}
|
||||
|
||||
const saveButton = () => screen.getByRole("button", { name: /^save$/i });
|
||||
@@ -52,22 +52,10 @@ const lookUpNow = () => screen.getByRole("button", { name: /look up now/i });
|
||||
describe("AcoustIDCard", () => {
|
||||
test("shows where the library’s recording ids came from", async () => {
|
||||
await renderCard();
|
||||
const cov = screen.getByTestId("acoustid-coverage");
|
||||
const cov = screen.getByTestId("acoustid-coverage").textContent?.replace(/\s+/g, " ");
|
||||
// 1000 - 700 - 150 have no id.
|
||||
for (const [label, n] of [
|
||||
["From tags", "700"],
|
||||
["Looked up", "150"],
|
||||
["None", "150"],
|
||||
["Waiting to be looked up", "55"],
|
||||
["No match", "80"],
|
||||
["Ambiguous", "12"],
|
||||
["Failed", "3"],
|
||||
]) {
|
||||
const row = [...cov.querySelectorAll("dt")].find(
|
||||
(dt) => dt.textContent?.trim() === label,
|
||||
);
|
||||
expect(row?.nextElementSibling?.textContent?.trim(), label).toBe(n);
|
||||
}
|
||||
expect(cov).toMatch(/Recording ids 700 from tags · 150 looked up · 150 none/);
|
||||
expect(cov).toMatch(/Without one 55 waiting · 80 no match · 12 ambiguous · 3 failed/);
|
||||
});
|
||||
|
||||
test("never shows the stored key, and sends one only when typed", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import AcquisitionServiceRow from './AcquisitionServiceRow.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
|
||||
// Where Minstrel looks for music and what downloads it (M502 #5518),
|
||||
// Fabled Librarian's model: two lists of connections the operator adds and
|
||||
@@ -88,109 +89,78 @@
|
||||
}
|
||||
|
||||
const addButtonClass =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover ' +
|
||||
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary';
|
||||
</script>
|
||||
|
||||
{#snippet addMenu(kind: 'search' | 'download', label: string)}
|
||||
{#if adding === kind}
|
||||
<div class="space-y-2" data-testid="add-{kind}">
|
||||
{#each choices[kind] as choice (choice.protocol)}
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full rounded-lg border border-border px-4 py-3 text-left hover:bg-surface-hover
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onclick={() => add(choice.protocol)}
|
||||
<div class="px-4 py-3">
|
||||
{#if adding === kind}
|
||||
<div class="space-y-2" data-testid="add-{kind}">
|
||||
{#each choices[kind] as choice (choice.protocol)}
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full rounded-md border border-border px-4 py-2 text-left hover:bg-surface-hover"
|
||||
onclick={() => add(choice.protocol)}
|
||||
>
|
||||
<span class="block text-sm text-text-primary">{choice.name}</span>
|
||||
<span class="block text-xs text-text-secondary">{choice.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="text-sm text-text-secondary underline hover:no-underline" onclick={() => (adding = null)}
|
||||
>Cancel</button
|
||||
>
|
||||
<span class="block text-sm font-medium text-text-primary">{choice.name}</span>
|
||||
<span class="block text-xs text-text-secondary">{choice.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-text-secondary underline hover:no-underline"
|
||||
onclick={() => (adding = null)}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class={addButtonClass} onclick={() => (adding = kind)}>{label}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class={addButtonClass} onclick={() => (adding = kind)}>{label}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section
|
||||
class="space-y-5 rounded-xl border border-border bg-surface p-5"
|
||||
data-testid="acquisition-card"
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">
|
||||
Indexers and download clients
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Two jobs, kept apart: something to search with, and something to download with. Minstrel
|
||||
speaks Prowlarr, Torznab and the qBittorrent WebUI, so a stock setup is a complete one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6" data-testid="acquisition-card">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load indexers and download clients.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if services === null}
|
||||
<p class="text-sm text-text-secondary">Loading indexers and download clients…</p>
|
||||
{:else}
|
||||
<!-- The verdict first (#189): can music be found and downloaded at all. -->
|
||||
<p class="text-sm" data-testid="acquisition-health">
|
||||
{#if !canFetch}
|
||||
<span class="text-action-destructive">
|
||||
No download client is switched on, so nothing can be downloaded.
|
||||
</span>
|
||||
<span class="text-error-fg">No download client is switched on, so nothing can be downloaded.</span>
|
||||
{:else if !canSearch}
|
||||
<span class="text-action-destructive">
|
||||
No indexer is switched on, so nothing new can be found.
|
||||
</span>
|
||||
<span class="text-error-fg">No indexer is switched on, so nothing new can be found.</span>
|
||||
{:else}
|
||||
<span class="text-text-secondary">Music can be searched for and downloaded.</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="space-y-3" data-testid="indexers">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-text-primary">Indexers: where music is looked for</h4>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Every indexer switched on here is searched, and the results are merged. If one is
|
||||
unreachable the search still runs, narrower.
|
||||
</p>
|
||||
</div>
|
||||
{#each indexers as service (service.code)}
|
||||
<AcquisitionServiceRow {service} onChanged={load} />
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">No indexer yet. Prowlarr is the usual choice.</p>
|
||||
{/each}
|
||||
{#if searchingExchanges.length > 0}
|
||||
<p class="text-xs text-text-secondary">
|
||||
Also searched: {searchingExchanges.map((s) => s.label).join(', ')}, set up under download
|
||||
clients below.
|
||||
</p>
|
||||
{/if}
|
||||
{@render addMenu('search', 'Add indexer')}
|
||||
<div data-testid="indexers">
|
||||
<SettingsGroup title="Indexers" id="indexers" hint="Where music is looked for. Every one switched on is searched.">
|
||||
{#each indexers as service (service.code)}
|
||||
<AcquisitionServiceRow {service} onChanged={load} />
|
||||
{:else}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">No indexer yet. Prowlarr is the usual choice.</p>
|
||||
{/each}
|
||||
{#if searchingExchanges.length > 0}
|
||||
<p class="px-4 py-3 text-xs text-text-secondary">
|
||||
Also searched: {searchingExchanges.map((s) => s.label).join(', ')}, under download clients.
|
||||
</p>
|
||||
{/if}
|
||||
{@render addMenu('search', 'Add indexer')}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 border-t border-border pt-5" data-testid="download-clients">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-text-primary">
|
||||
Download clients: what actually downloads
|
||||
</h4>
|
||||
<p class="text-xs text-text-secondary">
|
||||
What a grab is handed to. A FabledExchange here can do more than download, and its entry
|
||||
says which of its jobs are switched on.
|
||||
</p>
|
||||
</div>
|
||||
{#each clients as service (service.code)}
|
||||
<AcquisitionServiceRow {service} onChanged={load} />
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">No download client yet.</p>
|
||||
{/each}
|
||||
{@render addMenu('download', 'Add download client')}
|
||||
<div data-testid="download-clients">
|
||||
<SettingsGroup title="Download clients" id="download-clients" hint="What a grab is handed to.">
|
||||
{#each clients as service (service.code)}
|
||||
<AcquisitionServiceRow {service} onChanged={load} />
|
||||
{:else}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">No download client yet.</p>
|
||||
{/each}
|
||||
{@render addMenu('download', 'Add download client')}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
'focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50';
|
||||
</script>
|
||||
|
||||
<article class="space-y-3 rounded-lg border border-border p-4" data-testid="acquisition-service">
|
||||
<article class="space-y-3 px-4 py-3" data-testid="acquisition-service">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={form.enabled} aria-label="Use {form.label}" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
getFingerprintSettings,
|
||||
updateFingerprintSettings,
|
||||
@@ -8,9 +7,11 @@
|
||||
} 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';
|
||||
|
||||
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
|
||||
// Lives on the Duplicates page, beside the results these settings shape.
|
||||
// On Admin › Library (#5607); Duplicates says whether it is on and links here.
|
||||
|
||||
let { libraryTotal = 0, onSaved }: { libraryTotal?: number; onSaved?: () => void } =
|
||||
$props();
|
||||
@@ -81,150 +82,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
||||
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
|
||||
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>
|
||||
|
||||
<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">Fingerprinting</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
How tracks are fingerprinted, and how alike two must sound to be proposed as the same
|
||||
recording. Identical files are always found, whatever these say.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Fingerprinting" id="fingerprint-settings" hint="How alike two tracks must sound to be one recording. Identical files are always found.">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
<p class="px-4 py-3 text-sm text-error-fg">
|
||||
Couldn't load fingerprinting 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>
|
||||
<p class="px-4 py-3 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">Fingerprint tracks</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off stops all audio decoding: new files aren't fingerprinted and the library isn't
|
||||
worked through in the background. Moved files are still recognised, because that only
|
||||
reads the file. Tracks without a fingerprint aren't compared.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" bind:checked={form.auto_resolve} class="mt-1" />
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Merges a song held twice on one album, and a video rip with a clean copy elsewhere, into
|
||||
the copy that fits best. A song kept on several releases counts as one song, and video
|
||||
rips are kept out of radio and the mixes. Off, every group waits here for you.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Seconds of audio to fingerprint</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
More tells apart recordings that only differ later in the track, and takes longer for
|
||||
each file.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="30"
|
||||
max="600"
|
||||
bind:value={form.chromaprint_length_sec}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Minimum match (%)</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
How alike two recordings must sound to be proposed as one. Higher proposes fewer, surer
|
||||
groups. Unrelated songs score around 50%.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="65"
|
||||
max="99"
|
||||
value={matchPercent}
|
||||
oninput={(e) => onMatchInput(e.currentTarget.value)}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Files fingerprinted at once</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
The background pass competes with playback for CPU and with streaming for the disk.
|
||||
Lower it if playback stutters while it runs.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="8"
|
||||
bind:value={form.backfill_concurrency}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Hours between sweeps</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
The least time between automatic duplicate sweeps. A sweep only runs when something has
|
||||
changed, and Sweep now doesn't wait.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
bind:value={form.sweep_interval_hours}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SettingRow label="Fingerprint tracks" for="fp-enabled" hint="Off stops decoding audio; unfingerprinted tracks aren't compared.">
|
||||
<input id="fp-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="Resolve duplicates automatically"
|
||||
for="fp-auto"
|
||||
hint="Merges clear cases into the best copy. Off, every group waits for you."
|
||||
>
|
||||
<input id="fp-auto" type="checkbox" role="switch" aria-checked={form.auto_resolve} bind:checked={form.auto_resolve} class="h-4 w-4 cursor-pointer accent-accent" />
|
||||
</SettingRow>
|
||||
<SettingRow label="Seconds of audio to fingerprint" for="fp-length" hint="More tells apart late differences; slower per file.">
|
||||
<input id="fp-length" type="number" min="30" max="600" bind:value={form.chromaprint_length_sec} class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Minimum match" for="fp-match" hint="Higher proposes fewer, surer groups. Unrelated songs score about 50%.">
|
||||
<input
|
||||
id="fp-match"
|
||||
type="number"
|
||||
min="65"
|
||||
max="99"
|
||||
value={matchPercent}
|
||||
oninput={(e) => onMatchInput(e.currentTarget.value)}
|
||||
class={field}
|
||||
/>
|
||||
<span class="text-sm text-text-secondary">%</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Files fingerprinted at once" for="fp-concurrency" hint="Lower it if playback stutters while it runs.">
|
||||
<input id="fp-concurrency" type="number" min="1" max="8" bind:value={form.backfill_concurrency} class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Hours between sweeps" for="fp-sweep" hint="At least this long between automatic duplicate sweeps.">
|
||||
<input id="fp-sweep" type="number" min="1" max="168" bind:value={form.sweep_interval_hours} class={field} />
|
||||
</SettingRow>
|
||||
{#if lengthChanged}
|
||||
<p
|
||||
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
|
||||
data-testid="length-warning"
|
||||
>
|
||||
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
|
||||
<span>
|
||||
Saving re-fingerprints {libraryTotal > 0
|
||||
? `all ${libraryTotal.toLocaleString()} tracks`
|
||||
: 'every track'} at the new length. Fingerprints taken at different lengths can't be
|
||||
compared, so a track is left out of the duplicate search until it's redone, and groups
|
||||
of similar recordings come back as that work finishes.
|
||||
</span>
|
||||
<!-- The one setting with a cost not visible here: fingerprints of two
|
||||
lengths can't be compared, so a new length redoes the library. -->
|
||||
<p class="px-4 py-3 text-sm text-text-primary" data-testid="length-warning">
|
||||
Saving re-fingerprints {libraryTotal > 0 ? `all ${libraryTotal.toLocaleString()} tracks` : 'every track'};
|
||||
duplicates come back as that finishes.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if problems.length > 0}
|
||||
<ul class="space-y-1 text-xs text-action-destructive" data-testid="settings-problems">
|
||||
{#each problems as problem (problem)}
|
||||
<li>{problem}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/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-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving || problems.length > 0}
|
||||
onclick={save}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3">
|
||||
{#if problems.length > 0}
|
||||
<ul class="mr-auto space-y-1 text-xs text-error-fg" data-testid="settings-problems">
|
||||
{#each problems as problem (problem)}
|
||||
<li>{problem}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button type="button" class={button} disabled={!dirty || saving || problems.length > 0} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('FingerprintSettingsCard', () => {
|
||||
test('auto-resolve can be turned off and is saved with the rest', async () => {
|
||||
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, auto_resolve: false });
|
||||
await renderCard();
|
||||
const toggle = screen.getByRole('checkbox', { name: /resolve duplicates automatically/i }) as HTMLInputElement;
|
||||
const toggle = screen.getByRole('switch', { name: /resolve duplicates automatically/i }) as HTMLInputElement;
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
@@ -137,7 +137,7 @@ describe('FingerprintSettingsCard', () => {
|
||||
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, enabled: false });
|
||||
await renderCard({}, { onSaved });
|
||||
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: /fingerprint tracks/i }));
|
||||
await fireEvent.click(screen.getByRole('switch', { name: /fingerprint tracks/i }));
|
||||
await fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
|
||||
expect(updateFingerprintSettings).toHaveBeenCalledWith(
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
} 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
|
||||
@@ -77,121 +79,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
||||
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
|
||||
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>
|
||||
|
||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-medium">Loudness analysis</h2>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Measures how loud each track is, so playback can even out the volume between tracks. It runs
|
||||
in the background; until a track is measured, it plays at its own volume.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Loudness" id="loudness" hint="Measures each track so playback can even out the volume.">
|
||||
{#if coverage && coverage.total > 0}
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm" data-testid="loudness-coverage">
|
||||
<span>
|
||||
{coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured
|
||||
</span>
|
||||
{#if coverage.pending > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span>{coverage.pending.toLocaleString()} pending</span>
|
||||
{/if}
|
||||
{#if !coverage.enabled && coverage.pending > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span>paused while analysis is off</span>
|
||||
{/if}
|
||||
{#if coverage.silent > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span
|
||||
class="cursor-help"
|
||||
title="These tracks are silent or too short to measure. They play without adjustment."
|
||||
>
|
||||
{coverage.silent.toLocaleString()} silent
|
||||
</span>
|
||||
{/if}
|
||||
{#if coverage.unreadable > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span
|
||||
class="cursor-help"
|
||||
title="ffmpeg couldn't decode these files. Each is tried again when its file changes."
|
||||
>
|
||||
{coverage.unreadable.toLocaleString()} unreadable
|
||||
</span>
|
||||
{/if}
|
||||
{@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="text-sm text-action-destructive">
|
||||
<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="text-sm text-text-secondary">Loading…</p>
|
||||
<p class="px-4 py-3 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">Analyze tracks</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off stops the background analysis, including for new tracks. Tracks already measured keep
|
||||
their values, so they stay leveled.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Files analyzed at once</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Each analysis reads the whole file, competing with playback for CPU and with streaming for
|
||||
the disk. Lower it if playback stutters while it runs.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="8"
|
||||
bind:value={form.backfill_concurrency}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Speaker cache (MB)</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Leveled copies for Sonos and UPnP speakers. The least recently played go first.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="256"
|
||||
max="65536"
|
||||
step="256"
|
||||
bind:value={form.leveled_cache_mb}
|
||||
class={inputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if !valid}
|
||||
<div class="text-xs text-action-destructive" 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}
|
||||
|
||||
<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-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving || !valid}
|
||||
onclick={save}
|
||||
>
|
||||
<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}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Save, TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
getNetworkSettings,
|
||||
updateNetworkSettings,
|
||||
type NetworkSettings
|
||||
} from '#lib/api/admin.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';
|
||||
|
||||
// Client IP detection, on Admin › Email & network: decides which address
|
||||
// is recorded for each sign-in on the Active sessions list.
|
||||
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';
|
||||
|
||||
let settings = $state<NetworkSettings | null>(null);
|
||||
let hops = $state(1);
|
||||
@@ -50,86 +56,55 @@
|
||||
}
|
||||
</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">Client IP detection</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
How many reverse proxies sit in front of Minstrel. This decides which address is
|
||||
recorded for each sign-in on the <span class="whitespace-nowrap">Active sessions</span> card,
|
||||
so getting it right is what makes an unfamiliar login visible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Proxies" id="proxies" hint="How many reverse proxies sit in front of Minstrel.">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
<p class="px-4 py-3 text-sm text-error-fg">
|
||||
Couldn't load network settings.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if settings === null}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-text-secondary">Trusted proxies</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={settings.max_hops}
|
||||
bind:value={hops}
|
||||
class="w-24 rounded border border-border bg-background px-2 py-1
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5
|
||||
text-sm hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
||||
disabled:opacity-50"
|
||||
disabled={saving || !dirty}
|
||||
onclick={save}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
<SettingRow
|
||||
label="Trusted proxies"
|
||||
for="proxy-hops"
|
||||
hint="0 is none, 1 a proxy such as nginx or Caddy, 2 a CDN in front of it."
|
||||
>
|
||||
<input
|
||||
id="proxy-hops"
|
||||
type="number"
|
||||
min="0"
|
||||
max={settings.max_hops}
|
||||
bind:value={hops}
|
||||
class="w-20 rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button type="button" class={button} disabled={saving || !dirty} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Verification, not decoration: the number is abstract, but "the address
|
||||
Minstrel currently sees for YOU" is checkable against the machine
|
||||
you're sitting at. -->
|
||||
<dl class="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
|
||||
<dt class="text-text-secondary">Your address right now</dt>
|
||||
<dd class="font-mono">{settings.detected_client_ip || 'unknown'}</dd>
|
||||
<dt class="text-text-secondary">Direct connection from</dt>
|
||||
<dd class="font-mono">{settings.remote_addr || 'unknown'}</dd>
|
||||
<dt class="text-text-secondary">Forwarded chain</dt>
|
||||
<dd class="font-mono break-all">{settings.forwarded_chain || '(none)'}</dd>
|
||||
</dl>
|
||||
|
||||
</SettingRow>
|
||||
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
|
||||
<p class="text-sm text-text-secondary">
|
||||
<p class="px-4 py-3 text-sm text-text-primary">
|
||||
This request arrived with {suggested}
|
||||
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, which usually means
|
||||
{suggested}
|
||||
{suggested === 1 ? 'proxy' : 'proxies'} in front of Minstrel.
|
||||
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, so {suggested}
|
||||
{suggested === 1 ? 'proxy is' : 'proxies are'} likely.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2 rounded border border-border bg-background p-3 text-sm">
|
||||
<p class="flex items-start gap-2 text-text-secondary">
|
||||
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
|
||||
<span>
|
||||
Count your proxies — don't guess high. This number tells Minstrel how much of the
|
||||
<span class="font-mono">X-Forwarded-For</span> header to believe, and that header is
|
||||
written by whoever connects. Set it higher than your real chain, or above 0 with no
|
||||
proxy at all, and a visitor can choose which address their own session shows — which
|
||||
defeats the point of the sessions list.
|
||||
</span>
|
||||
</p>
|
||||
<ul class="ml-6 list-disc space-y-1 text-text-secondary">
|
||||
<li><strong>0</strong> — no proxy; Minstrel is reached directly.</li>
|
||||
<li><strong>1</strong> — one reverse proxy, e.g. nginx, Caddy or Traefik terminating TLS.</li>
|
||||
<li><strong>2</strong> — a CDN in front of your own proxy, e.g. Cloudflare → nginx.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Verification, not decoration: "the address Minstrel sees for you"
|
||||
can be checked against the machine you're at. -->
|
||||
<SettingRow label="Your address right now">
|
||||
<span class="font-mono text-sm">{settings.detected_client_ip || 'unknown'}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Direct connection from">
|
||||
<span class="font-mono text-sm">{settings.remote_addr || 'unknown'}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Forwarded chain">
|
||||
<span class="break-all font-mono text-sm">{settings.forwarded_chain || 'none'}</span>
|
||||
</SettingRow>
|
||||
<!-- X-Forwarded-For is written by whoever connects, so a depth set too
|
||||
high lets a visitor pick the address their session shows. -->
|
||||
<p class="px-4 py-3 text-xs text-text-secondary">
|
||||
Count your proxies, don't guess high: set too high, a visitor can choose the address their
|
||||
session shows.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
} 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';
|
||||
|
||||
// When notification emails go out (M489 #5346). Nothing is emailed per
|
||||
// event: new music is a daily summary, everything else a batch.
|
||||
|
||||
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<NotificationEmailSettings | null>(null);
|
||||
let form = $state<NotificationEmailSettings | null>(null);
|
||||
let saving = $state(false);
|
||||
@@ -58,69 +64,30 @@
|
||||
}
|
||||
</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">Notification emails</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Nothing is emailed per event. Each person chooses which notifications reach their email in
|
||||
their own settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Notification emails" id="notification-emails" hint="Never one per event. Each person picks theirs in their own settings.">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
<p class="px-4 py-3 text-sm text-error-fg">
|
||||
Couldn't load the email schedule.
|
||||
<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 the email schedule…</p>
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading the email schedule…</p>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">New music summary</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Once a day at this hour, in each person's own timezone.
|
||||
</span>
|
||||
<select
|
||||
bind:value={form.summary_hour}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
>
|
||||
{#each hours as h (h)}
|
||||
<option value={h}>{hourLabel(h)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Batch everything else (minutes)</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
One email this long after the first item, holding all that came in.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
max="1440"
|
||||
bind:value={form.batch_window_minutes}
|
||||
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-text-primary focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving}
|
||||
onclick={save}
|
||||
>
|
||||
<SettingRow label="New music summary" for="summary-hour" hint="Daily, at this hour in each person's timezone.">
|
||||
<select id="summary-hour" bind:value={form.summary_hour} class={field}>
|
||||
{#each hours as h (h)}
|
||||
<option value={h}>{hourLabel(h)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="Batch everything else" for="batch-window" hint="One email this long after the first item.">
|
||||
<input id="batch-window" type="number" min="15" max="1440" bind:value={form.batch_window_minutes} class={field} />
|
||||
<span class="text-sm text-text-secondary">minutes</span>
|
||||
</SettingRow>
|
||||
<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}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Save, TriangleAlert } from 'lucide-svelte';
|
||||
import { getNetworkSettings, updatePublicUrl } 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';
|
||||
|
||||
// The address links in email point at (#4981), on Admin › Email & network.
|
||||
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';
|
||||
|
||||
let saved = $state<string | null>(null);
|
||||
let value = $state('');
|
||||
@@ -43,65 +48,38 @@
|
||||
}
|
||||
</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">Public address</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
The address people use to reach Minstrel. Password-reset emails link here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Public address" id="public-address" hint="Where people reach Minstrel. Password-reset emails link here.">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
Couldn't load network settings.
|
||||
<p class="px-4 py-3 text-sm text-error-fg">
|
||||
Couldn't load the public address.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if saved === null}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-text-secondary">Address</span>
|
||||
<input
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://music.example.com"
|
||||
bind:value
|
||||
class="w-full rounded border border-border bg-background px-2 py-1 font-mono
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
{#if here && value.trim() !== here}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onclick={() => (value = here)}
|
||||
>
|
||||
Use {here}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5
|
||||
text-sm hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
||||
disabled:opacity-50"
|
||||
disabled={saving || !dirty}
|
||||
onclick={save}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
<SettingRow label="Address" for="public-url">
|
||||
<input
|
||||
id="public-url"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://music.example.com"
|
||||
bind:value
|
||||
class="w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 font-mono text-sm"
|
||||
/>
|
||||
<button type="button" class={button} disabled={saving || !dirty} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</SettingRow>
|
||||
{#if here && value.trim() !== here}
|
||||
<SettingRow label="This page's address" hint={here}>
|
||||
<button type="button" class={button} onclick={() => (value = here)}>Use {here}</button>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
{#if !saved}
|
||||
<p class="flex items-start gap-2 text-sm text-text-secondary" role="status">
|
||||
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
|
||||
<span>
|
||||
Not set, so password-reset emails are not being sent. Minstrel won't build the link from
|
||||
whatever address a request claims, because anyone can claim any address.
|
||||
</span>
|
||||
<!-- Not built from the request's own Host: anyone can claim any address. -->
|
||||
<p class="px-4 py-3 text-sm text-error-fg" role="status">
|
||||
Not set, so password-reset emails are not being sent.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
getReacquisitionSettings,
|
||||
updateReacquisitionSettings,
|
||||
@@ -8,6 +7,8 @@
|
||||
} 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
|
||||
@@ -15,6 +16,10 @@
|
||||
// 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);
|
||||
@@ -69,97 +74,37 @@
|
||||
|
||||
</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 put its album back on the
|
||||
<a href="/collection/missing" class="text-accent-fg hover:underline">wanted list</a>
|
||||
by itself. It's wanted per album, not per track: a whole folder going missing is one
|
||||
want, not forty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Missing files" id="reacquisition" hint="Put an album back on the wanted list when its files go missing.">
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
<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="text-sm text-text-secondary">Loading…</p>
|
||||
<p class="px-4 py-3 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">Want missing albums back 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 wanting it back</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Hours a file must be missing before its album is wanted. 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 wants.
|
||||
</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-solid 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 albums wanted 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-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<!-- Stated rather than left to be inferred: without this, an operator
|
||||
watching those rows never get wanted 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 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 wanted:
|
||||
there's no way to identify the release. A re-scan after tagging fixes it.
|
||||
</span>
|
||||
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
|
||||
<p class="px-4 py-3 text-sm text-text-primary">
|
||||
{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 wanted. Tag and re-scan to fix.
|
||||
</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-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||
disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!dirty || saving}
|
||||
onclick={save}
|
||||
>
|
||||
<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}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
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
|
||||
@@ -16,6 +18,9 @@
|
||||
// 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);
|
||||
|
||||
@@ -99,170 +104,115 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="search-settings-card">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Searching and grabbing</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Minstrel searches your indexers for each album on the
|
||||
<a href="/collection/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
|
||||
release it finds, and hands the best one to your download client. Each album's releases and
|
||||
the reason for every verdict are on the Wanted page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsGroup title="Searching" id="searching" hint="Each wanted album is searched for, judged, and the best release downloaded.">
|
||||
{#if query.isPending}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||
{:else if query.isError || !form || !query.data}
|
||||
<p class="text-sm text-action-destructive">Couldn't load the search settings.</p>
|
||||
<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}
|
||||
<label class="flex items-start gap-3">
|
||||
{@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="mt-1"
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Take the best release without asking</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off, a search only lists what it found, and you choose from the Wanted page.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Search each album again after</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Hours between searches for an album still wanted. 0 switches the schedule off; Search on the
|
||||
Wanted page still works.
|
||||
</span>
|
||||
<span class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="720"
|
||||
value={form.search_interval_hours}
|
||||
oninput={(e) => edit({ search_interval_hours: Number(e.currentTarget.value) })}
|
||||
class="w-24 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
<span class="text-sm text-text-secondary">hours</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm text-text-primary">Qualities to take</legend>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
The best of these is taken first. A release that names no format has its file list read to
|
||||
find out.
|
||||
</span>
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#each query.data.tiers as t (t.tier)}
|
||||
<label class="flex items-start gap-2">
|
||||
<input type="checkbox" checked={form.allowed.includes(t.tier)} onchange={() => toggle(t.tier)} class="mt-0.5" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{t.label}
|
||||
<span class="block text-xs text-text-secondary">{t.what}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</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>
|
||||
</fieldset>
|
||||
|
||||
{#if query.data.scan_paths.length > 1}
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">File new music into</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
A download that checks out is copied here as Artist/Album (Year)/Artist - Album - NN -
|
||||
Title, tagged with its MusicBrainz ids.
|
||||
</span>
|
||||
{/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
|
||||
value={form.import_root || query.data.scan_paths[0]}
|
||||
id="search-root"
|
||||
value={form.import_root || data.scan_paths[0]}
|
||||
onchange={(e) => edit({ import_root: e.currentTarget.value })}
|
||||
class="mt-1 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
class="rounded-md border border-border bg-background px-2 py-1.5 font-mono text-sm"
|
||||
>
|
||||
{#each query.data.scan_paths as p (p)}
|
||||
{#each data.scan_paths as p (p)}
|
||||
<option value={p}>{p}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else if query.data.scan_paths.length === 1}
|
||||
<p class="text-xs text-text-secondary">
|
||||
A download that checks out is copied into <span class="font-mono">{query.data.scan_paths[0]}</span>
|
||||
as Artist/Album (Year)/Artist - Album - NN - Title, tagged with its MusicBrainz ids.
|
||||
</p>
|
||||
</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}
|
||||
|
||||
<fieldset class="space-y-3">
|
||||
<legend class="text-sm text-text-primary">After the download</legend>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-text-secondary">
|
||||
The files are copied into the library, so the download goes on sharing until either limit,
|
||||
then is removed with its files. A ratio of 0 removes it once imported; 0 days is no time
|
||||
limit.
|
||||
</span>
|
||||
<span class="mt-1 flex flex-wrap items-center gap-2 text-sm text-text-secondary">
|
||||
Seed to a ratio of
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
aria-label="Seed ratio"
|
||||
value={form.seed_ratio}
|
||||
oninput={(e) => edit({ seed_ratio: Number(e.currentTarget.value) })}
|
||||
class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
or for
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
aria-label="Seed days"
|
||||
value={form.seed_days}
|
||||
oninput={(e) => edit({ seed_days: Number(e.currentTarget.value) })}
|
||||
class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
days
|
||||
</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs text-text-secondary">
|
||||
A download nobody is sharing is asked for again three times, ten minutes apart, then given
|
||||
up: removed, held back for a week, and the album searched for again. 0 never gives up for
|
||||
that; a download the client fails is always given up.
|
||||
</span>
|
||||
<span class="mt-1 flex flex-wrap items-center gap-2 text-sm text-text-secondary">
|
||||
Give up on a stalled download after
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="720"
|
||||
aria-label="Stall hours"
|
||||
value={form.stall_hours}
|
||||
oninput={(e) => edit({ stall_hours: Number(e.currentTarget.value) })}
|
||||
class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
hours
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<p class="text-xs text-text-secondary" data-testid="last-pass">{lastPass(query.data)}</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={save}
|
||||
disabled={!dirty || saving || form.allowed.length === 0}
|
||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<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}
|
||||
</section>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('SearchSettingsCard', () => {
|
||||
test('shows the settings and what the last pass did', () => {
|
||||
setup();
|
||||
expect((screen.getByLabelText(/take the best release without asking/i) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText(/hours between searches/i) as HTMLInputElement).value).toBe('24');
|
||||
expect((screen.getByLabelText(/search again after/i) as HTMLInputElement).value).toBe('24');
|
||||
expect((screen.getByLabelText(/^unknown/i) as HTMLInputElement).checked).toBe(false);
|
||||
const last = screen.getByTestId('last-pass').textContent ?? '';
|
||||
expect(last).toMatch(/searched 12, took 3, 2 waiting for you, 7 with nothing found\./);
|
||||
@@ -93,7 +93,7 @@ describe('SearchSettingsCard', () => {
|
||||
expect((screen.getByLabelText(/seed ratio/i) as HTMLInputElement).value).toBe('2');
|
||||
await fireEvent.input(screen.getByLabelText(/seed ratio/i), { target: { value: '1.5' } });
|
||||
await fireEvent.input(screen.getByLabelText(/seed days/i), { target: { value: '7' } });
|
||||
await fireEvent.input(screen.getByLabelText(/stall hours/i), { target: { value: '0' } });
|
||||
await fireEvent.input(screen.getByLabelText(/give up on a stalled download/i), { target: { value: '0' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(putSearchSettings).toHaveBeenCalledWith(
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
let isSaving = $state(false);
|
||||
let isTesting = $state(false);
|
||||
|
||||
function takeLists(t: LidarrTestResult) {
|
||||
function takeLists(t: Extract<LidarrTestResult, { ok: true }>) {
|
||||
testQualityProfiles = t.quality_profiles ?? [];
|
||||
testMetadataProfiles = t.metadata_profiles ?? [];
|
||||
testRootFolders = t.root_folders ?? [];
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<SettingRow label={p.display_name} for="src-{p.id}">
|
||||
{#snippet hint()}
|
||||
{supplies(p)}
|
||||
{#if note}{' · '}{@render note(p)}{/if}
|
||||
{#if note}<span class="block">{@render note(p)}</span>{/if}
|
||||
{/snippet}
|
||||
{#if l}
|
||||
<input
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Admin › Metadata sources', () => {
|
||||
|
||||
test('a tag source is switched on with its key in one save', async () => {
|
||||
setup();
|
||||
vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true });
|
||||
vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true, version_bumped: true });
|
||||
const row = source('lastfm');
|
||||
await fireEvent.click(within(row).getByRole('switch'));
|
||||
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'lfmkey' } });
|
||||
|
||||
Reference in New Issue
Block a user