Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -696,3 +696,29 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
|
|||||||
staleTime: 120_000
|
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<ReacquisitionSettings> {
|
||||||
|
return api.get<ReacquisitionSettings>('/api/admin/library/reacquisition');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReacquisitionSettings(
|
||||||
|
s: ReacquisitionSettings
|
||||||
|
): Promise<ReacquisitionSettings> {
|
||||||
|
return api.put<ReacquisitionSettings>('/api/admin/library/reacquisition', s);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { TriangleAlert } from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
getReacquisitionSettings,
|
||||||
|
updateReacquisitionSettings,
|
||||||
|
type ReacquisitionSettings
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
// Policy for turning a missing file back into a Lidarr request
|
||||||
|
// (milestone #290). Lives on the missing-files page rather than under
|
||||||
|
// Integrations because this is where the operator meets the problem it
|
||||||
|
// solves; Lidarr is the mechanism, not the subject.
|
||||||
|
|
||||||
|
let saved = $state<ReacquisitionSettings | null>(null);
|
||||||
|
let form = $state<ReacquisitionSettings | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
let loadError = $state(false);
|
||||||
|
|
||||||
|
const dirty = $derived(
|
||||||
|
!!saved && !!form && JSON.stringify(stripDerived(saved)) !== JSON.stringify(stripDerived(form))
|
||||||
|
);
|
||||||
|
|
||||||
|
// unnameable_albums is server-computed and read-only; comparing it would
|
||||||
|
// make the form look dirty whenever the library changed underneath. Listed
|
||||||
|
// explicitly rather than destructured-and-spread so no lint rule has to be
|
||||||
|
// argued with about an intentionally unused binding.
|
||||||
|
function stripDerived(s: ReacquisitionSettings) {
|
||||||
|
return {
|
||||||
|
enabled: s.enabled,
|
||||||
|
grace_hours: s.grace_hours,
|
||||||
|
backoff_base_hours: s.backoff_base_hours,
|
||||||
|
backoff_max_hours: s.backoff_max_hours,
|
||||||
|
max_attempts: s.max_attempts,
|
||||||
|
max_per_pass: s.max_per_pass,
|
||||||
|
auto_approve: s.auto_approve
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
saved = await getReacquisitionSettings();
|
||||||
|
form = { ...saved };
|
||||||
|
loadError = false;
|
||||||
|
} catch {
|
||||||
|
loadError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
saved = await updateReacquisitionSettings(form);
|
||||||
|
form = { ...saved };
|
||||||
|
pushToast('Re-acquisition settings saved.');
|
||||||
|
} catch (e) {
|
||||||
|
// The server validates the same ranges the database CHECKs enforce and
|
||||||
|
// names the offending field, so surface its message rather than a
|
||||||
|
// generic failure.
|
||||||
|
pushToast(e instanceof Error ? e.message : "Couldn't save settings.", 'error');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-language echo of what the numbers add up to. The individual fields
|
||||||
|
// are meaningless on their own — "6 hours" says nothing until you know it
|
||||||
|
// doubles — so the card states the resulting schedule.
|
||||||
|
const schedule = $derived.by(() => {
|
||||||
|
if (!form) return '';
|
||||||
|
const steps: string[] = [];
|
||||||
|
let hours = form.backoff_base_hours;
|
||||||
|
for (let i = 0; i < form.max_attempts; i++) {
|
||||||
|
steps.push(`${hours}h`);
|
||||||
|
hours = Math.min(hours * 2, form.backoff_max_hours);
|
||||||
|
}
|
||||||
|
return steps.join(' → ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
When a file has been missing for a while, Minstrel can ask Lidarr for the album
|
||||||
|
again by itself. Requests are made per album, not per track — a whole folder
|
||||||
|
going missing is one request, not forty.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<p class="text-sm text-action-destructive">
|
||||||
|
Couldn't load re-acquisition settings.
|
||||||
|
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||||
|
</p>
|
||||||
|
{:else if form === null}
|
||||||
|
<p class="text-sm text-text-secondary">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-text-primary">Ask Lidarr for missing albums automatically</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
Off leaves missing files listed here and does nothing about them.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-text-primary">Wait before the first attempt</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
Hours a file must be missing before anything is requested. A drive that
|
||||||
|
didn't mount, or a container that started before its media, resolves
|
||||||
|
itself well inside a day — this is what stops those becoming requests.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="720"
|
||||||
|
bind:value={form.grace_hours}
|
||||||
|
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-text-primary">Attempts before giving up</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
After this many tries the album is left alone. It's picked up again if the
|
||||||
|
file comes back and goes missing later.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
bind:value={form.max_attempts}
|
||||||
|
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-text-primary">First retry gap (hours)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
bind:value={form.backoff_base_hours}
|
||||||
|
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-text-primary">Longest gap (hours)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="720"
|
||||||
|
bind:value={form.backoff_max_hours}
|
||||||
|
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-text-primary">Albums per sweep</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
Ceiling on requests made each hour, so a large loss trickles.
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
bind:value={form.max_per_pass}
|
||||||
|
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
|
||||||
|
text-text-primary focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
|
||||||
|
Retry schedule: <span class="font-mono text-text-primary">{schedule}</span> after the
|
||||||
|
first attempt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input type="checkbox" bind:checked={form.auto_approve} class="mt-1" />
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-text-primary">Send requests to Lidarr without approval</span>
|
||||||
|
<span class="block text-xs text-text-secondary">
|
||||||
|
Off means requests appear in the Requests queue for you to approve, and
|
||||||
|
nothing is downloaded until you do.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if saved && saved.unnameable_albums > 0}
|
||||||
|
<!-- Stated rather than left to be inferred: without this, an operator
|
||||||
|
watching those rows never get a request would reasonably conclude
|
||||||
|
the feature is broken. -->
|
||||||
|
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
|
||||||
|
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{saved.unnameable_albums}
|
||||||
|
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
|
||||||
|
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be requested —
|
||||||
|
Lidarr has no way to identify the release. A re-scan after tagging fixes it.
|
||||||
|
</span>
|
||||||
|
</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}
|
||||||
|
onclick={save}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -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<ReacquisitionSettings> = {}) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { createMissingFilesQuery } from '$lib/api/admin';
|
import { createMissingFilesQuery } from '$lib/api/admin';
|
||||||
import { relativeTime } from '$lib/utils/relativeTime';
|
import { relativeTime } from '$lib/utils/relativeTime';
|
||||||
import { coverUrl } from '$lib/media/covers';
|
import { coverUrl } from '$lib/media/covers';
|
||||||
|
import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
|
||||||
import type { AdminMissingGroup } from '$lib/api/types';
|
import type { AdminMissingGroup } from '$lib/api/types';
|
||||||
|
|
||||||
// Files the scan looked for and could not find. Read-only on purpose:
|
// Files the scan looked for and could not find. Read-only on purpose:
|
||||||
@@ -57,6 +58,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- The policy sits above the list it governs: an operator looking at
|
||||||
|
missing files is exactly the person deciding what should happen to
|
||||||
|
them, and burying this under Integrations would separate the two. -->
|
||||||
|
<ReacquisitionSettingsCard />
|
||||||
|
|
||||||
{#if query.isPending}
|
{#if query.isPending}
|
||||||
<p class="text-text-secondary">Checking what's missing…</p>
|
<p class="text-text-secondary">Checking what's missing…</p>
|
||||||
{:else if query.isError}
|
{:else if query.isError}
|
||||||
|
|||||||
@@ -3,8 +3,22 @@ import { render, screen } from '@testing-library/svelte';
|
|||||||
import { mockQuery } from '../../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
import type { AdminMissingResponse } from '$lib/api/types';
|
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', () => ({
|
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';
|
import AdminMissingFilesPage from './+page.svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user