feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:14:33 -04:00
co-authored by Claude Opus 5.5
parent a4fbe44bd5
commit 358849de77
34 changed files with 994 additions and 356 deletions
@@ -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="/wanted/missing" class="text-accent-fg hover:underline">wanted list</a>
<a href="/collection/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>
@@ -104,7 +104,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="/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
<a href="/collection/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>
+4 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ChevronDown, Compass, House, LibraryBig, ListChecks } from 'lucide-svelte';
import { ChevronDown, Compass, Disc3, House, LibraryBig } 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,13 +52,14 @@
// bounce through the /library 308-redirect. isActive('/library')
// still matches all sub-routes by prefix so the active state covers
// every tab.
// Wanted (M502) is an admin's: what Minstrel is fetching for the library.
// Collection (M502) is an admin's: the catalogue of what the library holds
// and wants, and what Minstrel is fetching for it, shaped like Lidarr's.
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' },
...(user.value?.is_admin
? [{ href: '/wanted', label: 'Wanted', icon: ListChecks, matchPrefix: '/wanted' }]
? [{ href: '/collection', label: 'Collection', icon: Disc3, matchPrefix: '/collection' }]
: [])
]);
</script>
+4 -4
View File
@@ -67,19 +67,19 @@ describe('Shell', () => {
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
});
test('admins get Wanted after Discover; others do not', () => {
test('admins get Collection 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');
expect(primary()).not.toContain('Collection');
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');
expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Collection']);
expect(screen.getByRole('link', { name: 'Collection' })).toHaveAttribute('href', '/collection');
});
test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => {
-54
View File
@@ -1,54 +0,0 @@
<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,46 @@
<script lang="ts">
import Cover from '#lib/components/Cover.svelte';
import type { WantArtist } from '#lib/api/types.js';
// One artist in the Collection's catalogue (#5572), like a Lidarr artist
// poster: the picture, a bar of how many of their albums on the list are
// held, and whether Minstrel follows them for new releases.
let { artist: a }: { artist: WantArtist } = $props();
const pct = $derived(a.albums > 0 ? Math.round((a.held / a.albums) * 100) : 0);
const notes = $derived(
[
a.following ? 'Following' : '',
a.downloading ? `${a.downloading} downloading` : '',
a.missing ? `${a.missing} missing` : ''
].filter(Boolean)
);
</script>
<a
href="/collection/artist/{encodeURIComponent(a.artist_mbid)}"
class="group block overflow-hidden rounded-md border border-border bg-surface hover:border-accent focus-visible:ring-2 focus-visible:ring-accent"
data-testid="catalogue-artist"
>
<div class="relative aspect-square w-full overflow-hidden bg-surface-hover">
<Cover src={a.image_url} fallback="artist" class="transition-transform group-hover:scale-[1.03]" />
</div>
<div
class="h-1.5 w-full bg-surface-hover"
role="progressbar"
aria-label="{a.held} of {a.albums} albums held"
aria-valuemin={0}
aria-valuemax={a.albums}
aria-valuenow={a.held}
>
<div class="h-full bg-accent" style="width: {pct}%"></div>
</div>
<div class="space-y-0.5 px-2 py-1.5 text-center">
<div class="truncate text-sm font-medium text-text-primary">{a.artist_name}</div>
<div class="text-xs tabular-nums text-text-secondary">{a.held} / {a.albums} held</div>
{#if notes.length}
<div class="truncate text-xs text-text-muted">{notes.join(' · ')}</div>
{/if}
</div>
</a>
@@ -0,0 +1,110 @@
<script lang="ts">
import { ChevronDown, ListChecks } from 'lucide-svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import CatalogueArtistCard from './CatalogueArtistCard.svelte';
import type { WantArtist } from '#lib/api/types.js';
// The Collection's catalogue (#5572), its main page, shaped like Lidarr's
// artist index so a person coming from Lidarr finds their way: every
// artist on the list A–Z, each card showing how many of their albums are
// held. An artist opens on their albums. The library's albums were put on
// the list as held once (#5567), each matched to MusicBrainz (#5569); the
// ones no match was found for are named here.
let filter = $state('');
const q = $derived(filter.trim());
const artistsStore = $derived(createWantArtistsQuery(q));
const artists = $derived($artistsStore);
const items = $derived((artists.data?.pages ?? []).flatMap((p) => p.items) as WantArtist[]);
const library = $derived(artists.data?.pages[0]?.library);
let showUnmatched = $state(false);
</script>
{#if library && !library.listed_at}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
Your library's albums are being added to the catalogue. Albums whose tags don't name them are
matched on MusicBrainz first, by their artist, title and tracks, about one a second.
</p>
{:else if library && library.unlisted > 0}
<div class="mb-3 rounded-lg border border-border bg-surface p-3 text-sm" data-testid="library-listing">
<p class="text-text-primary">
{library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
{library.unlisted === 1 ? "isn't" : "aren't"} in the catalogue: no MusicBrainz album matched
{library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
{library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
</p>
{#if library.unmatched.length > 0}
<button
type="button"
class="mt-2 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
aria-expanded={showUnmatched}
onclick={() => (showUnmatched = !showUnmatched)}
>
<ChevronDown size={12} strokeWidth={2} class={showUnmatched ? 'rotate-180' : ''} />
{showUnmatched ? 'Hide them' : 'Show them'}
</button>
{#if showUnmatched}
<ul class="mt-2 space-y-2" data-testid="unmatched-albums">
{#each library.unmatched as u (u.album_id)}
<li>
<a href="/albums/{u.album_id}" class="text-text-primary hover:underline">{u.title}</a>
<span class="text-text-secondary">· {u.artist}</span>
<div class="text-xs text-text-muted">{u.detail}</div>
</li>
{/each}
</ul>
{#if library.unmatched.length < library.unlisted}
<p class="mt-2 text-xs text-text-muted">The first {library.unmatched.length} of {library.unlisted}.</p>
{/if}
{/if}
{/if}
</div>
{/if}
{#if !artists.isPending && (items.length > 0 || q)}
<div class="mb-3">
<QuickFilter bind:value={filter} placeholder="Filter artists and albums" />
</div>
{/if}
{#if artists.isPending}
<p class="text-text-secondary">Reading the catalogue…</p>
{:else if artists.isError && !artists.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the catalogue.</p>
{:else if items.length === 0 && q}
<p class="text-text-secondary" data-testid="catalogue-filter-empty">
No artist in the catalogue, or album of theirs, is named like <span class="font-medium">'{q}'</span>.
</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">The catalogue is empty.</p>
<p class="mt-1 text-sm text-text-secondary">
Your library's albums land here once it's been scanned, as do the albums users request and
the releases of artists Minstrel follows.
</p>
</div>
{:else}
<AlphabeticalGrid
{items}
getKey={(a: WantArtist) => a.sort_name || a.artist_name}
hasMore={!!artists.hasNextPage}
onLoadMore={() => artists.fetchNextPage()}
>
{#snippet item(a: WantArtist)}
<CatalogueArtistCard artist={a} />
{/snippet}
</AlphabeticalGrid>
<ListContinuation
hasMore={!!artists.hasNextPage}
loading={artists.isFetchingNextPage}
failed={artists.isFetchNextPageError}
onMore={() => artists.fetchNextPage()}
/>
{/if}
@@ -0,0 +1,103 @@
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 { LibraryListing, WantArtist, WantArtistsPage } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import CatalogueGrid from './CatalogueGrid.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function artist(mbid: string, name: string, extra: Partial<WantArtist> = {}): WantArtist {
return {
artist_mbid: mbid,
artist_name: name,
sort_name: name,
image_url: `/api/catalogue/art/artist/${mbid}`,
albums: 4,
held: 3,
wanted: 1,
missing: 0,
downloading: 0,
following: false,
...extra
};
}
function setup(
items: WantArtist[] = [
artist('blur', 'Blur', { following: true, downloading: 1 }),
artist('beatles', 'The Beatles', { sort_name: 'Beatles, The', albums: 13, held: 13, wanted: 0 })
],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
) {
const page: WantArtistsPage = {
items,
total: items.length,
limit: 96,
offset: 0,
counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0 },
library
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantArtistsQuery>
);
return render(CatalogueGrid);
}
afterEach(() => vi.clearAllMocks());
describe('CatalogueGrid', () => {
test('each artist is a card: how many albums are held, and whether they are followed', () => {
setup();
const cards = screen.getAllByTestId('catalogue-artist');
expect(cards.map((c) => c.getAttribute('href'))).toEqual(['/collection/artist/blur', '/collection/artist/beatles']);
expect(cards[0].textContent?.replace(/\s+/g, ' ')).toMatch(/Blur 3 \/ 4 held Following · 1 downloading/);
const bar = screen.getByRole('progressbar', { name: '13 of 13 albums held' });
expect(bar.getAttribute('aria-valuenow')).toBe('13');
expect((bar.firstElementChild as HTMLElement).style.width).toBe('100%');
});
test('the alphabet rail files an artist under their sort name', () => {
setup();
expect(document.getElementById('alpha-B')?.textContent).toMatch(/Blur/);
});
test('the filter asks the server for artists by name or album', async () => {
setup();
await fireEvent.input(screen.getByRole('searchbox', { name: /filter artists and albums/i }), {
target: { value: 'parklife' }
});
await waitFor(() => expect(createWantArtistsQuery).toHaveBeenLastCalledWith('parklife'));
});
test('an empty catalogue says what lands there', () => {
setup([]);
expect(screen.getByText(/the catalogue is empty/i)).toBeTruthy();
});
test('says while the library is being listed', () => {
setup([], { listed_at: null, unlisted: 0, unmatched: [] });
expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to the catalogue/i);
});
test('names the albums no MusicBrainz album matched, on request', async () => {
setup(undefined, {
listed_at: ago(DAY),
unlisted: 12,
unmatched: [
{ album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
]
});
expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't in the catalogue/i);
expect(screen.queryByTestId('unmatched-albums')).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
const list = screen.getByTestId('unmatched-albums');
expect(list.textContent).toMatch(/Demo Tape/);
expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
});
});
@@ -0,0 +1,58 @@
<script lang="ts">
import { page } from '$app/state';
import type { WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. On a
// narrow screen the rail runs across the top. 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: '/collection', label: 'Catalogue', count: counts?.artists },
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked }
]);
// An artist's page belongs to the catalogue.
function isActive(href: string): boolean {
const path = page.url.pathname;
if (href === '/collection') return path === '/collection' || path.startsWith('/collection/artist/');
return path.startsWith(href);
}
</script>
<nav aria-label="Collection sections" class="border-b border-border md:border-b-0 md:border-r md:pr-3">
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
{#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 md:rounded-md md:border-b-0 md:border-l-2 {active
? 'border-accent text-text-primary md:bg-surface-hover'
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
>
<span class="md:flex-1">{item.label}</span>
{#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 tabular-nums text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
+42 -64
View File
@@ -7,50 +7,51 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import Cover from '#lib/components/Cover.svelte';
import FollowControl from '#lib/components/FollowControl.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import WantTracks from './WantTracks.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. The
// library's albums were put on the list as held once (#5567), each matched to
// MusicBrainz (#5569); taking one off leaves its files and stops it being
// fetched back if they go. The ones no match was found for are named here.
// Albums on the Collection's list (M502 #5520–#5522): the ones still
// wanted, the missing ones wanted back, or, given an artist, every album
// of theirs in the catalogue, held or not, under a header (#5572). 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. Taking a held album off leaves its files and stops it
// being fetched back if they go.
//
// The list reads like the library (#5572): artists A–Z, each with its
// albums oldest first, and each album's tracks a click away. The filter
// narrows it to an artist or album by name, on the server, so it finds
// what has not been scrolled to yet.
let { list }: { list: WantState } = $props();
let { list = 'wanted', artist = '' }: { list?: WantState; artist?: string } = $props();
const client = useQueryClient();
let filter = $state('');
const q = $derived(filter.trim());
const wantsStore = $derived(createWantsQuery(list, q));
const wantsStore = $derived(artist ? createWantsQuery('all', '', artist) : createWantsQuery(list, q));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
const library = $derived(wants.data?.pages[0]?.library);
const head = $derived(wants.data?.pages[0]?.artist);
// The server sends the list artist by artist; each run of one artist's
// albums is a section.
type ArtistSection = { key: string; name: string; artistId?: string; wants: Want[] };
type ArtistSection = { key: string; name: string; mbid: string; wants: Want[] };
const sections = $derived.by(() => {
const out: ArtistSection[] = [];
for (const w of items) {
const key = w.artist_mbid || w.artist_name;
const last = out[out.length - 1];
if (last && last.key === key) last.wants.push(w);
else out.push({ key, name: w.artist_name, artistId: w.artist_id, wants: [w] });
else out.push({ key, name: w.artist_name, mbid: w.artist_mbid, wants: [w] });
}
return out;
});
let busy = $state<string | null>(null);
let showUnmatched = $state(false);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
let shown = $state<string | null>(null);
@@ -187,53 +188,30 @@
}
</script>
{#if list === 'held' && library}
{#if !library.listed_at}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
Your library's albums are being added to this list as held. Albums whose tags don't name them
are matched on MusicBrainz first, by their artist, title and tracks, about one a second.
</p>
{:else if library.unlisted > 0}
<div class="mb-3 rounded-lg border border-border bg-surface p-3 text-sm" data-testid="library-listing">
<p class="text-text-primary">
{library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
{library.unlisted === 1 ? "isn't" : "aren't"} on this list: no MusicBrainz album matched
{library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
{library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
</p>
{#if library.unmatched.length > 0}
<button
type="button"
class="mt-2 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
aria-expanded={showUnmatched}
onclick={() => (showUnmatched = !showUnmatched)}
>
<ChevronDown size={12} strokeWidth={2} class={showUnmatched ? 'rotate-180' : ''} />
{showUnmatched ? 'Hide them' : 'Show them'}
</button>
{#if showUnmatched}
<ul class="mt-2 space-y-2" data-testid="unmatched-albums">
{#each library.unmatched as u (u.album_id)}
<li>
<a href="/albums/{u.album_id}" class="text-text-primary hover:underline">{u.title}</a>
<span class="text-text-secondary">· {u.artist}</span>
<div class="text-xs text-text-muted">{u.detail}</div>
</li>
{/each}
</ul>
{#if library.unmatched.length < library.unlisted}
<p class="mt-2 text-xs text-text-muted">
The first {library.unmatched.length} of {library.unlisted}.
</p>
{/if}
{/if}
{/if}
{#if artist && head}
<header class="mb-4 flex items-center gap-4" data-testid="catalogue-artist-head">
<div class="relative h-24 w-24 shrink-0 overflow-hidden rounded-md bg-surface-hover">
<Cover src={head.image_url} fallback="artist" />
</div>
{/if}
<div class="min-w-0 space-y-1">
<a href="/collection" class="text-xs text-text-secondary hover:text-text-primary">← Catalogue</a>
<h2 class="truncate text-xl font-medium text-text-primary">{head.artist_name}</h2>
<p class="text-sm text-text-secondary" data-testid="catalogue-artist-counts">
{head.held} of {albums(head.albums)} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing
? ` · ${head.missing} with files missing`
: ''}{head.downloading ? ` · ${head.downloading} downloading` : ''}
</p>
<div class="flex flex-wrap items-center gap-3 text-sm">
<FollowControl artistMbid={head.artist_mbid} artistName={head.artist_name} />
{#if head.artist_id}
<a href="/artists/{head.artist_id}" class="text-accent-fg hover:underline">Open in the library</a>
{/if}
</div>
</div>
</header>
{/if}
{#if !wants.isPending && (items.length > 0 || q)}
{#if !artist && !wants.isPending && (items.length > 0 || q)}
<div class="mb-3">
<QuickFilter bind:value={filter} placeholder="Filter artists and albums" />
</div>
@@ -242,7 +220,7 @@
{#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>
<p class="text-error-fg">Couldn't load {artist ? "this artist's albums" : 'the wanted list'}.</p>
{:else if items.length === 0 && q}
<p class="text-text-secondary" data-testid="want-filter-empty">
Nothing on this list has an artist or album named like <span class="font-medium">'{q}'</span>.
@@ -251,7 +229,7 @@
<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 list === 'held'}Nothing on the list has been held yet.
{#if artist}None of this artist's albums are in the catalogue.
{:else if list === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
</p>
@@ -268,16 +246,16 @@
<div class="space-y-4">
{#each sections as a, i (a.key + ':' + i)}
<section data-testid="want-artist" aria-label={a.name}>
<h2 class="mb-1.5 flex items-baseline gap-2 px-1">
{#if !artist}<h2 class="mb-1.5 flex items-baseline gap-2 px-1">
<span class="truncate text-sm font-medium text-text-primary">
{#if a.artistId}
<a href="/artists/{a.artistId}" class="hover:underline">{a.name}</a>
{#if a.mbid}
<a href="/collection/artist/{encodeURIComponent(a.mbid)}" class="hover:underline">{a.name}</a>
{:else}
{a.name}
{/if}
</span>
<span class="shrink-0 text-xs text-text-muted">{albums(a.wants.length)}</span>
</h2>
</h2>{/if}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each a.wants as w (w.id)}
<li class="space-y-2 p-3" data-testid="want-row">
@@ -307,7 +285,7 @@
>
{status(w)}
{#if attention(w)}
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
{/if}
</div>
{:else if arrived(w)}
+97 -74
View File
@@ -3,18 +3,26 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
import type { LibraryListing, 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(),
createWantTracksQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
}));
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
// The artist page's FollowControl.
createFollowQuery: vi.fn(() => mockQuery({ data: { following: true } })),
createReleaseProfilesQuery: vi.fn(() => mockQuery({ data: { profiles: [], primary_types: [], secondary_types: [] } })),
putFollow: vi.fn(),
deleteFollow: vi.fn(),
createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
createWantTracksQuery: 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';
@@ -27,53 +35,55 @@ 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
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[],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] },
artist?: WantsPage['artist']
): 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 },
library
artist,
items,
total: items.length,
limit: 50,
offset: 0,
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
library
};
}
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: '',
end_detail: '',
imported_at: null,
imported_files: 0,
...extra
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: '',
end_detail: '',
imported_at: null,
imported_files: 0,
...extra
};
}
@@ -133,29 +143,6 @@ describe('WantList', () => {
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w1'));
});
test('the held tab says while the library is being listed', () => {
setup([], 'held', { listed_at: null, unlisted: 0, unmatched: [] });
expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to this list/i);
});
test('the held tab names the albums no MusicBrainz album matched, on request', async () => {
setup([want('w1', { held_at: ago(DAY) })], 'held', {
listed_at: ago(DAY),
unlisted: 12,
unmatched: [
{ album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
]
});
expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't on this list/i);
expect(screen.queryByTestId('unmatched-albums')).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
const list = screen.getByTestId('unmatched-albums');
expect(list.textContent).toMatch(/Demo Tape/);
expect(list.textContent).toMatch(/has no album called/);
expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
});
test('an empty list explains what lands there', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
@@ -184,7 +171,7 @@ describe('WantList', () => {
]);
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');
expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/collection/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);
});
@@ -251,9 +238,9 @@ describe('WantList', () => {
expect(sections[0].querySelectorAll('[data-testid="want-row"]').length).toBe(2);
expect(sections[0].textContent).toMatch(/2 albums/);
expect(sections[1].textContent).toMatch(/1 album/);
// The artist links into the library when it holds them.
expect(screen.getByRole('link', { name: 'Blur' }).getAttribute('href')).toBe('/artists/art-1');
expect(screen.queryByRole('link', { name: 'Camel' })).toBeNull();
// Each artist opens on their page in the catalogue.
expect(screen.getByRole('link', { name: 'Blur' }).getAttribute('href')).toBe('/collection/artist/blur');
expect(screen.getByRole('link', { name: 'Camel' }).getAttribute('href')).toBe('/collection/artist/camel');
});
test('Tracks shows the album\'s tracks, with the edition they come from', async () => {
@@ -291,4 +278,40 @@ describe('WantList', () => {
});
await waitFor(() => expect(createWantsQuery).toHaveBeenLastCalledWith('wanted', 'park'));
});
test("an artist's page lists every album of theirs under a header", () => {
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({
pages: [
page([want('w1', { held_at: ago(DAY), album_id: 'al-1', title: 'Parklife' }), want('w2')], undefined, {
artist_mbid: 'blur',
artist_name: 'Blur',
sort_name: 'Blur',
artist_id: 'art-1',
image_url: '/art/blur',
albums: 2,
held: 1,
wanted: 1,
missing: 0,
downloading: 0,
following: true
})
]
}) as unknown as ReturnType<typeof createWantsQuery>
);
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
);
render(WantList, { artist: 'blur' });
expect(createWantsQuery).toHaveBeenCalledWith('all', '', 'blur');
const head = screen.getByTestId('catalogue-artist-head');
expect(head.textContent).toMatch(/Blur/);
expect(screen.getByTestId('catalogue-artist-counts').textContent).toMatch(/1 of 2 albums held\s+·\s+1 wanted/);
expect(screen.getByRole('link', { name: /open in the library/i }).getAttribute('href')).toBe('/artists/art-1');
expect(screen.getByRole('link', { name: /catalogue/i }).getAttribute('href')).toBe('/collection');
// One artist: no filter, no artist headings, both albums.
expect(screen.queryByRole('searchbox')).toBeNull();
expect(screen.getAllByTestId('want-row')).toHaveLength(2);
expect(screen.queryByRole('link', { name: 'Blur' })).toBeNull();
});
});