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