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}
|
||||
+70
-76
@@ -1,15 +1,11 @@
|
||||
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 { Follow, ReleaseProfilesResponse, Want, WantsPage } from '#lib/api/types.js';
|
||||
import type { Want, WantGrab, WantsPage } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createWantsQuery: vi.fn(),
|
||||
createFollowsQuery: vi.fn(),
|
||||
createReleaseProfilesQuery: vi.fn(),
|
||||
removeWant: vi.fn().mockResolvedValue(undefined),
|
||||
putFollow: vi.fn().mockResolvedValue({ following: true }),
|
||||
deleteFollow: vi.fn().mockResolvedValue(undefined),
|
||||
searchWant: vi.fn().mockResolvedValue({
|
||||
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
|
||||
}),
|
||||
@@ -20,18 +16,10 @@ vi.mock('#lib/api/wanted.js', () => ({
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import AdminWantedPage from './+page.svelte';
|
||||
import WantList from './WantList.svelte';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import {
|
||||
createCandidatesQuery,
|
||||
createFollowsQuery,
|
||||
createReleaseProfilesQuery,
|
||||
createWantsQuery,
|
||||
deleteFollow,
|
||||
putFollow,
|
||||
removeWant,
|
||||
searchWant
|
||||
} from '#lib/api/wanted.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();
|
||||
@@ -61,54 +49,48 @@ function page(items: Want[]): WantsPage {
|
||||
total: items.length,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: { wanted: 2, missing: 1, held: 4, following: 1 }
|
||||
counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const follow: Follow = {
|
||||
artist_mbid: 'blur',
|
||||
artist_name: 'Blur',
|
||||
artist_id: 'art-1',
|
||||
profile_id: 'p-std',
|
||||
profile_name: 'Standard',
|
||||
followed_at: ago(10 * DAY),
|
||||
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']
|
||||
};
|
||||
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' })]) {
|
||||
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(createFollowsQuery).mockReturnValue(
|
||||
mockQuery({ data: [follow] }) as unknown as ReturnType<typeof createFollowsQuery>
|
||||
);
|
||||
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
|
||||
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
|
||||
);
|
||||
vi.mocked(createCandidatesQuery).mockReturnValue(
|
||||
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
|
||||
);
|
||||
return render(AdminWantedPage);
|
||||
return render(WantList, { state });
|
||||
}
|
||||
|
||||
describe('/admin/wanted', () => {
|
||||
test('each tab shows its count', () => {
|
||||
setup();
|
||||
expect(screen.getByRole('tab', { name: /wanted/i }).textContent).toMatch(/2/);
|
||||
expect(screen.getByRole('tab', { name: /held/i }).textContent).toMatch(/4/);
|
||||
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', () => {
|
||||
@@ -118,12 +100,6 @@ describe('/admin/wanted', () => {
|
||||
expect(why[1]).toMatch(/requested by sam/i);
|
||||
});
|
||||
|
||||
test('switching tab asks for that state', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /missing files/i }));
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('missing');
|
||||
});
|
||||
|
||||
test('remove takes a want off the list', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
|
||||
@@ -136,22 +112,6 @@ describe('/admin/wanted', () => {
|
||||
expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
|
||||
});
|
||||
|
||||
test('following lists artists; the profile can change and the artist be unfollowed', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /following/i }));
|
||||
expect(screen.getAllByTestId('follow-row')).toHaveLength(1);
|
||||
|
||||
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('an empty list explains what lands there', () => {
|
||||
setup([]);
|
||||
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
|
||||
@@ -164,15 +124,49 @@ describe('/admin/wanted', () => {
|
||||
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: { name: 'Blur - Blur (1997) [FLAC]', grabbed_at: ago(60_000), origin: 'schedule', quality: 'lossless' }
|
||||
})
|
||||
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 () => {
|
||||
@@ -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.",
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
|
||||
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
|
||||
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
|
||||
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
|
||||
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
|
||||
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
|
||||
@@ -427,8 +426,6 @@
|
||||
|
||||
<SearchSettingsCard />
|
||||
|
||||
<ReleaseProfilesCard />
|
||||
|
||||
<!-- Lidarr panel -->
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
|
||||
@@ -43,25 +43,10 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
addAcquisitionService: vi.fn()
|
||||
}));
|
||||
|
||||
// ReleaseProfilesCard reads its own query; settled, so it adds no "Loading…".
|
||||
// SearchSettingsCard reads its own query; settled, so it adds no "Loading…".
|
||||
vi.mock('#lib/api/wanted.js', async () => {
|
||||
const { mockQuery } = await import('../../../test-utils/query');
|
||||
return {
|
||||
createReleaseProfilesQuery: vi.fn(() =>
|
||||
mockQuery({
|
||||
data: {
|
||||
profiles: [
|
||||
{ id: 'p-std', name: 'Standard', primary_types: ['Album', 'EP', 'Single'], secondary_types: [], is_default: true, follow_count: 3 }
|
||||
],
|
||||
primary_types: ['Album', 'EP', 'Single'],
|
||||
secondary_types: ['Live']
|
||||
}
|
||||
})
|
||||
),
|
||||
createReleaseProfile: vi.fn(),
|
||||
updateReleaseProfile: vi.fn(),
|
||||
deleteReleaseProfile: vi.fn(),
|
||||
// SearchSettingsCard, settled the same way.
|
||||
createSearchSettingsQuery: vi.fn(() =>
|
||||
mockQuery({
|
||||
data: {
|
||||
@@ -71,7 +56,9 @@ vi.mock('#lib/api/wanted.js', async () => {
|
||||
allowed_qualities: ['lossy_high'],
|
||||
tiers: [{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320' }],
|
||||
last_pass_at: null,
|
||||
last_pass: {}
|
||||
last_pass: {},
|
||||
import_root: '',
|
||||
scan_paths: ['/music']
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
{#if r.held_at}
|
||||
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
|
||||
{:else if r.wanted_at}
|
||||
On the <a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline"
|
||||
On the <a href="/wanted/missing" class="text-accent-fg hover:underline"
|
||||
>wanted list</a
|
||||
> since {relativeTime(r.wanted_at)}.
|
||||
{/if}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('admin missing files', () => {
|
||||
expect(
|
||||
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
|
||||
).toBe(
|
||||
'/admin/wanted?tab=missing'
|
||||
'/wanted/missing'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
|
||||
<p class="text-text-secondary">
|
||||
Approve or set aside what users have asked for. An approved album or track goes on the
|
||||
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
|
||||
<a href="/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
|
||||
artist is followed, wanting what they've released and what they release next.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Album, ChevronDown, Disc3, ListChecks, Search, X } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createWantsQuery,
|
||||
createFollowsQuery,
|
||||
createReleaseProfilesQuery,
|
||||
removeWant,
|
||||
putFollow,
|
||||
deleteFollow,
|
||||
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 TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
||||
import type { Follow, SearchResult, Want, WantState } from '#lib/api/types.js';
|
||||
|
||||
// The wanted list (M502 #5520): every release group Minstrel is to get,
|
||||
// whether a user requested it, a followed artist released it, or its files
|
||||
// went missing. A want is held once the library has the album; the Held tab
|
||||
// is that history. Following lists the artists whose new releases are
|
||||
// wanted, each on a release-type profile that says which kinds of release.
|
||||
// Each open want says what its last search found (#5521); Releases lists
|
||||
// every candidate with its verdict, and Search runs one now.
|
||||
|
||||
type Tab = WantState | 'following';
|
||||
const fromUrl = page.url.searchParams.get('tab');
|
||||
let tab = $state<Tab>(
|
||||
fromUrl === 'missing' || fromUrl === 'held' || fromUrl === 'following' ? fromUrl : 'wanted'
|
||||
);
|
||||
// The Following tab still reads the wanted query: it carries every tab's count.
|
||||
const wantState = $derived<WantState>(tab === 'following' ? 'wanted' : tab);
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const wantsStore = $derived(createWantsQuery(wantState));
|
||||
const wants = $derived($wantsStore);
|
||||
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
|
||||
const counts = $derived(wants.data?.pages?.[0]?.counts);
|
||||
|
||||
const followsStore = createFollowsQuery();
|
||||
const follows = $derived($followsStore);
|
||||
const profilesStore = createReleaseProfilesQuery();
|
||||
const profiles = $derived($profilesStore.data?.profiles ?? []);
|
||||
|
||||
const tabs = $derived([
|
||||
{ id: 'wanted' as Tab, label: 'Wanted', count: counts?.wanted },
|
||||
{ id: 'missing' as Tab, label: 'Missing files', count: counts?.missing },
|
||||
{ id: 'held' as Tab, label: 'Held', count: counts?.held },
|
||||
{ id: 'following' as Tab, label: 'Following', count: counts?.following }
|
||||
]);
|
||||
|
||||
let busy = $state<string | null>(null);
|
||||
let searching = $state<string | null>(null);
|
||||
let open = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ['adminWants'] }),
|
||||
client.invalidateQueries({ queryKey: ['adminFollows'] })
|
||||
]);
|
||||
}
|
||||
|
||||
async function onRemove(w: Want) {
|
||||
busy = w.id;
|
||||
try {
|
||||
await removeWant(w.id);
|
||||
pushToast(`${w.title} is off the wanted list.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
// What the schedule last did about a want, in a line.
|
||||
function status(w: Want): string {
|
||||
if (w.grab) {
|
||||
const who = w.grab.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
|
||||
return `Downloading ${w.grab.name}, ${who} ${relativeTime(w.grab.grabbed_at)}`;
|
||||
}
|
||||
const s = w.search;
|
||||
if (!s) return 'Not searched yet';
|
||||
const when = `Searched ${relativeTime(s.searched_at)}`;
|
||||
switch (s.outcome) {
|
||||
case 'nothing':
|
||||
return s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
|
||||
case 'waiting':
|
||||
return `${when}: ${s.considered} found, nothing taken`;
|
||||
case 'grabbed':
|
||||
return `${when}: took ${s.best_release}`;
|
||||
default:
|
||||
return `${when}: ${s.error}`;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Wanted')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Wanted</h2>
|
||||
<p class="text-text-secondary">
|
||||
Albums Minstrel is to get: ones users requested, new releases from the artists it follows,
|
||||
and albums whose files went missing. Minstrel searches your indexers for each one on a
|
||||
schedule and hands the best release to your download client; searching and quality are set
|
||||
under <a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
|
||||
An album leaves the list once the library holds it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<TabStrip ariaLabel="Wanted list" bind:value={tab} items={tabs} />
|
||||
|
||||
{#if tab === 'following'}
|
||||
<p class="text-sm text-text-secondary">
|
||||
Each artist is followed on a release-type profile, which says which kinds of release are
|
||||
wanted. Profiles are edited under
|
||||
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
|
||||
</p>
|
||||
{#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}
|
||||
{:else 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 tab === 'held'}Nothing on the list has been held yet.
|
||||
{:else if tab === 'missing'}No missing albums are wanted back.
|
||||
{:else}Nothing is wanted.{/if}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{#if tab === '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="truncate text-xs text-text-secondary" data-testid="want-search">{status(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}
|
||||
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}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { createWantsQuery } from '#lib/api/wanted.js';
|
||||
import WantedTabs from '#lib/components/WantedTabs.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
|
||||
// The Wanted view (M502): what Minstrel is getting for the library, its
|
||||
// downloads, and what it has set aside. Web-only and admin-only. The
|
||||
// wanted query's first page carries every tab's count; the Wanted tab
|
||||
// shares it, so this costs no extra request there.
|
||||
const wantsStore = createWantsQuery('wanted');
|
||||
const counts = $derived($wantsStore.data?.pages?.[0]?.counts);
|
||||
|
||||
// The one line that answers "does anything need me?"
|
||||
const verdict = $derived.by(() => {
|
||||
if (!counts) return '';
|
||||
if (counts.attention) {
|
||||
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`;
|
||||
}
|
||||
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
|
||||
if (counts.activity) parts.push(`${counts.activity} downloading`);
|
||||
return parts.join(', ') + '. Nothing needs you.';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Wanted</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Albums Minstrel is getting: ones users requested, new releases from the artists it follows,
|
||||
and albums whose files went missing. It searches your indexers on a schedule, checks each
|
||||
download against MusicBrainz, and files what's right into the library. Searching and quality
|
||||
are set under <a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
|
||||
</p>
|
||||
{#if verdict}
|
||||
<p class="text-sm {counts?.attention ? 'text-text-primary' : 'text-text-secondary'}" data-testid="wanted-verdict">
|
||||
{verdict}
|
||||
{#if counts?.attention}
|
||||
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<WantedTabs {counts} />
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { user } from '#lib/auth/store.svelte.js';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
// Hard route gate: the Wanted view is an admin's (M502). Runs before the
|
||||
// layout renders; auth is bootstrapped by the root +layout.ts, so user.value is
|
||||
// either a settled User object or null.
|
||||
export const load: LayoutLoad = () => {
|
||||
if (!user.value || !user.value.is_admin) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted')}</title></svelte:head>
|
||||
|
||||
<WantList state="wanted" />
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import DownloadList from '#lib/components/wanted/DownloadList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Activity')}</title></svelte:head>
|
||||
|
||||
<DownloadList />
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import BlockList from '#lib/components/wanted/BlockList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Blocked')}</title></svelte:head>
|
||||
|
||||
<BlockList />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import FollowList from '#lib/components/wanted/FollowList.svelte';
|
||||
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Following')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<FollowList />
|
||||
<ReleaseProfilesCard />
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Held')}</title></svelte:head>
|
||||
|
||||
<WantList state="held" />
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Wanted · Missing files')}</title></svelte:head>
|
||||
|
||||
<WantList state="missing" />
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { mockInfiniteQuery } from '../../test-utils/query';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import type { WantCounts, WantsPage } from '#lib/api/types.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
|
||||
vi.mock('$app/state', () => pageUrlModule(state));
|
||||
vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
|
||||
|
||||
import WantedLayout from './+layout.svelte';
|
||||
import { createWantsQuery } from '#lib/api/wanted.js';
|
||||
|
||||
const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
|
||||
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(c: WantCounts = counts, path = '/wanted') {
|
||||
state.pageUrl = new URL(`http://localhost${path}`);
|
||||
const page: WantsPage = { items: [], total: 0, limit: 50, offset: 0, counts: c };
|
||||
vi.mocked(createWantsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantsQuery>
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return render(WantedLayout, { children } as any);
|
||||
}
|
||||
|
||||
describe('/wanted layout', () => {
|
||||
test('tabs are links carrying their counts, the current one marked', () => {
|
||||
setup(counts, '/wanted/activity');
|
||||
const nav = screen.getByRole('navigation', { name: /wanted sections/i });
|
||||
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
|
||||
expect(links).toEqual([
|
||||
['/wanted', 'Wanted 5'],
|
||||
['/wanted/activity', 'Activity 3'],
|
||||
['/wanted/missing', 'Missing files 1'],
|
||||
['/wanted/held', 'Held 9'],
|
||||
['/wanted/following', 'Following 2'],
|
||||
['/wanted/blocked', 'Blocked 4']
|
||||
]);
|
||||
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByTestId('tab-body')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('when nothing needs a person the verdict says so', () => {
|
||||
setup();
|
||||
expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
|
||||
expect(screen.queryByTestId('tab-attention')).toBeNull();
|
||||
});
|
||||
|
||||
test('downloads waiting on a person lead the page and mark Activity', () => {
|
||||
setup({ ...counts, attention: 2 });
|
||||
const verdict = screen.getByTestId('wanted-verdict');
|
||||
expect(verdict.textContent).toMatch(/2 downloads need you/);
|
||||
expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity');
|
||||
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user