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
+7 -7
View File
@@ -27,8 +27,8 @@
import { runMutation } from '#lib/util/runMutation.js';
import type {
AdminQuarantineRow,
LidarrRequest,
LidarrRequestKind,
MusicRequest,
RequestKind,
LidarrQuarantineReason
} from '#lib/api/types.js';
@@ -83,17 +83,17 @@
}
// ---- Helpers ----
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 rowSubtitle(r: LidarrRequest): string {
function rowSubtitle(r: MusicRequest): string {
if (r.kind === 'artist') return 'Artist';
if (r.kind === 'album') return `Album · ${r.artist_name}`;
return `Track · ${r.artist_name}`;
@@ -117,13 +117,13 @@
]);
}
async function onApprove(r: LidarrRequest) {
async function onApprove(r: MusicRequest) {
await runMutation(async () => {
await approveRequest(r.id);
await invalidateRequests();
});
}
async function onReject(r: LidarrRequest) {
async function onReject(r: MusicRequest) {
await runMutation(async () => {
await rejectRequest(r.id);
await invalidateRequests();
@@ -29,6 +29,7 @@
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 NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
@@ -423,6 +424,8 @@
<AcquisitionCard />
<ReleaseProfilesCard />
<!-- Lidarr panel -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -43,6 +43,27 @@ vi.mock('#lib/api/admin.js', () => ({
addAcquisitionService: vi.fn()
}));
// ReleaseProfilesCard 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()
};
});
import IntegrationsPage from './+page.svelte';
import {
createLidarrConfigQuery,
+10 -21
View File
@@ -2,7 +2,7 @@
import { pageTitle } from '#lib/branding.js';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
import { relativeTime, timeUntil } from '#lib/utils/relativeTime.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
import type { AdminMissingGroup } from '#lib/api/types.js';
@@ -26,14 +26,6 @@
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
const hasMore = $derived(offset + shown < total);
// "once" / "twice" reads far better than "1 times" in the sentence these
// land in, and the count is almost always small.
function attemptLabel(n: number): string {
if (n === 1) return 'once';
if (n === 2) return 'twice';
return `${n} times`;
}
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
@@ -103,24 +95,21 @@
</span>
</div>
<!-- What the sweeper has done about this folder. Without it the only
way to tell "not tried yet" from "asked twice, nothing came
back" is to go and read the Requests queue. -->
<!-- Where this folder's album stands on the wanted list. Without it
the only way to tell "not wanted yet" from "wanted, nothing has
come" is to go and read the wanted list. -->
{#if group.reacquisition}
{@const r = group.reacquisition}
<p
class="border-b border-border px-4 py-2 text-xs text-text-secondary"
data-testid="reacquisition-state"
>
{#if r.gave_up_at}
<span class="text-action-destructive">Gave up</span>
after {attemptLabel(r.attempts)} — last tried
{relativeTime(r.last_attempt_at ?? r.gave_up_at)}. It'll be tried again
if the files come back and go missing later.
{:else if r.last_attempt_at}
Asked Lidarr {attemptLabel(r.attempts)}, last
{relativeTime(r.last_attempt_at)}{#if r.next_attempt_at}, next
{timeUntil(r.next_attempt_at)}{/if}.
{#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"
>wanted list</a
> since {relativeTime(r.wanted_at)}.
{/if}
</p>
{/if}
@@ -11,11 +11,7 @@ vi.mock('#lib/api/admin.js', () => ({
getReacquisitionSettings: vi.fn().mockResolvedValue({
enabled: true,
grace_hours: 24,
backoff_base_hours: 6,
backoff_max_hours: 168,
max_attempts: 3,
max_per_pass: 20,
auto_approve: true,
unnameable_albums: 0
}),
updateReacquisitionSettings: vi.fn()
@@ -125,45 +121,35 @@ describe('admin missing files', () => {
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
});
// Nothing attempted yet is the common case for a folder that just went
// missing; a line saying so would be noise on every row.
test('no re-acquisition line before anything has been attempted', () => {
// Not wanted yet is the common case for a folder that just went missing;
// a line saying so would be noise on every row.
test('no re-acquisition line before the album is wanted', () => {
renderWith(response);
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
});
test('an in-flight re-acquisition says how often and when next', () => {
test('a wanted album says since when, and links the wanted list', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 2,
last_attempt_at: new Date(Date.now() - 2 * DAY).toISOString(),
next_attempt_at: new Date(Date.now() + 4 * 3_600_000).toISOString(),
gave_up_at: null
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
held_at: null
};
renderWith(withState);
// Collapsed: the markup wraps mid-sentence, so textContent carries the
// template's newlines and indentation between the words.
const line = text('reacquisition-state');
expect(line).toMatch(/asked lidarr twice/i);
expect(line).toMatch(/last 2d ago/i);
// Forward-looking, not relativeTime — which would say "just now" for a
// future timestamp and read as nonsense.
expect(line).toMatch(/next in 4h/i);
expect(line).toMatch(/on the wanted list since 2d ago/i);
expect(screen.getByRole('link', { name: /wanted list/i }).getAttribute('href')).toBe(
'/admin/wanted?tab=missing'
);
});
test('a given-up album says so and says it can come back', () => {
test('an album held again says so', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 3,
last_attempt_at: new Date(Date.now() - 5 * DAY).toISOString(),
next_attempt_at: null,
gave_up_at: new Date(Date.now() - 5 * DAY).toISOString()
wanted_at: new Date(Date.now() - 5 * DAY).toISOString(),
held_at: new Date(Date.now() - DAY).toISOString()
};
renderWith(withState);
const line = text('reacquisition-state');
expect(line).toMatch(/gave up/i);
expect(line).toMatch(/after 3 times/i);
expect(line).toMatch(/tried again/i);
expect(text('reacquisition-state')).toMatch(/held again 1d ago/i);
});
// Paging only appears when it can do something: a single page of results
+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();
});
});
+268
View File
@@ -0,0 +1,268 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { Album, Disc3, ListChecks, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createWantsQuery,
createFollowsQuery,
createReleaseProfilesQuery,
removeWant,
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 ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import type { Follow, 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.
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);
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 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. An album leaves the list once the library holds it.
Minstrel doesn't search indexers for these yet; that comes next.
</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="flex items-center gap-3 p-3" data-testid="want-row">
<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>
</div>
{#if !w.held_at}
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
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}
</li>
{/each}
</ul>
<ListContinuation
hasMore={!!wants.hasNextPage}
loading={wants.isFetchingNextPage}
failed={wants.isFetchNextPageError}
onMore={() => wants.fetchNextPage()}
/>
{/if}
</div>
+145
View File
@@ -0,0 +1,145 @@
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';
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)
}));
import AdminWantedPage from './+page.svelte';
import {
createFollowsQuery,
createReleaseProfilesQuery,
createWantsQuery,
deleteFollow,
putFollow,
removeWant
} from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function want(id: string, extra: Partial<Want> = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren',
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
primary_type: 'Album',
secondary_types: [],
year: '2023',
reason: 'follow',
wanted_at: ago(2 * DAY),
held_at: null,
image_url: '',
...extra
};
}
function page(items: Want[]): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
counts: { wanted: 2, missing: 1, held: 4, following: 1 }
};
}
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']
};
afterEach(() => vi.clearAllMocks());
function setup(items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })]) {
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>
);
return render(AdminWantedPage);
}
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/);
});
test('a want says why it is wanted', () => {
setup();
const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i);
});
test('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 }));
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('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();
});
});