3a424c6f73
Adds CoverageRollup type, getCoverageRollup() against /api/admin/library/coverage, and createCoverageQuery() with the same 3s/30s pacing used by scan-status. New qk.coverage() key follows the existing zero-arg tuple pattern. Vitest covers the GET path and the empty-library zero-state plus the with_art + pending + settled = total invariant.
265 lines
7.7 KiB
TypeScript
265 lines
7.7 KiB
TypeScript
import { createQuery } from '@tanstack/svelte-query';
|
|
import { api } from './client';
|
|
import { qk } from './queries';
|
|
import type {
|
|
ActionResult,
|
|
AdminQuarantineRow,
|
|
LidarrConfig,
|
|
LidarrMetadataProfile,
|
|
LidarrQualityProfile,
|
|
LidarrQuarantineActionRow,
|
|
LidarrRequest,
|
|
LidarrRequestStatus,
|
|
LidarrRootFolder,
|
|
LidarrTestResult
|
|
} from './types';
|
|
|
|
// Admin Lidarr config -----------------------------------------------------
|
|
|
|
export async function getLidarrConfig(): Promise<LidarrConfig> {
|
|
return api.get<LidarrConfig>('/api/admin/lidarr/config');
|
|
}
|
|
|
|
export async function putLidarrConfig(cfg: LidarrConfig): Promise<LidarrConfig> {
|
|
return api.put<LidarrConfig>('/api/admin/lidarr/config', cfg);
|
|
}
|
|
|
|
// testLidarrConnection always returns 200 with a discriminated-union body —
|
|
// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants
|
|
// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed").
|
|
export async function testLidarrConnection(
|
|
body: { base_url?: string; api_key?: string } = {}
|
|
): Promise<LidarrTestResult> {
|
|
return api.post<LidarrTestResult>('/api/admin/lidarr/test', body);
|
|
}
|
|
|
|
export async function listQualityProfiles(): Promise<LidarrQualityProfile[]> {
|
|
return api.get<LidarrQualityProfile[]>('/api/admin/lidarr/quality-profiles');
|
|
}
|
|
|
|
export async function listMetadataProfiles(): Promise<LidarrMetadataProfile[]> {
|
|
return api.get<LidarrMetadataProfile[]>('/api/admin/lidarr/metadata-profiles');
|
|
}
|
|
|
|
export async function listRootFolders(): Promise<LidarrRootFolder[]> {
|
|
return api.get<LidarrRootFolder[]>('/api/admin/lidarr/root-folders');
|
|
}
|
|
|
|
// Admin request queue -----------------------------------------------------
|
|
|
|
export async function listAdminRequests(
|
|
status?: LidarrRequestStatus,
|
|
limit?: number
|
|
): Promise<LidarrRequest[]> {
|
|
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[]>(
|
|
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);
|
|
}
|
|
|
|
export async function rejectRequest(
|
|
id: string,
|
|
notes?: string
|
|
): Promise<LidarrRequest> {
|
|
const body = notes !== undefined ? { notes } : {};
|
|
return api.post<LidarrRequest>(`/api/admin/requests/${id}/reject`, body);
|
|
}
|
|
|
|
// Query factories ---------------------------------------------------------
|
|
|
|
export function createLidarrConfigQuery() {
|
|
return createQuery({
|
|
queryKey: qk.lidarrConfig(),
|
|
queryFn: getLidarrConfig,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// `enabled` is passed in by the caller — typically derived from
|
|
// LidarrConfig.enabled — so the query only fires once Lidarr is configured.
|
|
// Keeping it as a prop (vs. reading config inside this factory) preserves
|
|
// purity and lets the caller choose its own gating logic.
|
|
export function createQualityProfilesQuery(enabled: boolean = true) {
|
|
return createQuery({
|
|
queryKey: qk.lidarrQualityProfiles(),
|
|
queryFn: listQualityProfiles,
|
|
enabled
|
|
});
|
|
}
|
|
|
|
export function createMetadataProfilesQuery(enabled: boolean = true) {
|
|
return createQuery({
|
|
queryKey: qk.lidarrMetadataProfiles(),
|
|
queryFn: listMetadataProfiles,
|
|
enabled
|
|
});
|
|
}
|
|
|
|
export function createRootFoldersQuery(enabled: boolean = true) {
|
|
return createQuery({
|
|
queryKey: qk.lidarrRootFolders(),
|
|
queryFn: listRootFolders,
|
|
enabled
|
|
});
|
|
}
|
|
|
|
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
|
return createQuery({
|
|
queryKey: qk.adminRequests(status),
|
|
queryFn: () => listAdminRequests(status)
|
|
});
|
|
}
|
|
|
|
// Admin quarantine --------------------------------------------------------
|
|
|
|
export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> {
|
|
return api.get<AdminQuarantineRow[]>('/api/admin/quarantine');
|
|
}
|
|
|
|
export async function resolveQuarantine(trackID: string): Promise<ActionResult> {
|
|
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {});
|
|
}
|
|
|
|
export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> {
|
|
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {});
|
|
}
|
|
|
|
export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> {
|
|
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {});
|
|
}
|
|
|
|
export async function listQuarantineActions(
|
|
limit: number = 50
|
|
): Promise<LidarrQuarantineActionRow[]> {
|
|
return api.get<LidarrQuarantineActionRow[]>(
|
|
`/api/admin/quarantine/actions?limit=${limit}`
|
|
);
|
|
}
|
|
|
|
export function createAdminQuarantineQuery() {
|
|
return createQuery({
|
|
queryKey: qk.adminQuarantine(),
|
|
queryFn: listAdminQuarantine,
|
|
staleTime: 30_000
|
|
});
|
|
}
|
|
|
|
export function createQuarantineActionsQuery(limit: number = 50) {
|
|
return createQuery({
|
|
queryKey: qk.adminQuarantineActions(limit),
|
|
queryFn: () => listQuarantineActions(limit),
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Admin cover art ---------------------------------------------------------
|
|
|
|
export type RefetchAlbumCoverResponse = {
|
|
album_id: string;
|
|
cover_art_path: string | null;
|
|
cover_art_source: string | null;
|
|
};
|
|
|
|
export async function refetchAlbumCover(albumId: string): Promise<RefetchAlbumCoverResponse> {
|
|
return api.post<RefetchAlbumCoverResponse>(`/api/admin/albums/${albumId}/cover/refetch`, {});
|
|
}
|
|
|
|
export type RefetchMissingResponse = {
|
|
queued: number;
|
|
};
|
|
|
|
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
|
|
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
|
|
}
|
|
|
|
// Library scan -------------------------------------------------------------
|
|
|
|
export type ScanStageLibrary = {
|
|
scanned: number;
|
|
added: number;
|
|
updated: number;
|
|
skipped: number;
|
|
errored: number;
|
|
};
|
|
|
|
export type ScanStageMbidBackfill = {
|
|
processed: number;
|
|
healed: number;
|
|
skipped: number;
|
|
duplicates?: number;
|
|
};
|
|
|
|
export type ScanStageCoverEnrich = {
|
|
processed: number;
|
|
succeeded: number;
|
|
failed: number;
|
|
};
|
|
|
|
export type ScanStatus = {
|
|
id: string;
|
|
started_at: string;
|
|
finished_at: string | null;
|
|
library?: ScanStageLibrary;
|
|
mbid_backfill?: ScanStageMbidBackfill;
|
|
cover_enrich?: ScanStageCoverEnrich;
|
|
error_message?: string;
|
|
in_flight: boolean;
|
|
};
|
|
|
|
export async function getScanStatus(): Promise<ScanStatus> {
|
|
return api.get<ScanStatus>('/api/admin/scan/status');
|
|
}
|
|
|
|
export type TriggerScanResp = { id?: string };
|
|
|
|
export async function triggerScan(): Promise<TriggerScanResp> {
|
|
return api.post<TriggerScanResp>('/api/admin/scan/run', {});
|
|
}
|
|
|
|
// Polls every 3s while a scan is in flight; falls back to a 30s stale-time
|
|
// when idle so the section auto-updates as workers complete each stage.
|
|
export function createScanStatusQuery() {
|
|
return createQuery({
|
|
queryKey: qk.scanStatus(),
|
|
queryFn: getScanStatus,
|
|
staleTime: 30_000,
|
|
refetchInterval: 3_000
|
|
});
|
|
}
|
|
|
|
// Library coverage --------------------------------------------------------
|
|
|
|
export type CoverageRollup = {
|
|
total: number;
|
|
with_art: number;
|
|
pending: number;
|
|
settled: number;
|
|
pending_no_mbid: number;
|
|
};
|
|
|
|
export async function getCoverageRollup(): Promise<CoverageRollup> {
|
|
return api.get<CoverageRollup>('/api/admin/library/coverage');
|
|
}
|
|
|
|
// Same pacing as scan-status — 3s while polling, 30s stale-time. The
|
|
// query is cheap server-side (one aggregate per call) and we want
|
|
// the gauge to tick during a scan.
|
|
export function createCoverageQuery() {
|
|
return createQuery({
|
|
queryKey: qk.coverage(),
|
|
queryFn: getCoverageRollup,
|
|
staleTime: 30_000,
|
|
refetchInterval: 3_000
|
|
});
|
|
}
|