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
+3
View File
@@ -48,6 +48,9 @@ export const qk = {
// Search and grab (M502 #5521).
wantCandidates: (wantId: string) => ['wantCandidates', wantId] as const,
searchSettings: () => ['searchSettings'] as const,
// The Wanted view's Activity and Blocked tabs (#5522).
downloads: () => ['adminWants', 'downloads'] as const,
releaseBlocks: () => ['adminWants', 'releaseBlocks'] as const,
myQuarantine: () => ['myQuarantine'] as const,
adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) =>
+90 -1
View File
@@ -606,11 +606,32 @@ export type WantSearch = {
error: string;
};
// What an import made of a finished download (M502 #5522).
export type ImportState =
| ''
| 'downloading'
| 'waiting'
| 'importing'
| 'imported'
| 'refused'
| 'review'
| 'held'
| 'failed';
// A want's latest download, running or ended.
export type WantGrab = {
id: string;
name: string;
grabbed_at: string;
origin: 'schedule' | 'operator';
quality: string;
live: boolean;
end_reason: string;
progress: number;
import_state: ImportState;
import_reason: string;
imported_at: string | null;
imported_files: number;
};
// Search and grab (M502 #5521) ----------------------------------------------
@@ -685,9 +706,77 @@ export type SearchSettings = {
tiers: QualityTier[];
last_pass_at: string | null;
last_pass: SearchPass;
// The library folder new music is filed into ("" = the first of
// scan_paths), and the library's folders (#5522).
import_root: string;
scan_paths: string[];
};
export type WantCounts = { wanted: number; missing: number; held: number; following: number };
export type WantCounts = {
wanted: number;
missing: number;
held: number;
following: number;
// Downloads running, those of them waiting on a person, releases set aside.
activity: number;
attention: number;
blocked: number;
};
// What an import's check counted (#5522).
export type ImportEvidence = {
files: number;
tracks: number;
identified: number;
recordings: number;
lengths: number;
titles: number;
group_tag?: string;
album_tag?: string;
fingerprinted: boolean;
};
// One download on the Activity tab.
export type Download = {
id: string;
want_id: string | null;
want_title: string;
want_artist: string;
name: string;
indexer: string;
client: string;
quality: string;
origin: 'schedule' | 'operator';
grabbed_at: string;
ended_at: string | null;
end_reason: string;
progress: number;
import_state: ImportState;
import_reason: string;
imported_at: string | null;
imported_files: number;
destination: string;
edition?: string;
evidence?: ImportEvidence;
files: string[];
skipped: number;
rehomed_from?: string;
};
export type DownloadsPage = { items: Download[]; limit: number; offset: number };
// A release set aside: by a person, by its files, or by an import's check.
export type ReleaseBlock = {
info_hash: string;
name: string;
reason: 'operator' | 'files' | 'import';
detail: string;
blocked_at: string;
want_title: string;
want_artist: string;
};
export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset: number };
export type WantsPage = Page<Want> & { counts: WantCounts };
+52
View File
@@ -3,7 +3,9 @@ import { api } from './client';
import { qk } from './queries';
import type {
Candidate,
DownloadsPage,
Follow,
ReleaseBlocksPage,
FollowState,
ReleaseProfile,
ReleaseProfileInput,
@@ -157,6 +159,56 @@ export async function putSearchSettings(body: {
auto_grab: boolean;
search_interval_hours: number;
allowed_qualities: string[];
import_root: string;
}): Promise<SearchSettings> {
return api.put<SearchSettings>('/api/admin/acquisition/settings', body);
}
// Downloads and their imports (M502 #5522) ----------------------------------
// A page holding fewer than it asked for is the last.
function nextOffset(last: { items: unknown[]; limit: number; offset: number }): number | undefined {
return last.items.length < last.limit ? undefined : last.offset + last.items.length;
}
export async function listDownloads(offset: number = 0): Promise<DownloadsPage> {
return api.get<DownloadsPage>(`/api/admin/downloads?offset=${offset}`);
}
// Running downloads first; refreshed every few seconds while one runs.
export function createDownloadsQuery() {
return createInfiniteQuery({
queryKey: qk.downloads(),
queryFn: ({ pageParam }) => listDownloads(pageParam as number),
initialPageParam: 0,
getNextPageParam: nextOffset,
refetchInterval: (q) =>
q.state.data?.pages[0]?.items.some((d) => !d.ended_at) ? 5_000 : false
});
}
export async function importAnyway(id: string): Promise<void> {
await api.post(`/api/admin/downloads/${id}/import`, {});
}
export async function retryImport(id: string): Promise<void> {
await api.post(`/api/admin/downloads/${id}/retry`, {});
}
export async function refuseDownload(id: string): Promise<void> {
await api.post(`/api/admin/downloads/${id}/refuse`, {});
}
export async function listReleaseBlocks(offset: number = 0): Promise<ReleaseBlocksPage> {
return api.get<ReleaseBlocksPage>(`/api/admin/release-blocks?offset=${offset}`);
}
export function createReleaseBlocksQuery() {
return createInfiniteQuery({
queryKey: qk.releaseBlocks(),
queryFn: ({ pageParam }) => listReleaseBlocks(pageParam as number),
initialPageParam: 0,
getNextPageParam: nextOffset,
staleTime: 30_000
});
}
-1
View File
@@ -7,7 +7,6 @@
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/wanted', label: 'Wanted' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/missing-files', label: 'Missing files' },
{ href: '/admin/duplicates', label: 'Duplicates' },
+1 -3
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders all twelve tabs in order', () => {
test('renders all eleven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -60,8 +60,6 @@ describe('AdminTabs', () => {
'Overview',
'Integrations',
'Requests',
// Wanted follows Requests: an approved request goes on the wanted list.
'Wanted',
'Quarantine',
// Missing files sits with Quarantine and Playback errors: the three
// surfaces that show tracks needing an operator's attention.
@@ -74,7 +74,7 @@
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
<p class="mt-1 text-sm text-text-secondary">
When a file has been missing for a while, Minstrel can put its album back on the
<a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline">wanted list</a>
<a href="/wanted/missing" class="text-accent-fg hover:underline">wanted list</a>
by itself. It's wanted per album, not per track: a whole folder going missing is one
want, not forty.
</p>
@@ -167,8 +167,7 @@
<h3 class="font-display text-lg font-medium text-text-primary">Release-type profiles</h3>
<p class="mt-1 text-sm text-text-secondary">
Each followed artist is on one profile, which says which of their new releases are
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted</a>. Requests for a
specific album aren't filtered.
wanted. Requests for a specific album aren't filtered.
</p>
</div>
@@ -10,13 +10,14 @@
// 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.
// keeps to each indexer's searches-per-hour. A finished download is
// checked and filed into one of the library's folders (#5522).
const client = useQueryClient();
const store = createSearchSettingsQuery();
const query = $derived($store);
type Form = { auto_grab: boolean; search_interval_hours: number; allowed: string[] };
type Form = { auto_grab: boolean; search_interval_hours: number; allowed: string[]; import_root: string };
// The form is the server's settings until edited; saved holds what a save
// returned until the query catches up.
@@ -27,7 +28,12 @@
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] };
return {
auto_grab: s.auto_grab,
search_interval_hours: s.search_interval_hours,
allowed: [...s.allowed_qualities],
import_root: s.import_root
};
}
const dirty = $derived(!!edited && !!base && JSON.stringify(edited) !== JSON.stringify(fromSettings(base)));
@@ -48,7 +54,8 @@
saved = await putSearchSettings({
auto_grab: form.auto_grab,
search_interval_hours: Number(form.search_interval_hours),
allowed_qualities: form.allowed
allowed_qualities: form.allowed,
import_root: form.import_root
});
edited = null;
await client.invalidateQueries({ queryKey: qk.searchSettings() });
@@ -81,7 +88,7 @@
<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
<a href="/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>
@@ -146,6 +153,32 @@
</div>
</fieldset>
{#if query.data.scan_paths.length > 1}
<label class="block">
<span class="text-sm text-text-primary">File new music into</span>
<span class="block text-xs text-text-secondary">
A download that checks out is copied here as Artist/Album (Year)/Artist - Album - NN -
Title, tagged with its MusicBrainz ids. The download itself keeps seeding.
</span>
<select
value={form.import_root || query.data.scan_paths[0]}
onchange={(e) => edit({ import_root: e.currentTarget.value })}
class="mt-1 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"
>
{#each query.data.scan_paths as p (p)}
<option value={p}>{p}</option>
{/each}
</select>
</label>
{:else if query.data.scan_paths.length === 1}
<p class="text-xs text-text-secondary">
A download that checks out is copied into <span class="font-mono">{query.data.scan_paths[0]}</span>
as Artist/Album (Year)/Artist - Album - NN - Title, tagged with its MusicBrainz ids. The
download itself keeps seeding.
</p>
{/if}
<p class="text-xs text-text-secondary" data-testid="last-pass">{lastPass(query.data)}</p>
<div class="flex justify-end">
@@ -22,7 +22,9 @@ const settings: SearchSettings = {
{ 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 }
last_pass: { looked: 12, grabbed: 3, waiting: 2, nothing: 7, failed: 0, paced: true },
import_root: '',
scan_paths: ['/music', '/music2']
};
afterEach(() => vi.clearAllMocks());
@@ -57,11 +59,29 @@ describe('SearchSettingsCard', () => {
expect(putSearchSettings).toHaveBeenCalledWith({
auto_grab: false,
search_interval_hours: 24,
allowed_qualities: ['lossless', 'lossy_high', 'unknown']
allowed_qualities: ['lossless', 'lossy_high', 'unknown'],
import_root: ''
})
);
});
test('new music is filed into the chosen library folder, the first by default', async () => {
setup();
const folder = screen.getByLabelText(/file new music into/i) as HTMLSelectElement;
expect(folder.value).toBe('/music');
await fireEvent.change(folder, { target: { value: '/music2' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putSearchSettings).toHaveBeenCalledWith(expect.objectContaining({ import_root: '/music2' }))
);
});
test('with one library folder there is nothing to choose', () => {
setup({ ...settings, scan_paths: ['/music'] });
expect(screen.queryByLabelText(/file new music into/i)).toBeNull();
expect(screen.getByText('/music')).toBeTruthy();
});
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/);
+8 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
import { ChevronDown, Compass, House, LibraryBig, ListChecks } from 'lucide-svelte';
import { user, logout } from '#lib/auth/store.svelte.js';
import { player } from '#lib/player/store.svelte.js';
import { appName } from '#lib/branding.js';
@@ -52,11 +52,15 @@
// bounce through the /library 308-redirect. isActive('/library')
// still matches all sub-routes by prefix so the active state covers
// every tab.
const navItems = [
// Wanted (M502) is an admin's: what Minstrel is fetching for the library.
const navItems = $derived([
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' }
];
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' },
...(user.value?.is_admin
? [{ href: '/wanted', label: 'Wanted', icon: ListChecks, matchPrefix: '/wanted' }]
: [])
]);
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
+15
View File
@@ -67,6 +67,21 @@ describe('Shell', () => {
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
});
test('admins get Wanted after Discover; others do not', () => {
const primary = () =>
screen
.getAllByRole('link')
.filter((el) => el.closest('nav[aria-label="Primary"]') !== null)
.map((el) => el.textContent?.trim());
const { unmount } = render(Shell);
expect(primary()).not.toContain('Wanted');
unmount();
userState.current = { id: '1', username: 'alice', is_admin: true };
render(Shell);
expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Wanted']);
expect(screen.getByRole('link', { name: 'Wanted' })).toHaveAttribute('href', '/wanted');
});
test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => {
userState.current = { id: '1', username: 'alice', is_admin: true };
render(Shell);
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/state';
import type { WantCounts } from '#lib/api/types.js';
// The Wanted view's sections (M502 #5520–#5522). Activity carries a second,
// accented count when downloads are waiting on a person.
let { counts }: { counts?: WantCounts } = $props();
type Item = { href: string; label: string; count?: number; attention?: number };
const items = $derived<Item[]>([
{ href: '/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/wanted/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/wanted/missing', label: 'Missing files', count: counts?.missing },
{ href: '/wanted/held', label: 'Held', count: counts?.held },
{ href: '/wanted/following', label: 'Following', count: counts?.following },
{ href: '/wanted/blocked', label: 'Blocked', count: counts?.blocked }
]);
function isActive(href: string): boolean {
if (href === '/wanted') return page.url.pathname === '/wanted';
return page.url.pathname.startsWith(href);
}
</script>
<nav aria-label="Wanted sections" class="border-b border-border">
<ul class="flex gap-1 overflow-x-auto">
{#each items as item (item.href)}
{@const active = isActive(item.href)}
<li>
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors {active
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>
{item.label}
{#if item.attention}
<span
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
aria-label="{item.attention} need you"
data-testid="tab-attention">{item.attention}</span
>
{/if}
{#if item.count !== undefined}
<span class="text-xs text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
@@ -0,0 +1,88 @@
<script lang="ts">
import { Ban } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createReleaseBlocksQuery, unblockRelease } from '#lib/api/wanted.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 type { ReleaseBlock } from '#lib/api/types.js';
// The Blocked tab (M502 #5521–#5522): releases the search will never take
// again — set aside by a person, by what their files were, or by an
// import's check that found they weren't the album.
const client = useQueryClient();
const blocksStore = createReleaseBlocksQuery();
const blocks = $derived($blocksStore);
const items = $derived((blocks.data?.pages ?? []).flatMap((p) => p.items) as ReleaseBlock[]);
let busy = $state<string | null>(null);
async function onAllow(b: ReleaseBlock) {
busy = b.info_hash;
try {
await unblockRelease(b.info_hash);
pushToast(`${b.name} may be taken again.`);
await client.invalidateQueries({ queryKey: ['adminWants'] });
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
function by(b: ReleaseBlock): string {
if (b.reason === 'operator') return 'Set aside by you';
if (b.reason === 'files') return 'Set aside for its files';
return 'Refused on import';
}
</script>
{#if blocks.isPending}
<p class="text-text-secondary">Reading the blocked releases…</p>
{:else if blocks.isError && !blocks.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the blocked releases.</p>
{:else if items.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<Ban size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">No releases are blocked.</p>
<p class="mt-1 text-sm text-text-secondary">
A release you set aside, or one an import found wasn't the album, lands here so it's never
taken again.
</p>
</div>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each items as b (b.info_hash)}
<li class="flex flex-wrap items-start gap-3 p-3" data-testid="block-row">
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary" title={b.name}>{b.name || b.info_hash}</div>
{#if b.want_title}
<div class="truncate text-xs text-text-secondary">For {b.want_artist} – {b.want_title}</div>
{/if}
<div class="text-xs text-text-muted">{by(b)} {relativeTime(b.blocked_at)}</div>
{#if b.detail}
<p class="text-xs text-text-secondary">{b.detail}</p>
{/if}
</div>
<button
type="button"
onclick={() => onAllow(b)}
disabled={busy === b.info_hash}
aria-label="Allow {b.name} again"
class="shrink-0 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"
>
Allow again
</button>
</li>
{/each}
</ul>
<ListContinuation
hasMore={!!blocks.hasNextPage}
loading={blocks.isFetchingNextPage}
failed={blocks.isFetchNextPageError}
onMore={() => blocks.fetchNextPage()}
/>
{/if}
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { ReleaseBlock } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createReleaseBlocksQuery: vi.fn(),
unblockRelease: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import BlockList from './BlockList.svelte';
import { createReleaseBlocksQuery, unblockRelease } from '#lib/api/wanted.js';
const block: ReleaseBlock = {
info_hash: 'abc123',
name: 'Daft Punk - Random Access Memories (Drumless Edition)',
reason: 'import',
detail: 'The album tag says another version.',
blocked_at: new Date(Date.now() - 3_600_000).toISOString(),
want_title: 'Random Access Memories',
want_artist: 'Daft Punk'
};
afterEach(() => vi.clearAllMocks());
function setup(items: ReleaseBlock[]) {
vi.mocked(createReleaseBlocksQuery).mockReturnValue(
mockInfiniteQuery({ pages: [{ items, total: items.length, limit: 50, offset: 0 }] }) as unknown as ReturnType<
typeof createReleaseBlocksQuery
>
);
return render(BlockList);
}
describe('BlockList', () => {
test('a blocked release says why and can be allowed again', async () => {
setup([block]);
expect(screen.getByText(/refused on import/i)).toBeTruthy();
expect(screen.getByText(/another version/)).toBeTruthy();
expect(screen.getByText(/for daft punk – random access memories/i)).toBeTruthy();
await fireEvent.click(screen.getByRole('button', { name: /allow .* again/i }));
await waitFor(() => expect(unblockRelease).toHaveBeenCalledWith('abc123'));
});
test('with nothing blocked it says so', () => {
setup([]);
expect(screen.getByText(/no releases are blocked/i)).toBeTruthy();
});
});
@@ -0,0 +1,192 @@
<script lang="ts">
import { Download as DownloadIcon } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createDownloadsQuery, importAnyway, refuseDownload, retryImport } from '#lib/api/wanted.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 type { Download, ImportEvidence } from '#lib/api/types.js';
// The Activity tab (M502 #5522): every download Minstrel took, running
// ones first. A finished download is checked against MusicBrainz before it
// is filed; one the check could not settle waits here for a person, who
// imports it anyway or refuses it. A refused release is set aside (the
// Blocked tab) and its album searched for again.
const client = useQueryClient();
const downloadsStore = createDownloadsQuery();
const downloads = $derived($downloadsStore);
const items = $derived((downloads.data?.pages ?? []).flatMap((p) => p.items) as Download[]);
let busy = $state<string | null>(null);
async function act(d: Download, run: (id: string) => Promise<void>, said: string) {
busy = d.id;
try {
await run(d.id);
pushToast(said);
await client.invalidateQueries({ queryKey: ['adminWants'] });
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
const onImport = (d: Download) =>
act(d, importAnyway, `${d.name} will be imported within the minute.`);
const onRetry = (d: Download) => act(d, retryImport, `${d.name} will be tried again within the minute.`);
const onRefuse = (d: Download) =>
act(d, refuseDownload, `Refused ${d.name}. It's set aside and the album will be searched for again.`);
// The state of a download as a short label, and whether it needs a person.
function label(d: Download): string {
switch (d.import_state) {
case 'downloading':
case '':
return d.ended_at ? 'Ended' : 'Downloading';
case 'waiting':
return 'Waiting';
case 'importing':
return 'Importing';
case 'imported':
return 'Imported';
case 'refused':
return 'Refused';
case 'review':
return 'Needs you';
case 'held':
return 'Already held';
case 'failed':
return 'Import failed';
}
return d.import_state;
}
const needsYou = (d: Download) => !d.ended_at && (d.import_state === 'review' || d.import_state === 'failed');
function badge(d: Download): string {
if (needsYou(d)) return 'border-accent text-text-primary';
if (d.import_state === 'refused') return 'border-error-fg text-error-fg';
return 'border-border text-text-secondary';
}
// What the check counted, in a line.
function counted(e: ImportEvidence): string {
const parts = [`${e.files} files against ${e.tracks} tracks`];
if (e.fingerprinted) parts.push(`${e.recordings} identified by AcoustID`);
else parts.push('no AcoustID');
parts.push(`${e.lengths} lengths agree`, `${e.titles} titles agree`);
if (e.album_tag) parts.push(`album tag “${e.album_tag}”`);
return parts.join(' · ');
}
function when(d: Download): string {
const who = d.origin === 'operator' ? 'by you' : 'by the schedule';
let said = `Taken ${who} ${relativeTime(d.grabbed_at)}`;
if (d.indexer) said += ` from ${d.indexer}`;
if (d.imported_at) said += ` · imported ${relativeTime(d.imported_at)}`;
else if (d.ended_at) said += ` · ended ${relativeTime(d.ended_at)}`;
return said;
}
function filed(d: Download): string | null {
if (d.import_state !== 'imported' && d.import_state !== 'held') return null;
let said = d.imported_files === 1 ? '1 track filed' : `${d.imported_files} tracks filed`;
if (d.skipped) said += `, ${d.skipped} already held`;
if (d.destination) said += ` into ${d.destination}`;
return said;
}
const button =
'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';
</script>
{#if downloads.isPending}
<p class="text-text-secondary">Reading the downloads…</p>
{:else if downloads.isError && !downloads.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the downloads.</p>
{:else if items.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<DownloadIcon size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">Nothing has been downloaded yet.</p>
<p class="mt-1 text-sm text-text-secondary">
A release the search takes, or one you take from a want's Releases, shows here while it
downloads and once it's imported.
</p>
</div>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each items as d (d.id)}
<li class="space-y-1.5 p-3" data-testid="download-row">
<div class="flex flex-wrap items-start gap-x-3 gap-y-1">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">
{d.want_artist ? `${d.want_artist} – ${d.want_title}` : d.name}
</div>
<div class="truncate text-xs text-text-secondary" title={d.name}>
{d.name}{#if d.quality}<span class="text-text-muted"> · {d.quality}</span>{/if}
</div>
</div>
<span class="shrink-0 rounded-full border px-2 py-0.5 text-xs {badge(d)}" data-testid="download-state">
{label(d)}
</span>
</div>
{#if !d.ended_at && (d.import_state === 'downloading' || d.import_state === '')}
<div
class="h-1.5 overflow-hidden rounded-full bg-surface-hover"
role="progressbar"
aria-label="Downloaded"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.floor(d.progress * 100)}
>
<div class="h-full bg-accent" style="width: {Math.floor(d.progress * 100)}%"></div>
</div>
{/if}
{#if d.import_reason}
<p class="text-xs {needsYou(d) ? 'text-text-primary' : 'text-text-secondary'}" data-testid="download-reason">
{d.import_reason}
</p>
{/if}
{#if d.evidence}
<p class="text-xs text-text-muted">{counted(d.evidence)}</p>
{/if}
{#if filed(d)}
<p class="truncate text-xs text-text-secondary" title={d.destination}>{filed(d)}</p>
{/if}
{#if d.rehomed_from}
<p class="text-xs text-text-muted">Taken for {d.rehomed_from}; it turned out to be this album.</p>
{/if}
<p class="text-xs text-text-muted">{when(d)}</p>
{#if needsYou(d)}
<div class="flex flex-wrap gap-1.5 pt-1">
{#if d.import_state === 'review'}
<button type="button" class={button} disabled={busy === d.id} onclick={() => onImport(d)}>
Import anyway
</button>
{:else}
<button type="button" class={button} disabled={busy === d.id} onclick={() => onRetry(d)}>
Try again
</button>
{/if}
<button type="button" class={button} disabled={busy === d.id} onclick={() => onRefuse(d)}>
Refuse
</button>
</div>
{/if}
</li>
{/each}
</ul>
<ListContinuation
hasMore={!!downloads.hasNextPage}
loading={downloads.isFetchingNextPage}
failed={downloads.isFetchNextPageError}
onMore={() => downloads.fetchNextPage()}
/>
{/if}
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { Download } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createDownloadsQuery: vi.fn(),
importAnyway: vi.fn().mockResolvedValue(undefined),
retryImport: vi.fn().mockResolvedValue(undefined),
refuseDownload: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import DownloadList from './DownloadList.svelte';
import { createDownloadsQuery, importAnyway, refuseDownload, retryImport } from '#lib/api/wanted.js';
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function download(id: string, extra: Partial<Download> = {}): Download {
return {
id,
want_id: 'w1',
want_title: 'Random Access Memories',
want_artist: 'Daft Punk',
name: 'Daft Punk - Random Access Memories (2013) [FLAC]',
indexer: 'Prowlarr',
client: 'qbittorrent',
quality: 'lossless',
origin: 'schedule',
grabbed_at: ago(3_600_000),
ended_at: null,
end_reason: '',
progress: 0,
import_state: 'downloading',
import_reason: '',
imported_at: null,
imported_files: 0,
destination: '',
files: [],
skipped: 0,
...extra
};
}
afterEach(() => vi.clearAllMocks());
function setup(items: Download[]) {
vi.mocked(createDownloadsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [{ items, total: items.length, limit: 50, offset: 0 }] }) as unknown as ReturnType<
typeof createDownloadsQuery
>
);
return render(DownloadList);
}
describe('DownloadList', () => {
test('a running download shows how far it has got', () => {
setup([download('d1', { progress: 0.375 })]);
expect(screen.getByTestId('download-state').textContent).toMatch(/downloading/i);
expect(screen.getByRole('progressbar', { name: /downloaded/i }).getAttribute('aria-valuenow')).toBe('37');
expect(screen.queryByRole('button')).toBeNull();
});
test('a doubtful download says why and can be imported anyway or refused', async () => {
setup([
download('d1', {
import_state: 'review',
import_reason: 'Only the lengths agree. A person decides.',
evidence: { files: 13, tracks: 13, identified: 0, recordings: 0, lengths: 13, titles: 0, fingerprinted: false }
})
]);
expect(screen.getByTestId('download-state').textContent).toMatch(/needs you/i);
expect(screen.getByTestId('download-reason').textContent).toMatch(/a person decides/i);
expect(screen.getByText(/13 files against 13 tracks · no AcoustID · 13 lengths agree/)).toBeTruthy();
await fireEvent.click(screen.getByRole('button', { name: /import anyway/i }));
await waitFor(() => expect(importAnyway).toHaveBeenCalledWith('d1'));
await fireEvent.click(screen.getByRole('button', { name: /refuse/i }));
await waitFor(() => expect(refuseDownload).toHaveBeenCalledWith('d1'));
});
test('a failed import can be tried again', async () => {
setup([download('d1', { import_state: 'failed', import_reason: 'The library folder is not writable.' })]);
expect(screen.queryByRole('button', { name: /import anyway/i })).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
await waitFor(() => expect(retryImport).toHaveBeenCalledWith('d1'));
});
test('an imported download says what was filed where', () => {
setup([
download('d1', {
ended_at: ago(60_000),
end_reason: 'imported',
import_state: 'imported',
imported_at: ago(60_000),
imported_files: 12,
skipped: 1,
destination: '/music/Daft Punk/Random Access Memories (2013)'
})
]);
expect(screen.getByTestId('download-state').textContent).toMatch(/imported/i);
expect(screen.getByText(/12 tracks filed, 1 already held into \/music\/Daft Punk/)).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull();
});
test('with nothing downloaded it says what lands here', () => {
setup([]);
expect(screen.getByText(/nothing has been downloaded yet/i)).toBeTruthy();
});
});
@@ -0,0 +1,117 @@
<script lang="ts">
import { Disc3 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createFollowsQuery,
createReleaseProfilesQuery,
putFollow,
deleteFollow
} from '#lib/api/wanted.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 { Follow } from '#lib/api/types.js';
// The Following tab (M502 #5520): every artist whose new releases are
// wanted, each on a release-type profile saying which kinds of release.
const client = useQueryClient();
const followsStore = createFollowsQuery();
const follows = $derived($followsStore);
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
let busy = $state<string | null>(null);
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: ['adminFollows'] })
]);
}
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 hideBroken(e: Event) {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}
</script>
{#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}
@@ -0,0 +1,73 @@
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 { Follow, ReleaseProfilesResponse } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createFollowsQuery: vi.fn(),
createReleaseProfilesQuery: vi.fn(),
putFollow: vi.fn().mockResolvedValue({ following: true }),
deleteFollow: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import FollowList from './FollowList.svelte';
import { createFollowsQuery, createReleaseProfilesQuery, deleteFollow, putFollow } from '#lib/api/wanted.js';
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const follow: Follow = {
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
profile_id: 'p-std',
profile_name: 'Standard',
followed_at: ago(10 * 86_400_000),
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(follows: Follow[] = [follow]) {
vi.mocked(createFollowsQuery).mockReturnValue(
mockQuery({ data: follows }) as unknown as ReturnType<typeof createFollowsQuery>
);
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
);
return render(FollowList);
}
describe('FollowList', () => {
test('lists artists; the profile can change and the artist be unfollowed', async () => {
setup();
expect(screen.getAllByTestId('follow-row')).toHaveLength(1);
expect(screen.getByText(/2 albums wanted/)).toBeTruthy();
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('with no follows it says where to follow from', () => {
setup([]);
expect(screen.getByText(/follow one from its artist page/i)).toBeTruthy();
});
});
@@ -0,0 +1,263 @@
<script lang="ts">
import { Album, ChevronDown, ListChecks, Search, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createWantsQuery, removeWant, 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 WantCandidates from '#lib/components/WantCandidates.svelte';
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
// One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
// the missing albums wanted back, or the ones held. Each open want says
// what its last search found and how its download is getting on; Releases
// lists every candidate with its verdict, and Search runs one now.
let { state }: { state: WantState } = $props();
const client = useQueryClient();
const wantsStore = $derived(createWantsQuery(state));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
let busy = $state<string | null>(null);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
async function onRemove(w: Want) {
busy = w.id;
try {
await removeWant(w.id);
pushToast(`${w.title} is off the wanted list.`);
await client.invalidateQueries({ queryKey: ['adminWants'] });
} 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;
}
}
// A download still running, in a line: how far it has got, or what its
// import is doing or waiting on.
function downloading(w: Want): string | null {
const g = w.grab;
if (!g?.live) return null;
switch (g.import_state) {
case 'downloading':
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
case 'importing':
return `Downloaded; checking it is the album and filing it · ${g.name}`;
case 'waiting':
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
case 'review':
return `Needs you: ${g.import_reason}`;
case 'failed':
return `Import failed: ${g.import_reason}`;
}
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
}
// What the schedule last did about a want, in a line.
function status(w: Want): string {
const live = downloading(w);
if (live) return live;
const s = w.search;
let said = 'Not searched yet';
if (s) {
const when = `Searched ${relativeTime(s.searched_at)}`;
switch (s.outcome) {
case 'nothing':
said = s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
break;
case 'waiting':
said = `${when}: ${s.considered} found, nothing taken`;
break;
case 'grabbed':
said = `${when}: took ${s.best_release}`;
break;
default:
said = `${when}: ${s.error}`;
}
}
if (w.grab?.import_state === 'refused') said += `. Last download refused: ${w.grab.import_reason}`;
return said;
}
// How a held album arrived, when Minstrel fetched it.
function arrived(w: Want): string | null {
const g = w.grab;
if (!g || g.import_state !== 'imported' || !g.imported_at) return null;
const n = g.imported_files === 1 ? '1 track' : `${g.imported_files} tracks`;
return `Imported ${n} ${relativeTime(g.imported_at)} from ${g.name}`;
}
const attention = (w: Want) => w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
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>
{#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 state === 'held'}Nothing on the list has been held yet.
{:else if state === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
</p>
<p class="mt-1 text-sm text-text-secondary">
{#if state === '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="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
data-testid="want-search"
>
{status(w)}
{#if attention(w)}
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
{/if}
</div>
{:else if arrived(w)}
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(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?.live}
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}
@@ -0,0 +1,188 @@
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 { Want, WantGrab, WantsPage } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createWantsQuery: vi.fn(),
removeWant: 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 WantList from './WantList.svelte';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
import type { WantState } from '#lib/api/types.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, activity: 1, attention: 0, blocked: 0 }
};
}
function grab(extra: Partial<WantGrab> = {}): WantGrab {
return {
id: 'g1',
name: 'Blur - Blur (1997) [FLAC]',
grabbed_at: ago(60_000),
origin: 'schedule',
quality: 'lossless',
live: true,
end_reason: '',
progress: 0,
import_state: '',
import_reason: '',
imported_at: null,
imported_files: 0,
...extra
};
}
afterEach(() => vi.clearAllMocks());
function setup(
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
state: WantState = 'wanted'
) {
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType<typeof createWantsQuery>
);
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
);
return render(WantList, { state });
}
describe('WantList', () => {
test('asks for its own state', () => {
setup([], 'missing');
expect(createWantsQuery).toHaveBeenCalledWith('missing');
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
});
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('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('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: grab() }),
want('w4', { title: '13', grab: grab({ import_state: 'downloading', progress: 0.42 }) })
]);
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);
expect(lines[3]).toMatch(/downloading 42%/i);
});
test('a download waiting on a person says why and points to Activity', () => {
setup([
want('w1', { grab: grab({ import_state: 'review', import_reason: 'Only the lengths agree. A person decides.' }) })
]);
const line = screen.getByTestId('want-search');
expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity');
// A live download can't be searched for again.
expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true);
});
test('a refused download is named beside the search that follows it', () => {
setup([
want('w1', {
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', error: '' },
grab: grab({ live: false, end_reason: 'refused', import_state: 'refused', import_reason: 'Another album.' })
})
]);
expect(screen.getByTestId('want-search').textContent).toMatch(/last download refused: another album/i);
});
test('a held album says how it arrived', () => {
setup(
[
want('w1', {
held_at: ago(DAY),
album_id: 'al-1',
grab: grab({ live: false, end_reason: 'imported', import_state: 'imported', imported_at: ago(DAY), imported_files: 11 })
})
],
'held'
);
expect(screen.getByTestId('want-arrived').textContent).toMatch(/imported 11 tracks .* from blur - blur/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');
});
});
+3
View File
@@ -26,6 +26,9 @@
"bad_body": "Couldn't read the request.",
"bad_kind": "Invalid request type.",
"bad_paging": "Invalid page size or offset.",
"invalid_paging": "Invalid page size or offset.",
"not_decidable": "That download isn't waiting on a decision any more. Refresh the list.",
"bad_import_root": "New music has to be filed into one of the library's folders.",
"bad_reason": "Invalid quarantine reason.",
"mbid_required": "An MBID is required for this lookup.",
"system_playlist_readonly": "System playlists can't be edited directly.",