feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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

Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+66 -91
View File
@@ -4,74 +4,64 @@
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '#lib/api/admin.js';
import { createAdminRequestsQuery, approveRequest, rejectRequest } from '#lib/api/admin.js';
import { createReleaseProfilesQuery } from '#lib/api/wanted.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import StatusPill from '#lib/components/StatusPill.svelte';
import Modal from '#lib/components/Modal.svelte';
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '#lib/api/types.js';
import type { MusicRequest, RequestKind, RequestStatus } from '#lib/api/types.js';
// Approval queue. Tabs filter by status; rows expose Override/Approve/Reject
// per spec. The override modal collapses by default — most requests get the
// snapshot defaults, so we don't make the operator stare at two dropdowns
// they almost never touch.
// Approval queue. Tabs filter by status; rows expose Approve/Reject, and an
// artist request can be approved on a chosen release-type profile. Approving
// puts the request on the wanted list (M502 #5520): an album or track wants
// its album, an artist is followed.
const client = useQueryClient();
// The four tab filters. "Pending" is default — that's the actionable bucket;
// the rest are review-only views.
const tabs: { status: LidarrRequestStatus; label: string }[] = [
const tabs: { status: RequestStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'completed', label: 'Completed' },
{ status: 'rejected', label: 'Rejected' }
];
let activeStatus = $state<LidarrRequestStatus>('pending');
let activeStatus = $state<RequestStatus>('pending');
// The query factory is re-created on every status flip so TanStack treats
// each status as its own cache key. We don't need a single shared store.
const queryStore = $derived(createAdminRequestsQuery(activeStatus));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as LidarrRequest[]);
const rows = $derived((query.data ?? []) as MusicRequest[]);
// Quality + root dropdowns for the override modal. Always enabled; if Lidarr
// is disabled the modal is unreachable from this page anyway (the queue
// would be empty), and on this page we'd rather see the dropdown options
// than block the modal on a separate gate.
const profilesStore = createQualityProfilesQuery(true);
const profiles = $derived($profilesStore);
const foldersStore = createRootFoldersQuery(true);
const folders = $derived($foldersStore);
// Release-type profiles for the "Approve on a profile" modal: which kinds of
// release following the artist will want.
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
// Modal state. `overrideOpen` and `rejectOpen` carry the request id when
// Modal state. `profileOpen` and `rejectOpen` carry the request id when
// open; null otherwise. The current row is looked up by id at render time.
let overrideOpen = $state<string | null>(null);
let qualityOverride = $state<number | ''>('');
let rootOverride = $state<string>('');
let profileOpen = $state<string | null>(null);
let profileChoice = $state<string>('');
let rejectOpen = $state<string | null>(null);
let rejectNotes = $state<string>('');
function fallbackIcon(kind: LidarrRequestKind) {
function fallbackIcon(kind: RequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
function rowTitle(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
function rowAccessibleName(r: LidarrRequest): string {
function rowAccessibleName(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
return r.track_title ?? `this track by ${r.artist_name}`;
@@ -80,7 +70,7 @@
// The id is a UUID — first 8 chars is plenty to disambiguate without a real
// username, and matches what the polish pass will replace with the real
// username surface.
function rowMeta(r: LidarrRequest): string {
function rowMeta(r: MusicRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
const userBit = `by user ${r.user_id.slice(0, 8)}`;
return `${userBit} · ${when}`;
@@ -90,45 +80,35 @@
// Invalidate every status bucket so e.g. an approve flips a row from the
// pending list to the approved list once the operator switches tabs.
await client.invalidateQueries({ queryKey: ['adminRequests'] });
await client.invalidateQueries({ queryKey: ['adminWants'] });
}
async function onApprove(
r: LidarrRequest,
overrides?: { quality_profile_id?: number; root_folder_path?: string }
) {
async function onApprove(r: MusicRequest, profileId?: string) {
try {
if (overrides && (overrides.quality_profile_id !== undefined || overrides.root_folder_path !== undefined)) {
await approveRequest(r.id, overrides);
} else {
await approveRequest(r.id);
}
await approveRequest(r.id, profileId);
await invalidate();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
function openOverride(r: LidarrRequest) {
overrideOpen = r.id;
// Reset to "Use defaults" each time — operators should consciously opt in
// to an override, not inherit the previous row's choice.
qualityOverride = '';
rootOverride = '';
function openProfile(r: MusicRequest) {
profileOpen = r.id;
// Start from the default each time, not the previous row's choice.
profileChoice = profiles.find((p) => p.is_default)?.id ?? '';
}
function cancelOverride() {
overrideOpen = null;
function cancelProfile() {
profileOpen = null;
}
async function confirmOverride(r: LidarrRequest) {
const overrides: { quality_profile_id?: number; root_folder_path?: string } = {};
if (qualityOverride !== '') overrides.quality_profile_id = Number(qualityOverride);
if (rootOverride !== '') overrides.root_folder_path = rootOverride;
overrideOpen = null;
await onApprove(r, overrides);
async function confirmProfile(r: MusicRequest) {
const choice = profileChoice;
profileOpen = null;
await onApprove(r, choice || undefined);
}
function openReject(r: LidarrRequest) {
function openReject(r: MusicRequest) {
rejectOpen = r.id;
rejectNotes = '';
}
@@ -137,7 +117,7 @@
rejectOpen = null;
}
async function confirmReject(r: LidarrRequest) {
async function confirmReject(r: MusicRequest) {
const notes = rejectNotes.trim();
rejectOpen = null;
try {
@@ -151,8 +131,8 @@
// Modal lookups: if the active tab has been refetched while a modal is
// open, the row may no longer be in `rows`. Guard against that by treating
// missing-row as closed.
const overrideRow = $derived(
overrideOpen ? rows.find((r) => r.id === overrideOpen) ?? null : null
const profileRow = $derived(
profileOpen ? rows.find((r) => r.id === profileOpen) ?? null : null
);
const rejectRow = $derived(
rejectOpen ? rows.find((r) => r.id === rejectOpen) ?? null : null
@@ -164,7 +144,11 @@
<div class="space-y-6">
<header class="space-y-1">
<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.</p>
<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
artist is followed, wanting what they've released and what they release next.
</p>
</header>
<TabStrip
@@ -220,7 +204,7 @@
{/if}
{#if r.kind === 'track' && r.album_title}
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
Approving will add the album <em class="font-medium text-text-primary">{r.album_title}</em>.
Approving wants the album <em class="font-medium text-text-primary">{r.album_title}</em>.
</div>
{/if}
{#if r.notes}
@@ -236,12 +220,16 @@
onclick: () => onApprove(r)
}}
{@const secondary: RowAction[] = [
{
icon: SlidersHorizontal,
label: 'Override',
ariaLabel: `Override ${rowAccessibleName(r)}`,
onclick: () => openOverride(r)
},
...(r.kind === 'artist'
? [
{
icon: SlidersHorizontal,
label: 'Approve on a profile',
ariaLabel: `Approve ${rowAccessibleName(r)} on a profile`,
onclick: () => openProfile(r)
}
]
: []),
{
icon: X,
label: 'Reject',
@@ -261,36 +249,23 @@
</div>
<Modal
title="Approve with override"
open={overrideRow !== null}
onClose={cancelOverride}
title="Approve on a profile"
open={profileRow !== null}
onClose={cancelProfile}
>
<p class="-mt-2 text-sm text-text-secondary">
Leave fields blank to use the saved defaults.
The profile says which kinds of release following
{profileRow?.artist_name ?? 'this artist'} wants. Profiles are edited under Integrations.
</p>
<label class="mt-4 block">
<span class="block text-sm text-text-secondary">Quality profile</span>
<span class="block text-sm text-text-secondary">Release-type profile</span>
<select
bind:value={qualityOverride}
bind:value={profileChoice}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each profiles.data ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Root folder</span>
<select
bind:value={rootOverride}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each folders.data ?? [] as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (not accessible)'}</option>
{#each profiles as p (p.id)}
<option value={p.id}>{p.name}{p.is_default ? ' (default)' : ''}</option>
{/each}
</select>
</label>
@@ -299,17 +274,17 @@
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={cancelOverride}
onclick={cancelProfile}
>
Cancel
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={() => overrideRow && confirmOverride(overrideRow)}
onclick={() => profileRow && confirmProfile(profileRow)}
>
<Check size={14} strokeWidth={2} />
Approve with override
Approve and follow
</button>
</div>
</Modal>
+42 -41
View File
@@ -1,34 +1,30 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrRequest } from '#lib/api/types.js';
import type { MusicRequest } from '#lib/api/types.js';
// Wrap useQueryClient so the page can call invalidateQueries() without a
// real QueryClient context. Everything else from svelte-query passes through.
vi.mock('#lib/api/admin.js', () => ({
createAdminRequestsQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
approveRequest: vi.fn(),
rejectRequest: vi.fn()
}));
vi.mock('#lib/api/wanted.js', () => ({
createReleaseProfilesQuery: vi.fn()
}));
import AdminRequestsPage from './+page.svelte';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '#lib/api/admin.js';
import { createAdminRequestsQuery, approveRequest, rejectRequest } from '#lib/api/admin.js';
import { createReleaseProfilesQuery } from '#lib/api/wanted.js';
const baseRow: LidarrRequest = {
const baseRow: MusicRequest = {
id: 'r1',
user_id: 'u1234567-aaaa-bbbb-cccc-deadbeef0001',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'a-mbid',
lidarr_album_mbid: 'al-mbid',
artist_mbid: 'a-mbid',
album_mbid: 'al-mbid',
artist_name: 'Boards of Canada',
album_title: 'Geogaddi',
requested_at: '2026-04-29T10:00:00Z',
@@ -39,15 +35,21 @@ const baseRow: LidarrRequest = {
afterEach(() => vi.clearAllMocks());
function setup(rows: LidarrRequest[] = [baseRow]) {
function setup(rows: MusicRequest[] = [baseRow]) {
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: rows })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ id: 1, name: 'Standard' }] })
);
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
(createReleaseProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album'], secondary_types: [], is_default: true, follow_count: 0 },
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
],
primary_types: ['Album'],
secondary_types: ['Live']
}
})
);
return render(AdminRequestsPage);
}
@@ -59,30 +61,29 @@ describe('/admin/requests', () => {
expect(createAdminRequestsQuery).toHaveBeenCalledWith('approved');
});
test('Approve without override fires POST with no override body', async () => {
test('Approve sends no profile', async () => {
setup();
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
expect(approveRequest).toHaveBeenCalledWith('r1');
expect(approveRequest).toHaveBeenCalledWith('r1', undefined);
});
test('Override modal Confirm sends chosen values', async () => {
test('An album request has no profile choice', () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /override geogaddi/i }));
await fireEvent.change(screen.getByLabelText(/quality profile/i), {
target: { value: '1' }
});
await fireEvent.change(screen.getByLabelText(/root folder/i), {
target: { value: '/music' }
});
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
expect(screen.queryByRole('button', { name: /on a profile/i })).toBeNull();
});
test('An artist request is approved on the chosen profile', async () => {
setup([{ ...baseRow, kind: 'artist', album_mbid: undefined, album_title: undefined }]);
await fireEvent.click(
screen.getByRole('button', { name: /approve with override/i })
screen.getByRole('button', { name: /approve boards of canada on a profile/i })
);
expect(approveRequest).toHaveBeenCalledWith('r1', {
quality_profile_id: 1,
root_folder_path: '/music'
});
const select = screen.getByLabelText(/release-type profile/i) as HTMLSelectElement;
expect(select.value).toBe('p-std');
await fireEvent.change(select, { target: { value: 'p-live' } });
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await fireEvent.click(screen.getByRole('button', { name: /approve and follow/i }));
expect(approveRequest).toHaveBeenCalledWith('r1', 'p-live');
});
test('Reject opens notes textarea; confirm sends notes', async () => {
@@ -100,16 +101,16 @@ describe('/admin/requests', () => {
expect(rejectRequest).toHaveBeenCalledWith('r1', 'duplicate');
});
test('Lidarr-unreachable error shows toast', async () => {
test('An approve error shows a toast', async () => {
setup();
(approveRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
code: 'lidarr_unreachable',
message: 'unreachable',
status: 503
code: 'request_not_pending',
message: 'not pending',
status: 409
});
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
await waitFor(() =>
expect(screen.getByText(/lidarr is unreachable/i)).toBeInTheDocument()
expect(screen.getByText(/no longer pending/i)).toBeInTheDocument()
);
});
@@ -122,6 +123,6 @@ describe('/admin/requests', () => {
album_title: 'Geogaddi'
}
]);
expect(screen.getByText(/will add the album/i)).toBeInTheDocument();
expect(screen.getByText(/wants the album/i)).toBeInTheDocument();
});
});