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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user