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