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
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:
@@ -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) =>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user