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:
@@ -7,6 +7,7 @@
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/wanted', label: 'Wanted' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/missing-files', label: 'Missing files' },
|
||||
{ href: '/admin/duplicates', label: 'Duplicates' },
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders all eleven tabs in order', () => {
|
||||
test('renders all twelve tabs in order', () => {
|
||||
state.pageUrl = new URL('http://localhost/admin');
|
||||
render(AdminTabs);
|
||||
const links = screen.getAllByRole('link');
|
||||
@@ -60,6 +60,8 @@ describe('AdminTabs', () => {
|
||||
'Overview',
|
||||
'Integrations',
|
||||
'Requests',
|
||||
// Wanted follows Requests: an approved request goes on the wanted list.
|
||||
'Wanted',
|
||||
'Quarantine',
|
||||
// Missing files sits with Quarantine and Playback errors: the three
|
||||
// surfaces that show tracks needing an operator's attention.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { BellPlus, BellRing } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createFollowQuery,
|
||||
createReleaseProfilesQuery,
|
||||
putFollow,
|
||||
deleteFollow
|
||||
} 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 Modal from './Modal.svelte';
|
||||
|
||||
// Follow an artist for the wanted list (M502 #5520): their new releases are
|
||||
// wanted, filtered by a release-type profile. Admin-only; the artist page
|
||||
// shows it when the artist carries a MusicBrainz id.
|
||||
|
||||
let { artistMbid, artistName }: { artistMbid: string; artistName: string } = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
const followStore = $derived(createFollowQuery(artistMbid));
|
||||
const follow = $derived($followStore);
|
||||
const profilesStore = createReleaseProfilesQuery();
|
||||
const profiles = $derived($profilesStore.data?.profiles ?? []);
|
||||
|
||||
const following = $derived(follow.data?.following === true);
|
||||
|
||||
let open = $state(false);
|
||||
let profileChoice = $state('');
|
||||
let wantExisting = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
function openModal() {
|
||||
profileChoice = follow.data?.profile_id ?? profiles.find((p) => p.is_default)?.id ?? '';
|
||||
wantExisting = false;
|
||||
open = true;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: qk.adminFollow(artistMbid) }),
|
||||
client.invalidateQueries({ queryKey: ['adminFollows'] }),
|
||||
client.invalidateQueries({ queryKey: ['adminWants'] })
|
||||
]);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await putFollow(artistMbid, {
|
||||
artist_name: artistName,
|
||||
profile_id: profileChoice || undefined,
|
||||
want_existing: wantExisting
|
||||
});
|
||||
open = false;
|
||||
pushToast(
|
||||
wantExisting
|
||||
? `Following ${artistName}. What they've released that isn't here is wanted on the next check.`
|
||||
: `Following ${artistName}. New releases will be wanted.`
|
||||
);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unfollow() {
|
||||
saving = true;
|
||||
try {
|
||||
await deleteFollow(artistMbid);
|
||||
open = false;
|
||||
pushToast(`No longer following ${artistName}. What was already wanted stays wanted.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={openModal}
|
||||
disabled={follow.isPending}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
{#if following}
|
||||
<BellRing size={14} strokeWidth={1.5} />
|
||||
Following · {follow.data?.profile_name}
|
||||
{:else}
|
||||
<BellPlus size={14} strokeWidth={1.5} />
|
||||
Follow
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Modal title={following ? `Following ${artistName}` : `Follow ${artistName}`} {open} onClose={() => (open = false)}>
|
||||
<p class="-mt-2 text-sm text-text-secondary">
|
||||
New releases from {artistName} go on the wanted list. The profile says which kinds of
|
||||
release are wanted.
|
||||
</p>
|
||||
|
||||
<label class="mt-4 block">
|
||||
<span class="block text-sm text-text-secondary">Release-type profile</span>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
{#each profiles as p (p.id)}
|
||||
<option value={p.id}>{p.name}{p.is_default ? ' (default)' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="mt-3 flex items-start gap-3">
|
||||
<input type="checkbox" bind:checked={wantExisting} class="mt-1" />
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Also want what they've released that isn't here</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off wants only releases from now on.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
{#if following}
|
||||
<button
|
||||
type="button"
|
||||
onclick={unfollow}
|
||||
disabled={saving}
|
||||
class="mr-auto rounded-md px-3 py-1.5 text-sm text-text-muted hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = false)}
|
||||
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 || !profileChoice}
|
||||
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
||||
>
|
||||
{following ? 'Save' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
import type { FollowState, ReleaseProfilesResponse } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createFollowQuery: vi.fn(),
|
||||
createReleaseProfilesQuery: vi.fn(),
|
||||
putFollow: vi.fn().mockResolvedValue({ following: true }),
|
||||
deleteFollow: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import FollowControl from './FollowControl.svelte';
|
||||
import {
|
||||
createFollowQuery,
|
||||
createReleaseProfilesQuery,
|
||||
deleteFollow,
|
||||
putFollow
|
||||
} from '#lib/api/wanted.js';
|
||||
|
||||
const profiles: ReleaseProfilesResponse = {
|
||||
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']
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(state: FollowState) {
|
||||
vi.mocked(createFollowQuery).mockReturnValue(
|
||||
mockQuery({ data: state }) as unknown as ReturnType<typeof createFollowQuery>
|
||||
);
|
||||
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
|
||||
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
|
||||
);
|
||||
return render(FollowControl, { artistMbid: 'mb-blur', artistName: 'Blur' });
|
||||
}
|
||||
|
||||
describe('FollowControl', () => {
|
||||
test('follows on the default profile, wanting what is already released when asked', async () => {
|
||||
setup({ following: false });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^follow$/i }));
|
||||
expect((screen.getByLabelText(/release-type profile/i) as HTMLSelectElement).value).toBe('p-std');
|
||||
await fireEvent.click(screen.getByLabelText(/also want what they've released/i));
|
||||
const buttons = screen.getAllByRole('button', { name: /^follow$/i });
|
||||
await fireEvent.click(buttons[buttons.length - 1]);
|
||||
await waitFor(() =>
|
||||
expect(putFollow).toHaveBeenCalledWith('mb-blur', {
|
||||
artist_name: 'Blur',
|
||||
profile_id: 'p-std',
|
||||
want_existing: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('a followed artist shows its profile and can be unfollowed', async () => {
|
||||
setup({ following: true, profile_id: 'p-live', profile_name: 'With live' });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /following · with live/i }));
|
||||
expect((screen.getByLabelText(/release-type profile/i) as HTMLSelectElement).value).toBe('p-live');
|
||||
await fireEvent.click(screen.getByRole('button', { name: /unfollow/i }));
|
||||
await waitFor(() => expect(deleteFollow).toHaveBeenCalledWith('mb-blur'));
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,11 @@
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
// Policy for turning a missing file back into a Lidarr request
|
||||
// (milestone #290). Lives on the missing-files page rather than under
|
||||
// Integrations because this is where the operator meets the problem it
|
||||
// solves; Lidarr is the mechanism, not the subject.
|
||||
// Policy for putting an album whose files went missing back on the wanted
|
||||
// list (milestone #290, M502 #5520). Lives on the missing-files page rather
|
||||
// than under Integrations because this is where the operator meets the
|
||||
// problem it solves. Searching for a want, and retrying, is the search
|
||||
// schedule's job, so there is no retry policy here.
|
||||
|
||||
let saved = $state<ReacquisitionSettings | null>(null);
|
||||
let form = $state<ReacquisitionSettings | null>(null);
|
||||
@@ -31,11 +32,7 @@
|
||||
return {
|
||||
enabled: s.enabled,
|
||||
grace_hours: s.grace_hours,
|
||||
backoff_base_hours: s.backoff_base_hours,
|
||||
backoff_max_hours: s.backoff_max_hours,
|
||||
max_attempts: s.max_attempts,
|
||||
max_per_pass: s.max_per_pass,
|
||||
auto_approve: s.auto_approve
|
||||
max_per_pass: s.max_per_pass
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,28 +67,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-language echo of what the numbers add up to. The individual fields
|
||||
// are meaningless on their own — "6 hours" says nothing until you know it
|
||||
// doubles — so the card states the resulting schedule.
|
||||
const schedule = $derived.by(() => {
|
||||
if (!form) return '';
|
||||
const steps: string[] = [];
|
||||
let hours = form.backoff_base_hours;
|
||||
for (let i = 0; i < form.max_attempts; i++) {
|
||||
steps.push(`${hours}h`);
|
||||
hours = Math.min(hours * 2, form.backoff_max_hours);
|
||||
}
|
||||
return steps.join(' → ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
When a file has been missing for a while, Minstrel can ask Lidarr for the album
|
||||
again by itself. Requests are made per album, not per track — a whole folder
|
||||
going missing is one request, not forty.
|
||||
When a file has been missing for a while, Minstrel can put its album back on the
|
||||
<a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline">wanted list</a>
|
||||
by itself. It's wanted per album, not per track: a whole folder going missing is one
|
||||
want, not forty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +91,7 @@
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Ask Lidarr for missing albums automatically</span>
|
||||
<span class="text-sm text-text-primary">Want missing albums back automatically</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off leaves missing files listed here and does nothing about them.
|
||||
</span>
|
||||
@@ -115,11 +100,11 @@
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Wait before the first attempt</span>
|
||||
<span class="text-sm text-text-primary">Wait before wanting it back</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Hours a file must be missing before anything is requested. A drive that
|
||||
Hours a file must be missing before its album is wanted. A drive that
|
||||
didn't mount, or a container that started before its media, resolves
|
||||
itself well inside a day — this is what stops those becoming requests.
|
||||
itself well inside a day; this is what stops those becoming wants.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -132,53 +117,10 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Attempts before giving up</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
After this many tries the album is left alone. It's picked up again if the
|
||||
file comes back and goes missing later.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={form.max_attempts}
|
||||
class="mt-1 w-28 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>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">First retry gap (hours)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
bind:value={form.backoff_base_hours}
|
||||
class="mt-1 w-28 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>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Longest gap (hours)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="720"
|
||||
bind:value={form.backoff_max_hours}
|
||||
class="mt-1 w-28 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>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-text-primary">Albums per sweep</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Ceiling on requests made each hour, so a large loss trickles.
|
||||
Ceiling on albums wanted each hour, so a large loss trickles.
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -192,33 +134,17 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
|
||||
Retry schedule: <span class="font-mono text-text-primary">{schedule}</span> after the
|
||||
first attempt.
|
||||
</p>
|
||||
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" bind:checked={form.auto_approve} class="mt-1" />
|
||||
<span>
|
||||
<span class="text-sm text-text-primary">Send requests to Lidarr without approval</span>
|
||||
<span class="block text-xs text-text-secondary">
|
||||
Off means requests appear in the Requests queue for you to approve, and
|
||||
nothing is downloaded until you do.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if saved && saved.unnameable_albums > 0}
|
||||
<!-- Stated rather than left to be inferred: without this, an operator
|
||||
watching those rows never get a request would reasonably conclude
|
||||
watching those rows never get wanted would reasonably conclude
|
||||
the feature is broken. -->
|
||||
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
|
||||
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
|
||||
<span>
|
||||
{saved.unnameable_albums}
|
||||
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
|
||||
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be requested —
|
||||
Lidarr has no way to identify the release. A re-scan after tagging fixes it.
|
||||
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted:
|
||||
there's no way to identify the release. A re-scan after tagging fixes it.
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -17,11 +17,7 @@ import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
const base: ReacquisitionSettings = {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -35,21 +31,14 @@ async function renderCard(over: Partial<ReacquisitionSettings> = {}) {
|
||||
}
|
||||
|
||||
describe('ReacquisitionSettingsCard', () => {
|
||||
// The individual numbers say nothing on their own — "6 hours" is meaningless
|
||||
// until you know it doubles — so the card spells the schedule out.
|
||||
test('states the retry schedule the numbers add up to', async () => {
|
||||
// Searching and retrying belong to the wanted list's search schedule, so
|
||||
// the card holds no retry policy of its own.
|
||||
test('has no retry or approval settings', async () => {
|
||||
await renderCard();
|
||||
await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
|
||||
});
|
||||
|
||||
test('the schedule clamps at the longest gap', async () => {
|
||||
await renderCard({ backoff_base_hours: 6, backoff_max_hours: 12, max_attempts: 4 });
|
||||
await waitFor(() => expect(screen.getByText('6h → 12h → 12h → 12h')).toBeTruthy());
|
||||
});
|
||||
|
||||
test('the schedule follows the attempt count', async () => {
|
||||
await renderCard({ max_attempts: 1 });
|
||||
await waitFor(() => expect(screen.getByText('6h')).toBeTruthy());
|
||||
await screen.findByRole('spinbutton', { name: /wait before wanting it back/i });
|
||||
expect(screen.queryByText(/attempts/i)).toBeNull();
|
||||
expect(screen.queryByText(/retry/i)).toBeNull();
|
||||
expect(screen.queryByText(/without approval/i)).toBeNull();
|
||||
});
|
||||
|
||||
// Saving an unchanged form would be a pointless round-trip, and a live Save
|
||||
@@ -59,7 +48,7 @@ describe('ReacquisitionSettingsCard', () => {
|
||||
const save = await screen.findByRole('button', { name: /save/i });
|
||||
expect(save).toHaveProperty('disabled', true);
|
||||
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
|
||||
await fireEvent.input(grace, { target: { value: '48' } });
|
||||
await waitFor(() => expect(save).toHaveProperty('disabled', false));
|
||||
});
|
||||
@@ -68,7 +57,7 @@ describe('ReacquisitionSettingsCard', () => {
|
||||
vi.mocked(updateReacquisitionSettings).mockResolvedValue({ ...base, grace_hours: 48 });
|
||||
await renderCard();
|
||||
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
|
||||
await fireEvent.input(grace, { target: { value: '48' } });
|
||||
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
|
||||
@@ -95,7 +84,7 @@ describe('ReacquisitionSettingsCard', () => {
|
||||
});
|
||||
await renderCard();
|
||||
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
|
||||
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
|
||||
await fireEvent.input(grace, { target: { value: '900' } });
|
||||
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
|
||||
@@ -104,7 +93,7 @@ describe('ReacquisitionSettingsCard', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Without this the operator watches those albums never get a request and
|
||||
// Without this the operator watches those albums never get wanted and
|
||||
// reasonably concludes the feature is broken.
|
||||
test('unnameable albums are called out with the reason', async () => {
|
||||
await renderCard({ unnameable_albums: 3 });
|
||||
@@ -114,7 +103,7 @@ describe('ReacquisitionSettingsCard', () => {
|
||||
|
||||
test('no warning when every missing album is identifiable', async () => {
|
||||
await renderCard({ unnameable_albums: 0 });
|
||||
await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
|
||||
await screen.findByRole('spinbutton', { name: /wait before wanting it back/i });
|
||||
expect(screen.queryByText(/MusicBrainz ID/i)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
import type { ReleaseProfilesResponse } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
createReleaseProfilesQuery: vi.fn(),
|
||||
createReleaseProfile: vi.fn().mockResolvedValue({}),
|
||||
updateReleaseProfile: vi.fn().mockResolvedValue({}),
|
||||
deleteReleaseProfile: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import ReleaseProfilesCard from './ReleaseProfilesCard.svelte';
|
||||
import {
|
||||
createReleaseProfile,
|
||||
createReleaseProfilesQuery,
|
||||
deleteReleaseProfile,
|
||||
updateReleaseProfile
|
||||
} from '#lib/api/wanted.js';
|
||||
|
||||
const data: ReleaseProfilesResponse = {
|
||||
profiles: [
|
||||
{ id: 'p-std', name: 'Standard', primary_types: ['Album', 'EP', 'Single'], secondary_types: [], is_default: true, follow_count: 3 },
|
||||
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
|
||||
],
|
||||
primary_types: ['Album', 'EP', 'Single', 'Broadcast', 'Other'],
|
||||
secondary_types: ['Compilation', 'Live', 'Remix']
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup() {
|
||||
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
|
||||
mockQuery({ data }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
|
||||
);
|
||||
return render(ReleaseProfilesCard);
|
||||
}
|
||||
|
||||
describe('ReleaseProfilesCard', () => {
|
||||
test('says what each profile wants', () => {
|
||||
setup();
|
||||
const rows = screen.getAllByTestId('profile-row');
|
||||
expect(rows[0].textContent).toMatch(/studio only/i);
|
||||
expect(rows[0].textContent).toMatch(/3 artists/i);
|
||||
expect(rows[1].textContent).toMatch(/including live/i);
|
||||
});
|
||||
|
||||
// The default can't be deleted; the server refuses it, so the card doesn't offer it.
|
||||
test('the default profile has no delete button', () => {
|
||||
setup();
|
||||
expect(screen.queryByRole('button', { name: /delete standard/i })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /delete with live/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('editing ticks a secondary type and saves it', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /edit standard/i }));
|
||||
const editor = screen.getByTestId('profile-editor');
|
||||
await fireEvent.click(within(editor).getByLabelText('Live'));
|
||||
await fireEvent.click(within(editor).getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() =>
|
||||
expect(updateReleaseProfile).toHaveBeenCalledWith('p-std', {
|
||||
name: 'Standard',
|
||||
primary_types: ['Album', 'EP', 'Single'],
|
||||
secondary_types: ['Live'],
|
||||
is_default: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('a new profile is created', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /new profile/i }));
|
||||
const editor = screen.getByTestId('profile-editor');
|
||||
await fireEvent.input(within(editor).getByLabelText(/name/i), { target: { value: 'Everything live' } });
|
||||
await fireEvent.click(within(editor).getByLabelText('Live'));
|
||||
await fireEvent.click(within(editor).getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() =>
|
||||
expect(createReleaseProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Everything live', secondary_types: ['Live'] })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('delete removes a profile', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /delete with live/i }));
|
||||
await waitFor(() => expect(deleteReleaseProfile).toHaveBeenCalledWith('p-live'));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" module>
|
||||
import type { LidarrRequestStatus } from '#lib/api/types.js';
|
||||
import type { RequestStatus } from '#lib/api/types.js';
|
||||
|
||||
type Tone = 'warning' | 'info' | 'success' | 'error';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Voice-rule labels per spec §6 (and project_design_system.md). Pending requests
|
||||
// are an "awaiting" moment, completed requests are "kept" (Minstrel-voice for
|
||||
// success), rejected is "set aside", failed is the error register.
|
||||
const STATUS_TABLE: Record<LidarrRequestStatus, StatusEntry> = {
|
||||
const STATUS_TABLE: Record<RequestStatus, StatusEntry> = {
|
||||
pending: { label: 'Awaiting review', tone: 'warning' },
|
||||
approved: { label: 'Approved · downloading', tone: 'info' },
|
||||
completed: { label: 'Kept', tone: 'success' },
|
||||
@@ -18,7 +18,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let { status }: { status: LidarrRequestStatus } = $props();
|
||||
let { status }: { status: RequestStatus } = $props();
|
||||
|
||||
const entry = $derived(STATUS_TABLE[status]);
|
||||
</script>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
try {
|
||||
await createRequest({
|
||||
kind: 'artist',
|
||||
lidarr_artist_mbid: s.mbid,
|
||||
artist_mbid: s.mbid,
|
||||
artist_name: s.name
|
||||
});
|
||||
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('SuggestionFeed', () => {
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
|
||||
expect(createRequest).toHaveBeenCalledWith({
|
||||
kind: 'artist',
|
||||
lidarr_artist_mbid: 'mb1',
|
||||
artist_mbid: 'mb1',
|
||||
artist_name: 'Outsider'
|
||||
});
|
||||
expect(invalidateMock).toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user