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

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:
2026-10-06 23:29:34 -04:00
co-authored by Claude Opus 5.5
parent 3c575b137c
commit 0a7f788390
5 changed files with 600 additions and 17 deletions
+60
View File
@@ -398,6 +398,66 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise<Loudn
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s); return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
} }
// AcoustID recording-id lookup (M401) ---------------------------------------
export type AcoustIDSettings = {
enabled: boolean;
min_score: number;
/** The key itself is never sent back, only whether one is stored. */
api_key_set: boolean;
};
export type AcoustIDOverview = {
settings: AcoustIDSettings;
/** Switched on and holding a key: the worker will call AcoustID. */
ready: boolean;
status: { running: boolean; last_pass_at: string | null; problem: string };
/** from_tag + from_acoustid + tracks with no MBID = total. */
coverage: {
total: number;
from_tag: number;
from_acoustid: number;
ambiguous: number;
no_match: number;
failed: number;
pending: number;
};
};
export type AcoustIDUnsettled = {
track_id: string;
title: string;
artist: string;
album: string;
file_path: string;
state: 'ambiguous' | 'failed';
best_score: number | null;
candidates: number;
detail: string | null;
looked_up_at: string;
};
export async function getAcoustID(): Promise<AcoustIDOverview> {
return api.get<AcoustIDOverview>('/api/admin/library/acoustid');
}
/** api_key left out keeps the stored key; an empty string clears it. */
export async function updateAcoustIDSettings(s: {
enabled: boolean;
min_score: number;
api_key?: string;
}): Promise<AcoustIDSettings> {
return api.put<AcoustIDSettings>('/api/admin/library/acoustid-settings', s);
}
export async function runAcoustIDLookup(): Promise<void> {
await api.post<null>('/api/admin/library/acoustid/run');
}
export async function listUnsettledAcoustID(): Promise<AcoustIDUnsettled[]> {
return api.get<AcoustIDUnsettled[]>('/api/admin/library/acoustid/unsettled');
}
// Cover-art providers ------------------------------------------------------ // Cover-art providers ------------------------------------------------------
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart'; export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
+337
View File
@@ -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>
+193
View File
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import type { AcoustIDOverview } from "$lib/api/admin";
vi.mock("$lib/api/admin", () => ({
getAcoustID: vi.fn(),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn(),
}));
vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() }));
import AcoustIDCard from "./AcoustIDCard.svelte";
import {
getAcoustID,
listUnsettledAcoustID,
runAcoustIDLookup,
updateAcoustIDSettings,
} from "$lib/api/admin";
const base: AcoustIDOverview = {
settings: { enabled: true, min_score: 0.85, api_key_set: true },
ready: true,
status: { running: false, last_pass_at: null, problem: "" },
coverage: {
total: 1000,
from_tag: 700,
from_acoustid: 150,
ambiguous: 12,
no_match: 80,
failed: 3,
pending: 55,
},
};
function overview(over: Partial<AcoustIDOverview> = {}): AcoustIDOverview {
return { ...base, ...over, settings: { ...base.settings, ...over.settings } };
}
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 });
}
const saveButton = () => screen.getByRole("button", { name: /^save$/i });
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");
// 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);
}
});
test("never shows the stored key, and sends one only when typed", async () => {
vi.mocked(updateAcoustIDSettings).mockResolvedValue(base.settings);
await renderCard();
const key = screen.getByLabelText(/api key/i) as HTMLInputElement;
expect(key.value).toBe("");
expect(key.placeholder).toBe("Stored");
expect(saveButton()).toHaveProperty("disabled", true);
await fireEvent.input(
screen.getByRole("spinbutton", { name: /minimum score/i }),
{
target: { value: "0.9" },
},
);
await fireEvent.click(saveButton());
await waitFor(() =>
expect(updateAcoustIDSettings).toHaveBeenCalledWith({
enabled: true,
min_score: 0.9,
}),
);
await fireEvent.input(key, { target: { value: " new-key " } });
await fireEvent.click(saveButton());
await waitFor(() =>
expect(updateAcoustIDSettings).toHaveBeenLastCalledWith({
enabled: true,
min_score: 0.85,
api_key: "new-key",
}),
);
});
test("a score outside 0.5 to 1 is named and cannot be saved", async () => {
await renderCard();
await fireEvent.input(
screen.getByRole("spinbutton", { name: /minimum score/i }),
{
target: { value: "0.3" },
},
);
await waitFor(() =>
expect(screen.getByTestId("acoustid-problems").textContent).toMatch(
/0\.5 to 1/,
),
);
expect(saveButton()).toHaveProperty("disabled", true);
});
// Rule 164: off, keyless or unreachable is a visible state, not a silent one.
test("says why it is not looking anything up", async () => {
await renderCard(
overview({
ready: false,
settings: { enabled: true, min_score: 0.85, api_key_set: false },
}),
);
expect(screen.getByTestId("acoustid-state").textContent).toMatch(
/needs a key/i,
);
expect(lookUpNow()).toHaveProperty("disabled", true);
});
test("a pass that stopped short says why", async () => {
await renderCard(
overview({
status: {
running: false,
last_pass_at: "2026-10-07T03:00:00Z",
problem: "AcoustID refused the API key. Check it in Settings.",
},
}),
);
expect(screen.getByTestId("acoustid-problem").textContent).toMatch(
/refused the API key/,
);
expect(screen.getByTestId("acoustid-state").textContent).toMatch(
/needs attention/i,
);
});
test("look up now starts a pass", async () => {
vi.mocked(runAcoustIDLookup).mockResolvedValue();
await renderCard();
await fireEvent.click(lookUpNow());
await waitFor(() => expect(runAcoustIDLookup).toHaveBeenCalledTimes(1));
});
test("the tracks a lookup could not settle can be found", async () => {
vi.mocked(listUnsettledAcoustID).mockResolvedValue([
{
track_id: "t1",
title: "WWW",
artist: "Moe Shop",
album: "WWW",
file_path: "/music/a.mp3",
state: "ambiguous",
best_score: 0.97,
candidates: 2,
detail: null,
looked_up_at: "2026-10-07T03:00:00Z",
},
]);
await renderCard();
const details = screen
.getByText(/couldn't settle \(15\)/i)
.closest("details")!;
details.open = true;
await fireEvent(details, new Event("toggle"));
const list = await screen.findByTestId("acoustid-unsettled");
expect(list.textContent).toMatch(/WWW/);
expect(list.textContent).toMatch(/2 recordings/);
});
test("a failed load offers a retry", async () => {
vi.mocked(getAcoustID).mockRejectedValue(new Error("boom"));
render(AcoustIDCard);
await screen.findByText(/couldn't load acoustid settings/i);
expect(screen.getByRole("button", { name: /try again/i })).toBeTruthy();
});
});
+4 -16
View File
@@ -27,6 +27,7 @@
import { errCode } from '$lib/api/errors'; import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte'; import { pushToast } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import AcoustIDCard from '$lib/components/AcoustIDCard.svelte';
import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte'; import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '$lib/components/PublicAddressCard.svelte'; import PublicAddressCard from '$lib/components/PublicAddressCard.svelte';
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
@@ -549,22 +550,9 @@
</div> </div>
</section> </section>
<!-- MusicBrainz overrides — visually present, not implemented. Foreshadows <!-- AcoustID recording-id lookup (M401). Took the place of a "MusicBrainz
future integrations so the page doesn't read like Lidarr-only. --> overrides" placeholder that held the slot for a non-Lidarr integration. -->
<section class="space-y-2 rounded-xl border border-border bg-surface p-5 opacity-60"> <AcoustIDCard />
<div class="flex items-center justify-between">
<h3 class="font-display text-lg font-medium text-text-primary">
MusicBrainz overrides
</h3>
<span
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
>
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
unset
</span>
</div>
<p class="text-sm text-text-secondary">Not yet configured.</p>
</section>
<!-- Cover art providers --> <!-- Cover art providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5"> <section class="space-y-4 rounded-xl border border-border bg-surface p-5">
@@ -28,7 +28,12 @@ vi.mock('$lib/api/admin', () => ({
testTagProvider: vi.fn(), testTagProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(), createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(), updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn() testSMTPConfig: vi.fn(),
// AcoustIDCard loads on mount; a never-settling read leaves it on "Loading…".
getAcoustID: vi.fn(() => new Promise(() => {})),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn()
})); }));
import IntegrationsPage from './+page.svelte'; import IntegrationsPage from './+page.svelte';