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:
@@ -48,6 +48,9 @@ export const qk = {
|
||||
// Search and grab (M502 #5521).
|
||||
wantCandidates: (wantId: string) => ['wantCandidates', wantId] as const,
|
||||
searchSettings: () => ['searchSettings'] as const,
|
||||
// The Wanted view's Activity and Blocked tabs (#5522).
|
||||
downloads: () => ['adminWants', 'downloads'] as const,
|
||||
releaseBlocks: () => ['adminWants', 'releaseBlocks'] as const,
|
||||
myQuarantine: () => ['myQuarantine'] as const,
|
||||
adminQuarantine: () => ['adminQuarantine'] as const,
|
||||
adminQuarantineActions: (limit?: number) =>
|
||||
|
||||
@@ -606,11 +606,32 @@ export type WantSearch = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
// What an import made of a finished download (M502 #5522).
|
||||
export type ImportState =
|
||||
| ''
|
||||
| 'downloading'
|
||||
| 'waiting'
|
||||
| 'importing'
|
||||
| 'imported'
|
||||
| 'refused'
|
||||
| 'review'
|
||||
| 'held'
|
||||
| 'failed';
|
||||
|
||||
// A want's latest download, running or ended.
|
||||
export type WantGrab = {
|
||||
id: string;
|
||||
name: string;
|
||||
grabbed_at: string;
|
||||
origin: 'schedule' | 'operator';
|
||||
quality: string;
|
||||
live: boolean;
|
||||
end_reason: string;
|
||||
progress: number;
|
||||
import_state: ImportState;
|
||||
import_reason: string;
|
||||
imported_at: string | null;
|
||||
imported_files: number;
|
||||
};
|
||||
|
||||
// Search and grab (M502 #5521) ----------------------------------------------
|
||||
@@ -685,9 +706,77 @@ export type SearchSettings = {
|
||||
tiers: QualityTier[];
|
||||
last_pass_at: string | null;
|
||||
last_pass: SearchPass;
|
||||
// The library folder new music is filed into ("" = the first of
|
||||
// scan_paths), and the library's folders (#5522).
|
||||
import_root: string;
|
||||
scan_paths: string[];
|
||||
};
|
||||
|
||||
export type WantCounts = { wanted: number; missing: number; held: number; following: number };
|
||||
export type WantCounts = {
|
||||
wanted: number;
|
||||
missing: number;
|
||||
held: number;
|
||||
following: number;
|
||||
// Downloads running, those of them waiting on a person, releases set aside.
|
||||
activity: number;
|
||||
attention: number;
|
||||
blocked: number;
|
||||
};
|
||||
|
||||
// What an import's check counted (#5522).
|
||||
export type ImportEvidence = {
|
||||
files: number;
|
||||
tracks: number;
|
||||
identified: number;
|
||||
recordings: number;
|
||||
lengths: number;
|
||||
titles: number;
|
||||
group_tag?: string;
|
||||
album_tag?: string;
|
||||
fingerprinted: boolean;
|
||||
};
|
||||
|
||||
// One download on the Activity tab.
|
||||
export type Download = {
|
||||
id: string;
|
||||
want_id: string | null;
|
||||
want_title: string;
|
||||
want_artist: string;
|
||||
name: string;
|
||||
indexer: string;
|
||||
client: string;
|
||||
quality: string;
|
||||
origin: 'schedule' | 'operator';
|
||||
grabbed_at: string;
|
||||
ended_at: string | null;
|
||||
end_reason: string;
|
||||
progress: number;
|
||||
import_state: ImportState;
|
||||
import_reason: string;
|
||||
imported_at: string | null;
|
||||
imported_files: number;
|
||||
destination: string;
|
||||
edition?: string;
|
||||
evidence?: ImportEvidence;
|
||||
files: string[];
|
||||
skipped: number;
|
||||
rehomed_from?: string;
|
||||
};
|
||||
|
||||
export type DownloadsPage = { items: Download[]; limit: number; offset: number };
|
||||
|
||||
// A release set aside: by a person, by its files, or by an import's check.
|
||||
export type ReleaseBlock = {
|
||||
info_hash: string;
|
||||
name: string;
|
||||
reason: 'operator' | 'files' | 'import';
|
||||
detail: string;
|
||||
blocked_at: string;
|
||||
want_title: string;
|
||||
want_artist: string;
|
||||
};
|
||||
|
||||
export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset: number };
|
||||
|
||||
export type WantsPage = Page<Want> & { counts: WantCounts };
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type {
|
||||
Candidate,
|
||||
DownloadsPage,
|
||||
Follow,
|
||||
ReleaseBlocksPage,
|
||||
FollowState,
|
||||
ReleaseProfile,
|
||||
ReleaseProfileInput,
|
||||
@@ -157,6 +159,56 @@ export async function putSearchSettings(body: {
|
||||
auto_grab: boolean;
|
||||
search_interval_hours: number;
|
||||
allowed_qualities: string[];
|
||||
import_root: string;
|
||||
}): Promise<SearchSettings> {
|
||||
return api.put<SearchSettings>('/api/admin/acquisition/settings', body);
|
||||
}
|
||||
|
||||
// Downloads and their imports (M502 #5522) ----------------------------------
|
||||
|
||||
// A page holding fewer than it asked for is the last.
|
||||
function nextOffset(last: { items: unknown[]; limit: number; offset: number }): number | undefined {
|
||||
return last.items.length < last.limit ? undefined : last.offset + last.items.length;
|
||||
}
|
||||
|
||||
export async function listDownloads(offset: number = 0): Promise<DownloadsPage> {
|
||||
return api.get<DownloadsPage>(`/api/admin/downloads?offset=${offset}`);
|
||||
}
|
||||
|
||||
// Running downloads first; refreshed every few seconds while one runs.
|
||||
export function createDownloadsQuery() {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.downloads(),
|
||||
queryFn: ({ pageParam }) => listDownloads(pageParam as number),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: nextOffset,
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages[0]?.items.some((d) => !d.ended_at) ? 5_000 : false
|
||||
});
|
||||
}
|
||||
|
||||
export async function importAnyway(id: string): Promise<void> {
|
||||
await api.post(`/api/admin/downloads/${id}/import`, {});
|
||||
}
|
||||
|
||||
export async function retryImport(id: string): Promise<void> {
|
||||
await api.post(`/api/admin/downloads/${id}/retry`, {});
|
||||
}
|
||||
|
||||
export async function refuseDownload(id: string): Promise<void> {
|
||||
await api.post(`/api/admin/downloads/${id}/refuse`, {});
|
||||
}
|
||||
|
||||
export async function listReleaseBlocks(offset: number = 0): Promise<ReleaseBlocksPage> {
|
||||
return api.get<ReleaseBlocksPage>(`/api/admin/release-blocks?offset=${offset}`);
|
||||
}
|
||||
|
||||
export function createReleaseBlocksQuery() {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.releaseBlocks(),
|
||||
queryFn: ({ pageParam }) => listReleaseBlocks(pageParam as number),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: nextOffset,
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/wanted', label: 'Wanted' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/missing-files', label: 'Missing files' },
|
||||
{ href: '/admin/duplicates', label: 'Duplicates' },
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders all twelve tabs in order', () => {
|
||||
test('renders all eleven tabs in order', () => {
|
||||
state.pageUrl = new URL('http://localhost/admin');
|
||||
render(AdminTabs);
|
||||
const links = screen.getAllByRole('link');
|
||||
@@ -60,8 +60,6 @@ describe('AdminTabs', () => {
|
||||
'Overview',
|
||||
'Integrations',
|
||||
'Requests',
|
||||
// Wanted follows Requests: an approved request goes on the wanted list.
|
||||
'Wanted',
|
||||
'Quarantine',
|
||||
// Missing files sits with Quarantine and Playback errors: the three
|
||||
// surfaces that show tracks needing an operator's attention.
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
When a file has been missing for a while, Minstrel can put its album back on the
|
||||
<a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline">wanted list</a>
|
||||
<a href="/wanted/missing" class="text-accent-fg hover:underline">wanted list</a>
|
||||
by itself. It's wanted per album, not per track: a whole folder going missing is one
|
||||
want, not forty.
|
||||
</p>
|
||||
|
||||
@@ -167,8 +167,7 @@
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Release-type profiles</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Each followed artist is on one profile, which says which of their new releases are
|
||||
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted</a>. Requests for a
|
||||
specific album aren't filtered.
|
||||
wanted. Requests for a specific album aren't filtered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
// Searching and grabbing (M502 #5521): how often each want is searched
|
||||
// for, whether the best release is taken without asking, and which
|
||||
// qualities are taken at all. The schedule wakes every ten minutes and
|
||||
// keeps to each indexer's searches-per-hour.
|
||||
// keeps to each indexer's searches-per-hour. A finished download is
|
||||
// checked and filed into one of the library's folders (#5522).
|
||||
|
||||
const client = useQueryClient();
|
||||
const store = createSearchSettingsQuery();
|
||||
const query = $derived($store);
|
||||
|
||||
type Form = { auto_grab: boolean; search_interval_hours: number; allowed: string[] };
|
||||
type Form = { auto_grab: boolean; search_interval_hours: number; allowed: string[]; import_root: string };
|
||||
|
||||
// The form is the server's settings until edited; saved holds what a save
|
||||
// returned until the query catches up.
|
||||
@@ -27,7 +28,12 @@
|
||||
const form = $derived<Form | null>(edited ?? (base ? fromSettings(base) : null));
|
||||
|
||||
function fromSettings(s: SearchSettings): Form {
|
||||
return { auto_grab: s.auto_grab, search_interval_hours: s.search_interval_hours, allowed: [...s.allowed_qualities] };
|
||||
return {
|
||||
auto_grab: s.auto_grab,
|
||||
search_interval_hours: s.search_interval_hours,
|
||||
allowed: [...s.allowed_qualities],
|
||||
import_root: s.import_root
|
||||
};
|
||||
}
|
||||
|
||||
const dirty = $derived(!!edited && !!base && JSON.stringify(edited) !== JSON.stringify(fromSettings(base)));
|
||||
@@ -48,7 +54,8 @@
|
||||
saved = await putSearchSettings({
|
||||
auto_grab: form.auto_grab,
|
||||
search_interval_hours: Number(form.search_interval_hours),
|
||||
allowed_qualities: form.allowed
|
||||
allowed_qualities: form.allowed,
|
||||
import_root: form.import_root
|
||||
});
|
||||
edited = null;
|
||||
await client.invalidateQueries({ queryKey: qk.searchSettings() });
|
||||
@@ -81,7 +88,7 @@
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Searching and grabbing</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Minstrel searches your indexers for each album on the
|
||||
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
|
||||
<a href="/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
|
||||
release it finds, and hands the best one to your download client. Each album's releases and
|
||||
the reason for every verdict are on the Wanted page.
|
||||
</p>
|
||||
@@ -146,6 +153,32 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if query.data.scan_paths.length > 1}
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">File new music into</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
A download that checks out is copied here as Artist/Album (Year)/Artist - Album - NN -
|
||||
Title, tagged with its MusicBrainz ids. The download itself keeps seeding.
|
||||
</span>
|
||||
<select
|
||||
value={form.import_root || query.data.scan_paths[0]}
|
||||
onchange={(e) => edit({ import_root: e.currentTarget.value })}
|
||||
class="mt-1 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{#each query.data.scan_paths as p (p)}
|
||||
<option value={p}>{p}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else if query.data.scan_paths.length === 1}
|
||||
<p class="text-xs text-text-secondary">
|
||||
A download that checks out is copied into <span class="font-mono">{query.data.scan_paths[0]}</span>
|
||||
as Artist/Album (Year)/Artist - Album - NN - Title, tagged with its MusicBrainz ids. The
|
||||
download itself keeps seeding.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-text-secondary" data-testid="last-pass">{lastPass(query.data)}</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -22,7 +22,9 @@ const settings: SearchSettings = {
|
||||
{ tier: 'unknown', label: 'Unknown', what: 'Nothing says what format it is' }
|
||||
],
|
||||
last_pass_at: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
last_pass: { looked: 12, grabbed: 3, waiting: 2, nothing: 7, failed: 0, paced: true }
|
||||
last_pass: { looked: 12, grabbed: 3, waiting: 2, nothing: 7, failed: 0, paced: true },
|
||||
import_root: '',
|
||||
scan_paths: ['/music', '/music2']
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -57,11 +59,29 @@ describe('SearchSettingsCard', () => {
|
||||
expect(putSearchSettings).toHaveBeenCalledWith({
|
||||
auto_grab: false,
|
||||
search_interval_hours: 24,
|
||||
allowed_qualities: ['lossless', 'lossy_high', 'unknown']
|
||||
allowed_qualities: ['lossless', 'lossy_high', 'unknown'],
|
||||
import_root: ''
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('new music is filed into the chosen library folder, the first by default', async () => {
|
||||
setup();
|
||||
const folder = screen.getByLabelText(/file new music into/i) as HTMLSelectElement;
|
||||
expect(folder.value).toBe('/music');
|
||||
await fireEvent.change(folder, { target: { value: '/music2' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(putSearchSettings).toHaveBeenCalledWith(expect.objectContaining({ import_root: '/music2' }))
|
||||
);
|
||||
});
|
||||
|
||||
test('with one library folder there is nothing to choose', () => {
|
||||
setup({ ...settings, scan_paths: ['/music'] });
|
||||
expect(screen.queryByLabelText(/file new music into/i)).toBeNull();
|
||||
expect(screen.getByText('/music')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('before the first pass it says so', () => {
|
||||
setup({ ...settings, last_pass_at: null, last_pass: {} });
|
||||
expect(screen.getByTestId('last-pass').textContent).toMatch(/hasn’t run yet/);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
|
||||
import { ChevronDown, Compass, House, LibraryBig, ListChecks } from 'lucide-svelte';
|
||||
import { user, logout } from '#lib/auth/store.svelte.js';
|
||||
import { player } from '#lib/player/store.svelte.js';
|
||||
import { appName } from '#lib/branding.js';
|
||||
@@ -52,11 +52,15 @@
|
||||
// bounce through the /library 308-redirect. isActive('/library')
|
||||
// still matches all sub-routes by prefix so the active state covers
|
||||
// every tab.
|
||||
const navItems = [
|
||||
// Wanted (M502) is an admin's: what Minstrel is fetching for the library.
|
||||
const navItems = $derived([
|
||||
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
|
||||
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
|
||||
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' }
|
||||
];
|
||||
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' },
|
||||
...(user.value?.is_admin
|
||||
? [{ href: '/wanted', label: 'Wanted', icon: ListChecks, matchPrefix: '/wanted' }]
|
||||
: [])
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
||||
|
||||
@@ -67,6 +67,21 @@ describe('Shell', () => {
|
||||
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
|
||||
});
|
||||
|
||||
test('admins get Wanted after Discover; others do not', () => {
|
||||
const primary = () =>
|
||||
screen
|
||||
.getAllByRole('link')
|
||||
.filter((el) => el.closest('nav[aria-label="Primary"]') !== null)
|
||||
.map((el) => el.textContent?.trim());
|
||||
const { unmount } = render(Shell);
|
||||
expect(primary()).not.toContain('Wanted');
|
||||
unmount();
|
||||
userState.current = { id: '1', username: 'alice', is_admin: true };
|
||||
render(Shell);
|
||||
expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Wanted']);
|
||||
expect(screen.getByRole('link', { name: 'Wanted' })).toHaveAttribute('href', '/wanted');
|
||||
});
|
||||
|
||||
test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => {
|
||||
userState.current = { id: '1', username: 'alice', is_admin: true };
|
||||
render(Shell);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import type { WantCounts } from '#lib/api/types.js';
|
||||
|
||||
// The Wanted view's sections (M502 #5520–#5522). Activity carries a second,
|
||||
// accented count when downloads are waiting on a person.
|
||||
|
||||
let { counts }: { counts?: WantCounts } = $props();
|
||||
|
||||
type Item = { href: string; label: string; count?: number; attention?: number };
|
||||
|
||||
const items = $derived<Item[]>([
|
||||
{ href: '/wanted', label: 'Wanted', count: counts?.wanted },
|
||||
{ href: '/wanted/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
|
||||
{ href: '/wanted/missing', label: 'Missing files', count: counts?.missing },
|
||||
{ href: '/wanted/held', label: 'Held', count: counts?.held },
|
||||
{ href: '/wanted/following', label: 'Following', count: counts?.following },
|
||||
{ href: '/wanted/blocked', label: 'Blocked', count: counts?.blocked }
|
||||
]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/wanted') return page.url.pathname === '/wanted';
|
||||
return page.url.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav aria-label="Wanted sections" class="border-b border-border">
|
||||
<ul class="flex gap-1 overflow-x-auto">
|
||||
{#each items as item (item.href)}
|
||||
{@const active = isActive(item.href)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors {active
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
>
|
||||
{item.label}
|
||||
{#if item.attention}
|
||||
<span
|
||||
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
|
||||
aria-label="{item.attention} need you"
|
||||
data-testid="tab-attention">{item.attention}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.count !== undefined}
|
||||
<span class="text-xs text-text-muted">{item.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,9 @@
|
||||
"bad_body": "Couldn't read the request.",
|
||||
"bad_kind": "Invalid request type.",
|
||||
"bad_paging": "Invalid page size or offset.",
|
||||
"invalid_paging": "Invalid page size or offset.",
|
||||
"not_decidable": "That download isn't waiting on a decision any more. Refresh the list.",
|
||||
"bad_import_root": "New music has to be filed into one of the library's folders.",
|
||||
"bad_reason": "Invalid quarantine reason.",
|
||||
"mbid_required": "An MBID is required for this lookup.",
|
||||
"system_playlist_readonly": "System playlists can't be edited directly.",
|
||||
|
||||
Reference in New Issue
Block a user