Files
ersatztv/web/src/api/libraries.ts
T
timothyandClaude Fable 5 bee686c846
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(web): libraries scan-flow review fixes for #88
- Normalize scan-status percent at the API boundary: the wire value is a
  0-1 fraction despite the field name; the bar previously showed ~0% for
  the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
  survives status pruning until seen active or a 3-tick grace window
  expires; the poll runs while pending ∪ active is nonempty (previously
  the queue-to-start lag could mean polling never started and the button
  re-enabled mid-scan); grace ticks also drain on persistent status-fetch
  errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
  pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
  disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
  middleware); loadScanStatuses returns its promise; poll effect depends
  on derived hasActiveScans; synchronous ref double-submit guard;
  --radius-xs fallback 3px; StatusDot label on source cards (75 tests)

Review: PR #128 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:01:05 +02:00

338 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
sources: MediaSource[];
}
export type LibrariesScreenQueryState =
| {
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A trigger'd scan disappears from the button-disabled set once either: it has been
// observed at least once in scan-status (promoted to "active"), or this many poll
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
const PENDING_GRACE_TICKS = 3;
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
// must call this exactly once per tick before that write to keep it a single, ref-free
// computation that's safe to run under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
});
return nextPending;
}
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
const [state, setState] = useState<LibrariesScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const hadScanInProgressRef = useRef(false);
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
// guard against double-submits without waiting for a render.
const pendingIdsRef = useRef<Set<number>>(new Set());
const activeIdsRef = useRef<Set<number>>(new Set());
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSources = useCallback(() => {
getMediaSources()
.then((sources) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
})
.catch(() => {
// A completion refresh should not hide the screen if the source reload fails.
});
}, []);
const loadScanStatuses = useCallback(() => {
return getLibraryScanStatus()
.then((scanStatuses) => {
if (!activeRef.current) {
return;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
activeIdsRef.current = activeScanIds;
const hadScanInProgress = hadScanInProgressRef.current;
hadScanInProgressRef.current = scanStatuses.length > 0;
// Compute the pruned/promoted pending set - and write the grace-tick and pending
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
// invokes them (which would double-decrement grace ticks) and concurrent rendering
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
activeScanIds.has(libraryId)
);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
pendingLibraryIds: nextPending,
status: 'success'
};
});
if (hadScanInProgress && scanStatuses.length === 0) {
loadSources();
}
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A status-fetch failure never promotes a pending id to active, so it never
// survives via "seen active" - it just burns down the same grace budget as a
// successful poll that never saw it. This keeps a persistently-erroring endpoint
// from leaving the scan button disabled and the poll interval running forever.
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
// failure must not kill an in-progress scan's UI.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: current.error,
pendingLibraryIds: nextPending,
status: 'success'
};
});
});
}, [loadSources]);
const load = useCallback(() => {
Promise.all([getMediaSources(), getLibraryScanStatus()])
.then(([sources, scanStatuses]) => {
if (!activeRef.current) {
return;
}
hadScanInProgressRef.current = scanStatuses.length > 0;
pendingIdsRef.current = new Set();
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
pendingGraceTicksRef.current = new Map();
setState({
data: { scanStatuses, sources },
error: null,
pendingLibraryIds: new Set(),
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (!hasActiveScans) {
return undefined;
}
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
return Promise.resolve();
}
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: null,
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
const pendingLibraryIds = new Set(current.pendingLibraryIds);
pendingLibraryIds.delete(libraryId);
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
pendingLibraryIds,
status: 'success'
};
});
});
}, [loadScanStatuses]);
if (state.status === 'success') {
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
return {
data: state.data,
error: state.error,
refresh,
scanLibrary: triggerScan,
scanningLibraryIds,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}