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