diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index b257cb6d..c8426095 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -398,6 +398,66 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise('/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 { + return api.get('/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 { + return api.put('/api/admin/library/acoustid-settings', s); +} + +export async function runAcoustIDLookup(): Promise { + await api.post('/api/admin/library/acoustid/run'); +} + +export async function listUnsettledAcoustID(): Promise { + return api.get('/api/admin/library/acoustid/unsettled'); +} + // Cover-art providers ------------------------------------------------------ export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart'; diff --git a/web/src/lib/components/AcoustIDCard.svelte b/web/src/lib/components/AcoustIDCard.svelte new file mode 100644 index 00000000..c7973727 --- /dev/null +++ b/web/src/lib/components/AcoustIDCard.svelte @@ -0,0 +1,337 @@ + + +
+
+
+

AcoustID

+

+ Finds MusicBrainz ids for untagged tracks, so Songs-like and radio can place them. +

+
+ {#if overview} + {#if overview.ready && !overview.status.problem} + + + {overview.status.running ? 'Looking up' : 'On'} + + {:else} + + + {#if overview.ready}Needs attention{:else if !overview.settings.api_key_set}Needs a key{:else}Off{/if} + + {/if} + {/if} +
+ + {#if loadError} +

+ Couldn't load AcoustID settings. + +

+ {:else if overview === null} +

Loading…

+ {:else} + {#if overview.status.problem} +

+ {overview.status.problem} +

+ {/if} + +
+ + +
+ +
+ + {#if overview.settings.api_key_set} + + {/if} +
+
+ + +
+ + {#if !scoreOk} +

+ Minimum score must be from 0.5 to 1. +

+ {/if} + +
+ +
+ + {#if overview.coverage.total > 0} +
+

Recording ids

+
+
+
From tags
+
{overview.coverage.from_tag.toLocaleString()}
+
+
+
Looked up
+
{overview.coverage.from_acoustid.toLocaleString()}
+
+
+
None
+
{withoutId.toLocaleString()}
+
+
+
Waiting to be looked up
+
{overview.coverage.pending.toLocaleString()}
+
+
+
+ No match +
+
{overview.coverage.no_match.toLocaleString()}
+
+
+
+ Ambiguous +
+
{overview.coverage.ambiguous.toLocaleString()}
+
+
+
+ Failed +
+
{overview.coverage.failed.toLocaleString()}
+
+
+
+ +
+
+ + {#if overview.coverage.ambiguous + overview.coverage.failed > 0} +
+ + Tracks the lookup couldn't settle ({( + overview.coverage.ambiguous + overview.coverage.failed + ).toLocaleString()}) + + {#if unsettled === null} +

Loading…

+ {:else} +
    + {#each unsettled as t (t.track_id)} +
  • + + {t.title} + {t.artist} · {t.album} + + + {t.state === 'ambiguous' ? `${t.candidates} recordings` : 'failed'} + +
  • + {/each} +
+ {/if} +
+ {/if} + {/if} + {/if} +
diff --git a/web/src/lib/components/AcoustIDCard.test.ts b/web/src/lib/components/AcoustIDCard.test.ts new file mode 100644 index 00000000..ef4cf927 --- /dev/null +++ b/web/src/lib/components/AcoustIDCard.test.ts @@ -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 { + 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(); + }); +}); diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index eb749efb..9d6bae2d 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -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 @@ - -
-
-

- MusicBrainz overrides -

- - - unset - -
-

Not yet configured.

-
+ +
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts index 360d41a3..053bcb06 100644 --- a/web/src/routes/admin/integrations/integrations.test.ts +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -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';