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
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:
@@ -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>
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user