Files
minstrel/web/src/lib/components/AcoustIDCard.svelte
T
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
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>
2026-10-08 10:50:39 -04:00

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>