Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126

Merged
bvandeusen merged 23 commits from dev into main 2026-08-17 16:28:14 -04:00
5 changed files with 403 additions and 1 deletions
Showing only changes of commit 952132714e - Show all commits
+26
View File
@@ -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<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 { 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 @@
</p>
</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}
<p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError}
@@ -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';