feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

- internal/release: reads a release name (format, bitrate, bit depth and
  sample rate, source, year, video, discography) and judges it against the
  album wanted: the artist and title must be in it; video rips,
  discographies, other versions (drumless, instrumental, karaoke, remix,
  live unless the album is live…) and singles or EPs for an album are
  refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read
  for format, video files and a track count short of the album.
- internal/acquire: fan-out across every search source with a narrowed
  search kept, a process-wide pacer per source, every candidate judged and
  kept with its reasons, and the grab: one live grab per want, tie-broken
  (a person's grab beats the schedule's; a better release replaces a worse
  one unless it is nearly done). Each download lands in its own folder
  under the client's downloads folder.
- internal/wanted: the search schedule, every ten minutes, never-searched
  first, then requests, missing files and followed artists, taking the
  best release when auto-grab is on (the default).
- Migration 0082: acquisition_settings (auto-grab, interval, allowed
  qualities seeded from Lidarr's "Any"), want search columns,
  want_candidates, grabs, release_blocks.
- Web: the Wanted page shows each want's last search, a Search button and
  every release found with its verdict (Grab, Grab anyway, Never this one,
  Allow again). Integrations gains "Searching and grabbing".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 08:56:47 -04:00
co-authored by Claude Opus 5.5
parent 1a1646a920
commit 89b3b44779
40 changed files with 5332 additions and 49 deletions
@@ -0,0 +1,162 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSearchSettingsQuery, putSearchSettings } from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import type { SearchSettings } from '#lib/api/types.js';
// Searching and grabbing (M502 #5521): how often each want is searched
// for, whether the best release is taken without asking, and which
// qualities are taken at all. The schedule wakes every ten minutes and
// keeps to each indexer's searches-per-hour.
const client = useQueryClient();
const store = createSearchSettingsQuery();
const query = $derived($store);
type Form = { auto_grab: boolean; search_interval_hours: number; allowed: string[] };
// The form is the server's settings until edited; saved holds what a save
// returned until the query catches up.
let saved = $state<SearchSettings | null>(null);
let edited = $state<Form | null>(null);
let saving = $state(false);
const base = $derived(saved ?? query.data ?? null);
const form = $derived<Form | null>(edited ?? (base ? fromSettings(base) : null));
function fromSettings(s: SearchSettings): Form {
return { auto_grab: s.auto_grab, search_interval_hours: s.search_interval_hours, allowed: [...s.allowed_qualities] };
}
const dirty = $derived(!!edited && !!base && JSON.stringify(edited) !== JSON.stringify(fromSettings(base)));
function edit(change: Partial<Form>) {
if (form) edited = { ...form, ...change };
}
function toggle(tier: string) {
if (!form) return;
edit({ allowed: form.allowed.includes(tier) ? form.allowed.filter((t) => t !== tier) : [...form.allowed, tier] });
}
async function save() {
if (!form) return;
saving = true;
try {
saved = await putSearchSettings({
auto_grab: form.auto_grab,
search_interval_hours: Number(form.search_interval_hours),
allowed_qualities: form.allowed
});
edited = null;
await client.invalidateQueries({ queryKey: qk.searchSettings() });
pushToast('Search settings saved. The next pass uses them.');
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
function lastPass(s: SearchSettings): string {
const p = s.last_pass;
if (!s.last_pass_at) return 'The schedule hasn’t run yet.';
const when = `Last pass ${relativeTime(s.last_pass_at)}`;
if (p.off) return `${when}: the schedule is off.`;
if (p.unconfigured) return `${when}: no indexer is set up, so nothing was searched.`;
if (p.error) return `${when} failed: ${p.error}`;
if (!p.looked) return `${when}: nothing was due.`;
let said = `${when}: searched ${p.looked}, took ${p.grabbed ?? 0}, ${p.waiting ?? 0} waiting for you, ${p.nothing ?? 0} with nothing found`;
if (p.failed) said += `, ${p.failed} failed`;
said += '.';
if (p.paced) said += ' It stopped to keep to your indexers’ searches per hour; the rest wait for the next pass.';
return said;
}
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="search-settings-card">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Searching and grabbing</h3>
<p class="mt-1 text-sm text-text-secondary">
Minstrel searches your indexers for each album on the
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
release it finds, and hands the best one to your download client. Each album's releases and
the reason for every verdict are on the Wanted page.
</p>
</div>
{#if query.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if query.isError || !form || !query.data}
<p class="text-sm text-action-destructive">Couldn't load the search settings.</p>
{:else}
<label class="flex items-start gap-3">
<input
type="checkbox"
checked={form.auto_grab}
onchange={(e) => edit({ auto_grab: e.currentTarget.checked })}
class="mt-1"
/>
<span>
<span class="text-sm text-text-primary">Take the best release without asking</span>
<span class="block text-xs text-text-secondary">
Off, a search only lists what it found, and you choose from the Wanted page.
</span>
</span>
</label>
<label class="block">
<span class="text-sm text-text-primary">Search each album again after</span>
<span class="block text-xs text-text-secondary">
Hours between searches for an album still wanted. 0 switches the schedule off; Search on the
Wanted page still works.
</span>
<span class="mt-1 flex items-center gap-2">
<input
type="number"
min="0"
max="720"
value={form.search_interval_hours}
oninput={(e) => edit({ search_interval_hours: Number(e.currentTarget.value) })}
class="w-24 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
<span class="text-sm text-text-secondary">hours</span>
</span>
</label>
<fieldset>
<legend class="text-sm text-text-primary">Qualities to take</legend>
<span class="block text-xs text-text-secondary">
The best of these is taken first. A release that names no format has its file list read to
find out.
</span>
<div class="mt-2 space-y-1.5">
{#each query.data.tiers as t (t.tier)}
<label class="flex items-start gap-2">
<input type="checkbox" checked={form.allowed.includes(t.tier)} onchange={() => toggle(t.tier)} class="mt-0.5" />
<span class="text-sm text-text-primary">
{t.label}
<span class="block text-xs text-text-secondary">{t.what}</span>
</span>
</label>
{/each}
</div>
</fieldset>
<p class="text-xs text-text-secondary" data-testid="last-pass">{lastPass(query.data)}</p>
<div class="flex justify-end">
<button
type="button"
onclick={save}
disabled={!dirty || saving || form.allowed.length === 0}
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { SearchSettings } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createSearchSettingsQuery: vi.fn(),
putSearchSettings: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import SearchSettingsCard from './SearchSettingsCard.svelte';
import { createSearchSettingsQuery, putSearchSettings } from '#lib/api/wanted.js';
const settings: SearchSettings = {
auto_grab: true,
search_interval_hours: 24,
allowed_qualities: ['lossless', 'lossy_high'],
tiers: [
{ tier: 'lossless', label: 'Lossless', what: 'FLAC, ALAC' },
{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320 or V0' },
{ tier: 'unknown', label: 'Unknown', what: 'Nothing says what format it is' }
],
last_pass_at: new Date(Date.now() - 5 * 60_000).toISOString(),
last_pass: { looked: 12, grabbed: 3, waiting: 2, nothing: 7, failed: 0, paced: true }
};
afterEach(() => vi.clearAllMocks());
function setup(s: SearchSettings = settings) {
vi.mocked(createSearchSettingsQuery).mockReturnValue(
mockQuery({ data: s }) as unknown as ReturnType<typeof createSearchSettingsQuery>
);
vi.mocked(putSearchSettings).mockImplementation(async (body) => ({ ...s, ...body }));
return render(SearchSettingsCard);
}
describe('SearchSettingsCard', () => {
test('shows the settings and what the last pass did', () => {
setup();
expect((screen.getByLabelText(/take the best release without asking/i) as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText(/hours between searches/i) as HTMLInputElement).value).toBe('24');
expect((screen.getByLabelText(/^unknown/i) as HTMLInputElement).checked).toBe(false);
const last = screen.getByTestId('last-pass').textContent ?? '';
expect(last).toMatch(/searched 12, took 3, 2 waiting for you, 7 with nothing found\./);
expect(last).toMatch(/searches per hour/);
});
test('saves what changed', async () => {
setup();
const save = screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
await fireEvent.click(screen.getByLabelText(/take the best release without asking/i));
await fireEvent.click(screen.getByLabelText(/^unknown/i));
await fireEvent.click(save);
await waitFor(() =>
expect(putSearchSettings).toHaveBeenCalledWith({
auto_grab: false,
search_interval_hours: 24,
allowed_qualities: ['lossless', 'lossy_high', 'unknown']
})
);
});
test('before the first pass it says so', () => {
setup({ ...settings, last_pass_at: null, last_pass: {} });
expect(screen.getByTestId('last-pass').textContent).toMatch(/hasn’t run yet/);
});
});
@@ -0,0 +1,184 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import {
createCandidatesQuery,
grabCandidate,
blockCandidate,
unblockRelease
} from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import type { Candidate, CandidateVerdict, GrabResult } from '#lib/api/types.js';
// Every release a want's last search found (M502 #5521), with the verdict
// and the reasons behind it, so a person can see why nothing was taken and
// take something anyway, or set a release aside for good.
let { wantId, title }: { wantId: string; title: string } = $props();
const client = useQueryClient();
const store = $derived(createCandidatesQuery(wantId));
const query = $derived($store);
const items = $derived(query.data ?? []);
let busy = $state<string | null>(null);
const verdictLabel: Record<CandidateVerdict, string> = {
grab: 'Would take',
review: 'Needs you',
refused: 'Quality refused',
rejected: 'Not the album',
blocked: 'Set aside'
};
const verdictClass: Record<CandidateVerdict, string> = {
grab: 'bg-accent-tint text-accent-fg',
review: 'bg-surface-hover text-text-primary',
refused: 'bg-surface-hover text-text-secondary',
rejected: 'bg-surface-hover text-text-muted',
blocked: 'bg-surface-hover text-text-muted'
};
function size(bytes: number): string {
if (bytes <= 0) return '';
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
return `${Math.max(1, Math.round(bytes / 1e6))} MB`;
}
function meta(c: Candidate): string {
const parts = [c.format || 'format not stated', size(c.size_bytes)];
parts.push(c.seeders === 1 ? '1 seeder' : `${c.seeders} seeders`);
if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer);
return parts.filter(Boolean).join(' · ');
}
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.wantCandidates(wantId) }),
client.invalidateQueries({ queryKey: ['adminWants'] })
]);
}
function grabbed(r: GrabResult): string {
if (r.added) return r.replaced ? 'Taken, replacing the download that was running.' : `Taken for ${title}.`;
if (r.kept) return r.reason ?? 'A download for this album is already running.';
if (r.gone) return 'The indexers no longer offer that release.';
if (r.unconfigured) {
return r.missing === 'download_client'
? 'No download client is set up. Add one under Integrations.'
: 'No indexer is set up. Add one under Integrations.';
}
if (r.refused) return r.reason ? `Not taken: ${r.reason}` : 'Not taken.';
return r.error ?? 'Not taken.';
}
async function onGrab(c: Candidate) {
busy = c.key;
try {
const r = await grabCandidate(wantId, c.key, c.verdict !== 'grab');
pushToast(grabbed(r), r.added ? 'info' : 'error');
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
async function onBlock(c: Candidate) {
busy = c.key;
try {
await blockCandidate(wantId, c.key);
pushToast('Set aside. No search will offer it again.');
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
async function onUnblock(c: Candidate) {
busy = c.key;
try {
await unblockRelease(c.info_hash);
pushToast('Allowed again. The next search judges it afresh.');
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
</script>
<div class="space-y-2" data-testid="want-candidates">
{#if query.isPending}
<p class="text-xs text-text-secondary">Loading what the last search found…</p>
{:else if query.isError}
<p class="text-xs text-error-fg">Couldn't load the releases.</p>
{:else if items.length === 0}
<p class="text-xs text-text-secondary">
Nothing kept from a search yet. Search now, or wait for the schedule.
</p>
{:else}
<p class="text-xs text-text-muted">
{items.length === 1 ? '1 release' : `${items.length} releases`} found {relativeTime(items[0].searched_at)}, best first.
</p>
<ul class="divide-y divide-border rounded-md border border-border">
{#each items as c (c.key)}
<li class="flex flex-wrap items-start gap-2 p-2" data-testid="candidate-row">
<div class="min-w-0 flex-1 space-y-0.5">
<div class="break-words text-xs text-text-primary">{c.name}</div>
<div class="text-xs text-text-muted">{meta(c)}</div>
<div class="flex flex-wrap items-center gap-1.5 text-xs">
<span class="rounded-full px-2 py-0.5 {verdictClass[c.verdict]}">
{c.downloading ? 'Downloading' : verdictLabel[c.verdict]}
</span>
<span class="text-text-secondary" data-testid="candidate-reason">{c.reason}</span>
</div>
</div>
<div class="flex shrink-0 gap-1.5">
{#if c.verdict === 'blocked'}
<button
type="button"
onclick={() => onUnblock(c)}
disabled={busy !== null}
aria-label="Allow {c.name} again"
class="rounded-md border border-border px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Allow again
</button>
{:else if !c.downloading}
<button
type="button"
onclick={() => onGrab(c)}
disabled={busy !== null}
aria-label="{c.verdict === 'grab' ? 'Grab' : 'Grab anyway'} {c.name}"
class="rounded-md px-2 py-1 text-xs disabled:opacity-50
{c.verdict === 'grab'
? 'bg-action-primary text-action-fg hover:opacity-90'
: 'border border-border text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
{busy === c.key ? 'Taking…' : c.verdict === 'grab' ? 'Grab' : 'Grab anyway'}
</button>
{#if c.info_hash}
<button
type="button"
onclick={() => onBlock(c)}
disabled={busy !== null}
aria-label="Never take {c.name}"
class="rounded-md px-2 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Never this one
</button>
{/if}
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { Candidate } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn().mockResolvedValue({ added: true, refused: false, gone: false, unconfigured: false, kept: false, replaced: false }),
blockCandidate: vi.fn().mockResolvedValue(undefined),
unblockRelease: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import WantCandidates from './WantCandidates.svelte';
import { blockCandidate, createCandidatesQuery, grabCandidate, unblockRelease } from '#lib/api/wanted.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
function cand(key: string, extra: Partial<Candidate> = {}): Candidate {
return {
key,
name: `Daft Punk - Random Access Memories (2013) [${key}]`,
info_hash: key,
indexer: 'Knaben',
size_bytes: 450_000_000,
seeders: 12,
files_count: 13,
format: 'FLAC',
quality: 'lossless',
year: 2013,
verdict: 'grab',
reason: 'The album, FLAC.',
reasons: [],
folded_from: [],
searched_at: new Date().toISOString(),
downloading: false,
...extra
};
}
afterEach(() => vi.clearAllMocks());
function setup(items: Candidate[]) {
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: items }) as unknown as ReturnType<typeof createCandidatesQuery>
);
return render(WantCandidates, { wantId: 'w1', title: 'Random Access Memories' });
}
describe('WantCandidates', () => {
test('every release shows its verdict and why', () => {
setup([
cand('aaaa'),
cand('bbbb', { verdict: 'rejected', reason: 'This is a drumless edition (“drumless”).' })
]);
expect(screen.getAllByTestId('candidate-row')).toHaveLength(2);
expect(screen.getByText('Would take')).toBeTruthy();
expect(screen.getByText('Not the album')).toBeTruthy();
expect(screen.getByText(/drumless edition/)).toBeTruthy();
expect(screen.getAllByText(/FLAC · 450 MB · 12 seeders · Knaben/)).toHaveLength(2);
});
test('a release the verdict would take is grabbed; any other is grabbed anyway', async () => {
setup([cand('aaaa'), cand('bbbb', { verdict: 'review', reason: 'Nobody is sharing it right now.' })]);
await fireEvent.click(screen.getByRole('button', { name: /^grab daft punk.*\[aaaa\]/i }));
await waitFor(() => expect(grabCandidate).toHaveBeenCalledWith('w1', 'aaaa', false));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Taken for Random Access Memories.', 'info'));
await fireEvent.click(screen.getByRole('button', { name: /^grab anyway daft punk.*\[bbbb\]/i }));
await waitFor(() => expect(grabCandidate).toHaveBeenCalledWith('w1', 'bbbb', true));
});
test('a release can be set aside, and allowed again', async () => {
setup([cand('aaaa'), cand('bbbb', { verdict: 'blocked', reason: 'You set this release aside.' })]);
await fireEvent.click(screen.getByRole('button', { name: /never take .*\[aaaa\]/i }));
await waitFor(() => expect(blockCandidate).toHaveBeenCalledWith('w1', 'aaaa'));
await fireEvent.click(screen.getByRole('button', { name: /allow .*\[bbbb\] again/i }));
await waitFor(() => expect(unblockRelease).toHaveBeenCalledWith('bbbb'));
expect(screen.queryByRole('button', { name: /grab.*\[bbbb\]/i })).toBeNull();
});
test('the release downloading says so and offers nothing', () => {
setup([cand('aaaa', { downloading: true })]);
expect(screen.getByText('Downloading')).toBeTruthy();
expect(screen.queryByRole('button', { name: /grab/i })).toBeNull();
});
test('before any search, it says how to get one', () => {
setup([]);
expect(screen.getByText(/nothing kept from a search yet/i)).toBeTruthy();
});
});