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
+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)}