feat: the library's albums go on the wanted list as held, once; after that the list is the truth (M502 #5567)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.

Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:13:46 -04:00
co-authored by Claude Opus 5.5
parent bc198a9389
commit 2c677b3184
19 changed files with 965 additions and 172 deletions
+37 -2
View File
@@ -13,7 +13,9 @@
// 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.
// lists every candidate with its verdict, and Search runs one now. The
// library's albums were put on the list as held once (#5567); taking one
// off leaves its files and stops it being fetched back if they go.
let { list }: { list: WantState } = $props();
@@ -21,6 +23,7 @@
const wantsStore = $derived(createWantsQuery(list));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
const library = $derived(wants.data?.pages[0]?.library);
let busy = $state<string | null>(null);
let searching = $state<string | null>(null);
@@ -30,7 +33,11 @@
busy = w.id;
try {
await removeWant(w.id);
pushToast(`${w.title} is off the wanted list.`);
pushToast(
w.held_at
? `${w.title} is off the list. Its files stay, and it won't be fetched back if they go missing.`
: `${w.title} is off the wanted list.`
);
await client.invalidateQueries({ queryKey: ['adminWants'] });
} catch (e) {
pushToast(errMessage(e), 'error');
@@ -137,6 +144,7 @@
function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
if (w.reason === 'follow') return 'New from a followed artist';
if (w.reason === 'library') return 'In your library when the list began';
return 'Its files went missing';
}
@@ -151,6 +159,22 @@
}
</script>
{#if list === 'held' && library}
{#if !library.listed_at}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
Your library's albums are being added to this list as held. Albums whose tags name only a
release are looked up on MusicBrainz first, about one a second.
</p>
{:else if library.unlisted > 0}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
{library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
{library.unlisted === 1 ? "isn't" : "aren't"} on this list: nothing in {library.unlisted === 1 ? 'its' : 'their'}
tags names {library.unlisted === 1 ? 'it' : 'them'} to MusicBrainz. Those are never fetched back if
their files go missing.
</p>
{/if}
{/if}
{#if wants.isPending}
<p class="text-text-secondary">Reading the wanted list…</p>
{:else if wants.isError && !wants.isFetchNextPageError}
@@ -216,6 +240,17 @@
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div>
{/if}
</div>
{#if w.held_at}
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Take {w.title} off the list"
class="inline-flex shrink-0 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>
{/if}
{#if !w.held_at}
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<button
+240 -93
View File
@@ -1,25 +1,39 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
import type { Want, WantGrab, WantsPage } from '#lib/api/types.js';
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 {
LibraryListing,
Want,
WantGrab,
WantsPage,
} from "#lib/api/types.js";
vi.mock('#lib/api/wanted.js', () => ({
vi.mock("#lib/api/wanted.js", () => ({
createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
outcome: "nothing",
considered: 0,
best_release: "",
best_verdict: "",
narrowed: [],
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
unblockRelease: vi.fn(),
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
import WantList from './WantList.svelte';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
import type { WantState } from '#lib/api/types.js';
import WantList from "./WantList.svelte";
import { pushToast } from "#lib/stores/toast.svelte.js";
import {
createCandidatesQuery,
createWantsQuery,
removeWant,
searchWant,
} from "#lib/api/wanted.js";
import type { WantState } from "#lib/api/types.js";
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -28,172 +42,305 @@ function want(id: string, extra: Partial<Want> = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren',
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
primary_type: 'Album',
title: "The Ballad of Darren",
artist_mbid: "blur",
artist_name: "Blur",
artist_id: "art-1",
primary_type: "Album",
secondary_types: [],
year: '2023',
reason: 'follow',
year: "2023",
reason: "follow",
wanted_at: ago(2 * DAY),
held_at: null,
image_url: '',
...extra
image_url: "",
...extra,
};
}
function page(items: Want[]): WantsPage {
function page(
items: Want[],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 }
counts: {
wanted: 2,
missing: 1,
held: 4,
following: 1,
activity: 1,
attention: 0,
blocked: 0,
},
library,
};
}
function grab(extra: Partial<WantGrab> = {}): WantGrab {
return {
id: 'g1',
name: 'Blur - Blur (1997) [FLAC]',
id: "g1",
name: "Blur - Blur (1997) [FLAC]",
grabbed_at: ago(60_000),
origin: 'schedule',
quality: 'lossless',
origin: "schedule",
quality: "lossless",
live: true,
end_reason: '',
end_reason: "",
progress: 0,
import_state: '',
import_reason: '',
end_detail: '',
import_state: "",
import_reason: "",
end_detail: "",
imported_at: null,
imported_files: 0,
...extra
...extra,
};
}
afterEach(() => vi.clearAllMocks());
function setup(
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
state: WantState = 'wanted'
items: Want[] = [
want("w1"),
want("w2", { reason: "request", requested_by: "sam", title: "Parklife" }),
],
state: WantState = "wanted",
library?: LibraryListing,
) {
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType<typeof createWantsQuery>
mockInfiniteQuery({
pages: [page(items, library)],
}) as unknown as ReturnType<typeof createWantsQuery>,
);
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
mockQuery({ data: [] }) as unknown as ReturnType<
typeof createCandidatesQuery
>,
);
return render(WantList, { list: state });
}
describe('WantList', () => {
test('asks for its own state', () => {
setup([], 'missing');
expect(createWantsQuery).toHaveBeenCalledWith('missing');
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', () => {
test("a want says why it is wanted", () => {
setup();
const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
const why = screen
.getAllByTestId("want-why")
.map((e) => e.textContent ?? "");
expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i);
});
test('remove takes a want off the list', async () => {
test("remove takes a want off the list", async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
await fireEvent.click(
screen.getByRole("button", { name: /stop wanting parklife/i }),
);
await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w2"));
});
test('a held want has no remove button', () => {
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })]);
expect(screen.queryByRole('button', { name: /stop wanting/i })).toBeNull();
expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
test("a held album can be taken off the list, and is not searched for", async () => {
setup(
[want("w1", { held_at: ago(DAY), album_id: "al-1", reason: "library" })],
"held",
);
expect(
screen.queryByRole("button", { name: /stop wanting|search for/i }),
).toBeNull();
expect(
screen
.getByRole("link", { name: /the ballad of darren/i })
.getAttribute("href"),
).toBe("/albums/al-1");
expect(screen.getByTestId("want-why").textContent).toMatch(
/in your library when the list began/i,
);
await fireEvent.click(
screen.getByRole("button", {
name: /take the ballad of darren off the list/i,
}),
);
await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w1"));
});
test('an empty list explains what lands there', () => {
test("the held tab says while the library is being listed, and what could not be", () => {
setup([], "held", { listed_at: null, unlisted: 0 });
expect(screen.getByTestId("library-listing").textContent).toMatch(
/being added to this list/i,
);
});
test("the held tab names how many albums nothing names to MusicBrainz", () => {
setup([want("w1", { held_at: ago(DAY) })], "held", {
listed_at: ago(DAY),
unlisted: 12,
});
expect(screen.getByTestId("library-listing").textContent).toMatch(
/12 albums in your library\s+aren't on this list/i,
);
});
test("an empty list explains what lands there", () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
});
test('each open want says what its last search found', () => {
test("each open want says what its last search found", () => {
setup([
want('w1'),
want('w2', {
title: 'Parklife',
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' }
want("w1"),
want("w2", {
title: "Parklife",
search: {
searched_at: ago(3_600_000),
outcome: "nothing",
considered: 4,
best_release: "x",
best_verdict: "rejected",
error: "",
},
}),
want("w3", { title: "Blur", grab: grab() }),
want("w4", {
title: "13",
grab: grab({ import_state: "downloading", progress: 0.42 }),
}),
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 ?? '');
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[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', () => {
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.' }) })
want("w1", {
grab: grab({
import_state: "review",
import_reason: "Only the lengths agree. A person decides.",
}),
}),
]);
const line = screen.getByTestId('want-search');
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');
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);
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', () => {
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.' })
})
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);
expect(screen.getByTestId("want-search").textContent).toMatch(
/last download refused: another album/i,
);
});
test('a download given up on is named beside the search that follows it', () => {
test("a download given up on is named beside the search that follows it", () => {
setup([
want('w1', {
search: { searched_at: ago(3_600_000), outcome: 'waiting', considered: 4, best_release: '', best_verdict: '', error: '' },
grab: grab({ live: false, end_reason: 'stalled', import_state: 'downloading', end_detail: 'Nobody was sharing it.' })
})
want("w1", {
search: {
searched_at: ago(3_600_000),
outcome: "waiting",
considered: 4,
best_release: "",
best_verdict: "",
error: "",
},
grab: grab({
live: false,
end_reason: "stalled",
import_state: "downloading",
end_detail: "Nobody was sharing it.",
}),
}),
]);
expect(screen.getByTestId('want-search').textContent).toMatch(/last download given up: nobody was sharing it/i);
expect(screen.getByTestId("want-search").textContent).toMatch(
/last download given up: nobody was sharing it/i,
);
});
test('a held album says how it arrived', () => {
test("a held album says how it arrived", () => {
setup(
[
want('w1', {
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 })
})
album_id: "al-1",
grab: grab({
live: false,
end_reason: "imported",
import_state: "imported",
imported_at: ago(DAY),
imported_files: 11,
}),
}),
],
'held'
"held",
);
expect(screen.getByTestId("want-arrived").textContent).toMatch(
/imported 11 tracks .* from blur - blur/i,
);
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 () => {
test("Search runs one now and says what came of it", async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2'));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info'));
await fireEvent.click(
screen.getByRole("button", { name: /search for parklife now/i }),
);
await waitFor(() => expect(searchWant).toHaveBeenCalledWith("w2"));
await waitFor(() =>
expect(pushToast).toHaveBeenCalledWith(
"Nothing found for Parklife.",
"info",
),
);
});
test('Releases opens what the last search found', async () => {
test("Releases opens what the last search found", async () => {
setup();
expect(screen.queryByTestId('want-candidates')).toBeNull();
const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
expect(screen.queryByTestId("want-candidates")).toBeNull();
const toggle = screen.getByRole("button", {
name: /releases found for parklife/i,
});
await fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByTestId('want-candidates')).toBeTruthy();
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId("want-candidates")).toBeTruthy();
expect(createCandidatesQuery).toHaveBeenCalledWith("w2");
});
});