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:
@@ -41,7 +41,9 @@ export const qk = {
|
||||
['adminRequests', { status: status ?? 'all' }] as const,
|
||||
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
|
||||
// invalidation refreshes every tab and its counts.
|
||||
adminWants: (state: string, q: string = '') => ['adminWants', { state, q }] as const,
|
||||
adminWants: (state: string, q: string = '', artist: string = '') =>
|
||||
['adminWants', { state, q, artist }] as const,
|
||||
wantArtists: (q: string = '') => ['adminWants', 'artists', { q }] as const,
|
||||
wantTracks: (id: string) => ['wantTracks', id] as const,
|
||||
adminFollows: () => ['adminFollows'] as const,
|
||||
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
|
||||
|
||||
@@ -571,7 +571,8 @@ export type ResolvedDuplicatesResponse = {
|
||||
|
||||
// library: in the library when the list began (#5567).
|
||||
export type WantReason = 'request' | 'follow' | 'missing' | 'library';
|
||||
export type WantState = 'wanted' | 'missing' | 'held';
|
||||
// all: one artist's page, every album of theirs on the list (#5572).
|
||||
export type WantState = 'wanted' | 'missing' | 'held' | 'all';
|
||||
|
||||
// One release group on the wanted list. held_at is set once the library
|
||||
// holds it; artist_id and album_id link into the library when it has them.
|
||||
@@ -722,6 +723,8 @@ export type SearchSettings = {
|
||||
};
|
||||
|
||||
export type WantCounts = {
|
||||
// Artists in the Collection's catalogue (#5572).
|
||||
artists: number;
|
||||
wanted: number;
|
||||
missing: number;
|
||||
held: number;
|
||||
@@ -819,7 +822,26 @@ export type UnmatchedAlbum = { album_id: string; title: string; artist: string;
|
||||
|
||||
export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
|
||||
|
||||
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing };
|
||||
// artist heads the list when it is narrowed to one artist's page.
|
||||
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
|
||||
|
||||
// One artist in the Collection's catalogue (#5572), like Lidarr's artist
|
||||
// index: how many of their albums on the list are held, of how many.
|
||||
export type WantArtist = {
|
||||
artist_mbid: string;
|
||||
artist_name: string;
|
||||
sort_name: string;
|
||||
artist_id?: string;
|
||||
image_url: string;
|
||||
albums: number;
|
||||
held: number;
|
||||
wanted: number;
|
||||
missing: number;
|
||||
downloading: number;
|
||||
following: boolean;
|
||||
};
|
||||
|
||||
export type WantArtistsPage = Page<WantArtist> & { counts: WantCounts; library: LibraryListing };
|
||||
|
||||
// An album's tracks in the Wanted view (#5572): the library's own for an
|
||||
// album it holds (a lost file marked missing), else MusicBrainz's standard
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GrabResult,
|
||||
SearchResult,
|
||||
SearchSettings,
|
||||
WantArtistsPage,
|
||||
WantState,
|
||||
WantTracks,
|
||||
WantsPage
|
||||
@@ -22,10 +23,17 @@ import type {
|
||||
// artists it follows for new releases, and the release-type profiles that
|
||||
// say which kinds of release a follow wants.
|
||||
|
||||
// q narrows the list to an artist or album whose name holds it (#5572).
|
||||
export async function listWants(state: WantState, offset: number = 0, q: string = ''): Promise<WantsPage> {
|
||||
// q narrows the list to an artist or album whose name holds it; artist to
|
||||
// one artist's page, which the response then heads (#5572).
|
||||
export async function listWants(
|
||||
state: WantState,
|
||||
offset: number = 0,
|
||||
q: string = '',
|
||||
artist: string = ''
|
||||
): Promise<WantsPage> {
|
||||
const params = new URLSearchParams({ state, offset: String(offset) });
|
||||
if (q) params.set('q', q);
|
||||
if (artist) params.set('artist', artist);
|
||||
return api.get<WantsPage>(`/api/admin/wanted?${params}`);
|
||||
}
|
||||
|
||||
@@ -35,10 +43,10 @@ export function wantsNextOffset(last: WantsPage): number | undefined {
|
||||
}
|
||||
|
||||
// Loads as the page scrolls (rule 172), one tab at a time.
|
||||
export function createWantsQuery(state: WantState, q: string = '') {
|
||||
export function createWantsQuery(state: WantState, q: string = '', artist: string = '') {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.adminWants(state, q),
|
||||
queryFn: ({ pageParam }) => listWants(state, pageParam as number, q),
|
||||
queryKey: qk.adminWants(state, q, artist),
|
||||
queryFn: ({ pageParam }) => listWants(state, pageParam as number, q, artist),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: wantsNextOffset,
|
||||
// A new filter keeps the list it narrows on screen until its answer comes.
|
||||
@@ -47,6 +55,29 @@ export function createWantsQuery(state: WantState, q: string = '') {
|
||||
});
|
||||
}
|
||||
|
||||
// The Collection's catalogue (#5572): every artist on the list, A–Z, with
|
||||
// how many of their albums are held. Its first page carries every section's
|
||||
// count.
|
||||
export async function listWantArtists(offset: number = 0, q: string = ''): Promise<WantArtistsPage> {
|
||||
const params = new URLSearchParams({ offset: String(offset) });
|
||||
if (q) params.set('q', q);
|
||||
return api.get<WantArtistsPage>(`/api/admin/wanted/artists?${params}`);
|
||||
}
|
||||
|
||||
export function createWantArtistsQuery(q: string = '') {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.wantArtists(q),
|
||||
queryFn: ({ pageParam }) => listWantArtists(pageParam as number, q),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last: WantArtistsPage) => {
|
||||
const loaded = last.offset + last.items.length;
|
||||
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
// An album's tracks (#5572): the library's own for an album it holds,
|
||||
// otherwise MusicBrainz's standard edition.
|
||||
export async function getWantTracks(id: string): Promise<WantTracks> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
{#if r.held_at}
|
||||
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
|
||||
{:else if r.wanted_at}
|
||||
On the <a href="/wanted/missing" class="text-accent-fg hover:underline"
|
||||
On the <a href="/collection/missing" class="text-accent-fg hover:underline"
|
||||
>wanted list</a
|
||||
> since {relativeTime(r.wanted_at)}.
|
||||
{/if}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('admin missing files', () => {
|
||||
expect(
|
||||
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
|
||||
).toBe(
|
||||
'/wanted/missing'
|
||||
'/collection/missing'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
|
||||
<p class="text-text-secondary">
|
||||
Approve or set aside what users have asked for. An approved album or track goes on the
|
||||
<a href="/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
|
||||
<a href="/collection/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
|
||||
artist is followed, wanting what they've released and what they release next.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -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,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 />
|
||||
+1
-1
@@ -3,6 +3,6 @@
|
||||
import DownloadList from '#lib/components/wanted/DownloadList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Activity')}</title></svelte:head>
|
||||
<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}
|
||||
+1
-1
@@ -3,6 +3,6 @@
|
||||
import BlockList from '#lib/components/wanted/BlockList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Blocked')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Blocked')}</title></svelte:head>
|
||||
|
||||
<BlockList />
|
||||
+28
-23
@@ -3,23 +3,23 @@ import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { mockInfiniteQuery } from '../../test-utils/query';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import type { WantCounts, WantsPage } from '#lib/api/types.js';
|
||||
import type { WantArtistsPage, WantCounts } from '#lib/api/types.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
|
||||
vi.mock('$app/state', () => pageUrlModule(state));
|
||||
vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
|
||||
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
|
||||
|
||||
import WantedLayout from './+layout.svelte';
|
||||
import { createWantsQuery } from '#lib/api/wanted.js';
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
|
||||
const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
|
||||
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 = '/wanted') {
|
||||
function setup(c: WantCounts = counts, path = '/collection') {
|
||||
state.pageUrl = new URL(`http://localhost${path}`);
|
||||
const page: WantsPage = {
|
||||
const page: WantArtistsPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
@@ -27,42 +27,47 @@ function setup(c: WantCounts = counts, path = '/wanted') {
|
||||
counts: c,
|
||||
library: { listed_at: null, unlisted: 0, unmatched: [] }
|
||||
};
|
||||
vi.mocked(createWantsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantsQuery>
|
||||
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('/wanted layout', () => {
|
||||
test('tabs are links carrying their counts, the current one marked', () => {
|
||||
setup(counts, '/wanted/activity');
|
||||
const nav = screen.getByRole('navigation', { name: /wanted sections/i });
|
||||
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([
|
||||
['/wanted', 'Wanted 5'],
|
||||
['/wanted/activity', 'Activity 3'],
|
||||
['/wanted/missing', 'Missing files 1'],
|
||||
['/wanted/held', 'Held 9'],
|
||||
['/wanted/following', 'Following 2'],
|
||||
['/wanted/blocked', 'Blocked 4']
|
||||
['/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: /^wanted/i })).not.toHaveAttribute('aria-current');
|
||||
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('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
|
||||
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('wanted-verdict');
|
||||
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('/wanted/activity');
|
||||
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');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Following')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Following')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<FollowList />
|
||||
+1
-1
@@ -3,6 +3,6 @@
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Missing files')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Missing files')}</title></svelte:head>
|
||||
|
||||
<WantList list="missing" />
|
||||
@@ -3,6 +3,6 @@
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted')}</title></svelte:head>
|
||||
<svelte:head><title>{pageTitle('Collection · Wanted')}</title></svelte:head>
|
||||
|
||||
<WantList list="wanted" />
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createWantsQuery } from '#lib/api/wanted.js';
|
||||
import WantedTabs from '#lib/components/WantedTabs.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
|
||||
// The Wanted view (M502): what Minstrel is getting for the library, its
|
||||
// downloads, and what it has set aside. Web-only and admin-only. The
|
||||
// wanted query's first page carries every tab's count; the Wanted tab
|
||||
// shares it, so this costs no extra request there.
|
||||
const wantsStore = createWantsQuery('wanted');
|
||||
const counts = $derived($wantsStore.data?.pages?.[0]?.counts);
|
||||
|
||||
// The one line that answers "does anything need me?"
|
||||
const verdict = $derived.by(() => {
|
||||
if (!counts) return '';
|
||||
if (counts.attention) {
|
||||
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`;
|
||||
}
|
||||
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
|
||||
if (counts.activity) parts.push(`${counts.activity} downloading`);
|
||||
return parts.join(', ') + '. Nothing needs you.';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Wanted</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Albums Minstrel is getting: ones users requested, new releases from the artists it follows,
|
||||
and albums whose files went missing. It searches your indexers on a schedule, checks each
|
||||
download against MusicBrainz, and files what's right into the library. Searching and quality
|
||||
are set under <a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
|
||||
</p>
|
||||
{#if verdict}
|
||||
<p class="text-sm {counts?.attention ? 'text-text-primary' : 'text-text-secondary'}" data-testid="wanted-verdict">
|
||||
{verdict}
|
||||
{#if counts?.attention}
|
||||
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<WantedTabs {counts} />
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Held')}</title></svelte:head>
|
||||
|
||||
<WantList list="held" />
|
||||
Reference in New Issue
Block a user