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
+3
View File
@@ -45,6 +45,9 @@ export const qk = {
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
releaseProfiles: () => ['releaseProfiles'] as const,
// Search and grab (M502 #5521).
wantCandidates: (wantId: string) => ['wantCandidates', wantId] as const,
searchSettings: () => ['searchSettings'] as const,
myQuarantine: () => ['myQuarantine'] as const,
adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) =>
+95
View File
@@ -590,6 +590,101 @@ export type Want = {
wanted_at: string;
held_at: string | null;
image_url: string;
// The search schedule's last look, and the download running (#5521).
search?: WantSearch | null;
grab?: WantGrab | null;
};
export type SearchOutcome = 'grabbed' | 'waiting' | 'nothing' | 'error';
export type WantSearch = {
searched_at: string;
outcome: SearchOutcome;
considered: number;
best_release: string;
best_verdict: string;
error: string;
};
export type WantGrab = {
name: string;
grabbed_at: string;
origin: 'schedule' | 'operator';
quality: string;
};
// Search and grab (M502 #5521) ----------------------------------------------
export type CandidateVerdict = 'grab' | 'review' | 'refused' | 'rejected' | 'blocked';
// One release a want's last search found, with what was decided about it.
export type Candidate = {
key: string;
name: string;
info_hash: string;
indexer: string;
size_bytes: number;
seeders: number;
files_count: number;
format: string;
quality: string;
year: number;
verdict: CandidateVerdict;
reason: string;
reasons: { kind: 'hard' | 'soft'; code: string; text: string }[];
folded_from: string[];
searched_at: string;
downloading: boolean;
};
export type Narrowing = { source: string; label: string; reason: string };
export type SearchResult = {
outcome: SearchOutcome;
considered: number;
best_release: string;
best_verdict: string;
grabbed?: string;
narrowed: Narrowing[];
error?: string;
};
export type GrabResult = {
added: boolean;
refused: boolean;
gone: boolean;
unconfigured: boolean;
missing?: string;
kept: boolean;
replaced: boolean;
verdict?: CandidateVerdict;
reason?: string;
error?: string;
};
export type QualityTier = { tier: string; label: string; what: string };
// The last scheduled pass; empty before the first.
export type SearchPass = {
looked?: number;
grabbed?: number;
waiting?: number;
nothing?: number;
failed?: number;
paced?: boolean;
off?: boolean;
unconfigured?: boolean;
auto_grab?: boolean;
error?: string;
};
export type SearchSettings = {
auto_grab: boolean;
search_interval_hours: number;
allowed_qualities: string[];
tiers: QualityTier[];
last_pass_at: string | null;
last_pass: SearchPass;
};
export type WantCounts = { wanted: number; missing: number; held: number; following: number };
+60
View File
@@ -2,11 +2,15 @@ import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
Candidate,
Follow,
FollowState,
ReleaseProfile,
ReleaseProfileInput,
ReleaseProfilesResponse,
GrabResult,
SearchResult,
SearchSettings,
WantState,
WantsPage
} from './types';
@@ -100,3 +104,59 @@ export async function updateReleaseProfile(id: string, body: ReleaseProfileInput
export async function deleteReleaseProfile(id: string): Promise<void> {
await api.del(`/api/admin/release-profiles/${id}`);
}
// Search and grab (M502 #5521): each want's last search, a person's Search,
// Grab and "never this one", and the schedule's settings.
export async function listCandidates(wantId: string): Promise<Candidate[]> {
const res = await api.get<{ items: Candidate[] }>(`/api/admin/wanted/${wantId}/candidates`);
return res.items;
}
export function createCandidatesQuery(wantId: string) {
return createQuery({
queryKey: qk.wantCandidates(wantId),
queryFn: () => listCandidates(wantId),
staleTime: 30_000
});
}
// Searches now, as the schedule would: takes the best release when
// auto-grab is on. Can take most of a minute.
export async function searchWant(wantId: string): Promise<SearchResult> {
return api.post<SearchResult>(`/api/admin/wanted/${wantId}/search`, {});
}
// Takes the release named by key; force takes what the verdict would not.
export async function grabCandidate(wantId: string, key: string, force: boolean): Promise<GrabResult> {
return api.post<GrabResult>(`/api/admin/wanted/${wantId}/grab`, { key, force });
}
// Never takes this release again, for any want.
export async function blockCandidate(wantId: string, key: string): Promise<void> {
await api.post(`/api/admin/wanted/${wantId}/block`, { key });
}
export async function unblockRelease(infoHash: string): Promise<void> {
await api.del(`/api/admin/release-blocks/${infoHash}`);
}
export async function getSearchSettings(): Promise<SearchSettings> {
return api.get<SearchSettings>('/api/admin/acquisition/settings');
}
export function createSearchSettingsQuery() {
return createQuery({
queryKey: qk.searchSettings(),
queryFn: getSearchSettings,
staleTime: 30_000
});
}
export async function putSearchSettings(body: {
auto_grab: boolean;
search_interval_hours: number;
allowed_qualities: string[];
}): Promise<SearchSettings> {
return api.put<SearchSettings>('/api/admin/acquisition/settings', body);
}
@@ -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();
});
});
+7 -1
View File
@@ -67,5 +67,11 @@
"profile_is_default": "The default profile can't be deleted. Make another one the default first.",
"want_not_found": "That's no longer on the wanted list. Refresh the page.",
"follow_not_found": "That artist isn't followed any more. Refresh the page.",
"invalid_profile": "That isn't a release-type profile."
"invalid_profile": "That isn't a release-type profile.",
"bad_interval": "Hours between searches must be between 0 and 720.",
"bad_quality": "Choose at least one quality to take.",
"candidate_not_found": "That release is no longer in the list. Search again.",
"no_infohash": "That release can't be set aside: its indexer gave no infohash to know it by.",
"want_held": "The library already holds that album.",
"block_not_found": "That release isn't set aside any more."
}
@@ -30,6 +30,7 @@
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
@@ -424,6 +425,8 @@
<AcquisitionCard />
<SearchSettingsCard />
<ReleaseProfilesCard />
<!-- Lidarr panel -->
@@ -60,7 +60,22 @@ vi.mock('#lib/api/wanted.js', async () => {
),
createReleaseProfile: vi.fn(),
updateReleaseProfile: vi.fn(),
deleteReleaseProfile: vi.fn()
deleteReleaseProfile: vi.fn(),
// SearchSettingsCard, settled the same way.
createSearchSettingsQuery: vi.fn(() =>
mockQuery({
data: {
auto_grab: true,
search_interval_hours: 24,
// Not "Lossless": a Lidarr quality profile on this page is named that.
allowed_qualities: ['lossy_high'],
tiers: [{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320' }],
last_pass_at: null,
last_pass: {}
}
})
),
putSearchSettings: vi.fn()
};
});
+134 -39
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { Album, Disc3, ListChecks, X } from 'lucide-svelte';
import { Album, ChevronDown, Disc3, ListChecks, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createWantsQuery,
@@ -9,20 +9,25 @@
createReleaseProfilesQuery,
removeWant,
putFollow,
deleteFollow
deleteFollow,
searchWant
} 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 ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import type { Follow, Want, WantState } from '#lib/api/types.js';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import type { Follow, SearchResult, Want, WantState } from '#lib/api/types.js';
// The wanted list (M502 #5520): every release group Minstrel is to get,
// whether a user requested it, a followed artist released it, or its files
// went missing. A want is held once the library has the album; the Held tab
// is that history. Following lists the artists whose new releases are
// wanted, each on a release-type profile that says which kinds of release.
// Each open want says what its last search found (#5521); Releases lists
// every candidate with its verdict, and Search runs one now.
type Tab = WantState | 'following';
const fromUrl = page.url.searchParams.get('tab');
@@ -52,6 +57,8 @@
]);
let busy = $state<string | null>(null);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
async function refresh() {
await Promise.all([
@@ -98,6 +105,62 @@
}
}
function searched(w: Want, r: SearchResult): string {
let said: string;
switch (r.outcome) {
case 'grabbed':
said = `Took ${r.grabbed ?? r.best_release}.`;
break;
case 'waiting':
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
break;
case 'nothing':
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
break;
default:
said = r.error ?? 'The search failed.';
}
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
return said;
}
async function onSearch(w: Want) {
searching = w.id;
try {
const r = await searchWant(w.id);
pushToast(searched(w, r), r.outcome === 'error' ? 'error' : 'info');
await Promise.all([
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: qk.wantCandidates(w.id) })
]);
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
searching = null;
}
}
// What the schedule last did about a want, in a line.
function status(w: Want): string {
if (w.grab) {
const who = w.grab.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
return `Downloading ${w.grab.name}, ${who} ${relativeTime(w.grab.grabbed_at)}`;
}
const s = w.search;
if (!s) return 'Not searched yet';
const when = `Searched ${relativeTime(s.searched_at)}`;
switch (s.outcome) {
case 'nothing':
return s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
case 'waiting':
return `${when}: ${s.considered} found, nothing taken`;
case 'grabbed':
return `${when}: took ${s.best_release}`;
default:
return `${when}: ${s.error}`;
}
}
function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
if (w.reason === 'follow') return 'New from a followed artist';
@@ -122,8 +185,10 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Wanted</h2>
<p class="text-text-secondary">
Albums Minstrel is to get: ones users requested, new releases from the artists it follows,
and albums whose files went missing. An album leaves the list once the library holds it.
Minstrel doesn't search indexers for these yet; that comes next.
and albums whose files went missing. Minstrel searches your indexers for each one on a
schedule and hands the best release to your download client; searching and quality are set
under <a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
An album leaves the list once the library holds it.
</p>
</header>
@@ -216,43 +281,73 @@
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each items as w (w.id)}
<li class="flex items-center gap-3 p-3" data-testid="want-row">
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
<Album size={22} strokeWidth={1} class="text-text-muted" />
{#if w.image_url}
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
<li class="space-y-2 p-3" data-testid="want-row">
<div class="flex items-center gap-3">
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
<Album size={22} strokeWidth={1} class="text-text-muted" />
{#if w.image_url}
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary">
{#if w.album_id && w.held_at}
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
{:else}
{w.title}
{/if}
</div>
<div class="truncate text-xs text-text-secondary">
{#if w.artist_id}
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
{:else}
{w.artist_name}
{/if}
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
</div>
<div class="text-xs text-text-muted" data-testid="want-why">
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
</div>
{#if !w.held_at}
<div class="truncate text-xs text-text-secondary" data-testid="want-search">{status(w)}</div>
{/if}
</div>
{#if !w.held_at}
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<button
type="button"
onclick={() => onSearch(w)}
disabled={searching !== null || !!w.grab}
aria-label="Search for {w.title} now"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onclick={() => (open = open === w.id ? null : w.id)}
aria-expanded={open === w.id}
aria-label="Releases found for {w.title}"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
>
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
</button>
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<X size={12} strokeWidth={2} /> Remove
</button>
</div>
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary">
{#if w.album_id && w.held_at}
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
{:else}
{w.title}
{/if}
{#if open === w.id && !w.held_at}
<div class="sm:pl-[4.25rem]">
<WantCandidates wantId={w.id} title={w.title} />
</div>
<div class="truncate text-xs text-text-secondary">
{#if w.artist_id}
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
{:else}
{w.artist_name}
{/if}
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
</div>
<div class="text-xs text-text-muted" data-testid="want-why">
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
</div>
</div>
{#if !w.held_at}
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<X size={12} strokeWidth={2} /> Remove
</button>
{/if}
</li>
{/each}
+51 -2
View File
@@ -9,17 +9,28 @@ vi.mock('#lib/api/wanted.js', () => ({
createReleaseProfilesQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
putFollow: vi.fn().mockResolvedValue({ following: true }),
deleteFollow: vi.fn().mockResolvedValue(undefined)
deleteFollow: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import AdminWantedPage from './+page.svelte';
import { pushToast } from '#lib/stores/toast.svelte.js';
import {
createCandidatesQuery,
createFollowsQuery,
createReleaseProfilesQuery,
createWantsQuery,
deleteFollow,
putFollow,
removeWant
removeWant,
searchWant
} from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
@@ -87,6 +98,9 @@ function setup(items: Want[] = [want('w1'), want('w2', { reason: 'request', requ
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
);
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
);
return render(AdminWantedPage);
}
@@ -142,4 +156,39 @@ describe('/admin/wanted', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
});
test('each open want says what its last search found', () => {
setup([
want('w1'),
want('w2', {
title: 'Parklife',
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' }
}),
want('w3', {
title: 'Blur',
grab: { name: 'Blur - Blur (1997) [FLAC]', grabbed_at: ago(60_000), origin: 'schedule', quality: 'lossless' }
})
]);
const lines = screen.getAllByTestId('want-search').map((e) => e.textContent ?? '');
expect(lines[0]).toMatch(/not searched yet/i);
expect(lines[1]).toMatch(/4 found, none of them this album/i);
expect(lines[2]).toMatch(/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i);
});
test('Search runs one now and says what came of it', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2'));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info'));
});
test('Releases opens what the last search found', async () => {
setup();
expect(screen.queryByTestId('want-candidates')).toBeNull();
const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
await fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByTestId('want-candidates')).toBeTruthy();
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
});
});