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
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:
@@ -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>
|
||||
Reference in New Issue
Block a user