309 lines
14 KiB
Svelte
309 lines
14 KiB
Svelte
<script lang="ts">
|
||
import { plural } from '#lib/utils/plural.js';
|
||
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';
|
||
import { errMessage } from '#lib/api/errors.js';
|
||
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 { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
|
||
import type { Want, WantState } from '#lib/api/types.js';
|
||
|
||
// 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.
|
||
//
|
||
// Upgrades (#5524) are held albums wanted for a better copy: below the
|
||
// quality set under Acquisition, or holding a video rip. They search and
|
||
// download like an open want, and say what copy they have.
|
||
//
|
||
// 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 = 'wanted', artist = '' }: { list?: WantState; artist?: string } = $props();
|
||
|
||
const client = useQueryClient();
|
||
let filter = $state('');
|
||
const q = $derived(filter.trim());
|
||
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 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; 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, mbid: w.artist_mbid, wants: [w] });
|
||
}
|
||
return out;
|
||
});
|
||
|
||
let busy = $state<string | null>(null);
|
||
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;
|
||
try {
|
||
await removeWant(w.id);
|
||
pushToast(
|
||
w.held_at
|
||
? `${w.title} is off the list. Its files stay, and it won't be fetched back if they go missing.`
|
||
: `${w.title} is off the wanted list.`
|
||
);
|
||
await client.invalidateQueries({ queryKey: ['adminWants'] });
|
||
} catch (e) {
|
||
pushToast(errMessage(e), 'error');
|
||
} finally {
|
||
busy = null;
|
||
}
|
||
}
|
||
|
||
async function onSearch(w: Want) {
|
||
searching = w.id;
|
||
try {
|
||
const r = await searchWant(w.id);
|
||
pushToast(searched(w, r), r.outcome === 'error' ? 'error' : 'info');
|
||
await Promise.all([
|
||
client.invalidateQueries({ queryKey: ['adminWants'] }),
|
||
client.invalidateQueries({ queryKey: qk.wantCandidates(w.id) })
|
||
]);
|
||
} catch (e) {
|
||
pushToast(errMessage(e), 'error');
|
||
} finally {
|
||
searching = null;
|
||
}
|
||
}
|
||
|
||
// How a held album arrived, when Minstrel fetched it.
|
||
function arrived(w: Want): string | null {
|
||
const g = w.grab;
|
||
if (!g || g.import_state !== 'imported' || !g.imported_at) return null;
|
||
return `Imported ${plural(g.imported_files, 'track', 'tracks')} ${relativeTime(g.imported_at)} from ${g.name}`;
|
||
}
|
||
|
||
// A want the schedule still searches for: open, or held but wanted for a
|
||
// better copy.
|
||
const searchable = (w: Want) => !w.held_at || !!w.upgrade;
|
||
|
||
function upgradeLine(w: Want): string {
|
||
if (w.upgrade === 'rip') return 'A track is a video rip; looking for a clean copy';
|
||
return w.held_quality ? `You have ${w.held_quality}; looking for better` : 'Looking for a better copy';
|
||
}
|
||
|
||
|
||
function why(w: Want): string {
|
||
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
|
||
if (w.reason === 'follow') return 'New from a followed artist';
|
||
if (w.reason === 'library') return 'In your library when the list began';
|
||
return 'Its files went missing';
|
||
}
|
||
|
||
|
||
function kind(w: Want): string {
|
||
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
|
||
if (w.year) parts.push(w.year);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function hideBroken(e: Event) {
|
||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||
}
|
||
</script>
|
||
|
||
{#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>
|
||
<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 {plural(head.albums, 'album', '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 !artist && !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 {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>.
|
||
</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">
|
||
{#if artist}None of this artist's albums are in the catalogue.
|
||
{:else if list === 'upgrade'}No album is wanted for a better copy.
|
||
{:else}Nothing is wanted.{/if}
|
||
</p>
|
||
<p class="mt-1 text-sm text-text-secondary">
|
||
{#if list === 'upgrade'}
|
||
An album you have below the quality set under
|
||
<a href="/admin/acquisition" class="text-accent-fg hover:underline">Acquisition</a>, or with a video
|
||
rip among its tracks, lands here.
|
||
{:else}
|
||
A request you approve, a new release from a followed artist, or an album whose files went
|
||
missing lands here.
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-4">
|
||
{#each sections as a, i (a.key + ':' + i)}
|
||
<section data-testid="want-artist" aria-label={a.name}>
|
||
{#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.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">{plural(a.wants.length, 'album', 'albums')}</span>
|
||
</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">
|
||
<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 && w.upgrade}
|
||
<div class="truncate text-xs text-text-secondary" data-testid="want-upgrade">{upgradeLine(w)}</div>
|
||
{/if}
|
||
{#if searchable(w)}
|
||
<div
|
||
class="text-xs {needsYou(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
||
data-testid="want-search"
|
||
>
|
||
{wantStatus(w)}
|
||
{#if needsYou(w)}
|
||
<a href="/collection/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 searchable(w)}
|
||
<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>
|
||
{#if shown === w.id}
|
||
<div class="sm:pl-[4.25rem]">
|
||
<WantTracks wantId={w.id} />
|
||
</div>
|
||
{/if}
|
||
{#if open === w.id && searchable(w)}
|
||
<div class="sm:pl-[4.25rem]">
|
||
<WantCandidates wantId={w.id} title={w.title} />
|
||
</div>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</section>
|
||
{/each}
|
||
</div>
|
||
|
||
<ListContinuation
|
||
hasMore={!!wants.hasNextPage}
|
||
loading={wants.isFetchingNextPage}
|
||
failed={wants.isFetchNextPageError}
|
||
onMore={() => wants.fetchNextPage()}
|
||
/>
|
||
{/if}
|