diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts
index c4cdd519..2428d79f 100644
--- a/web/src/lib/api/admin.ts
+++ b/web/src/lib/api/admin.ts
@@ -696,3 +696,29 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
staleTime: 120_000
});
}
+
+// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
+
+export type ReacquisitionSettings = {
+ enabled: boolean;
+ grace_hours: number;
+ backoff_base_hours: number;
+ backoff_max_hours: number;
+ max_attempts: number;
+ max_per_pass: number;
+ auto_approve: boolean;
+ // Albums with missing files that can never be auto-requested because
+ // neither they nor their artist carries an MBID. Read-only; the server
+ // computes it, and the card states it so the gap isn't a mystery.
+ unnameable_albums: number;
+};
+
+export async function getReacquisitionSettings(): Promise {
+ return api.get('/api/admin/library/reacquisition');
+}
+
+export async function updateReacquisitionSettings(
+ s: ReacquisitionSettings
+): Promise {
+ return api.put('/api/admin/library/reacquisition', s);
+}
diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte
new file mode 100644
index 00000000..9f37b1dc
--- /dev/null
+++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte
@@ -0,0 +1,236 @@
+
+
+
diff --git a/web/src/lib/components/ReacquisitionSettingsCard.test.ts b/web/src/lib/components/ReacquisitionSettingsCard.test.ts
new file mode 100644
index 00000000..7cfea1dd
--- /dev/null
+++ b/web/src/lib/components/ReacquisitionSettingsCard.test.ts
@@ -0,0 +1,120 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+import type { ReacquisitionSettings } from '$lib/api/admin';
+
+vi.mock('$lib/api/admin', () => ({
+ getReacquisitionSettings: vi.fn(),
+ updateReacquisitionSettings: vi.fn()
+}));
+
+vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
+
+import ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte';
+import { getReacquisitionSettings, updateReacquisitionSettings } from '$lib/api/admin';
+import { pushToast } from '$lib/stores/toast.svelte';
+
+const base: ReacquisitionSettings = {
+ enabled: true,
+ grace_hours: 24,
+ backoff_base_hours: 6,
+ backoff_max_hours: 168,
+ max_attempts: 3,
+ max_per_pass: 20,
+ auto_approve: true,
+ unnameable_albums: 0
+};
+
+afterEach(() => vi.clearAllMocks());
+
+async function renderCard(over: Partial = {}) {
+ vi.mocked(getReacquisitionSettings).mockResolvedValue({ ...base, ...over });
+ const r = render(ReacquisitionSettingsCard);
+ await waitFor(() => expect(getReacquisitionSettings).toHaveBeenCalled());
+ return r;
+}
+
+describe('ReacquisitionSettingsCard', () => {
+ // The individual numbers say nothing on their own — "6 hours" is meaningless
+ // until you know it doubles — so the card spells the schedule out.
+ test('states the retry schedule the numbers add up to', async () => {
+ await renderCard();
+ await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
+ });
+
+ test('the schedule clamps at the longest gap', async () => {
+ await renderCard({ backoff_base_hours: 6, backoff_max_hours: 12, max_attempts: 4 });
+ await waitFor(() => expect(screen.getByText('6h → 12h → 12h → 12h')).toBeTruthy());
+ });
+
+ test('the schedule follows the attempt count', async () => {
+ await renderCard({ max_attempts: 1 });
+ await waitFor(() => expect(screen.getByText('6h')).toBeTruthy());
+ });
+
+ // Saving an unchanged form would be a pointless round-trip, and a live Save
+ // button invites the operator to wonder whether anything happened.
+ test('save is disabled until something changes', async () => {
+ await renderCard();
+ const save = await screen.findByRole('button', { name: /save/i });
+ expect(save).toHaveProperty('disabled', true);
+
+ const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
+ await fireEvent.input(grace, { target: { value: '48' } });
+ await waitFor(() => expect(save).toHaveProperty('disabled', false));
+ });
+
+ test('saving sends the edited values', async () => {
+ vi.mocked(updateReacquisitionSettings).mockResolvedValue({ ...base, grace_hours: 48 });
+ await renderCard();
+
+ const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
+ await fireEvent.input(grace, { target: { value: '48' } });
+ await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
+
+ await waitFor(() =>
+ expect(updateReacquisitionSettings).toHaveBeenCalledWith(
+ expect.objectContaining({ grace_hours: 48 })
+ )
+ );
+ });
+
+ // The server names the offending field ("grace_hours must be 1-720"); a
+ // generic "couldn't save" would throw that away.
+ test('a rejected save surfaces the server message', async () => {
+ vi.mocked(updateReacquisitionSettings).mockRejectedValue(
+ new Error('grace_hours must be 1-720')
+ );
+ await renderCard();
+
+ const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
+ await fireEvent.input(grace, { target: { value: '900' } });
+ await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
+
+ await waitFor(() =>
+ expect(pushToast).toHaveBeenCalledWith('grace_hours must be 1-720', 'error')
+ );
+ });
+
+ // Without this the operator watches those albums never get a request and
+ // reasonably concludes the feature is broken.
+ test('unnameable albums are called out with the reason', async () => {
+ await renderCard({ unnameable_albums: 3 });
+ await waitFor(() => expect(screen.getByText(/no\s+MusicBrainz ID/i)).toBeTruthy());
+ expect(screen.getByText(/3\s+albums have/i)).toBeTruthy();
+ });
+
+ test('no warning when every missing album is identifiable', async () => {
+ await renderCard({ unnameable_albums: 0 });
+ await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
+ expect(screen.queryByText(/MusicBrainz ID/i)).toBeNull();
+ });
+
+ test('a failed load offers a retry rather than an empty card', async () => {
+ vi.mocked(getReacquisitionSettings).mockRejectedValue(new Error('nope'));
+ render(ReacquisitionSettingsCard);
+ await waitFor(() =>
+ expect(screen.getByText(/couldn't load re-acquisition settings/i)).toBeTruthy()
+ );
+ expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
+ });
+});
diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte
index 9c552ef7..3e8a4172 100644
--- a/web/src/routes/admin/missing-files/+page.svelte
+++ b/web/src/routes/admin/missing-files/+page.svelte
@@ -4,6 +4,7 @@
import { createMissingFilesQuery } from '$lib/api/admin';
import { relativeTime } from '$lib/utils/relativeTime';
import { coverUrl } from '$lib/media/covers';
+ import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
import type { AdminMissingGroup } from '$lib/api/types';
// Files the scan looked for and could not find. Read-only on purpose:
@@ -57,6 +58,11 @@
+
+
+
{#if query.isPending}
Checking what's missing…
{:else if query.isError}
diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts
index e1e5c515..2ba2970e 100644
--- a/web/src/routes/admin/missing-files/missing-files.test.ts
+++ b/web/src/routes/admin/missing-files/missing-files.test.ts
@@ -3,8 +3,22 @@ import { render, screen } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminMissingResponse } from '$lib/api/types';
+// The page now embeds ReacquisitionSettingsCard, which loads its own settings
+// from this same module on mount. Stubbing both keeps these tests about the
+// missing-files list — the card has its own suite.
vi.mock('$lib/api/admin', () => ({
- createMissingFilesQuery: vi.fn()
+ createMissingFilesQuery: vi.fn(),
+ getReacquisitionSettings: vi.fn().mockResolvedValue({
+ enabled: true,
+ grace_hours: 24,
+ backoff_base_hours: 6,
+ backoff_max_hours: 168,
+ max_attempts: 3,
+ max_per_pass: 20,
+ auto_approve: true,
+ unnameable_albums: 0
+ }),
+ updateReacquisitionSettings: vi.fn()
}));
import AdminMissingFilesPage from './+page.svelte';