feat: import with verification, and a Wanted view of its own (M502 #5522)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
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

A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 10:36:21 -04:00
co-authored by Claude Opus 5.5
parent 77081f1cef
commit ebbc49b902
63 changed files with 4892 additions and 555 deletions
@@ -29,7 +29,6 @@
import Modal from '#lib/components/Modal.svelte';
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';
@@ -427,8 +426,6 @@
<SearchSettingsCard />
<ReleaseProfilesCard />
<!-- Lidarr panel -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -43,25 +43,10 @@ vi.mock('#lib/api/admin.js', () => ({
addAcquisitionService: vi.fn()
}));
// ReleaseProfilesCard reads its own query; settled, so it adds no "Loading…".
// SearchSettingsCard reads its own query; settled, so it adds no "Loading…".
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
createReleaseProfilesQuery: vi.fn(() =>
mockQuery({
data: {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album', 'EP', 'Single'], secondary_types: [], is_default: true, follow_count: 3 }
],
primary_types: ['Album', 'EP', 'Single'],
secondary_types: ['Live']
}
})
),
createReleaseProfile: vi.fn(),
updateReleaseProfile: vi.fn(),
deleteReleaseProfile: vi.fn(),
// SearchSettingsCard, settled the same way.
createSearchSettingsQuery: vi.fn(() =>
mockQuery({
data: {
@@ -71,7 +56,9 @@ vi.mock('#lib/api/wanted.js', async () => {
allowed_qualities: ['lossy_high'],
tiers: [{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320' }],
last_pass_at: null,
last_pass: {}
last_pass: {},
import_root: '',
scan_paths: ['/music']
}
})
),
@@ -107,7 +107,7 @@
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
On the <a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline"
On the <a href="/wanted/missing" class="text-accent-fg hover:underline"
>wanted list</a
> since {relativeTime(r.wanted_at)}.
{/if}
@@ -140,7 +140,7 @@ describe('admin missing files', () => {
expect(
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
).toBe(
'/admin/wanted?tab=missing'
'/wanted/missing'
);
});
+1 -1
View File
@@ -146,7 +146,7 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
<p class="text-text-secondary">
Approve or set aside what users have asked for. An approved album or track goes on the
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
<a href="/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
artist is followed, wanting what they've released and what they release next.
</p>
</header>
-363
View File
@@ -1,363 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { Album, ChevronDown, Disc3, ListChecks, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createWantsQuery,
createFollowsQuery,
createReleaseProfilesQuery,
removeWant,
putFollow,
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 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');
let tab = $state<Tab>(
fromUrl === 'missing' || fromUrl === 'held' || fromUrl === 'following' ? fromUrl : 'wanted'
);
// The Following tab still reads the wanted query: it carries every tab's count.
const wantState = $derived<WantState>(tab === 'following' ? 'wanted' : tab);
const client = useQueryClient();
const wantsStore = $derived(createWantsQuery(wantState));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
const counts = $derived(wants.data?.pages?.[0]?.counts);
const followsStore = createFollowsQuery();
const follows = $derived($followsStore);
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
const tabs = $derived([
{ id: 'wanted' as Tab, label: 'Wanted', count: counts?.wanted },
{ id: 'missing' as Tab, label: 'Missing files', count: counts?.missing },
{ id: 'held' as Tab, label: 'Held', count: counts?.held },
{ id: 'following' as Tab, label: 'Following', count: counts?.following }
]);
let busy = $state<string | null>(null);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: ['adminFollows'] })
]);
}
async function onRemove(w: Want) {
busy = w.id;
try {
await removeWant(w.id);
pushToast(`${w.title} is off the wanted list.`);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
async function onProfile(f: Follow, profileId: string) {
busy = f.artist_mbid;
try {
await putFollow(f.artist_mbid, { artist_name: f.artist_name, profile_id: profileId });
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
async function onUnfollow(f: Follow) {
busy = f.artist_mbid;
try {
await deleteFollow(f.artist_mbid);
pushToast(`No longer following ${f.artist_name}. What was already wanted stays wanted.`);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
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';
return 'Its files went missing';
}
function kind(w: Want): string {
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
if (w.year) parts.push(w.year);
return parts.join(' · ');
}
function hideBroken(e: Event) {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}
</script>
<svelte:head><title>{pageTitle('Admin · Wanted')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">
<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. 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>
<TabStrip ariaLabel="Wanted list" bind:value={tab} items={tabs} />
{#if tab === 'following'}
<p class="text-sm text-text-secondary">
Each artist is followed on a release-type profile, which says which kinds of release are
wanted. Profiles are edited under
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
</p>
{#if follows.isPending}
<p class="text-text-secondary">Reading who's followed…</p>
{:else if follows.isError}
<p class="text-error-fg">Couldn't load the followed artists.</p>
{:else if (follows.data ?? []).length === 0}
<p class="text-text-secondary">
No artists are followed. Follow one from its artist page.
</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each follows.data ?? [] as f (f.artist_mbid)}
<li class="flex flex-wrap items-center gap-3 p-3" data-testid="follow-row">
<div class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-hover" aria-hidden="true">
<Disc3 size={20} strokeWidth={1} class="text-text-muted" />
{#if f.image_url}
<img src={f.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 f.artist_id}
<a href="/artists/{f.artist_id}" class="hover:underline">{f.artist_name}</a>
{:else}
{f.artist_name}
{/if}
</div>
<div class="text-xs text-text-secondary">
{f.open_wants === 1 ? '1 album wanted' : `${f.open_wants} albums wanted`} ·
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
</div>
</div>
<label class="flex items-center gap-2 text-xs text-text-secondary">
<span class="sr-only">Profile for {f.artist_name}</span>
<select
value={f.profile_id}
disabled={busy === f.artist_mbid}
onchange={(e) => onProfile(f, (e.currentTarget as HTMLSelectElement).value)}
class="rounded-md border border-border bg-background px-2 py-1 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
{#each profiles as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<button
type="button"
onclick={() => onUnfollow(f)}
disabled={busy === f.artist_mbid}
aria-label="Unfollow {f.artist_name}"
class="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"
>
Unfollow
</button>
</li>
{/each}
</ul>
{/if}
{:else if wants.isPending}
<p class="text-text-secondary">Reading the wanted list…</p>
{:else if wants.isError && !wants.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the wanted list.</p>
{:else if items.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">
{#if tab === 'held'}Nothing on the list has been held yet.
{:else if tab === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
</p>
<p class="mt-1 text-sm text-text-secondary">
{#if tab === 'missing'}
An album whose files stay missing past the grace window on the Missing files page lands here.
{:else}
A request you approve, a new release from a followed artist, or an album whose files went
missing lands here.
{/if}
</p>
</div>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each items as w (w.id)}
<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>
{#if open === w.id && !w.held_at}
<div class="sm:pl-[4.25rem]">
<WantCandidates wantId={w.id} title={w.title} />
</div>
{/if}
</li>
{/each}
</ul>
<ListContinuation
hasMore={!!wants.hasNextPage}
loading={wants.isFetchingNextPage}
failed={wants.isFetchNextPageError}
onMore={() => wants.fetchNextPage()}
/>
{/if}
</div>
-194
View File
@@ -1,194 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
import type { Follow, ReleaseProfilesResponse, Want, WantsPage } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createWantsQuery: vi.fn(),
createFollowsQuery: vi.fn(),
createReleaseProfilesQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
putFollow: vi.fn().mockResolvedValue({ following: true }),
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,
searchWant
} from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function want(id: string, extra: Partial<Want> = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren',
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
primary_type: 'Album',
secondary_types: [],
year: '2023',
reason: 'follow',
wanted_at: ago(2 * DAY),
held_at: null,
image_url: '',
...extra
};
}
function page(items: Want[]): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
counts: { wanted: 2, missing: 1, held: 4, following: 1 }
};
}
const follow: Follow = {
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
profile_id: 'p-std',
profile_name: 'Standard',
followed_at: ago(10 * DAY),
checked_at: ago(3 * 3_600_000),
open_wants: 2,
image_url: ''
};
const profiles: ReleaseProfilesResponse = {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album'], secondary_types: [], is_default: true, follow_count: 1 },
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
],
primary_types: ['Album'],
secondary_types: ['Live']
};
afterEach(() => vi.clearAllMocks());
function setup(items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })]) {
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType<typeof createWantsQuery>
);
vi.mocked(createFollowsQuery).mockReturnValue(
mockQuery({ data: [follow] }) as unknown as ReturnType<typeof createFollowsQuery>
);
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);
}
describe('/admin/wanted', () => {
test('each tab shows its count', () => {
setup();
expect(screen.getByRole('tab', { name: /wanted/i }).textContent).toMatch(/2/);
expect(screen.getByRole('tab', { name: /held/i }).textContent).toMatch(/4/);
});
test('a want says why it is wanted', () => {
setup();
const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i);
});
test('switching tab asks for that state', async () => {
setup();
await fireEvent.click(screen.getByRole('tab', { name: /missing files/i }));
expect(createWantsQuery).toHaveBeenCalledWith('missing');
});
test('remove takes a want off the list', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
});
test('a held want has no remove button', () => {
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })]);
expect(screen.queryByRole('button', { name: /stop wanting/i })).toBeNull();
expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
});
test('following lists artists; the profile can change and the artist be unfollowed', async () => {
setup();
await fireEvent.click(screen.getByRole('tab', { name: /following/i }));
expect(screen.getAllByTestId('follow-row')).toHaveLength(1);
const select = screen.getByLabelText(/profile for blur/i) as HTMLSelectElement;
expect(select.value).toBe('p-std');
await fireEvent.change(select, { target: { value: 'p-live' } });
await waitFor(() =>
expect(putFollow).toHaveBeenCalledWith('blur', { artist_name: 'Blur', profile_id: 'p-live' })
);
await fireEvent.click(screen.getByRole('button', { name: /unfollow blur/i }));
await waitFor(() => expect(deleteFollow).toHaveBeenCalledWith('blur'));
});
test('an empty list explains what lands there', () => {
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');
});
});
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { createWantsQuery } from '#lib/api/wanted.js';
import WantedTabs from '#lib/components/WantedTabs.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
// The Wanted view (M502): what Minstrel is getting for the library, its
// downloads, and what it has set aside. Web-only and admin-only. The
// wanted query's first page carries every tab's count; the Wanted tab
// shares it, so this costs no extra request there.
const wantsStore = createWantsQuery('wanted');
const counts = $derived($wantsStore.data?.pages?.[0]?.counts);
// The one line that answers "does anything need me?"
const verdict = $derived.by(() => {
if (!counts) return '';
if (counts.attention) {
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`;
}
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
if (counts.activity) parts.push(`${counts.activity} downloading`);
return parts.join(', ') + '. Nothing needs you.';
});
</script>
<div class="space-y-4">
<header class="space-y-1">
<h1 class="font-display text-2xl font-medium text-text-primary">Wanted</h1>
<p class="text-sm text-text-secondary">
Albums Minstrel is getting: ones users requested, new releases from the artists it follows,
and albums whose files went missing. It searches your indexers on a schedule, checks each
download against MusicBrainz, and files what's right into the library. Searching and quality
are set under <a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
</p>
{#if verdict}
<p class="text-sm {counts?.attention ? 'text-text-primary' : 'text-text-secondary'}" data-testid="wanted-verdict">
{verdict}
{#if counts?.attention}
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
{/if}
</p>
{/if}
</header>
<WantedTabs {counts} />
{@render children?.()}
</div>
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import { user } from '#lib/auth/store.svelte.js';
import type { LayoutLoad } from './$types';
// Hard route gate: the Wanted view is an admin's (M502). Runs before the
// layout renders; auth is bootstrapped by the root +layout.ts, so user.value is
// either a settled User object or null.
export const load: LayoutLoad = () => {
if (!user.value || !user.value.is_admin) {
throw redirect(302, '/');
}
return {};
};
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted')}</title></svelte:head>
<WantList state="wanted" />
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import DownloadList from '#lib/components/wanted/DownloadList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Activity')}</title></svelte:head>
<DownloadList />
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import BlockList from '#lib/components/wanted/BlockList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Blocked')}</title></svelte:head>
<BlockList />
@@ -0,0 +1,12 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import FollowList from '#lib/components/wanted/FollowList.svelte';
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Following')}</title></svelte:head>
<div class="space-y-6">
<FollowList />
<ReleaseProfilesCard />
</div>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Held')}</title></svelte:head>
<WantList state="held" />
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Missing files')}</title></svelte:head>
<WantList state="missing" />
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import { mockInfiniteQuery } from '../../test-utils/query';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
import type { WantCounts, WantsPage } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
vi.mock('$app/state', () => pageUrlModule(state));
vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantsQuery } from '#lib/api/wanted.js';
const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
afterEach(() => vi.clearAllMocks());
function setup(c: WantCounts = counts, path = '/wanted') {
state.pageUrl = new URL(`http://localhost${path}`);
const page: WantsPage = { items: [], total: 0, limit: 50, offset: 0, counts: c };
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantsQuery>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any);
}
describe('/wanted layout', () => {
test('tabs are links carrying their counts, the current one marked', () => {
setup(counts, '/wanted/activity');
const nav = screen.getByRole('navigation', { name: /wanted sections/i });
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
expect(links).toEqual([
['/wanted', 'Wanted 5'],
['/wanted/activity', 'Activity 3'],
['/wanted/missing', 'Missing files 1'],
['/wanted/held', 'Held 9'],
['/wanted/following', 'Following 2'],
['/wanted/blocked', 'Blocked 4']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('tab-body')).toBeTruthy();
});
test('when nothing needs a person the verdict says so', () => {
setup();
expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
expect(screen.queryByTestId('tab-attention')).toBeNull();
});
test('downloads waiting on a person lead the page and mark Activity', () => {
setup({ ...counts, attention: 2 });
const verdict = screen.getByTestId('wanted-verdict');
expect(verdict.textContent).toMatch(/2 downloads need you/);
expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity');
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
});