feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+8 -16
View File
@@ -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
View File
@@ -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;
+3 -3
View File
@@ -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),
+6
View File
@@ -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) =>
+21 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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[];
};
+102
View File
@@ -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}`);
}
+1
View File
@@ -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' },
+3 -1
View File
@@ -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.
+155
View File
@@ -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'));
});
});
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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();
+12 -1
View File
@@ -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."
}