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
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { createWantArtistsQuery } from '#lib/api/wanted.js';
import CollectionRail from '#lib/components/wanted/CollectionRail.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
// The Collection (M502, #5572): what the library holds and wants, and what
// Minstrel is fetching for it. Web-only and admin-only. Shaped like Lidarr
// so a person moving from it finds their way: the catalogue of artists is
// the main section, and the sections down the left show state. The
// catalogue's first page carries every section's count; the Catalogue
// shares it, so this costs no extra request there.
const artistsStore = createWantArtistsQuery();
const counts = $derived($artistsStore.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">Collection</h1>
<p class="text-sm text-text-secondary">
Every artist and album in your collection: what the library holds, and what Minstrel is
getting for it, from user requests, new releases of 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="collection-verdict">
{verdict}
{#if counts?.attention}
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
{/if}
</p>
{/if}
</header>
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
<div class="mb-4 md:mb-0">
<div class="md:sticky md:top-4">
<CollectionRail {counts} />
</div>
</div>
<div class="min-w-0">
{@render children?.()}
</div>
</div>
</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 CatalogueGrid from '#lib/components/wanted/CatalogueGrid.svelte';
</script>
<svelte:head><title>{pageTitle('Collection')}</title></svelte:head>
<CatalogueGrid />
@@ -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('Collection · Activity')}</title></svelte:head>
<DownloadList />
@@ -0,0 +1,15 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
// One artist in the Collection's catalogue (#5572): every album of theirs
// on the list, held or not, with its tracks a click away.
const mbid = $derived(page.params.mbid ?? '');
</script>
<svelte:head><title>{pageTitle('Collection · Artist')}</title></svelte:head>
{#key mbid}
<WantList artist={mbid} />
{/key}
@@ -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('Collection · Blocked')}</title></svelte:head>
<BlockList />
@@ -0,0 +1,73 @@
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 { WantArtistsPage, WantCounts } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
vi.mock('$app/state', () => pageUrlModule(state));
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
const counts: WantCounts = { artists: 7, 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 = '/collection') {
state.pageUrl = new URL(`http://localhost${path}`);
const page: WantArtistsPage = {
items: [],
total: 0,
limit: 50,
offset: 0,
counts: c,
library: { listed_at: null, unlisted: 0, unmatched: [] }
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantArtistsQuery>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any);
}
describe('/collection layout', () => {
test('the sections down the left carry their counts, the current one marked', () => {
setup(counts, '/collection/activity');
const nav = screen.getByRole('navigation', { name: /collection sections/i });
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
expect(links).toEqual([
['/collection', 'Catalogue 7'],
['/collection/wanted', 'Wanted 5'],
['/collection/missing', 'Missing files 1'],
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
['/collection/blocked', 'Blocked 4']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^catalogue/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('collection-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('collection-verdict');
expect(verdict.textContent).toMatch(/2 downloads need you/);
expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/collection/activity');
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
test("an artist's page sits under Catalogue", () => {
setup(counts, '/collection/artist/blur');
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
});
});
@@ -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('Collection · Following')}</title></svelte:head>
<div class="space-y-6">
<FollowList />
<ReleaseProfilesCard />
</div>
@@ -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('Collection · Missing files')}</title></svelte:head>
<WantList list="missing" />
@@ -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('Collection · Wanted')}</title></svelte:head>
<WantList list="wanted" />