feat: the Wanted view lists artists A–Z with their albums inside, each album's tracks a click away (M502 #5572)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The list runs like the library: artists by the library's sort name, each artist's albums oldest first, grouped under the artist (linked into the library when it holds them). A filter narrows it to an artist or album by name, on the server, so it finds what has not been scrolled to. Tracks opens an album's track list: the library's own for an album it holds, a lost file marked, otherwise MusicBrainz's standard edition, named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,8 @@ 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) => ['adminWants', { state }] as const,
|
||||
adminWants: (state: string, q: string = '') => ['adminWants', { state, q }] as const,
|
||||
wantTracks: (id: string) => ['wantTracks', id] as const,
|
||||
adminFollows: () => ['adminFollows'] as const,
|
||||
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
|
||||
releaseProfiles: () => ['releaseProfiles'] as const,
|
||||
|
||||
@@ -821,6 +821,13 @@ export type LibraryListing = { listed_at: string | null; unlisted: number; unmat
|
||||
|
||||
export type WantsPage = Page<Want> & { 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
|
||||
// edition, named by edition; none when MusicBrainz lists no edition.
|
||||
export type WantTrack = { disc: number; number: number; title: string; length_ms: number; missing: boolean };
|
||||
|
||||
export type WantTracks = { source: 'library' | 'musicbrainz' | 'none'; edition?: string; tracks: WantTrack[] };
|
||||
|
||||
export type Follow = {
|
||||
artist_mbid: string;
|
||||
artist_name: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
|
||||
import { createInfiniteQuery, createQuery, keepPreviousData } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type {
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
SearchResult,
|
||||
SearchSettings,
|
||||
WantState,
|
||||
WantTracks,
|
||||
WantsPage
|
||||
} from './types';
|
||||
|
||||
@@ -21,8 +22,11 @@ import type {
|
||||
// artists it follows for new releases, and the release-type profiles that
|
||||
// say which kinds of release a follow wants.
|
||||
|
||||
export async function listWants(state: WantState, offset: number = 0): Promise<WantsPage> {
|
||||
return api.get<WantsPage>(`/api/admin/wanted?state=${state}&offset=${offset}`);
|
||||
// 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> {
|
||||
const params = new URLSearchParams({ state, offset: String(offset) });
|
||||
if (q) params.set('q', q);
|
||||
return api.get<WantsPage>(`/api/admin/wanted?${params}`);
|
||||
}
|
||||
|
||||
export function wantsNextOffset(last: WantsPage): number | undefined {
|
||||
@@ -31,16 +35,32 @@ export function wantsNextOffset(last: WantsPage): number | undefined {
|
||||
}
|
||||
|
||||
// Loads as the page scrolls (rule 172), one tab at a time.
|
||||
export function createWantsQuery(state: WantState) {
|
||||
export function createWantsQuery(state: WantState, q: string = '') {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.adminWants(state),
|
||||
queryFn: ({ pageParam }) => listWants(state, pageParam as number),
|
||||
queryKey: qk.adminWants(state, q),
|
||||
queryFn: ({ pageParam }) => listWants(state, pageParam as number, q),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: wantsNextOffset,
|
||||
// A new filter keeps the list it narrows on screen until its answer comes.
|
||||
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> {
|
||||
return api.get<WantTracks>(`/api/admin/wanted/${id}/tracks`);
|
||||
}
|
||||
|
||||
export function createWantTracksQuery(id: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.wantTracks(id),
|
||||
queryFn: () => getWantTracks(id),
|
||||
staleTime: 5 * 60_000
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeWant(id: string): Promise<void> {
|
||||
await api.del(`/api/admin/wanted/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Album, ChevronDown, ListChecks, Search, X } from 'lucide-svelte';
|
||||
import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
@@ -7,7 +7,9 @@
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import ListContinuation from '#lib/components/ListContinuation.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,
|
||||
@@ -17,19 +19,41 @@
|
||||
// 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.
|
||||
//
|
||||
// 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();
|
||||
|
||||
const client = useQueryClient();
|
||||
const wantsStore = $derived(createWantsQuery(list));
|
||||
let filter = $state('');
|
||||
const q = $derived(filter.trim());
|
||||
const wantsStore = $derived(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);
|
||||
|
||||
// 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[] };
|
||||
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] });
|
||||
}
|
||||
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);
|
||||
|
||||
async function onRemove(w: Want) {
|
||||
busy = w.id;
|
||||
@@ -150,6 +174,8 @@
|
||||
return 'Its files went missing';
|
||||
}
|
||||
|
||||
const albums = (n: number) => (n === 1 ? '1 album' : `${n} albums`);
|
||||
|
||||
function kind(w: Want): string {
|
||||
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
|
||||
if (w.year) parts.push(w.year);
|
||||
@@ -207,10 +233,20 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !wants.isPending && (items.length > 0 || q)}
|
||||
<div class="mb-3">
|
||||
<QuickFilter bind:value={filter} placeholder="Filter artists and albums" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
{: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>.
|
||||
</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" />
|
||||
@@ -229,100 +265,113 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each items as w (w.id)}
|
||||
<li class="space-y-2 p-3" data-testid="want-row">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
|
||||
<Album size={22} strokeWidth={1} class="text-text-muted" />
|
||||
{#if w.image_url}
|
||||
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
|
||||
<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">
|
||||
<span class="truncate text-sm font-medium text-text-primary">
|
||||
{#if a.artistId}
|
||||
<a href="/artists/{a.artistId}" class="hover:underline">{a.name}</a>
|
||||
{:else}
|
||||
{a.name}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<div class="truncate text-sm font-medium text-text-primary">
|
||||
{#if w.album_id && w.held_at}
|
||||
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
|
||||
{:else}
|
||||
{w.title}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{#if w.artist_id}
|
||||
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
|
||||
{:else}
|
||||
{w.artist_name}
|
||||
{/if}
|
||||
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
|
||||
</div>
|
||||
<div class="text-xs text-text-muted" data-testid="want-why">
|
||||
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
|
||||
</div>
|
||||
{#if !w.held_at}
|
||||
<div
|
||||
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
||||
data-testid="want-search"
|
||||
>
|
||||
{status(w)}
|
||||
{#if attention(w)}
|
||||
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-text-muted">{albums(a.wants.length)}</span>
|
||||
</h2>
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
|
||||
<Album size={22} strokeWidth={1} class="text-text-muted" />
|
||||
{#if w.image_url}
|
||||
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<div class="truncate text-sm font-medium text-text-primary">
|
||||
{#if w.album_id && w.held_at}
|
||||
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
|
||||
{:else}
|
||||
{w.title}
|
||||
{/if}
|
||||
{#if kind(w)}<span class="text-xs font-normal text-text-muted"> · {kind(w)}</span>{/if}
|
||||
</div>
|
||||
<div class="text-xs text-text-muted" data-testid="want-why">
|
||||
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
|
||||
</div>
|
||||
{#if !w.held_at}
|
||||
<div
|
||||
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
||||
data-testid="want-search"
|
||||
>
|
||||
{status(w)}
|
||||
{#if attention(w)}
|
||||
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if arrived(w)}
|
||||
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (shown = shown === w.id ? null : w.id)}
|
||||
aria-expanded={shown === w.id}
|
||||
aria-label="Tracks of {w.title}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
<ListMusic size={12} strokeWidth={2} /> Tracks
|
||||
<ChevronDown size={12} strokeWidth={2} class={shown === w.id ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
{#if !w.held_at}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSearch(w)}
|
||||
disabled={searching !== null || !!w.grab?.live}
|
||||
aria-label="Search for {w.title} now"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = open === w.id ? null : w.id)}
|
||||
aria-expanded={open === w.id}
|
||||
aria-label="Releases found for {w.title}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onRemove(w)}
|
||||
disabled={busy === w.id}
|
||||
aria-label={w.held_at ? `Take ${w.title} off the list` : `Stop wanting ${w.title}`}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<X size={12} strokeWidth={2} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if arrived(w)}
|
||||
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if w.held_at}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onRemove(w)}
|
||||
disabled={busy === w.id}
|
||||
aria-label="Take {w.title} off the list"
|
||||
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<X size={12} strokeWidth={2} /> Remove
|
||||
</button>
|
||||
{/if}
|
||||
{#if !w.held_at}
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSearch(w)}
|
||||
disabled={searching !== null || !!w.grab?.live}
|
||||
aria-label="Search for {w.title} now"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = open === w.id ? null : w.id)}
|
||||
aria-expanded={open === w.id}
|
||||
aria-label="Releases found for {w.title}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onRemove(w)}
|
||||
disabled={busy === w.id}
|
||||
aria-label="Stop wanting {w.title}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<X size={12} strokeWidth={2} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if open === w.id && !w.held_at}
|
||||
<div class="sm:pl-[4.25rem]">
|
||||
<WantCandidates wantId={w.id} title={w.title} />
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{#if shown === w.id}
|
||||
<div class="sm:pl-[4.25rem]">
|
||||
<WantTracks wantId={w.id} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if open === w.id && !w.held_at}
|
||||
<div class="sm:pl-[4.25rem]">
|
||||
<WantCandidates wantId={w.id} title={w.title} />
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ListContinuation
|
||||
hasMore={!!wants.hasNextPage}
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('#lib/api/wanted.js', () => ({
|
||||
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
|
||||
}),
|
||||
createCandidatesQuery: vi.fn(),
|
||||
createWantTracksQuery: vi.fn(),
|
||||
grabCandidate: vi.fn(),
|
||||
blockCandidate: vi.fn(),
|
||||
unblockRelease: vi.fn()
|
||||
@@ -18,8 +19,8 @@ vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import WantList from './WantList.svelte';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||
import type { WantState } from '#lib/api/types.js';
|
||||
import { createCandidatesQuery, createWantTracksQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||
import type { WantState, WantTracks } from '#lib/api/types.js';
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
@@ -81,7 +82,15 @@ afterEach(() => vi.clearAllMocks());
|
||||
function setup(
|
||||
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
|
||||
state: WantState = 'wanted',
|
||||
library?: LibraryListing
|
||||
library?: LibraryListing,
|
||||
tracks: WantTracks = {
|
||||
source: 'musicbrainz',
|
||||
edition: '2023 · CD · GB',
|
||||
tracks: [
|
||||
{ disc: 1, number: 1, title: 'The Ballad', length_ms: 214000, missing: false },
|
||||
{ disc: 1, number: 2, title: 'St. Charles Square', length_ms: 3_725_000, missing: false }
|
||||
]
|
||||
}
|
||||
) {
|
||||
vi.mocked(createWantsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page(items, library)] }) as unknown as ReturnType<typeof createWantsQuery>
|
||||
@@ -89,13 +98,16 @@ function setup(
|
||||
vi.mocked(createCandidatesQuery).mockReturnValue(
|
||||
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
|
||||
);
|
||||
vi.mocked(createWantTracksQuery).mockReturnValue(
|
||||
mockQuery({ data: tracks }) as unknown as ReturnType<typeof createWantTracksQuery>
|
||||
);
|
||||
return render(WantList, { list: state });
|
||||
}
|
||||
|
||||
describe('WantList', () => {
|
||||
test('asks for its own state', () => {
|
||||
setup([], 'missing');
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('missing');
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('missing', '');
|
||||
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -227,4 +239,56 @@ describe('WantList', () => {
|
||||
expect(screen.getByTestId('want-candidates')).toBeTruthy();
|
||||
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
|
||||
});
|
||||
|
||||
test('albums sit under their artist, as in the library', () => {
|
||||
setup([
|
||||
want('w1', { artist_mbid: 'blur', artist_name: 'Blur', artist_id: 'art-1', title: 'Parklife' }),
|
||||
want('w2', { artist_mbid: 'blur', artist_name: 'Blur', artist_id: 'art-1', title: '13' }),
|
||||
want('w3', { artist_mbid: 'camel', artist_name: 'Camel', artist_id: undefined, title: 'Moonmadness' })
|
||||
]);
|
||||
const sections = screen.getAllByTestId('want-artist');
|
||||
expect(sections.map((s) => s.getAttribute('aria-label'))).toEqual(['Blur', 'Camel']);
|
||||
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();
|
||||
});
|
||||
|
||||
test('Tracks shows the album\'s tracks, with the edition they come from', async () => {
|
||||
setup();
|
||||
expect(screen.queryByTestId('want-tracks')).toBeNull();
|
||||
const toggle = screen.getByRole('button', { name: /tracks of parklife/i });
|
||||
await fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(createWantTracksQuery).toHaveBeenCalledWith('w2');
|
||||
const panel = screen.getByTestId('want-tracks');
|
||||
expect(panel.textContent).toMatch(/MusicBrainz's standard edition\s+·\s+2023 · CD · GB/);
|
||||
const rows = screen.getAllByTestId('want-track').map((e) => e.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(rows).toEqual(['1 The Ballad 3:34', '2 St. Charles Square 1:02:05']);
|
||||
});
|
||||
|
||||
test('a held album\'s tracks are its own, a lost file marked', async () => {
|
||||
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })], 'held', undefined, {
|
||||
source: 'library',
|
||||
tracks: [
|
||||
{ disc: 1, number: 1, title: 'The Ballad', length_ms: 214000, missing: true },
|
||||
{ disc: 2, number: 1, title: 'Bonus', length_ms: 0, missing: false }
|
||||
]
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: /tracks of the ballad of darren/i }));
|
||||
const panel = screen.getByTestId('want-tracks');
|
||||
expect(panel.textContent).not.toMatch(/MusicBrainz/);
|
||||
const rows = screen.getAllByTestId('want-track').map((e) => e.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(rows).toEqual(['1-1 The Ballad file missing 3:34', '2-1 Bonus']);
|
||||
});
|
||||
|
||||
test('the filter asks the server for the artists and albums it names', async () => {
|
||||
setup();
|
||||
await fireEvent.input(screen.getByRole('searchbox', { name: /filter artists and albums/i }), {
|
||||
target: { value: ' park ' }
|
||||
});
|
||||
await waitFor(() => expect(createWantsQuery).toHaveBeenLastCalledWith('wanted', 'park'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { createWantTracksQuery } from '#lib/api/wanted.js';
|
||||
import { formatDuration } from '#lib/media/duration.js';
|
||||
import { errCode } from '#lib/api/errors.js';
|
||||
import type { WantTrack } from '#lib/api/types.js';
|
||||
|
||||
// An album's tracks under its row in the Wanted view (#5572): the
|
||||
// library's own for an album it holds, a lost file marked, otherwise
|
||||
// MusicBrainz's standard edition of what is wanted.
|
||||
|
||||
let { wantId }: { wantId: string } = $props();
|
||||
|
||||
const tracksStore = $derived(createWantTracksQuery(wantId));
|
||||
const tracks = $derived($tracksStore);
|
||||
const list = $derived(tracks.data?.tracks ?? []);
|
||||
const discs = $derived(list.some((t) => t.disc > 1));
|
||||
const away = $derived(tracks.isError && errCode(tracks.error) === 'musicbrainz_unreachable');
|
||||
|
||||
const position = (t: WantTrack) => (discs ? `${t.disc}-${t.number}` : String(t.number || ''));
|
||||
</script>
|
||||
|
||||
<div class="text-xs" data-testid="want-tracks">
|
||||
{#if tracks.isPending}
|
||||
<p class="text-text-secondary">Reading the tracks…</p>
|
||||
{:else if tracks.isError}
|
||||
<p class="text-text-secondary">
|
||||
{away ? "MusicBrainz can't be reached, so the tracks can't be shown." : "Couldn't load the tracks."}
|
||||
</p>
|
||||
{:else if tracks.data.source === 'none' || list.length === 0}
|
||||
<p class="text-text-secondary">MusicBrainz lists no edition of this album, so there are no tracks to show.</p>
|
||||
{:else}
|
||||
{#if tracks.data.source === 'musicbrainz'}
|
||||
<p class="mb-1 text-text-muted">
|
||||
MusicBrainz's standard edition{tracks.data.edition ? ` · ${tracks.data.edition}` : ''}
|
||||
</p>
|
||||
{/if}
|
||||
<ol class="space-y-0.5">
|
||||
{#each list as t, i (i)}
|
||||
<li class="flex items-baseline gap-2" data-testid="want-track">
|
||||
<span class="w-8 shrink-0 text-right tabular-nums text-text-muted">{position(t)}</span>
|
||||
<span class="min-w-0 flex-1 truncate {t.missing ? 'text-text-muted line-through' : 'text-text-primary'}">
|
||||
{t.title}
|
||||
</span>
|
||||
{#if t.missing}<span class="shrink-0 text-text-secondary">file missing</span>{/if}
|
||||
{#if t.length_ms > 0}
|
||||
<span class="shrink-0 tabular-nums text-text-muted">{formatDuration(t.length_ms / 1000)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user