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
@@ -0,0 +1,233 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import {
createReleaseProfilesQuery,
createReleaseProfile,
updateReleaseProfile,
deleteReleaseProfile
} 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 type { ReleaseProfile, ReleaseProfileInput } from '#lib/api/types.js';
// Release-type profiles (M502 #5520): which kinds of release a followed
// artist's new releases must be to be wanted. A release passes when its
// primary type is ticked and so is every one of its secondary types, so a
// profile with no secondary types wants studio releases only and leaves out
// live recordings, compilations and remixes.
const client = useQueryClient();
const store = createReleaseProfilesQuery();
const query = $derived($store);
const profiles = $derived(query.data?.profiles ?? []);
const primaryTypes = $derived(query.data?.primary_types ?? []);
const secondaryTypes = $derived(query.data?.secondary_types ?? []);
// The profile being edited: its id, 'new', or null when none is.
let editing = $state<string | null>(null);
let form = $state<ReleaseProfileInput>({ name: '', primary_types: [], secondary_types: [], is_default: false });
let saving = $state(false);
function startEdit(p: ReleaseProfile) {
editing = p.id;
form = {
name: p.name,
primary_types: [...p.primary_types],
secondary_types: [...p.secondary_types],
is_default: p.is_default
};
}
function startNew() {
editing = 'new';
form = { name: '', primary_types: ['Album', 'EP'], secondary_types: [], is_default: false };
}
function toggle(list: string[], value: string): string[] {
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
}
async function refresh() {
await client.invalidateQueries({ queryKey: qk.releaseProfiles() });
await client.invalidateQueries({ queryKey: ['adminFollows'] });
}
async function save() {
saving = true;
try {
if (editing === 'new') {
await createReleaseProfile(form);
pushToast(`${form.name} added.`);
} else if (editing) {
await updateReleaseProfile(editing, form);
pushToast(`${form.name} saved. Followed artists judge new releases by it from their next check.`);
}
editing = null;
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
async function remove(p: ReleaseProfile) {
try {
await deleteReleaseProfile(p.id);
pushToast(`${p.name} deleted.`);
if (editing === p.id) editing = null;
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
function describe(p: ReleaseProfile): string {
const extra = p.secondary_types.length ? `, including ${p.secondary_types.join(', ')}` : ', studio only';
return p.primary_types.join(', ') + extra;
}
</script>
{#snippet editor()}
<div class="space-y-4 rounded-lg border border-border p-4" data-testid="profile-editor">
<label class="block">
<span class="text-sm text-text-primary">Name</span>
<input
type="text"
bind:value={form.name}
class="mt-1 w-full max-w-xs 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"
/>
</label>
<fieldset>
<legend class="text-sm text-text-primary">Kinds of release</legend>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{#each primaryTypes as t (t)}
<label class="flex items-center gap-1.5 text-sm text-text-secondary">
<input
type="checkbox"
checked={form.primary_types.includes(t)}
onchange={() => (form.primary_types = toggle(form.primary_types, t))}
/>
{t}
</label>
{/each}
</div>
</fieldset>
<fieldset>
<legend class="text-sm text-text-primary">Also allow</legend>
<span class="block text-xs text-text-secondary">
Leave these unticked for studio releases only. A live album is wanted only when Live is
ticked here.
</span>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{#each secondaryTypes as t (t)}
<label class="flex items-center gap-1.5 text-sm text-text-secondary">
<input
type="checkbox"
checked={form.secondary_types.includes(t)}
onchange={() => (form.secondary_types = toggle(form.secondary_types, t))}
/>
{t}
</label>
{/each}
</div>
</fieldset>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={form.is_default} />
Default for new follows
</label>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (editing = null)}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
>
Cancel
</button>
<button
type="button"
onclick={save}
disabled={saving || !form.name.trim() || form.primary_types.length === 0}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save profile'}
</button>
</div>
</div>
{/snippet}
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="release-profiles-card">
<div>
<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.
</p>
</div>
{#if query.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if query.isError}
<p class="text-sm text-action-destructive">Couldn't load the profiles.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border">
{#each profiles as p (p.id)}
<li class="space-y-3 p-3" data-testid="profile-row">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm font-medium text-text-primary">
{p.name}
{#if p.is_default}
<span class="rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg">Default</span>
{/if}
</div>
<div class="text-xs text-text-secondary">
{describe(p)} · {p.follow_count === 1 ? '1 artist' : `${p.follow_count} artists`}
</div>
</div>
<button
type="button"
onclick={() => startEdit(p)}
aria-label="Edit {p.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"
>
Edit
</button>
{#if !p.is_default}
<button
type="button"
onclick={() => remove(p)}
aria-label="Delete {p.name}"
class="rounded-md px-2.5 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary"
>
Delete
</button>
{/if}
</div>
{#if editing === p.id}
{@render editor()}
{/if}
</li>
{/each}
</ul>
{#if editing === 'new'}
{@render editor()}
{:else}
<button
type="button"
onclick={startNew}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
>
New profile
</button>
{/if}
{/if}
</section>