import { createQuery } from '@tanstack/svelte-query'; import { api } from './client'; import { qk } from './queries'; import type { ActionResult, AdminPlaybackError, AdminQuarantineRow, LidarrConfig, LidarrMetadataProfile, LidarrQualityProfile, LidarrQuarantineActionRow, LidarrRequest, LidarrRequestStatus, LidarrRootFolder, LidarrTestResult, PlaybackErrorResolution } from './types'; // Admin Lidarr config ----------------------------------------------------- export async function getLidarrConfig(): Promise { return api.get('/api/admin/lidarr/config'); } export async function putLidarrConfig(cfg: LidarrConfig): Promise { return api.put('/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 { return api.post('/api/admin/lidarr/test', body); } export async function listQualityProfiles(): Promise { return api.get('/api/admin/lidarr/quality-profiles'); } export async function listMetadataProfiles(): Promise { return api.get('/api/admin/lidarr/metadata-profiles'); } export async function listRootFolders(): Promise { return api.get('/api/admin/lidarr/root-folders'); } // Admin request queue ----------------------------------------------------- export async function listAdminRequests( status?: LidarrRequestStatus, limit?: number ): Promise { 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( qs ? `/api/admin/requests?${qs}` : '/api/admin/requests' ); } export async function approveRequest( id: string, overrides: { quality_profile_id?: number; root_folder_path?: string } = {} ): Promise { return api.post(`/api/admin/requests/${id}/approve`, overrides); } export async function rejectRequest( id: string, notes?: string ): Promise { const body = notes !== undefined ? { notes } : {}; return api.post(`/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), // Only the 'approved' tab needs polling — that's where in-flight // ingests live. Other tabs (pending/rejected/completed) are static // until the operator acts on them. refetchInterval: (query) => { if (status !== 'approved') return false; const rows = query.state.data as LidarrRequest[] | undefined; return hasInFlightRequest(rows) ? 12_000 : false; } }); } function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined): boolean { return rows?.some((r) => r.status === 'approved') ?? false; } // Admin quarantine -------------------------------------------------------- export async function listAdminQuarantine(): Promise { return api.get('/api/admin/quarantine'); } export async function resolveQuarantine(trackID: string): Promise { return api.post(`/api/admin/quarantine/${trackID}/resolve`, {}); } export async function deleteQuarantineFile(trackID: string): Promise { return api.post(`/api/admin/quarantine/${trackID}/delete-file`, {}); } export async function deleteQuarantineViaLidarr(trackID: string): Promise { return api.post(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); } export async function listQuarantineActions( limit: number = 50 ): Promise { return api.get( `/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 playback errors --------------------------------------------------- export async function listAdminPlaybackErrors( resolved: boolean = false ): Promise { return api.get( `/api/admin/playback-errors?resolved=${resolved}` ); } export async function resolvePlaybackError( id: string, resolution: PlaybackErrorResolution ): Promise<{ id: string }> { return api.post<{ id: string }>( `/api/admin/playback-errors/${id}/resolve`, { resolution } ); } export function createAdminPlaybackErrorsQuery(resolved: boolean = false) { return createQuery({ queryKey: qk.adminPlaybackErrors(resolved), queryFn: () => listAdminPlaybackErrors(resolved), staleTime: 30_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 { return api.post(`/api/admin/albums/${albumId}/cover/refetch`, {}); } export type RefetchMissingResponse = { started: boolean; }; export async function refetchMissingCovers(): Promise { return api.post('/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 { return api.get('/api/admin/scan/status'); } export type TriggerScanResp = { id?: string }; export async function triggerScan(): Promise { return api.post('/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 { return api.get('/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 { return api.get('/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 { return api.patch(`/api/admin/cover-sources/${id}`, patch); } export type TestCoverProviderResponse = { ok: boolean; duration_ms?: number; error?: string; }; export async function testCoverProvider(id: string): Promise { return api.post(`/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 { return api.post('/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 }); } // Tag-enrichment providers ------------------------------------------------- // Parallel to the cover-art providers surface, over /api/admin/tag-sources. // Independent settings so a new folksonomy source is added without touching // art config (#1490). export type TagProviderCapability = 'track_tags'; export type TagProvider = { id: string; display_name: string; requires_api_key: boolean; supports: TagProviderCapability[]; enabled: boolean; api_key_set: boolean; display_order: number; testable: boolean; }; export type TagProvidersResponse = { providers: TagProvider[]; sources_version: number; }; export async function getTagProviders(): Promise { return api.get('/api/admin/tag-sources'); } export type UpdateTagProviderPatch = { enabled?: boolean; api_key?: string; }; export type UpdateTagProviderResponse = TagProvider & { version_bumped: boolean; }; export async function updateTagProvider( id: string, patch: UpdateTagProviderPatch ): Promise { return api.patch(`/api/admin/tag-sources/${id}`, patch); } export type TestTagProviderResponse = { ok: boolean; duration_ms?: number; error?: string; }; export async function testTagProvider(id: string): Promise { return api.post(`/api/admin/tag-sources/${id}/test`, {}); } export function createTagProvidersQuery() { return createQuery({ queryKey: qk.tagProviders(), queryFn: getTagProviders, staleTime: 60_000 }); } // Admin user-management ------------------------------------------------------ export type AdminUser = { id: string; username: string; display_name: string | null; is_admin: boolean; auto_approve_requests: boolean; debug_mode_enabled: 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 { const res = await api.get<{ users: AdminUser[] }>('/api/admin/users'); return res.users; } export async function updateUserAdmin(id: string, isAdmin: boolean): Promise { return api.put(`/api/admin/users/${id}/admin`, { is_admin: isAdmin }); } export async function listInvites(): Promise { const res = await api.get<{ invites: AdminInvite[] }>('/api/admin/invites'); return res.invites; } export async function createInvite(note?: string): Promise { return api.post('/api/admin/invites', note ? { note } : {}); } export async function deleteInvite(token: string): Promise { 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 { return api.post('/api/admin/users', input); } export async function deleteUser(id: string): Promise { await api.del(`/api/admin/users/${id}`); } export async function resetUserPassword(id: string, password: string): Promise { await api.post(`/api/admin/users/${id}/reset-password`, { password }); } export async function updateUserAutoApprove(id: string, autoApprove: boolean): Promise { return api.put(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove }); } // Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set; // the client obeys it (with a local per-device OFF switch). export async function updateUserDebugMode(id: string, enabled: boolean): Promise { return api.put(`/api/admin/users/${id}/debug-mode`, { enabled }); } 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 { return api.get('/api/admin/smtp-config'); } export async function updateSMTPConfig(input: SMTPConfig): Promise { await api.put('/api/admin/smtp-config', input); } export async function testSMTPConfig(): Promise { await api.post('/api/admin/smtp-config/test', {}); } export function createSMTPConfigQuery() { return createQuery({ queryKey: qk.smtpConfig(), queryFn: getSMTPConfig, staleTime: 60_000 }); } // Device diagnostics (M9) --------------------------------------------------- // Coarse event category. The finer event sub-type lives inside `payload`. export type DiagnosticKind = | 'connectivity' | 'upnp_sync' | 'power' | 'lifecycle' | 'heartbeat' | 'http'; export type AdminDiagnostic = { id: string; user_id: string; username: string; client_id: string; app_version?: string; os_version?: string; kind: DiagnosticKind | string; payload: Record; occurred_at: string; received_at: string; }; export type AdminDiagnosticDevice = { client_id: string; user_id: string; username: string; app_version: string; os_version: string; last_seen: string; event_count: number; }; export type DiagnosticsFilter = { userId?: string; clientId?: string; kind?: string; from?: string; // RFC3339 to?: string; // RFC3339 limit?: number; }; export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise { const params = new URLSearchParams(); if (f.userId) params.set('user_id', f.userId); if (f.clientId) params.set('client_id', f.clientId); if (f.kind) params.set('kind', f.kind); if (f.from) params.set('from', f.from); if (f.to) params.set('to', f.to); if (f.limit !== undefined) params.set('limit', String(f.limit)); const qs = params.toString(); return api.get(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics'); } export async function listDiagnosticDevices(userId?: string): Promise { const qs = userId ? `?user_id=${userId}` : ''; return api.get(`/api/admin/diagnostics/devices${qs}`); } export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) { return createQuery({ queryKey: qk.adminDiagnostics(f as Record), queryFn: () => listAdminDiagnostics(f), // The operator enables debug then watches events stream in; a short // poll keeps the timeline live without manual refresh. refetchInterval: 10_000, staleTime: 5_000 }); } export function createDiagnosticDevicesQuery(userId?: string) { return createQuery({ queryKey: qk.adminDiagnosticDevices(userId), queryFn: () => listDiagnosticDevices(userId), staleTime: 15_000 }); } // Trusted-proxy depth (#2453) --------------------------------------------- // detected_client_ip / forwarded_chain / remote_addr describe THIS request // under the current setting, so the admin card can be verified rather than // reasoned about: change the number, see what address you resolve to. export type NetworkSettings = { trusted_proxy_hops: number; max_hops: number; detected_client_ip: string; forwarded_chain: string; remote_addr: string; }; export async function getNetworkSettings(): Promise { return api.get('/api/admin/network-settings'); } // Returns the payload recomputed under the new value, so the card can show // the effect immediately instead of requiring a reload. export async function updateNetworkSettings(hops: number): Promise { return api.put('/api/admin/network-settings', { trusted_proxy_hops: hops }); }