Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
12 KiB
Svelte
338 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import {
|
|
getAcoustID,
|
|
listUnsettledAcoustID,
|
|
runAcoustIDLookup,
|
|
updateAcoustIDSettings,
|
|
type AcoustIDOverview,
|
|
type AcoustIDUnsettled
|
|
} from '#lib/api/admin.js';
|
|
import { errMessage } from '#lib/api/errors.js';
|
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
|
|
|
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
|
|
// untagged tracks lack, so the similarity arms can see them. Off until the
|
|
// operator adds their own key (rule 26), and it never gates anything else:
|
|
// when AcoustID is unreachable the card says so and nothing else notices.
|
|
|
|
let overview = $state<AcoustIDOverview | null>(null);
|
|
let enabled = $state(false);
|
|
let minScore = $state(0.85);
|
|
// Empty means "keep the stored key". The key itself never comes back.
|
|
let keyInput = $state('');
|
|
let saving = $state(false);
|
|
let starting = $state(false);
|
|
let loadError = $state(false);
|
|
let unsettled = $state<AcoustIDUnsettled[] | null>(null);
|
|
|
|
const scoreOk = $derived(Number.isFinite(minScore) && minScore >= 0.5 && minScore <= 1);
|
|
const dirty = $derived(
|
|
!!overview &&
|
|
(enabled !== overview.settings.enabled ||
|
|
minScore !== overview.settings.min_score ||
|
|
keyInput.trim() !== '')
|
|
);
|
|
const withoutId = $derived(
|
|
overview
|
|
? overview.coverage.total - overview.coverage.from_tag - overview.coverage.from_acoustid
|
|
: 0
|
|
);
|
|
|
|
async function load() {
|
|
try {
|
|
overview = await getAcoustID();
|
|
enabled = overview.settings.enabled;
|
|
minScore = overview.settings.min_score;
|
|
loadError = false;
|
|
} catch {
|
|
loadError = true;
|
|
}
|
|
}
|
|
|
|
// The figures move a track every second or two during a pass; a failed read
|
|
// keeps the last ones rather than blanking the card.
|
|
async function refresh() {
|
|
try {
|
|
overview = await getAcoustID();
|
|
} catch {
|
|
// Keep what we had.
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
load();
|
|
const timer = setInterval(refresh, 30_000);
|
|
return () => clearInterval(timer);
|
|
});
|
|
|
|
async function save() {
|
|
if (!scoreOk) return;
|
|
saving = true;
|
|
try {
|
|
const key = keyInput.trim();
|
|
await updateAcoustIDSettings({
|
|
enabled,
|
|
min_score: minScore,
|
|
...(key ? { api_key: key } : {})
|
|
});
|
|
keyInput = '';
|
|
pushToast('AcoustID settings saved.');
|
|
await load();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function forgetKey() {
|
|
saving = true;
|
|
try {
|
|
await updateAcoustIDSettings({ enabled, min_score: minScore, api_key: '' });
|
|
pushToast('AcoustID key removed.');
|
|
await load();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function runNow() {
|
|
starting = true;
|
|
try {
|
|
await runAcoustIDLookup();
|
|
pushToast('Looking up untagged tracks.');
|
|
await refresh();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
starting = false;
|
|
}
|
|
}
|
|
|
|
async function toggleUnsettled(e: Event) {
|
|
if ((e.currentTarget as HTMLDetailsElement).open && unsettled === null) {
|
|
try {
|
|
unsettled = await listUnsettledAcoustID();
|
|
} catch (err) {
|
|
pushToast(errMessage(err), 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
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';
|
|
</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>
|
|
|
|
{#if loadError}
|
|
<p class="text-sm text-action-destructive">
|
|
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>
|
|
{:else}
|
|
{#if overview.status.problem}
|
|
<p class="text-sm text-action-destructive" 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}
|
|
>
|
|
{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">
|
|
<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"
|
|
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>
|
|
</div>
|
|
|
|
{#if overview.coverage.ambiguous + overview.coverage.failed > 0}
|
|
<details class="border-t border-border pt-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()})
|
|
</summary>
|
|
{#if unsettled === null}
|
|
<p class="mt-2 text-sm text-text-secondary">Loading tracks…</p>
|
|
{:else}
|
|
<ul class="mt-2 divide-y divide-border text-sm" data-testid="acoustid-unsettled">
|
|
{#each unsettled as t (t.track_id)}
|
|
<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>
|
|
<span class="shrink-0 text-xs text-text-muted" title={t.detail ?? undefined}>
|
|
{t.state === 'ambiguous' ? `${t.candidates} recordings` : 'failed'}
|
|
</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</details>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</section>
|