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."
}
+7 -7
View File
@@ -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,
+10 -21
View File
@@ -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
+66 -91
View File
@@ -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>
+42 -41
View File
@@ -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();
});
});
+268
View File
@@ -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>
+145
View File
@@ -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();
});
});
+9 -1
View File
@@ -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>
+52 -3
View File
@@ -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();
});
});
});
+10 -10
View File
@@ -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' }
+3 -3
View File
@@ -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 -7
View File
@@ -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}`;
+16 -18
View File
@@ -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);