- 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>
338 lines
11 KiB
TypeScript
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;
|
|
}
|