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>