72f46a885b
Lands four of the U3 frontend surfaces. Splits T5 because the dispatch covering everything in one shot crashed the runtime mid-execution; this is the salvageable half. - /settings gains three cards alongside Appearance: Profile (display name + email), Password (current + new + confirm with client-side mismatch check), API Token (display + copy + regenerate-with-double-click-confirm). Server error codes map to clear toasts. - /forgot-password — public; takes an email and always shows the success message regardless of whether the email is on file (mirrors the server's no-enumeration posture). - Login page gets a "Forgot password?" link below the existing register link. - API client functions for the four /me endpoints (changePassword, updateProfile, getAPIToken, regenerateAPIToken), the SMTP admin trio (getSMTPConfig, updateSMTPConfig, testSMTPConfig), and the forgot/reset auth pair (forgotPassword, resetPassword). Tests for these surfaces + /reset-password page + admin SMTP card land in T5b (next follow-up).
495 lines
14 KiB
TypeScript
495 lines
14 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 ScanStageArtistArtEnrich = {
|
|
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;
|
|
artist_art_enrich?: ScanStageArtistArtEnrich;
|
|
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
|
|
});
|
|
}
|
|
|
|
// Cover-art providers ------------------------------------------------------
|
|
|
|
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
|
|
|
|
export type CoverProvider = {
|
|
id: string;
|
|
display_name: string;
|
|
requires_api_key: boolean;
|
|
supports: CoverProviderCapability[];
|
|
enabled: boolean;
|
|
api_key_set: boolean;
|
|
display_order: number;
|
|
testable: boolean;
|
|
};
|
|
|
|
export type CoverProvidersResponse = {
|
|
providers: CoverProvider[];
|
|
sources_version: number;
|
|
};
|
|
|
|
export async function getCoverProviders(): Promise<CoverProvidersResponse> {
|
|
return api.get<CoverProvidersResponse>('/api/admin/cover-sources');
|
|
}
|
|
|
|
export type UpdateCoverProviderPatch = {
|
|
enabled?: boolean;
|
|
api_key?: string;
|
|
};
|
|
|
|
export type UpdateCoverProviderResponse = CoverProvider & {
|
|
version_bumped: boolean;
|
|
};
|
|
|
|
export async function updateCoverProvider(
|
|
id: string,
|
|
patch: UpdateCoverProviderPatch
|
|
): Promise<UpdateCoverProviderResponse> {
|
|
return api.patch<UpdateCoverProviderResponse>(`/api/admin/cover-sources/${id}`, patch);
|
|
}
|
|
|
|
export type TestCoverProviderResponse = {
|
|
ok: boolean;
|
|
duration_ms?: number;
|
|
error?: string;
|
|
};
|
|
|
|
export async function testCoverProvider(id: string): Promise<TestCoverProviderResponse> {
|
|
return api.post<TestCoverProviderResponse>(`/api/admin/cover-sources/${id}/test`, {});
|
|
}
|
|
|
|
// Re-search missing art --------------------------------------------------
|
|
|
|
export type ResearchMissingArtResponse = { version: number };
|
|
|
|
// Bumps cover_art_sources_meta.current_version. Every 'none' row becomes
|
|
// eligible for retry against the current provider chain on the next
|
|
// enrichment pass. Existing positively-sourced rows are not affected.
|
|
export async function researchMissingArt(): Promise<ResearchMissingArtResponse> {
|
|
return api.post<ResearchMissingArtResponse>('/api/admin/cover-sources/research', {});
|
|
}
|
|
|
|
// Settings rarely change in operator time. 60s staleTime; refetches
|
|
// on window focus + after any mutation rather than polling.
|
|
export function createCoverProvidersQuery() {
|
|
return createQuery({
|
|
queryKey: qk.coverProviders(),
|
|
queryFn: getCoverProviders,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Scan schedule -----------------------------------------------------------
|
|
|
|
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
|
|
|
|
export type ScanSchedule = {
|
|
mode: ScanScheduleMode;
|
|
interval_hours: number | null;
|
|
time_of_day: string | null; // 'HH:MM'
|
|
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
|
|
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
|
|
};
|
|
|
|
export type ScanScheduleUpdate = {
|
|
mode: ScanScheduleMode;
|
|
interval_hours?: number | null;
|
|
time_of_day?: string | null;
|
|
weekly_day?: number | null;
|
|
};
|
|
|
|
export async function getScanSchedule(): Promise<ScanSchedule> {
|
|
return api.get<ScanSchedule>('/api/admin/scan/schedule');
|
|
}
|
|
|
|
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
|
|
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
|
|
}
|
|
|
|
// Settings rarely change in operator time. 60s staleTime; refetches
|
|
// on window focus + after any PATCH invalidation — no polling.
|
|
export function createScanScheduleQuery() {
|
|
return createQuery({
|
|
queryKey: qk.scanSchedule(),
|
|
queryFn: getScanSchedule,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Admin user-management ------------------------------------------------------
|
|
|
|
export type AdminUser = {
|
|
id: string;
|
|
username: string;
|
|
display_name: string | null;
|
|
is_admin: boolean;
|
|
auto_approve_requests: boolean;
|
|
created_at: string;
|
|
};
|
|
|
|
export type AdminInvite = {
|
|
token: string;
|
|
invited_by: string;
|
|
note: string | null;
|
|
created_at: string;
|
|
expires_at: string;
|
|
redeemed_at: string | null;
|
|
redeemed_by: string | null;
|
|
};
|
|
|
|
export async function listUsers(): Promise<AdminUser[]> {
|
|
const res = await api.get<{ users: AdminUser[] }>('/api/admin/users');
|
|
return res.users;
|
|
}
|
|
|
|
export async function updateUserAdmin(id: string, isAdmin: boolean): Promise<AdminUser> {
|
|
return api.put<AdminUser>(`/api/admin/users/${id}/admin`, { is_admin: isAdmin });
|
|
}
|
|
|
|
export async function listInvites(): Promise<AdminInvite[]> {
|
|
const res = await api.get<{ invites: AdminInvite[] }>('/api/admin/invites');
|
|
return res.invites;
|
|
}
|
|
|
|
export async function createInvite(note?: string): Promise<AdminInvite> {
|
|
return api.post<AdminInvite>('/api/admin/invites', note ? { note } : {});
|
|
}
|
|
|
|
export async function deleteInvite(token: string): Promise<void> {
|
|
await api.del(`/api/admin/invites/${token}`);
|
|
}
|
|
|
|
// U2 user-management actions -----------------------------------------------
|
|
|
|
export type CreateUserInput = {
|
|
username: string;
|
|
password: string;
|
|
display_name?: string;
|
|
is_admin?: boolean;
|
|
};
|
|
|
|
export async function createUser(input: CreateUserInput): Promise<AdminUser> {
|
|
return api.post<AdminUser>('/api/admin/users', input);
|
|
}
|
|
|
|
export async function deleteUser(id: string): Promise<void> {
|
|
await api.del(`/api/admin/users/${id}`);
|
|
}
|
|
|
|
export async function resetUserPassword(id: string, password: string): Promise<void> {
|
|
await api.post(`/api/admin/users/${id}/reset-password`, { password });
|
|
}
|
|
|
|
export async function updateUserAutoApprove(id: string, autoApprove: boolean): Promise<AdminUser> {
|
|
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
|
|
}
|
|
|
|
export function createAdminUsersQuery() {
|
|
return createQuery({
|
|
queryKey: qk.adminUsers(),
|
|
queryFn: listUsers
|
|
});
|
|
}
|
|
|
|
export function createAdminInvitesQuery() {
|
|
return createQuery({
|
|
queryKey: qk.adminInvites(),
|
|
queryFn: listInvites
|
|
});
|
|
}
|
|
|
|
// SMTP config (U3) ----------------------------------------------------------
|
|
|
|
export type SMTPConfig = {
|
|
enabled: boolean;
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string; // "***" when set, "" when unset
|
|
from_address: string;
|
|
from_name: string;
|
|
use_tls: boolean;
|
|
};
|
|
|
|
export async function getSMTPConfig(): Promise<SMTPConfig> {
|
|
return api.get<SMTPConfig>('/api/admin/smtp-config');
|
|
}
|
|
|
|
export async function updateSMTPConfig(input: SMTPConfig): Promise<void> {
|
|
await api.put('/api/admin/smtp-config', input);
|
|
}
|
|
|
|
export async function testSMTPConfig(): Promise<void> {
|
|
await api.post('/api/admin/smtp-config/test', {});
|
|
}
|
|
|
|
export function createSMTPConfigQuery() {
|
|
return createQuery({
|
|
queryKey: qk.smtpConfig(),
|
|
queryFn: getSMTPConfig,
|
|
staleTime: 60_000
|
|
});
|
|
}
|