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:
@@ -32,7 +32,7 @@ import type {
|
||||
LidarrConfig,
|
||||
LidarrQualityProfile,
|
||||
LidarrQuarantineActionRow,
|
||||
LidarrRequest,
|
||||
MusicRequest,
|
||||
LidarrRootFolder
|
||||
} from './types';
|
||||
|
||||
@@ -45,19 +45,17 @@ const baseConfig: LidarrConfig = {
|
||||
default_root_folder_path: '/music'
|
||||
};
|
||||
|
||||
const baseRow: LidarrRequest = {
|
||||
const baseRow: MusicRequest = {
|
||||
id: 'r1',
|
||||
user_id: 'u1',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: null,
|
||||
artist_mbid: 'art-mbid',
|
||||
album_mbid: 'alb-mbid',
|
||||
track_mbid: null,
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Drukqs',
|
||||
track_title: null,
|
||||
quality_profile_id: null,
|
||||
root_folder_path: null,
|
||||
decided_at: null,
|
||||
decided_by: null,
|
||||
notes: null,
|
||||
@@ -187,16 +185,10 @@ describe('approveRequest', () => {
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {});
|
||||
});
|
||||
|
||||
test('forwards quality_profile_id + root_folder_path overrides', async () => {
|
||||
test('sends the chosen release-type profile', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
|
||||
await approveRequest('r1', {
|
||||
quality_profile_id: 2,
|
||||
root_folder_path: '/music'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {
|
||||
quality_profile_id: 2,
|
||||
root_folder_path: '/music'
|
||||
});
|
||||
await approveRequest('r1', 'p1');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', { profile_id: 'p1' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+18
-20
@@ -16,8 +16,8 @@ import type {
|
||||
LidarrMetadataProfile,
|
||||
LidarrQualityProfile,
|
||||
LidarrQuarantineActionRow,
|
||||
LidarrRequest,
|
||||
LidarrRequestStatus,
|
||||
MusicRequest,
|
||||
RequestStatus,
|
||||
LidarrRootFolder,
|
||||
LidarrTestResult,
|
||||
PlaybackErrorResolution
|
||||
@@ -57,31 +57,33 @@ export async function listRootFolders(): Promise<LidarrRootFolder[]> {
|
||||
// Admin request queue -----------------------------------------------------
|
||||
|
||||
export async function listAdminRequests(
|
||||
status?: LidarrRequestStatus,
|
||||
status?: RequestStatus,
|
||||
limit?: number
|
||||
): Promise<LidarrRequest[]> {
|
||||
): Promise<MusicRequest[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
return api.get<LidarrRequest[]>(
|
||||
return api.get<MusicRequest[]>(
|
||||
qs ? `/api/admin/requests?${qs}` : '/api/admin/requests'
|
||||
);
|
||||
}
|
||||
|
||||
export async function approveRequest(
|
||||
id: string,
|
||||
overrides: { quality_profile_id?: number; root_folder_path?: string } = {}
|
||||
): Promise<LidarrRequest> {
|
||||
return api.post<LidarrRequest>(`/api/admin/requests/${id}/approve`, overrides);
|
||||
// Approving puts the request on the wanted list (M502 #5520). An artist
|
||||
// request is followed on profileId, or the default profile when omitted.
|
||||
export async function approveRequest(id: string, profileId?: string): Promise<MusicRequest> {
|
||||
return api.post<MusicRequest>(
|
||||
`/api/admin/requests/${id}/approve`,
|
||||
profileId ? { profile_id: profileId } : {}
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectRequest(
|
||||
id: string,
|
||||
notes?: string
|
||||
): Promise<LidarrRequest> {
|
||||
): Promise<MusicRequest> {
|
||||
const body = notes !== undefined ? { notes } : {};
|
||||
return api.post<LidarrRequest>(`/api/admin/requests/${id}/reject`, body);
|
||||
return api.post<MusicRequest>(`/api/admin/requests/${id}/reject`, body);
|
||||
}
|
||||
|
||||
// Query factories ---------------------------------------------------------
|
||||
@@ -122,7 +124,7 @@ export function createRootFoldersQuery(enabled: boolean = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
||||
export function createAdminRequestsQuery(status?: RequestStatus) {
|
||||
return createQuery({
|
||||
queryKey: qk.adminRequests(status),
|
||||
queryFn: () => listAdminRequests(status),
|
||||
@@ -131,13 +133,13 @@ export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
||||
// until the operator acts on them.
|
||||
refetchInterval: (query) => {
|
||||
if (status !== 'approved') return false;
|
||||
const rows = query.state.data as LidarrRequest[] | undefined;
|
||||
const rows = query.state.data as MusicRequest[] | undefined;
|
||||
return hasInFlightRequest(rows) ? 12_000 : false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined): boolean {
|
||||
function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): boolean {
|
||||
return rows?.some((r) => r.status === 'approved') ?? false;
|
||||
}
|
||||
|
||||
@@ -973,12 +975,8 @@ export async function setSuspectVerdict(trackId: string, verdict: SuspectVerdict
|
||||
export type ReacquisitionSettings = {
|
||||
enabled: boolean;
|
||||
grace_hours: number;
|
||||
backoff_base_hours: number;
|
||||
backoff_max_hours: number;
|
||||
max_attempts: number;
|
||||
max_per_pass: number;
|
||||
auto_approve: boolean;
|
||||
// Albums with missing files that can never be auto-requested because
|
||||
// Albums with missing files that can never be wanted automatically because
|
||||
// neither they nor their artist carries an MBID. Read-only; the server
|
||||
// computes it, and the card states it so the gap isn't a mystery.
|
||||
unnameable_albums: number;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { CatalogueSearchResult, LidarrRequestKind } from './types';
|
||||
import type { CatalogueSearchResult, RequestKind } from './types';
|
||||
|
||||
// searchCatalogue asks MusicBrainz through Minstrel's release catalogue
|
||||
// (M502 #5519). URLSearchParams handles encoding (spaces, quotes,
|
||||
// ampersands) so callers can pass q raw.
|
||||
export async function searchCatalogue(
|
||||
q: string,
|
||||
kind: LidarrRequestKind
|
||||
kind: RequestKind
|
||||
): Promise<CatalogueSearchResult[]> {
|
||||
const params = new URLSearchParams({ q, kind });
|
||||
return api.get<CatalogueSearchResult[]>(`/api/catalogue/search?${params.toString()}`);
|
||||
@@ -16,7 +16,7 @@ export async function searchCatalogue(
|
||||
|
||||
// MusicBrainz allows a request a second, so a repeated search within a minute
|
||||
// is served from the client's cache rather than asked again.
|
||||
export function createCatalogueSearchQuery(q: string, kind: LidarrRequestKind) {
|
||||
export function createCatalogueSearchQuery(q: string, kind: RequestKind) {
|
||||
return createQuery({
|
||||
queryKey: qk.catalogueSearch(q, kind),
|
||||
queryFn: () => searchCatalogue(q, kind),
|
||||
|
||||
@@ -39,6 +39,12 @@ export const qk = {
|
||||
lidarrRootFolders: () => ['lidarrRootFolders'] as const,
|
||||
adminRequests: (status?: string) =>
|
||||
['adminRequests', { status: status ?? 'all' }] as const,
|
||||
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
|
||||
// invalidation refreshes every tab and its counts.
|
||||
adminWants: (state: string) => ['adminWants', { state }] as const,
|
||||
adminFollows: () => ['adminFollows'] as const,
|
||||
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
|
||||
releaseProfiles: () => ['releaseProfiles'] as const,
|
||||
myQuarantine: () => ['myQuarantine'] as const,
|
||||
adminQuarantine: () => ['adminQuarantine'] as const,
|
||||
adminQuarantineActions: (limit?: number) =>
|
||||
|
||||
@@ -19,21 +19,19 @@ import {
|
||||
} from './requests';
|
||||
import { api, apiFetch } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { LidarrRequest } from './types';
|
||||
import type { MusicRequest } from './types';
|
||||
|
||||
const mockRow: LidarrRequest = {
|
||||
const mockRow: MusicRequest = {
|
||||
id: 'r1',
|
||||
user_id: 'u1',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: null,
|
||||
artist_mbid: 'art-mbid',
|
||||
album_mbid: 'alb-mbid',
|
||||
track_mbid: null,
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Selected Ambient Works',
|
||||
track_title: null,
|
||||
quality_profile_id: null,
|
||||
root_folder_path: null,
|
||||
decided_at: null,
|
||||
decided_by: null,
|
||||
notes: null,
|
||||
@@ -60,16 +58,16 @@ describe('createRequest', () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
const out = await createRequest({
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
artist_mbid: 'art-mbid',
|
||||
album_mbid: 'alb-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Selected Ambient Works'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/requests', {
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
album_mbid: 'alb-mbid',
|
||||
album_title: 'Selected Ambient Works'
|
||||
});
|
||||
expect(out).toBe(mockRow);
|
||||
@@ -79,10 +77,10 @@ describe('createRequest', () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
await createRequest({
|
||||
kind: 'artist',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
lidarr_album_mbid: '',
|
||||
lidarr_track_mbid: ''
|
||||
album_mbid: '',
|
||||
track_mbid: ''
|
||||
});
|
||||
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<
|
||||
string,
|
||||
@@ -90,11 +88,11 @@ describe('createRequest', () => {
|
||||
>;
|
||||
expect(body).toEqual({
|
||||
kind: 'artist',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin'
|
||||
});
|
||||
expect(body).not.toHaveProperty('lidarr_album_mbid');
|
||||
expect(body).not.toHaveProperty('lidarr_track_mbid');
|
||||
expect(body).not.toHaveProperty('album_mbid');
|
||||
expect(body).not.toHaveProperty('track_mbid');
|
||||
expect(body).not.toHaveProperty('album_title');
|
||||
expect(body).not.toHaveProperty('track_title');
|
||||
});
|
||||
@@ -103,19 +101,19 @@ describe('createRequest', () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
|
||||
await createRequest({
|
||||
kind: 'track',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: 'trk-mbid',
|
||||
artist_mbid: 'art-mbid',
|
||||
album_mbid: 'alb-mbid',
|
||||
track_mbid: 'trk-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
album_title: 'Drukqs',
|
||||
track_title: 'Avril 14th'
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith('/api/requests', {
|
||||
kind: 'track',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
artist_mbid: 'art-mbid',
|
||||
artist_name: 'Aphex Twin',
|
||||
lidarr_album_mbid: 'alb-mbid',
|
||||
lidarr_track_mbid: 'trk-mbid',
|
||||
album_mbid: 'alb-mbid',
|
||||
track_mbid: 'trk-mbid',
|
||||
album_title: 'Drukqs',
|
||||
track_title: 'Avril 14th'
|
||||
});
|
||||
|
||||
+22
-22
@@ -1,7 +1,7 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api, apiFetch } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { LidarrRequest, LidarrRequestKind } from './types';
|
||||
import type { MusicRequest, RequestKind } from './types';
|
||||
|
||||
/** Cadence for in-flight request polling. Tuned to match the operator's
|
||||
"fast enough to feel live, slow enough to not hammer the server"
|
||||
@@ -11,7 +11,7 @@ const POLL_INTERVAL_MS = 12_000;
|
||||
/** True if any row is mid-ingest (status === 'approved'). The status flips
|
||||
to 'completed' / 'rejected' when Lidarr finishes, so this predicate
|
||||
lets the page poll exactly while there's something interesting to watch. */
|
||||
export function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined): boolean {
|
||||
export function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): boolean {
|
||||
return rows?.some((r) => r.status === 'approved') ?? false;
|
||||
}
|
||||
|
||||
@@ -20,21 +20,21 @@ export function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined):
|
||||
// strings (the backend tolerates either, but this keeps test expectations
|
||||
// crisp and matches the spec §5 wire shape).
|
||||
export type CreateRequestParams = {
|
||||
kind: LidarrRequestKind;
|
||||
lidarr_artist_mbid: string;
|
||||
lidarr_album_mbid?: string;
|
||||
lidarr_track_mbid?: string;
|
||||
kind: RequestKind;
|
||||
artist_mbid: string;
|
||||
album_mbid?: string;
|
||||
track_mbid?: string;
|
||||
artist_name: string;
|
||||
album_title?: string;
|
||||
track_title?: string;
|
||||
};
|
||||
|
||||
type CreateRequestBody = {
|
||||
kind: LidarrRequestKind;
|
||||
lidarr_artist_mbid: string;
|
||||
kind: RequestKind;
|
||||
artist_mbid: string;
|
||||
artist_name: string;
|
||||
lidarr_album_mbid?: string;
|
||||
lidarr_track_mbid?: string;
|
||||
album_mbid?: string;
|
||||
track_mbid?: string;
|
||||
album_title?: string;
|
||||
track_title?: string;
|
||||
};
|
||||
@@ -42,33 +42,33 @@ type CreateRequestBody = {
|
||||
function buildCreateBody(params: CreateRequestParams): CreateRequestBody {
|
||||
const body: CreateRequestBody = {
|
||||
kind: params.kind,
|
||||
lidarr_artist_mbid: params.lidarr_artist_mbid,
|
||||
artist_mbid: params.artist_mbid,
|
||||
artist_name: params.artist_name
|
||||
};
|
||||
if (params.lidarr_album_mbid) body.lidarr_album_mbid = params.lidarr_album_mbid;
|
||||
if (params.lidarr_track_mbid) body.lidarr_track_mbid = params.lidarr_track_mbid;
|
||||
if (params.album_mbid) body.album_mbid = params.album_mbid;
|
||||
if (params.track_mbid) body.track_mbid = params.track_mbid;
|
||||
if (params.album_title) body.album_title = params.album_title;
|
||||
if (params.track_title) body.track_title = params.track_title;
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function createRequest(params: CreateRequestParams): Promise<LidarrRequest> {
|
||||
return api.post<LidarrRequest>('/api/requests', buildCreateBody(params));
|
||||
export async function createRequest(params: CreateRequestParams): Promise<MusicRequest> {
|
||||
return api.post<MusicRequest>('/api/requests', buildCreateBody(params));
|
||||
}
|
||||
|
||||
export async function listMyRequests(): Promise<LidarrRequest[]> {
|
||||
return api.get<LidarrRequest[]>('/api/requests');
|
||||
export async function listMyRequests(): Promise<MusicRequest[]> {
|
||||
return api.get<MusicRequest[]>('/api/requests');
|
||||
}
|
||||
|
||||
export async function getRequest(id: string): Promise<LidarrRequest> {
|
||||
return api.get<LidarrRequest>(`/api/requests/${id}`);
|
||||
export async function getRequest(id: string): Promise<MusicRequest> {
|
||||
return api.get<MusicRequest>(`/api/requests/${id}`);
|
||||
}
|
||||
|
||||
// Server returns the cancelled row body (not 204) so callers can patch the
|
||||
// cache without a refetch. api.del's return type is fixed to null, so we
|
||||
// drop down to apiFetch here to keep typing honest.
|
||||
export async function cancelRequest(id: string): Promise<LidarrRequest> {
|
||||
return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise<LidarrRequest>;
|
||||
export async function cancelRequest(id: string): Promise<MusicRequest> {
|
||||
return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise<MusicRequest>;
|
||||
}
|
||||
|
||||
export function createMyRequestsQuery() {
|
||||
@@ -81,7 +81,7 @@ export function createMyRequestsQuery() {
|
||||
// refetchIntervalInBackground defaults to false → polling pauses
|
||||
// while the tab is hidden and resumes on focus.
|
||||
refetchInterval: (query) =>
|
||||
hasInFlightRequest(query.state.data as LidarrRequest[] | undefined)
|
||||
hasInFlightRequest(query.state.data as MusicRequest[] | undefined)
|
||||
? POLL_INTERVAL_MS
|
||||
: false
|
||||
});
|
||||
|
||||
+82
-22
@@ -51,6 +51,8 @@ export type ArtistDetail = ArtistRef & {
|
||||
albums: AlbumRef[];
|
||||
// Genres across this artist's tracks (#367). Server guarantees an array.
|
||||
genres: string[];
|
||||
// MusicBrainz id, which following is keyed by (M502 #5520); "" when none.
|
||||
mbid: string;
|
||||
};
|
||||
|
||||
export type AlbumDetail = AlbumRef & {
|
||||
@@ -133,18 +135,18 @@ export type EventRequest =
|
||||
export type PlayStartedResponse = { play_event_id: string; session_id: string };
|
||||
export type EventOkResponse = { ok: true };
|
||||
|
||||
// Lidarr / requests shared enums + shapes.
|
||||
// Status + Kind are used across lidarr.ts, requests.ts, and admin.ts so they
|
||||
// live here. Per-module helpers (CreateRequestParams, etc.) stay in their
|
||||
// Request shared enums + shapes.
|
||||
// Status + Kind are used across catalogue.ts, requests.ts, and admin.ts so
|
||||
// they live here. Per-module helpers (CreateRequestParams, etc.) stay in their
|
||||
// respective modules.
|
||||
export type LidarrRequestStatus =
|
||||
export type RequestStatus =
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type LidarrRequestKind = 'artist' | 'album' | 'track';
|
||||
export type RequestKind = 'artist' | 'album' | 'track';
|
||||
|
||||
// One row of the request search (GET /api/catalogue/search, M502 #5519).
|
||||
// An album is a MusicBrainz release group; image_url is Minstrel's own art
|
||||
@@ -163,19 +165,18 @@ export type CatalogueSearchResult = {
|
||||
// pgtype.UUID and pgtype.Timestamptz JSON-marshal as a native string when
|
||||
// Valid, and as `null` (or omitted, with omitempty) otherwise. We unify both
|
||||
// "absent" cases as `string | null` so callers branch on a single check.
|
||||
export type LidarrRequest = {
|
||||
export type MusicRequest = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: LidarrRequestStatus;
|
||||
kind: LidarrRequestKind;
|
||||
lidarr_artist_mbid: string;
|
||||
lidarr_album_mbid?: string | null;
|
||||
lidarr_track_mbid?: string | null;
|
||||
status: RequestStatus;
|
||||
kind: RequestKind;
|
||||
artist_mbid: string;
|
||||
// The album's MusicBrainz release group.
|
||||
album_mbid?: string | null;
|
||||
track_mbid?: string | null;
|
||||
artist_name: string;
|
||||
album_title?: string | null;
|
||||
track_title?: string | null;
|
||||
quality_profile_id?: number | null;
|
||||
root_folder_path?: string | null;
|
||||
decided_at?: string | null;
|
||||
decided_by?: string | null;
|
||||
notes?: string | null;
|
||||
@@ -397,16 +398,12 @@ export type AdminMissingTrack = {
|
||||
last_played_at: string | null;
|
||||
};
|
||||
|
||||
// What the re-acquisition sweeper has done about this group's album (#290).
|
||||
// null when nothing has been attempted yet — the common case for a folder
|
||||
// that just went missing, and distinct from attempts: 0, which cannot occur.
|
||||
// What the wanted list says about this group's album (#290, M502 #5520):
|
||||
// when it was wanted back, and when the library held it again. null while the
|
||||
// album is inside the grace window, or when MusicBrainz can't name it.
|
||||
export type AdminReacquisitionState = {
|
||||
attempts: number;
|
||||
last_attempt_at: string | null;
|
||||
// Projected from the last attempt plus the configured backoff, so it moves
|
||||
// when the operator edits the schedule. Null once the album has given up.
|
||||
next_attempt_at: string | null;
|
||||
gave_up_at: string | null;
|
||||
wanted_at: string | null;
|
||||
held_at: string | null;
|
||||
};
|
||||
|
||||
// A directory's worth of missing tracks. The server groups because the unit an
|
||||
@@ -569,3 +566,66 @@ export type ResolvedDuplicatesResponse = {
|
||||
offset: number;
|
||||
items: ResolvedDuplicateActivity[];
|
||||
};
|
||||
|
||||
// Wanted list (M502 #5520) ------------------------------------------------
|
||||
|
||||
export type WantReason = 'request' | 'follow' | 'missing';
|
||||
export type WantState = 'wanted' | 'missing' | 'held';
|
||||
|
||||
// One release group on the wanted list. held_at is set once the library
|
||||
// holds it; artist_id and album_id link into the library when it has them.
|
||||
export type Want = {
|
||||
id: string;
|
||||
release_group_mbid: string;
|
||||
title: string;
|
||||
artist_mbid: string;
|
||||
artist_name: string;
|
||||
artist_id?: string;
|
||||
primary_type: string;
|
||||
secondary_types: string[];
|
||||
year: string;
|
||||
reason: WantReason;
|
||||
requested_by?: string;
|
||||
album_id?: string;
|
||||
wanted_at: string;
|
||||
held_at: string | null;
|
||||
image_url: string;
|
||||
};
|
||||
|
||||
export type WantCounts = { wanted: number; missing: number; held: number; following: number };
|
||||
|
||||
export type WantsPage = Page<Want> & { counts: WantCounts };
|
||||
|
||||
export type Follow = {
|
||||
artist_mbid: string;
|
||||
artist_name: string;
|
||||
artist_id?: string;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
followed_at: string;
|
||||
checked_at: string | null;
|
||||
open_wants: number;
|
||||
image_url: string;
|
||||
};
|
||||
|
||||
export type FollowState = { following: boolean; profile_id?: string; profile_name?: string };
|
||||
|
||||
// A release-type profile: which kinds of release a follow wants. A group
|
||||
// passes when its primary type is listed and every secondary type is too;
|
||||
// no secondary types means studio releases only.
|
||||
export type ReleaseProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
primary_types: string[];
|
||||
secondary_types: string[];
|
||||
is_default: boolean;
|
||||
follow_count: number;
|
||||
};
|
||||
|
||||
export type ReleaseProfileInput = Omit<ReleaseProfile, 'id' | 'follow_count'>;
|
||||
|
||||
export type ReleaseProfilesResponse = {
|
||||
profiles: ReleaseProfile[];
|
||||
primary_types: string[];
|
||||
secondary_types: string[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type {
|
||||
Follow,
|
||||
FollowState,
|
||||
ReleaseProfile,
|
||||
ReleaseProfileInput,
|
||||
ReleaseProfilesResponse,
|
||||
WantState,
|
||||
WantsPage
|
||||
} from './types';
|
||||
|
||||
// The wanted list (M502 #5520): release groups Minstrel is to get, the
|
||||
// artists it follows for new releases, and the release-type profiles that
|
||||
// say which kinds of release a follow wants.
|
||||
|
||||
export async function listWants(state: WantState, offset: number = 0): Promise<WantsPage> {
|
||||
return api.get<WantsPage>(`/api/admin/wanted?state=${state}&offset=${offset}`);
|
||||
}
|
||||
|
||||
export function wantsNextOffset(last: WantsPage): number | undefined {
|
||||
const loaded = last.offset + last.items.length;
|
||||
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
|
||||
}
|
||||
|
||||
// Loads as the page scrolls (rule 172), one tab at a time.
|
||||
export function createWantsQuery(state: WantState) {
|
||||
return createInfiniteQuery({
|
||||
queryKey: qk.adminWants(state),
|
||||
queryFn: ({ pageParam }) => listWants(state, pageParam as number),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: wantsNextOffset,
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeWant(id: string): Promise<void> {
|
||||
await api.del(`/api/admin/wanted/${id}`);
|
||||
}
|
||||
|
||||
export async function listFollows(): Promise<Follow[]> {
|
||||
return api.get<Follow[]>('/api/admin/follows');
|
||||
}
|
||||
|
||||
export function createFollowsQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.adminFollows(),
|
||||
queryFn: listFollows,
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFollow(artistMbid: string): Promise<FollowState> {
|
||||
return api.get<FollowState>(`/api/admin/follows/${artistMbid}`);
|
||||
}
|
||||
|
||||
export function createFollowQuery(artistMbid: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.adminFollow(artistMbid),
|
||||
queryFn: () => getFollow(artistMbid),
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
// Follows an artist, or moves a follow to another profile. wantExisting also
|
||||
// wants what the artist has already released that the profile passes and the
|
||||
// library doesn't hold. An omitted profile is the default.
|
||||
export async function putFollow(
|
||||
artistMbid: string,
|
||||
body: { artist_name: string; profile_id?: string; want_existing?: boolean }
|
||||
): Promise<FollowState> {
|
||||
return api.put<FollowState>(`/api/admin/follows/${artistMbid}`, body);
|
||||
}
|
||||
|
||||
export async function deleteFollow(artistMbid: string): Promise<void> {
|
||||
await api.del(`/api/admin/follows/${artistMbid}`);
|
||||
}
|
||||
|
||||
export async function listReleaseProfiles(): Promise<ReleaseProfilesResponse> {
|
||||
return api.get<ReleaseProfilesResponse>('/api/admin/release-profiles');
|
||||
}
|
||||
|
||||
export function createReleaseProfilesQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.releaseProfiles(),
|
||||
queryFn: listReleaseProfiles,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
export async function createReleaseProfile(body: ReleaseProfileInput): Promise<ReleaseProfile> {
|
||||
return api.post<ReleaseProfile>('/api/admin/release-profiles', body);
|
||||
}
|
||||
|
||||
export async function updateReleaseProfile(id: string, body: ReleaseProfileInput): Promise<ReleaseProfile> {
|
||||
return api.put<ReleaseProfile>(`/api/admin/release-profiles/${id}`, body);
|
||||
}
|
||||
|
||||
export async function deleteReleaseProfile(id: string): Promise<void> {
|
||||
await api.del(`/api/admin/release-profiles/${id}`);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -56,5 +56,16 @@
|
||||
"playlist_not_found": "That playlist no longer exists.",
|
||||
"user_not_found": "That user no longer exists.",
|
||||
"service_not_found": "That connection no longer exists. Refresh the page.",
|
||||
"too_many_services": "There are already twenty of these. Remove one you no longer use first."
|
||||
"too_many_services": "There are already twenty of these. Remove one you no longer use first.",
|
||||
"bad_state": "That isn't a tab of the wanted list.",
|
||||
"bad_profile": "That isn't a release-type profile.",
|
||||
"not_followable": "Various Artists and MusicBrainz placeholders can't be followed.",
|
||||
"profile_not_found": "That release-type profile no longer exists. Refresh the page.",
|
||||
"profile_invalid": "A profile needs a name and at least one primary type.",
|
||||
"profile_name_taken": "A profile already has that name.",
|
||||
"profile_in_use": "Artists are followed on this profile. Move them to another one first.",
|
||||
"profile_is_default": "The default profile can't be deleted. Make another one the default first.",
|
||||
"want_not_found": "That's no longer on the wanted list. Refresh the page.",
|
||||
"follow_not_found": "That artist isn't followed any more. Refresh the page.",
|
||||
"invalid_profile": "That isn't a release-type profile."
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@
|
||||
import CompactTrackCard from '#lib/components/CompactTrackCard.svelte';
|
||||
import HorizontalScrollRow from '#lib/components/HorizontalScrollRow.svelte';
|
||||
import LikeButton from '#lib/components/LikeButton.svelte';
|
||||
import FollowControl from '#lib/components/FollowControl.svelte';
|
||||
import { user } from '#lib/auth/store.svelte.js';
|
||||
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
|
||||
@@ -22,6 +24,7 @@
|
||||
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
const isAdmin = $derived(user.value?.is_admin === true);
|
||||
const queryStore = $derived(createArtistQuery(id));
|
||||
const query = $derived($queryStore);
|
||||
const showSkeleton = useDelayed(() => query.isPending);
|
||||
@@ -103,13 +106,18 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<PlayActions
|
||||
name={detail.name}
|
||||
disabled={isStartingPlay || detail.album_count === 0}
|
||||
{onPlay}
|
||||
{onShuffle}
|
||||
/>
|
||||
<!-- Following is keyed by MusicBrainz id, so an artist whose tags
|
||||
never named one can't be followed. -->
|
||||
{#if isAdmin && detail.mbid}
|
||||
<FollowControl artistMbid={detail.mbid} artistName={detail.name} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -25,6 +25,24 @@ vi.mock('#lib/api/queries.js', () => ({
|
||||
|
||||
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||
|
||||
const userState = vi.hoisted(() => ({
|
||||
current: null as { id: string; username: string; is_admin: boolean } | null
|
||||
}));
|
||||
|
||||
vi.mock('#lib/auth/store.svelte.js', () => ({
|
||||
user: { get value() { return userState.current; } }
|
||||
}));
|
||||
|
||||
vi.mock('#lib/api/wanted.js', async () => {
|
||||
const { mockQuery } = await import('../../../test-utils/query');
|
||||
return {
|
||||
createFollowQuery: vi.fn(() => mockQuery({ data: { following: false } })),
|
||||
createReleaseProfilesQuery: vi.fn(() => mockQuery({ data: { profiles: [], primary_types: [], secondary_types: [] } })),
|
||||
putFollow: vi.fn(),
|
||||
deleteFollow: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/client.js', () => apiClientMock());
|
||||
|
||||
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||
@@ -59,6 +77,7 @@ function album(id: string, title: string, year?: number): AlbumRef {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.pageParams = { id: 'abc' };
|
||||
userState.current = null;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -73,7 +92,7 @@ describe('artist detail page', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020), album('a2', 'Second')],
|
||||
genres: []
|
||||
genres: [], mbid: ''
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(ArtistPage);
|
||||
@@ -87,7 +106,7 @@ describe('artist detail page', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)],
|
||||
genres: []
|
||||
genres: [], mbid: ''
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
@@ -154,7 +173,7 @@ describe('artist detail page', () => {
|
||||
describe('Play and Shuffle', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)], genres: []
|
||||
albums: [album('a1', 'First', 2020)], genres: [], mbid: ''
|
||||
};
|
||||
const tracks = makeTracks(5);
|
||||
|
||||
@@ -176,4 +195,34 @@ describe('artist detail page', () => {
|
||||
expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(tracks.map((t) => t.id).sort());
|
||||
});
|
||||
});
|
||||
|
||||
// Following is for the wanted list (M502 #5520): an admin control, keyed by
|
||||
// the artist's MusicBrainz id.
|
||||
describe('Follow', () => {
|
||||
const detail = (mbid: string): ArtistDetail => ({
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)], genres: [], mbid
|
||||
});
|
||||
|
||||
test('an admin can follow an artist with a MusicBrainz id', () => {
|
||||
userState.current = { id: 'u1', username: 'admin', is_admin: true };
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail('mb-alice') }));
|
||||
render(ArtistPage);
|
||||
expect(screen.getByRole('button', { name: /^follow$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('no follow control without a MusicBrainz id', () => {
|
||||
userState.current = { id: 'u1', username: 'admin', is_admin: true };
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail('') }));
|
||||
render(ArtistPage);
|
||||
expect(screen.queryByRole('button', { name: /^follow$/i })).toBeNull();
|
||||
});
|
||||
|
||||
test('no follow control for a non-admin', () => {
|
||||
userState.current = { id: 'u2', username: 'sam', is_admin: false };
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail('mb-alice') }));
|
||||
render(ArtistPage);
|
||||
expect(screen.queryByRole('button', { name: /^follow$/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import type {
|
||||
CatalogueSearchResult,
|
||||
LidarrRequestKind
|
||||
RequestKind
|
||||
} from '#lib/api/types.js';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -21,7 +21,7 @@
|
||||
// pushing into `debouncedQ`, which is what the query factory observes.
|
||||
let inputValue = $state('');
|
||||
let debouncedQ = $state('');
|
||||
let activeKind: LidarrRequestKind = $state('artist');
|
||||
let activeKind: RequestKind = $state('artist');
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
$effect(() => {
|
||||
@@ -56,19 +56,19 @@
|
||||
// Backend validates kind→required-MBID-fields, not the human strings, so
|
||||
// imperfect derivations of artist_name from secondary_text won't block the
|
||||
// request. We pass the data we have and let the server take care of the rest.
|
||||
function buildRequestParams(r: CatalogueSearchResult, kind: LidarrRequestKind) {
|
||||
function buildRequestParams(r: CatalogueSearchResult, kind: RequestKind) {
|
||||
if (kind === 'artist') {
|
||||
return {
|
||||
kind,
|
||||
lidarr_artist_mbid: r.artist_mbid || r.mbid,
|
||||
artist_mbid: r.artist_mbid || r.mbid,
|
||||
artist_name: r.name
|
||||
};
|
||||
}
|
||||
if (kind === 'album') {
|
||||
return {
|
||||
kind,
|
||||
lidarr_artist_mbid: r.artist_mbid,
|
||||
lidarr_album_mbid: r.album_mbid || r.mbid,
|
||||
artist_mbid: r.artist_mbid,
|
||||
album_mbid: r.album_mbid || r.mbid,
|
||||
artist_name: r.secondary_text,
|
||||
album_title: r.name
|
||||
};
|
||||
@@ -76,9 +76,9 @@
|
||||
// track
|
||||
return {
|
||||
kind,
|
||||
lidarr_artist_mbid: r.artist_mbid,
|
||||
lidarr_album_mbid: r.album_mbid,
|
||||
lidarr_track_mbid: r.mbid,
|
||||
artist_mbid: r.artist_mbid,
|
||||
album_mbid: r.album_mbid,
|
||||
track_mbid: r.mbid,
|
||||
artist_name: r.secondary_text,
|
||||
album_title: r.secondary_text,
|
||||
track_title: r.name
|
||||
@@ -116,7 +116,7 @@
|
||||
modalResult = null;
|
||||
}
|
||||
|
||||
const tabs: { kind: LidarrRequestKind; label: string }[] = [
|
||||
const tabs: { kind: RequestKind; label: string }[] = [
|
||||
{ kind: 'artist', label: 'Artists' },
|
||||
{ kind: 'album', label: 'Albums' },
|
||||
{ kind: 'track', label: 'Tracks' }
|
||||
|
||||
@@ -263,9 +263,9 @@ describe('Discover page', () => {
|
||||
expect(mockedCreateRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'track',
|
||||
lidarr_track_mbid: 'tr-mbid',
|
||||
lidarr_album_mbid: 'al-mbid',
|
||||
lidarr_artist_mbid: 'art-mbid'
|
||||
track_mbid: 'tr-mbid',
|
||||
album_mbid: 'al-mbid',
|
||||
artist_mbid: 'art-mbid'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
import StatusPill from '#lib/components/StatusPill.svelte';
|
||||
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
||||
import DiscoverTabs from '#lib/components/DiscoverTabs.svelte';
|
||||
import type { LidarrRequest, LidarrRequestKind } from '#lib/api/types.js';
|
||||
import type { MusicRequest, RequestKind } from '#lib/api/types.js';
|
||||
|
||||
const queryStore = createMyRequestsQuery();
|
||||
const query = $derived($queryStore);
|
||||
const requests = $derived((query.data ?? []) as LidarrRequest[]);
|
||||
const requests = $derived((query.data ?? []) as MusicRequest[]);
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? '—';
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
// Used in aria-labels — '—' is meaningless to screen readers, so when a
|
||||
// title is missing fall back to a generic-but-intelligible phrase.
|
||||
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}`;
|
||||
@@ -48,13 +48,13 @@
|
||||
// Keep the meta line short and readable. We always lead with the artist,
|
||||
// then a relative-ish date — locale formatting is good enough for v1
|
||||
// and keeps tests deterministic without pinning Intl.RelativeTimeFormat.
|
||||
function rowMeta(r: LidarrRequest): string {
|
||||
function rowMeta(r: MusicRequest): string {
|
||||
const when = new Date(r.requested_at).toLocaleDateString();
|
||||
if (r.kind === 'artist') return `Requested ${when}`;
|
||||
return `by ${r.artist_name} · ${when}`;
|
||||
}
|
||||
|
||||
function listenHref(r: LidarrRequest): string | null {
|
||||
function listenHref(r: MusicRequest): string | null {
|
||||
if (r.matched_track_id) return `/tracks/${r.matched_track_id}`;
|
||||
if (r.matched_album_id) return `/albums/${r.matched_album_id}`;
|
||||
if (r.matched_artist_id) return `/artists/${r.matched_artist_id}`;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
||||
import type { LidarrRequest } from '#lib/api/types.js';
|
||||
import type { MusicRequest } from '#lib/api/types.js';
|
||||
import { pageUrlModule } from '../../test-utils/mocks/appState';
|
||||
|
||||
// $app/state / $app/navigation must be mocked or rendering the page
|
||||
@@ -43,20 +43,18 @@ import { qk } from '#lib/api/queries.js';
|
||||
const mockedCreateMyRequestsQuery = createMyRequestsQuery as ReturnType<typeof vi.fn>;
|
||||
const mockedCancelRequest = cancelRequest as ReturnType<typeof vi.fn>;
|
||||
|
||||
function req(over: Partial<LidarrRequest> = {}): LidarrRequest {
|
||||
function req(over: Partial<MusicRequest> = {}): MusicRequest {
|
||||
return {
|
||||
id: 'r1',
|
||||
user_id: 'u1',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
lidarr_artist_mbid: 'art-mbid',
|
||||
lidarr_album_mbid: 'al-mbid',
|
||||
lidarr_track_mbid: null,
|
||||
artist_mbid: 'art-mbid',
|
||||
album_mbid: 'al-mbid',
|
||||
track_mbid: null,
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Music Has The Right To Children',
|
||||
track_title: null,
|
||||
quality_profile_id: null,
|
||||
root_folder_path: null,
|
||||
decided_at: null,
|
||||
decided_by: null,
|
||||
notes: null,
|
||||
@@ -78,12 +76,12 @@ afterEach(() => {
|
||||
|
||||
describe('Requests page', () => {
|
||||
test('renders one row per request from the API', () => {
|
||||
const rows: LidarrRequest[] = [
|
||||
const rows: MusicRequest[] = [
|
||||
req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }),
|
||||
req({ id: 'r2', kind: 'album', album_title: 'Geogaddi', status: 'completed' }),
|
||||
req({ id: 'r3', kind: 'track', track_title: 'Roygbiv', status: 'rejected', notes: 'Already in library' })
|
||||
];
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
|
||||
render(RequestsPage);
|
||||
expect(screen.getAllByTestId('request-row')).toHaveLength(3);
|
||||
expect(screen.getByText('Boards of Canada')).toBeInTheDocument();
|
||||
@@ -92,10 +90,10 @@ describe('Requests page', () => {
|
||||
});
|
||||
|
||||
test('pending row exposes Cancel; clicking calls cancelRequest and invalidates myRequests', async () => {
|
||||
const rows: LidarrRequest[] = [
|
||||
const rows: MusicRequest[] = [
|
||||
req({ id: 'r-pending', status: 'pending', kind: 'album', album_title: 'Geogaddi' })
|
||||
];
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
|
||||
render(RequestsPage);
|
||||
const cancelBtn = screen.getByRole('button', { name: /cancel request for geogaddi/i });
|
||||
await fireEvent.click(cancelBtn);
|
||||
@@ -106,7 +104,7 @@ describe('Requests page', () => {
|
||||
});
|
||||
|
||||
test('completed row with matched_track_id renders Listen link to /tracks/<id>', () => {
|
||||
const rows: LidarrRequest[] = [
|
||||
const rows: MusicRequest[] = [
|
||||
req({
|
||||
id: 'r-track',
|
||||
status: 'completed',
|
||||
@@ -117,14 +115,14 @@ describe('Requests page', () => {
|
||||
matched_artist_id: 'art-9'
|
||||
})
|
||||
];
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
|
||||
render(RequestsPage);
|
||||
const link = screen.getByRole('link', { name: /listen to roygbiv/i });
|
||||
expect(link).toHaveAttribute('href', '/tracks/tr-9');
|
||||
});
|
||||
|
||||
test('completed row with only matched_album_id renders Listen link to /albums/<id>', () => {
|
||||
const rows: LidarrRequest[] = [
|
||||
const rows: MusicRequest[] = [
|
||||
req({
|
||||
id: 'r-album',
|
||||
status: 'completed',
|
||||
@@ -135,14 +133,14 @@ describe('Requests page', () => {
|
||||
matched_artist_id: 'art-7'
|
||||
})
|
||||
];
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
|
||||
render(RequestsPage);
|
||||
const link = screen.getByRole('link', { name: /listen to geogaddi/i });
|
||||
expect(link).toHaveAttribute('href', '/albums/al-7');
|
||||
});
|
||||
|
||||
test('rejected row renders notes and hides Cancel and Listen', () => {
|
||||
const rows: LidarrRequest[] = [
|
||||
const rows: MusicRequest[] = [
|
||||
req({
|
||||
id: 'r-rej',
|
||||
status: 'rejected',
|
||||
@@ -151,7 +149,7 @@ describe('Requests page', () => {
|
||||
notes: 'Already kept in the library.'
|
||||
})
|
||||
];
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
|
||||
render(RequestsPage);
|
||||
expect(screen.getByTestId('rejection-notes')).toHaveTextContent('Already kept in the library.');
|
||||
expect(screen.queryByRole('button', { name: /cancel request/i })).not.toBeInTheDocument();
|
||||
@@ -159,7 +157,7 @@ describe('Requests page', () => {
|
||||
});
|
||||
|
||||
test('empty list shows the voice-rule "Nothing requested yet." copy', () => {
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: [] }));
|
||||
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: [] }));
|
||||
render(RequestsPage);
|
||||
expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('request-row')).toHaveLength(0);
|
||||
|
||||
Reference in New Issue
Block a user