Files
minstrel/web/src/lib/components/wanted/WantList.svelte
T

309 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}