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:
@@ -398,6 +398,66 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise<Loudn
|
||||
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 ------------------------------------------------------
|
||||
|
||||
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import AcoustIDCard from '$lib/components/AcoustIDCard.svelte';
|
||||
import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte';
|
||||
import PublicAddressCard from '$lib/components/PublicAddressCard.svelte';
|
||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||
@@ -549,22 +550,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MusicBrainz overrides — visually present, not implemented. Foreshadows
|
||||
future integrations so the page doesn't read like Lidarr-only. -->
|
||||
<section class="space-y-2 rounded-xl border border-border bg-surface p-5 opacity-60">
|
||||
<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>
|
||||
<!-- AcoustID recording-id lookup (M401). Took the place of a "MusicBrainz
|
||||
overrides" placeholder that held the slot for a non-Lidarr integration. -->
|
||||
<AcoustIDCard />
|
||||
|
||||
<!-- Cover art providers -->
|
||||
<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(),
|
||||
createSMTPConfigQuery: 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';
|
||||
|
||||
Reference in New Issue
Block a user