feat(web): AcoustID card on Integrations — key, threshold, coverage by source (M401 #3922)
release / go (push) Successful in 2m25s
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 21s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m34s
release / android (push) Canceled after 1m3s
release / Build signed APK (releases and dev) (push) Canceled after 1m3s
release / go (push) Successful in 2m25s
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 21s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m34s
release / android (push) Canceled after 1m3s
release / Build signed APK (releases and dev) (push) Canceled after 1m3s
The card takes the slot of the unimplemented "MusicBrainz overrides" placeholder. Rows: - the on switch - a write-only key field (the stored key is never sent back), with a link to register an application - the minimum score (0.5 to 1) Below them, recording-id coverage reads as a column: from tags, looked up, none, and of the none how many are waiting, no match, ambiguous or failed. There is a "Look up now" button and a folded list of the tracks the lookup could not settle. Off, keyless and stopped-short passes are each a visible state with the reason (rule 164). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getAcoustID,
|
||||
listUnsettledAcoustID,
|
||||
runAcoustIDLookup,
|
||||
updateAcoustIDSettings,
|
||||
type AcoustIDOverview,
|
||||
type AcoustIDUnsettled
|
||||
} from '$lib/api/admin';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// 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-none 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…</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 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 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…</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>
|
||||
Reference in New Issue
Block a user