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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user