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
@@ -30,6 +30,7 @@
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
@@ -424,6 +425,8 @@
<AcquisitionCard />
<SearchSettingsCard />
<ReleaseProfilesCard />
<!-- Lidarr panel -->
@@ -60,7 +60,22 @@ vi.mock('#lib/api/wanted.js', async () => {
),
createReleaseProfile: vi.fn(),
updateReleaseProfile: vi.fn(),
deleteReleaseProfile: vi.fn()
deleteReleaseProfile: vi.fn(),
// SearchSettingsCard, settled the same way.
createSearchSettingsQuery: vi.fn(() =>
mockQuery({
data: {
auto_grab: true,
search_interval_hours: 24,
// Not "Lossless": a Lidarr quality profile on this page is named that.
allowed_qualities: ['lossy_high'],
tiers: [{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320' }],
last_pass_at: null,
last_pass: {}
}
})
),
putSearchSettings: vi.fn()
};
});
+134 -39
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { Album, Disc3, ListChecks, X } from 'lucide-svelte';
import { Album, ChevronDown, Disc3, ListChecks, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createWantsQuery,
@@ -9,20 +9,25 @@
createReleaseProfilesQuery,
removeWant,
putFollow,
deleteFollow
deleteFollow,
searchWant
} from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import type { Follow, Want, WantState } from '#lib/api/types.js';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import type { Follow, SearchResult, Want, WantState } from '#lib/api/types.js';
// The wanted list (M502 #5520): every release group Minstrel is to get,
// whether a user requested it, a followed artist released it, or its files
// went missing. A want is held once the library has the album; the Held tab
// is that history. Following lists the artists whose new releases are
// wanted, each on a release-type profile that says which kinds of release.
// Each open want says what its last search found (#5521); Releases lists
// every candidate with its verdict, and Search runs one now.
type Tab = WantState | 'following';
const fromUrl = page.url.searchParams.get('tab');
@@ -52,6 +57,8 @@
]);
let busy = $state<string | null>(null);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
async function refresh() {
await Promise.all([
@@ -98,6 +105,62 @@
}
}
function searched(w: Want, r: SearchResult): string {
let said: string;
switch (r.outcome) {
case 'grabbed':
said = `Took ${r.grabbed ?? r.best_release}.`;
break;
case 'waiting':
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
break;
case 'nothing':
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
break;
default:
said = r.error ?? 'The search failed.';
}
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
return said;
}
async function onSearch(w: Want) {
searching = w.id;
try {
const r = await searchWant(w.id);
pushToast(searched(w, r), r.outcome === 'error' ? 'error' : 'info');
await Promise.all([
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: qk.wantCandidates(w.id) })
]);
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
searching = null;
}
}
// What the schedule last did about a want, in a line.
function status(w: Want): string {
if (w.grab) {
const who = w.grab.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
return `Downloading ${w.grab.name}, ${who} ${relativeTime(w.grab.grabbed_at)}`;
}
const s = w.search;
if (!s) return 'Not searched yet';
const when = `Searched ${relativeTime(s.searched_at)}`;
switch (s.outcome) {
case 'nothing':
return s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
case 'waiting':
return `${when}: ${s.considered} found, nothing taken`;
case 'grabbed':
return `${when}: took ${s.best_release}`;
default:
return `${when}: ${s.error}`;
}
}
function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
if (w.reason === 'follow') return 'New from a followed artist';
@@ -122,8 +185,10 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Wanted</h2>
<p class="text-text-secondary">
Albums Minstrel is to get: ones users requested, new releases from the artists it follows,
and albums whose files went missing. An album leaves the list once the library holds it.
Minstrel doesn't search indexers for these yet; that comes next.
and albums whose files went missing. Minstrel searches your indexers for each one on a
schedule and hands the best release to your download client; searching and quality are set
under <a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
An album leaves the list once the library holds it.
</p>
</header>
@@ -216,43 +281,73 @@
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each items as w (w.id)}
<li class="flex items-center gap-3 p-3" data-testid="want-row">
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
<Album size={22} strokeWidth={1} class="text-text-muted" />
{#if w.image_url}
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
<li class="space-y-2 p-3" data-testid="want-row">
<div class="flex items-center gap-3">
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
<Album size={22} strokeWidth={1} class="text-text-muted" />
{#if w.image_url}
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary">
{#if w.album_id && w.held_at}
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
{:else}
{w.title}
{/if}
</div>
<div class="truncate text-xs text-text-secondary">
{#if w.artist_id}
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
{:else}
{w.artist_name}
{/if}
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
</div>
<div class="text-xs text-text-muted" data-testid="want-why">
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
</div>
{#if !w.held_at}
<div class="truncate text-xs text-text-secondary" data-testid="want-search">{status(w)}</div>
{/if}
</div>
{#if !w.held_at}
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<button
type="button"
onclick={() => onSearch(w)}
disabled={searching !== null || !!w.grab}
aria-label="Search for {w.title} now"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onclick={() => (open = open === w.id ? null : w.id)}
aria-expanded={open === w.id}
aria-label="Releases found for {w.title}"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
>
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
</button>
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<X size={12} strokeWidth={2} /> Remove
</button>
</div>
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary">
{#if w.album_id && w.held_at}
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
{:else}
{w.title}
{/if}
{#if open === w.id && !w.held_at}
<div class="sm:pl-[4.25rem]">
<WantCandidates wantId={w.id} title={w.title} />
</div>
<div class="truncate text-xs text-text-secondary">
{#if w.artist_id}
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
{:else}
{w.artist_name}
{/if}
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
</div>
<div class="text-xs text-text-muted" data-testid="want-why">
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
</div>
</div>
{#if !w.held_at}
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<X size={12} strokeWidth={2} /> Remove
</button>
{/if}
</li>
{/each}
+51 -2
View File
@@ -9,17 +9,28 @@ vi.mock('#lib/api/wanted.js', () => ({
createReleaseProfilesQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
putFollow: vi.fn().mockResolvedValue({ following: true }),
deleteFollow: vi.fn().mockResolvedValue(undefined)
deleteFollow: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import AdminWantedPage from './+page.svelte';
import { pushToast } from '#lib/stores/toast.svelte.js';
import {
createCandidatesQuery,
createFollowsQuery,
createReleaseProfilesQuery,
createWantsQuery,
deleteFollow,
putFollow,
removeWant
removeWant,
searchWant
} from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
@@ -87,6 +98,9 @@ function setup(items: Want[] = [want('w1'), want('w2', { reason: 'request', requ
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
);
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
);
return render(AdminWantedPage);
}
@@ -142,4 +156,39 @@ describe('/admin/wanted', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
});
test('each open want says what its last search found', () => {
setup([
want('w1'),
want('w2', {
title: 'Parklife',
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' }
}),
want('w3', {
title: 'Blur',
grab: { name: 'Blur - Blur (1997) [FLAC]', grabbed_at: ago(60_000), origin: 'schedule', quality: 'lossless' }
})
]);
const lines = screen.getAllByTestId('want-search').map((e) => e.textContent ?? '');
expect(lines[0]).toMatch(/not searched yet/i);
expect(lines[1]).toMatch(/4 found, none of them this album/i);
expect(lines[2]).toMatch(/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i);
});
test('Search runs one now and says what came of it', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2'));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info'));
});
test('Releases opens what the last search found', async () => {
setup();
expect(screen.queryByTestId('want-candidates')).toBeNull();
const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
await fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByTestId('want-candidates')).toBeTruthy();
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
});
});