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."
}