feat: import with verification, and a Wanted view of its own (M502 #5522)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A finished download is checked against MusicBrainz before anything is filed: the files are paired with an edition's tracks by position or path, and AcoustID, tag ids, lengths and titles say whether it is the album. What is the album is copied into the library as Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's MusicBrainz ids, and scanned; the download keeps seeding. What is not is refused: set aside, removed from the client, and the album searched for again. A download the check cannot settle waits for a person, who imports it anyway or refuses it. Tracks the library already holds are skipped. The wanted list leaves Admin for a top-level Wanted view, admin-only and web-only, with tabs for Wanted, Activity (downloads and their imports, the ones needing a person first), Missing files, Held, Following (with the release-type profiles, moved off Integrations) and Blocked. The search settings gain "File new music into" when the library has several folders. The search pass logs why each failed search failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { Ban } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createReleaseBlocksQuery, unblockRelease } from '#lib/api/wanted.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 type { ReleaseBlock } from '#lib/api/types.js';
|
||||
|
||||
// The Blocked tab (M502 #5521–#5522): releases the search will never take
|
||||
// again — set aside by a person, by what their files were, or by an
|
||||
// import's check that found they weren't the album.
|
||||
|
||||
const client = useQueryClient();
|
||||
const blocksStore = createReleaseBlocksQuery();
|
||||
const blocks = $derived($blocksStore);
|
||||
const items = $derived((blocks.data?.pages ?? []).flatMap((p) => p.items) as ReleaseBlock[]);
|
||||
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
async function onAllow(b: ReleaseBlock) {
|
||||
busy = b.info_hash;
|
||||
try {
|
||||
await unblockRelease(b.info_hash);
|
||||
pushToast(`${b.name} may be taken again.`);
|
||||
await client.invalidateQueries({ queryKey: ['adminWants'] });
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function by(b: ReleaseBlock): string {
|
||||
if (b.reason === 'operator') return 'Set aside by you';
|
||||
if (b.reason === 'files') return 'Set aside for its files';
|
||||
return 'Refused on import';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if blocks.isPending}
|
||||
<p class="text-text-secondary">Reading the blocked releases…</p>
|
||||
{:else if blocks.isError && !blocks.isFetchNextPageError}
|
||||
<p class="text-error-fg">Couldn't load the blocked releases.</p>
|
||||
{:else if items.length === 0}
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<Ban size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">No releases are blocked.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A release you set aside, or one an import found wasn't the album, lands here so it's never
|
||||
taken again.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each items as b (b.info_hash)}
|
||||
<li class="flex flex-wrap items-start gap-3 p-3" data-testid="block-row">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<div class="truncate text-sm font-medium text-text-primary" title={b.name}>{b.name || b.info_hash}</div>
|
||||
{#if b.want_title}
|
||||
<div class="truncate text-xs text-text-secondary">For {b.want_artist} – {b.want_title}</div>
|
||||
{/if}
|
||||
<div class="text-xs text-text-muted">{by(b)} {relativeTime(b.blocked_at)}</div>
|
||||
{#if b.detail}
|
||||
<p class="text-xs text-text-secondary">{b.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAllow(b)}
|
||||
disabled={busy === b.info_hash}
|
||||
aria-label="Allow {b.name} again"
|
||||
class="shrink-0 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"
|
||||
>
|
||||
Allow again
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ListContinuation
|
||||
hasMore={!!blocks.hasNextPage}
|
||||
loading={blocks.isFetchingNextPage}
|
||||
failed={blocks.isFetchNextPageError}
|
||||
onMore={() => blocks.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import type { ReleaseBlock } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createReleaseBlocksQuery: vi.fn(),
|
||||
unblockRelease: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import BlockList from './BlockList.svelte';
|
||||
import { createReleaseBlocksQuery, unblockRelease } from '#lib/api/wanted.js';
|
||||
|
||||
const block: ReleaseBlock = {
|
||||
info_hash: 'abc123',
|
||||
name: 'Daft Punk - Random Access Memories (Drumless Edition)',
|
||||
reason: 'import',
|
||||
detail: 'The album tag says another version.',
|
||||
blocked_at: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
want_title: 'Random Access Memories',
|
||||
want_artist: 'Daft Punk'
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(items: ReleaseBlock[]) {
|
||||
vi.mocked(createReleaseBlocksQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [{ items, total: items.length, limit: 50, offset: 0 }] }) as unknown as ReturnType<
|
||||
typeof createReleaseBlocksQuery
|
||||
>
|
||||
);
|
||||
return render(BlockList);
|
||||
}
|
||||
|
||||
describe('BlockList', () => {
|
||||
test('a blocked release says why and can be allowed again', async () => {
|
||||
setup([block]);
|
||||
expect(screen.getByText(/refused on import/i)).toBeTruthy();
|
||||
expect(screen.getByText(/another version/)).toBeTruthy();
|
||||
expect(screen.getByText(/for daft punk – random access memories/i)).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /allow .* again/i }));
|
||||
await waitFor(() => expect(unblockRelease).toHaveBeenCalledWith('abc123'));
|
||||
});
|
||||
|
||||
test('with nothing blocked it says so', () => {
|
||||
setup([]);
|
||||
expect(screen.getByText(/no releases are blocked/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { Download as DownloadIcon } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createDownloadsQuery, importAnyway, refuseDownload, retryImport } from '#lib/api/wanted.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 type { Download, ImportEvidence } from '#lib/api/types.js';
|
||||
|
||||
// The Activity tab (M502 #5522): every download Minstrel took, running
|
||||
// ones first. A finished download is checked against MusicBrainz before it
|
||||
// is filed; one the check could not settle waits here for a person, who
|
||||
// imports it anyway or refuses it. A refused release is set aside (the
|
||||
// Blocked tab) and its album searched for again.
|
||||
|
||||
const client = useQueryClient();
|
||||
const downloadsStore = createDownloadsQuery();
|
||||
const downloads = $derived($downloadsStore);
|
||||
const items = $derived((downloads.data?.pages ?? []).flatMap((p) => p.items) as Download[]);
|
||||
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
async function act(d: Download, run: (id: string) => Promise<void>, said: string) {
|
||||
busy = d.id;
|
||||
try {
|
||||
await run(d.id);
|
||||
pushToast(said);
|
||||
await client.invalidateQueries({ queryKey: ['adminWants'] });
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
const onImport = (d: Download) =>
|
||||
act(d, importAnyway, `${d.name} will be imported within the minute.`);
|
||||
const onRetry = (d: Download) => act(d, retryImport, `${d.name} will be tried again within the minute.`);
|
||||
const onRefuse = (d: Download) =>
|
||||
act(d, refuseDownload, `Refused ${d.name}. It's set aside and the album will be searched for again.`);
|
||||
|
||||
// The state of a download as a short label, and whether it needs a person.
|
||||
function label(d: Download): string {
|
||||
switch (d.import_state) {
|
||||
case 'downloading':
|
||||
case '':
|
||||
return d.ended_at ? 'Ended' : 'Downloading';
|
||||
case 'waiting':
|
||||
return 'Waiting';
|
||||
case 'importing':
|
||||
return 'Importing';
|
||||
case 'imported':
|
||||
return 'Imported';
|
||||
case 'refused':
|
||||
return 'Refused';
|
||||
case 'review':
|
||||
return 'Needs you';
|
||||
case 'held':
|
||||
return 'Already held';
|
||||
case 'failed':
|
||||
return 'Import failed';
|
||||
}
|
||||
return d.import_state;
|
||||
}
|
||||
|
||||
const needsYou = (d: Download) => !d.ended_at && (d.import_state === 'review' || d.import_state === 'failed');
|
||||
|
||||
function badge(d: Download): string {
|
||||
if (needsYou(d)) return 'border-accent text-text-primary';
|
||||
if (d.import_state === 'refused') return 'border-error-fg text-error-fg';
|
||||
return 'border-border text-text-secondary';
|
||||
}
|
||||
|
||||
// What the check counted, in a line.
|
||||
function counted(e: ImportEvidence): string {
|
||||
const parts = [`${e.files} files against ${e.tracks} tracks`];
|
||||
if (e.fingerprinted) parts.push(`${e.recordings} identified by AcoustID`);
|
||||
else parts.push('no AcoustID');
|
||||
parts.push(`${e.lengths} lengths agree`, `${e.titles} titles agree`);
|
||||
if (e.album_tag) parts.push(`album tag “${e.album_tag}”`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function when(d: Download): string {
|
||||
const who = d.origin === 'operator' ? 'by you' : 'by the schedule';
|
||||
let said = `Taken ${who} ${relativeTime(d.grabbed_at)}`;
|
||||
if (d.indexer) said += ` from ${d.indexer}`;
|
||||
if (d.imported_at) said += ` · imported ${relativeTime(d.imported_at)}`;
|
||||
else if (d.ended_at) said += ` · ended ${relativeTime(d.ended_at)}`;
|
||||
return said;
|
||||
}
|
||||
|
||||
function filed(d: Download): string | null {
|
||||
if (d.import_state !== 'imported' && d.import_state !== 'held') return null;
|
||||
let said = d.imported_files === 1 ? '1 track filed' : `${d.imported_files} tracks filed`;
|
||||
if (d.skipped) said += `, ${d.skipped} already held`;
|
||||
if (d.destination) said += ` into ${d.destination}`;
|
||||
return said;
|
||||
}
|
||||
|
||||
const button =
|
||||
'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';
|
||||
</script>
|
||||
|
||||
{#if downloads.isPending}
|
||||
<p class="text-text-secondary">Reading the downloads…</p>
|
||||
{:else if downloads.isError && !downloads.isFetchNextPageError}
|
||||
<p class="text-error-fg">Couldn't load the downloads.</p>
|
||||
{:else if items.length === 0}
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<DownloadIcon size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">Nothing has been downloaded yet.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A release the search takes, or one you take from a want's Releases, shows here while it
|
||||
downloads and once it's imported.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each items as d (d.id)}
|
||||
<li class="space-y-1.5 p-3" data-testid="download-row">
|
||||
<div class="flex flex-wrap items-start gap-x-3 gap-y-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-text-primary">
|
||||
{d.want_artist ? `${d.want_artist} – ${d.want_title}` : d.name}
|
||||
</div>
|
||||
<div class="truncate text-xs text-text-secondary" title={d.name}>
|
||||
{d.name}{#if d.quality}<span class="text-text-muted"> · {d.quality}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-2 py-0.5 text-xs {badge(d)}" data-testid="download-state">
|
||||
{label(d)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !d.ended_at && (d.import_state === 'downloading' || d.import_state === '')}
|
||||
<div
|
||||
class="h-1.5 overflow-hidden rounded-full bg-surface-hover"
|
||||
role="progressbar"
|
||||
aria-label="Downloaded"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.floor(d.progress * 100)}
|
||||
>
|
||||
<div class="h-full bg-accent" style="width: {Math.floor(d.progress * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if d.import_reason}
|
||||
<p class="text-xs {needsYou(d) ? 'text-text-primary' : 'text-text-secondary'}" data-testid="download-reason">
|
||||
{d.import_reason}
|
||||
</p>
|
||||
{/if}
|
||||
{#if d.evidence}
|
||||
<p class="text-xs text-text-muted">{counted(d.evidence)}</p>
|
||||
{/if}
|
||||
{#if filed(d)}
|
||||
<p class="truncate text-xs text-text-secondary" title={d.destination}>{filed(d)}</p>
|
||||
{/if}
|
||||
{#if d.rehomed_from}
|
||||
<p class="text-xs text-text-muted">Taken for {d.rehomed_from}; it turned out to be this album.</p>
|
||||
{/if}
|
||||
<p class="text-xs text-text-muted">{when(d)}</p>
|
||||
|
||||
{#if needsYou(d)}
|
||||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||||
{#if d.import_state === 'review'}
|
||||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onImport(d)}>
|
||||
Import anyway
|
||||
</button>
|
||||
{:else}
|
||||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onRetry(d)}>
|
||||
Try again
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class={button} disabled={busy === d.id} onclick={() => onRefuse(d)}>
|
||||
Refuse
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ListContinuation
|
||||
hasMore={!!downloads.hasNextPage}
|
||||
loading={downloads.isFetchingNextPage}
|
||||
failed={downloads.isFetchNextPageError}
|
||||
onMore={() => downloads.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||
import type { Download } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createDownloadsQuery: vi.fn(),
|
||||
importAnyway: vi.fn().mockResolvedValue(undefined),
|
||||
retryImport: vi.fn().mockResolvedValue(undefined),
|
||||
refuseDownload: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import DownloadList from './DownloadList.svelte';
|
||||
import { createDownloadsQuery, importAnyway, refuseDownload, retryImport } from '#lib/api/wanted.js';
|
||||
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
function download(id: string, extra: Partial<Download> = {}): Download {
|
||||
return {
|
||||
id,
|
||||
want_id: 'w1',
|
||||
want_title: 'Random Access Memories',
|
||||
want_artist: 'Daft Punk',
|
||||
name: 'Daft Punk - Random Access Memories (2013) [FLAC]',
|
||||
indexer: 'Prowlarr',
|
||||
client: 'qbittorrent',
|
||||
quality: 'lossless',
|
||||
origin: 'schedule',
|
||||
grabbed_at: ago(3_600_000),
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
progress: 0,
|
||||
import_state: 'downloading',
|
||||
import_reason: '',
|
||||
imported_at: null,
|
||||
imported_files: 0,
|
||||
destination: '',
|
||||
files: [],
|
||||
skipped: 0,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(items: Download[]) {
|
||||
vi.mocked(createDownloadsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [{ items, total: items.length, limit: 50, offset: 0 }] }) as unknown as ReturnType<
|
||||
typeof createDownloadsQuery
|
||||
>
|
||||
);
|
||||
return render(DownloadList);
|
||||
}
|
||||
|
||||
describe('DownloadList', () => {
|
||||
test('a running download shows how far it has got', () => {
|
||||
setup([download('d1', { progress: 0.375 })]);
|
||||
expect(screen.getByTestId('download-state').textContent).toMatch(/downloading/i);
|
||||
expect(screen.getByRole('progressbar', { name: /downloaded/i }).getAttribute('aria-valuenow')).toBe('37');
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
test('a doubtful download says why and can be imported anyway or refused', async () => {
|
||||
setup([
|
||||
download('d1', {
|
||||
import_state: 'review',
|
||||
import_reason: 'Only the lengths agree. A person decides.',
|
||||
evidence: { files: 13, tracks: 13, identified: 0, recordings: 0, lengths: 13, titles: 0, fingerprinted: false }
|
||||
})
|
||||
]);
|
||||
expect(screen.getByTestId('download-state').textContent).toMatch(/needs you/i);
|
||||
expect(screen.getByTestId('download-reason').textContent).toMatch(/a person decides/i);
|
||||
expect(screen.getByText(/13 files against 13 tracks · no AcoustID · 13 lengths agree/)).toBeTruthy();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /import anyway/i }));
|
||||
await waitFor(() => expect(importAnyway).toHaveBeenCalledWith('d1'));
|
||||
await fireEvent.click(screen.getByRole('button', { name: /refuse/i }));
|
||||
await waitFor(() => expect(refuseDownload).toHaveBeenCalledWith('d1'));
|
||||
});
|
||||
|
||||
test('a failed import can be tried again', async () => {
|
||||
setup([download('d1', { import_state: 'failed', import_reason: 'The library folder is not writable.' })]);
|
||||
expect(screen.queryByRole('button', { name: /import anyway/i })).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
await waitFor(() => expect(retryImport).toHaveBeenCalledWith('d1'));
|
||||
});
|
||||
|
||||
test('an imported download says what was filed where', () => {
|
||||
setup([
|
||||
download('d1', {
|
||||
ended_at: ago(60_000),
|
||||
end_reason: 'imported',
|
||||
import_state: 'imported',
|
||||
imported_at: ago(60_000),
|
||||
imported_files: 12,
|
||||
skipped: 1,
|
||||
destination: '/music/Daft Punk/Random Access Memories (2013)'
|
||||
})
|
||||
]);
|
||||
expect(screen.getByTestId('download-state').textContent).toMatch(/imported/i);
|
||||
expect(screen.getByText(/12 tracks filed, 1 already held into \/music\/Daft Punk/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
test('with nothing downloaded it says what lands here', () => {
|
||||
setup([]);
|
||||
expect(screen.getByText(/nothing has been downloaded yet/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { Disc3 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createFollowsQuery,
|
||||
createReleaseProfilesQuery,
|
||||
putFollow,
|
||||
deleteFollow
|
||||
} from '#lib/api/wanted.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import type { Follow } from '#lib/api/types.js';
|
||||
|
||||
// The Following tab (M502 #5520): every artist whose new releases are
|
||||
// wanted, each on a release-type profile saying which kinds of release.
|
||||
|
||||
const client = useQueryClient();
|
||||
const followsStore = createFollowsQuery();
|
||||
const follows = $derived($followsStore);
|
||||
const profilesStore = createReleaseProfilesQuery();
|
||||
const profiles = $derived($profilesStore.data?.profiles ?? []);
|
||||
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ['adminWants'] }),
|
||||
client.invalidateQueries({ queryKey: ['adminFollows'] })
|
||||
]);
|
||||
}
|
||||
|
||||
async function onProfile(f: Follow, profileId: string) {
|
||||
busy = f.artist_mbid;
|
||||
try {
|
||||
await putFollow(f.artist_mbid, { artist_name: f.artist_name, profile_id: profileId });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnfollow(f: Follow) {
|
||||
busy = f.artist_mbid;
|
||||
try {
|
||||
await deleteFollow(f.artist_mbid);
|
||||
pushToast(`No longer following ${f.artist_name}. What was already wanted stays wanted.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideBroken(e: Event) {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if follows.isPending}
|
||||
<p class="text-text-secondary">Reading who's followed…</p>
|
||||
{:else if follows.isError}
|
||||
<p class="text-error-fg">Couldn't load the followed artists.</p>
|
||||
{:else if (follows.data ?? []).length === 0}
|
||||
<p class="text-text-secondary">No artists are followed. Follow one from its artist page.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each follows.data ?? [] as f (f.artist_mbid)}
|
||||
<li class="flex flex-wrap items-center gap-3 p-3" data-testid="follow-row">
|
||||
<div class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-hover" aria-hidden="true">
|
||||
<Disc3 size={20} strokeWidth={1} class="text-text-muted" />
|
||||
{#if f.image_url}
|
||||
<img src={f.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 f.artist_id}
|
||||
<a href="/artists/{f.artist_id}" class="hover:underline">{f.artist_name}</a>
|
||||
{:else}
|
||||
{f.artist_name}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
{f.open_wants === 1 ? '1 album wanted' : `${f.open_wants} albums wanted`} ·
|
||||
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<span class="sr-only">Profile for {f.artist_name}</span>
|
||||
<select
|
||||
value={f.profile_id}
|
||||
disabled={busy === f.artist_mbid}
|
||||
onchange={(e) => onProfile(f, (e.currentTarget as HTMLSelectElement).value)}
|
||||
class="rounded-md border border-border bg-background px-2 py-1 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
{#each profiles as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onUnfollow(f)}
|
||||
disabled={busy === f.artist_mbid}
|
||||
aria-label="Unfollow {f.artist_name}"
|
||||
class="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"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { Follow, ReleaseProfilesResponse } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createFollowsQuery: vi.fn(),
|
||||
createReleaseProfilesQuery: vi.fn(),
|
||||
putFollow: vi.fn().mockResolvedValue({ following: true }),
|
||||
deleteFollow: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import FollowList from './FollowList.svelte';
|
||||
import { createFollowsQuery, createReleaseProfilesQuery, deleteFollow, putFollow } from '#lib/api/wanted.js';
|
||||
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
const follow: Follow = {
|
||||
artist_mbid: 'blur',
|
||||
artist_name: 'Blur',
|
||||
artist_id: 'art-1',
|
||||
profile_id: 'p-std',
|
||||
profile_name: 'Standard',
|
||||
followed_at: ago(10 * 86_400_000),
|
||||
checked_at: ago(3 * 3_600_000),
|
||||
open_wants: 2,
|
||||
image_url: ''
|
||||
};
|
||||
|
||||
const profiles: ReleaseProfilesResponse = {
|
||||
profiles: [
|
||||
{ id: 'p-std', name: 'Standard', primary_types: ['Album'], secondary_types: [], is_default: true, follow_count: 1 },
|
||||
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
|
||||
],
|
||||
primary_types: ['Album'],
|
||||
secondary_types: ['Live']
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(follows: Follow[] = [follow]) {
|
||||
vi.mocked(createFollowsQuery).mockReturnValue(
|
||||
mockQuery({ data: follows }) as unknown as ReturnType<typeof createFollowsQuery>
|
||||
);
|
||||
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
|
||||
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
|
||||
);
|
||||
return render(FollowList);
|
||||
}
|
||||
|
||||
describe('FollowList', () => {
|
||||
test('lists artists; the profile can change and the artist be unfollowed', async () => {
|
||||
setup();
|
||||
expect(screen.getAllByTestId('follow-row')).toHaveLength(1);
|
||||
expect(screen.getByText(/2 albums wanted/)).toBeTruthy();
|
||||
|
||||
const select = screen.getByLabelText(/profile for blur/i) as HTMLSelectElement;
|
||||
expect(select.value).toBe('p-std');
|
||||
await fireEvent.change(select, { target: { value: 'p-live' } });
|
||||
await waitFor(() =>
|
||||
expect(putFollow).toHaveBeenCalledWith('blur', { artist_name: 'Blur', profile_id: 'p-live' })
|
||||
);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /unfollow blur/i }));
|
||||
await waitFor(() => expect(deleteFollow).toHaveBeenCalledWith('blur'));
|
||||
});
|
||||
|
||||
test('with no follows it says where to follow from', () => {
|
||||
setup([]);
|
||||
expect(screen.getByText(/follow one from its artist page/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { Album, ChevronDown, ListChecks, 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 WantCandidates from '#lib/components/WantCandidates.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.
|
||||
|
||||
let { state }: { state: WantState } = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
const wantsStore = $derived(createWantsQuery(state));
|
||||
const wants = $derived($wantsStore);
|
||||
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
|
||||
|
||||
let busy = $state<string | null>(null);
|
||||
let searching = $state<string | null>(null);
|
||||
let open = $state<string | null>(null);
|
||||
|
||||
async function onRemove(w: Want) {
|
||||
busy = w.id;
|
||||
try {
|
||||
await removeWant(w.id);
|
||||
pushToast(`${w.title} is off the wanted list.`);
|
||||
await client.invalidateQueries({ queryKey: ['adminWants'] });
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function searched(w: Want, r: SearchResult): string {
|
||||
let said: string;
|
||||
switch (r.outcome) {
|
||||
case 'grabbed':
|
||||
said = `Took ${r.grabbed ?? r.best_release}.`;
|
||||
break;
|
||||
case 'waiting':
|
||||
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
|
||||
break;
|
||||
case 'nothing':
|
||||
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
|
||||
break;
|
||||
default:
|
||||
said = r.error ?? 'The search failed.';
|
||||
}
|
||||
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
|
||||
return said;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// A download still running, in a line: how far it has got, or what its
|
||||
// import is doing or waiting on.
|
||||
function downloading(w: Want): string | null {
|
||||
const g = w.grab;
|
||||
if (!g?.live) return null;
|
||||
switch (g.import_state) {
|
||||
case 'downloading':
|
||||
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
|
||||
case 'importing':
|
||||
return `Downloaded; checking it is the album and filing it · ${g.name}`;
|
||||
case 'waiting':
|
||||
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
|
||||
case 'review':
|
||||
return `Needs you: ${g.import_reason}`;
|
||||
case 'failed':
|
||||
return `Import failed: ${g.import_reason}`;
|
||||
}
|
||||
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
|
||||
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
|
||||
}
|
||||
|
||||
// What the schedule last did about a want, in a line.
|
||||
function status(w: Want): string {
|
||||
const live = downloading(w);
|
||||
if (live) return live;
|
||||
const s = w.search;
|
||||
let said = 'Not searched yet';
|
||||
if (s) {
|
||||
const when = `Searched ${relativeTime(s.searched_at)}`;
|
||||
switch (s.outcome) {
|
||||
case 'nothing':
|
||||
said = s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
|
||||
break;
|
||||
case 'waiting':
|
||||
said = `${when}: ${s.considered} found, nothing taken`;
|
||||
break;
|
||||
case 'grabbed':
|
||||
said = `${when}: took ${s.best_release}`;
|
||||
break;
|
||||
default:
|
||||
said = `${when}: ${s.error}`;
|
||||
}
|
||||
}
|
||||
if (w.grab?.import_state === 'refused') said += `. Last download refused: ${w.grab.import_reason}`;
|
||||
return said;
|
||||
}
|
||||
|
||||
// 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;
|
||||
const n = g.imported_files === 1 ? '1 track' : `${g.imported_files} tracks`;
|
||||
return `Imported ${n} ${relativeTime(g.imported_at)} from ${g.name}`;
|
||||
}
|
||||
|
||||
const attention = (w: Want) => w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
|
||||
|
||||
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';
|
||||
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 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}
|
||||
<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 state === 'held'}Nothing on the list has been held yet.
|
||||
{:else if state === 'missing'}No missing albums are wanted back.
|
||||
{:else}Nothing is wanted.{/if}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{#if state === 'missing'}
|
||||
An album whose files stay missing past the grace window on the Missing files page 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}
|
||||
<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} />
|
||||
{/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}
|
||||
</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}
|
||||
<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>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ListContinuation
|
||||
hasMore={!!wants.hasNextPage}
|
||||
loading={wants.isFetchingNextPage}
|
||||
failed={wants.isFetchNextPageError}
|
||||
onMore={() => wants.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
|
||||
import type { Want, WantGrab, WantsPage } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createWantsQuery: vi.fn(),
|
||||
removeWant: vi.fn().mockResolvedValue(undefined),
|
||||
searchWant: vi.fn().mockResolvedValue({
|
||||
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
|
||||
}),
|
||||
createCandidatesQuery: vi.fn(),
|
||||
grabCandidate: vi.fn(),
|
||||
blockCandidate: vi.fn(),
|
||||
unblockRelease: vi.fn()
|
||||
}));
|
||||
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';
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
function want(id: string, extra: Partial<Want> = {}): Want {
|
||||
return {
|
||||
id,
|
||||
release_group_mbid: `rg-${id}`,
|
||||
title: 'The Ballad of Darren',
|
||||
artist_mbid: 'blur',
|
||||
artist_name: 'Blur',
|
||||
artist_id: 'art-1',
|
||||
primary_type: 'Album',
|
||||
secondary_types: [],
|
||||
year: '2023',
|
||||
reason: 'follow',
|
||||
wanted_at: ago(2 * DAY),
|
||||
held_at: null,
|
||||
image_url: '',
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function page(items: Want[]): WantsPage {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
function grab(extra: Partial<WantGrab> = {}): WantGrab {
|
||||
return {
|
||||
id: 'g1',
|
||||
name: 'Blur - Blur (1997) [FLAC]',
|
||||
grabbed_at: ago(60_000),
|
||||
origin: 'schedule',
|
||||
quality: 'lossless',
|
||||
live: true,
|
||||
end_reason: '',
|
||||
progress: 0,
|
||||
import_state: '',
|
||||
import_reason: '',
|
||||
imported_at: null,
|
||||
imported_files: 0,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(
|
||||
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
|
||||
state: WantState = 'wanted'
|
||||
) {
|
||||
vi.mocked(createWantsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType<typeof createWantsQuery>
|
||||
);
|
||||
vi.mocked(createCandidatesQuery).mockReturnValue(
|
||||
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
|
||||
);
|
||||
return render(WantList, { state });
|
||||
}
|
||||
|
||||
describe('WantList', () => {
|
||||
test('asks for its own state', () => {
|
||||
setup([], 'missing');
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('missing');
|
||||
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a want says why it is wanted', () => {
|
||||
setup();
|
||||
const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
|
||||
expect(why[0]).toMatch(/new from a followed artist/i);
|
||||
expect(why[1]).toMatch(/requested by sam/i);
|
||||
});
|
||||
|
||||
test('remove takes a want off the list', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
|
||||
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
|
||||
});
|
||||
|
||||
test('a held want has no remove button', () => {
|
||||
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })]);
|
||||
expect(screen.queryByRole('button', { name: /stop wanting/i })).toBeNull();
|
||||
expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
|
||||
});
|
||||
|
||||
test('an empty list explains what lands there', () => {
|
||||
setup([]);
|
||||
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('each open want says what its last search found', () => {
|
||||
setup([
|
||||
want('w1'),
|
||||
want('w2', {
|
||||
title: 'Parklife',
|
||||
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' }
|
||||
}),
|
||||
want('w3', { title: 'Blur', grab: grab() }),
|
||||
want('w4', { title: '13', grab: grab({ import_state: 'downloading', progress: 0.42 }) })
|
||||
]);
|
||||
const lines = screen.getAllByTestId('want-search').map((e) => e.textContent ?? '');
|
||||
expect(lines[0]).toMatch(/not searched yet/i);
|
||||
expect(lines[1]).toMatch(/4 found, none of them this album/i);
|
||||
expect(lines[2]).toMatch(/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i);
|
||||
expect(lines[3]).toMatch(/downloading 42%/i);
|
||||
});
|
||||
|
||||
test('a download waiting on a person says why and points to Activity', () => {
|
||||
setup([
|
||||
want('w1', { grab: grab({ import_state: 'review', import_reason: 'Only the lengths agree. A person decides.' }) })
|
||||
]);
|
||||
const line = screen.getByTestId('want-search');
|
||||
expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
|
||||
expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity');
|
||||
// A live download can't be searched for again.
|
||||
expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('a refused download is named beside the search that follows it', () => {
|
||||
setup([
|
||||
want('w1', {
|
||||
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', error: '' },
|
||||
grab: grab({ live: false, end_reason: 'refused', import_state: 'refused', import_reason: 'Another album.' })
|
||||
})
|
||||
]);
|
||||
expect(screen.getByTestId('want-search').textContent).toMatch(/last download refused: another album/i);
|
||||
});
|
||||
|
||||
test('a held album says how it arrived', () => {
|
||||
setup(
|
||||
[
|
||||
want('w1', {
|
||||
held_at: ago(DAY),
|
||||
album_id: 'al-1',
|
||||
grab: grab({ live: false, end_reason: 'imported', import_state: 'imported', imported_at: ago(DAY), imported_files: 11 })
|
||||
})
|
||||
],
|
||||
'held'
|
||||
);
|
||||
expect(screen.getByTestId('want-arrived').textContent).toMatch(/imported 11 tracks .* from blur - blur/i);
|
||||
});
|
||||
|
||||
test('Search runs one now and says what came of it', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
|
||||
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2'));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info'));
|
||||
});
|
||||
|
||||
test('Releases opens what the last search found', async () => {
|
||||
setup();
|
||||
expect(screen.queryByTestId('want-candidates')).toBeNull();
|
||||
const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
|
||||
await fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(screen.getByTestId('want-candidates')).toBeTruthy();
|
||||
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user