From 861dd8ef17418d943b02bef8cd82e85e55ef01a4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 10 May 2026 17:59:38 -0400 Subject: [PATCH] feat(web): auto-poll request progress when ingests are in flight (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Half of #369 — the auto-poll piece. Inbox deferred (separate task). Both /requests (user) and /admin/requests (admin, 'approved' tab only) now refetch every 12s while at least one row has status='approved'. Stops automatically when all rows settle to pending/completed/rejected. TanStack Query's default refetchIntervalInBackground=false handles the visibility behavior — polling pauses when the tab is hidden and resumes on focus. Predicate hasInFlightRequest() is exported + tested so the polling logic is auditable independent of TanStack Query's internals. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/api/admin.ts | 14 +++++++++++++- web/src/lib/api/requests.test.ts | 30 +++++++++++++++++++++++++++++- web/src/lib/api/requests.ts | 22 +++++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index e7012071..ece4d1ed 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -116,10 +116,22 @@ export function createRootFoldersQuery(enabled: boolean = true) { export function createAdminRequestsQuery(status?: LidarrRequestStatus) { return createQuery({ queryKey: qk.adminRequests(status), - queryFn: () => listAdminRequests(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 { diff --git a/web/src/lib/api/requests.test.ts b/web/src/lib/api/requests.test.ts index 94db17fd..38877fd6 100644 --- a/web/src/lib/api/requests.test.ts +++ b/web/src/lib/api/requests.test.ts @@ -14,7 +14,8 @@ import { createRequest, listMyRequests, getRequest, - cancelRequest + cancelRequest, + hasInFlightRequest } from './requests'; import { api, apiFetch } from './client'; import { qk } from './queries'; @@ -155,3 +156,30 @@ describe('qk.myRequests', () => { expect(qk.myRequests()).toEqual(['myRequests']); }); }); + +describe('hasInFlightRequest', () => { + test('false for undefined / empty', () => { + expect(hasInFlightRequest(undefined)).toBe(false); + expect(hasInFlightRequest([])).toBe(false); + }); + + test('false when no row is approved', () => { + expect( + hasInFlightRequest([ + { ...mockRow, status: 'pending' }, + { ...mockRow, status: 'completed' }, + { ...mockRow, status: 'rejected' } + ]) + ).toBe(false); + }); + + test('true when at least one row is approved (mid-ingest)', () => { + expect( + hasInFlightRequest([ + { ...mockRow, status: 'pending' }, + { ...mockRow, status: 'approved' }, + { ...mockRow, status: 'completed' } + ]) + ).toBe(true); + }); +}); diff --git a/web/src/lib/api/requests.ts b/web/src/lib/api/requests.ts index 0e601753..be716af2 100644 --- a/web/src/lib/api/requests.ts +++ b/web/src/lib/api/requests.ts @@ -3,6 +3,18 @@ import { api, apiFetch } from './client'; import { qk } from './queries'; import type { LidarrRequest, LidarrRequestKind } 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" + threshold from #369. */ +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 { + return rows?.some((r) => r.status === 'approved') ?? false; +} + // CreateRequestParams is the shape callers pass; unused MBID fields are // optional, and we omit them from the wire body rather than sending empty // strings (the backend tolerates either, but this keeps test expectations @@ -63,6 +75,14 @@ export function createMyRequestsQuery() { return createQuery({ queryKey: qk.myRequests(), queryFn: listMyRequests, - staleTime: 60_000 + staleTime: 60_000, + // Auto-poll while at least one user request is mid-ingest. Stops + // automatically when all rows are pending/completed/rejected. + // refetchIntervalInBackground defaults to false → polling pauses + // while the tab is hidden and resumes on focus. + refetchInterval: (query) => + hasInFlightRequest(query.state.data as LidarrRequest[] | undefined) + ? POLL_INTERVAL_MS + : false }); }