feat(web): auto-poll request progress when ingests are in flight (#369)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -116,10 +116,22 @@ export function createRootFoldersQuery(enabled: boolean = true) {
|
|||||||
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
||||||
return createQuery({
|
return createQuery({
|
||||||
queryKey: qk.adminRequests(status),
|
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 --------------------------------------------------------
|
// Admin quarantine --------------------------------------------------------
|
||||||
|
|
||||||
export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> {
|
export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
createRequest,
|
createRequest,
|
||||||
listMyRequests,
|
listMyRequests,
|
||||||
getRequest,
|
getRequest,
|
||||||
cancelRequest
|
cancelRequest,
|
||||||
|
hasInFlightRequest
|
||||||
} from './requests';
|
} from './requests';
|
||||||
import { api, apiFetch } from './client';
|
import { api, apiFetch } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
@@ -155,3 +156,30 @@ describe('qk.myRequests', () => {
|
|||||||
expect(qk.myRequests()).toEqual(['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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import { api, apiFetch } from './client';
|
|||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type { LidarrRequest, LidarrRequestKind } from './types';
|
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
|
// CreateRequestParams is the shape callers pass; unused MBID fields are
|
||||||
// optional, and we omit them from the wire body rather than sending empty
|
// optional, and we omit them from the wire body rather than sending empty
|
||||||
// strings (the backend tolerates either, but this keeps test expectations
|
// strings (the backend tolerates either, but this keeps test expectations
|
||||||
@@ -63,6 +75,14 @@ export function createMyRequestsQuery() {
|
|||||||
return createQuery({
|
return createQuery({
|
||||||
queryKey: qk.myRequests(),
|
queryKey: qk.myRequests(),
|
||||||
queryFn: listMyRequests,
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user