fix(web): libraries scan-flow review fixes for #88
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

- 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>
This commit is contained in:
2026-07-05 19:01:05 +02:00
co-authored by Claude Fable 5
parent 5c4acb9c7b
commit bee686c846
4 changed files with 414 additions and 56 deletions
+251 -14
View File
@@ -1013,7 +1013,7 @@ describe('ChicoryTV SPA scaffold', () => {
it('renders the Libraries screen from live media source and scan status APIs', async () => {
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 42 }],
libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }],
mediaSources: [
mediaSource({
connectionAddress: null,
@@ -1070,8 +1070,11 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
expect(screen.getByText('Other Videos')).toBeInTheDocument();
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
expect(screen.getByText('Scanning')).toBeInTheDocument();
expect(screen.getByText('42%')).toBeInTheDocument();
// "Scanning" now appears twice: once as the source-header status label (paired with
// the StatusDot, not color alone) and once as the per-library scanning Badge.
expect(screen.getAllByText('Scanning').length).toBeGreaterThan(0);
// Wire contract: percent is a 0-1 fraction (0.625) - the UI must render it as 63%, not 0.625%.
expect(screen.getByText('63%')).toBeInTheDocument();
expect(screen.getAllByText('Synced').length).toBeGreaterThan(0);
expect(screen.getAllByText('Never scanned').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Add Source' })).toBeDisabled();
@@ -1080,6 +1083,30 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object));
});
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
mockDashboardApi({
libraryScanStatuses: [
{ libraryId: 31, percent: 0 },
{ libraryId: 32, percent: 1 }
],
mediaSources: [
mediaSource({
libraries: [
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
]
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('0%')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('fetches Libraries data as a route delta and does not poll sources', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
@@ -1090,7 +1117,7 @@ describe('ChicoryTV SPA scaffold', () => {
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 10 }],
libraryScanStatuses: [{ libraryId: 31, percent: 0.1 }],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
@@ -1133,7 +1160,7 @@ describe('ChicoryTV SPA scaffold', () => {
});
mockDashboardApi({
libraryScanStatusSequence: [
[{ libraryId: 31, percent: 75 }],
[{ libraryId: 31, percent: 0.75 }],
[]
],
mediaSources: [
@@ -1157,7 +1184,7 @@ describe('ChicoryTV SPA scaffold', () => {
});
it('triggers a library scan, disables that library while in flight, and starts polling scan status', async () => {
const scanStatusesAfterMutation = [{ libraryId: 31, percent: 5 }];
const scanStatusesAfterMutation = [{ libraryId: 31, percent: 0.05 }];
mockDashboardApi({
libraryScanStatuses: [],
libraryScanStatusesAfterMutation: scanStatusesAfterMutation,
@@ -1180,13 +1207,177 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
});
it('shows Libraries API errors and retries', async () => {
it('keeps the scan button disabled and polling armed through the queue-to-start race, then clears once the scan finishes', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatusSequence: [
[], // initial screen load
[], // trigger's immediate post-POST fetch: scanner hasn't started yet
[{ libraryId: 31, percent: 0.3 }], // first poll tick: now active
[] // second poll tick: scan finished
],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
// The immediate post-trigger status fetch returned [] - without pending-id tracking
// the button would re-enable here and polling would never start. Both must hold.
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
intervalHandlers.forEach((handler) => handler());
expect(await screen.findByText('30%')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.queryByText('30%')).not.toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
expect(clearCount).toBeGreaterThan(0);
});
it('re-enables the scan button and stops polling when a queued scan never appears (grace window expiry)', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatuses: [],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
// The id never shows up in scan-status. It survives a couple of ticks...
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
// ...but expires once the grace window runs out, freeing the button and the poll.
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
});
expect(clearCount).toBeGreaterThan(0);
});
it('re-enables the scan button and stops polling when scan-status errors repeatedly after a trigger (grace window on error)', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatuses: [],
libraryScanStatusFailAfterTrigger: true,
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
// The immediate post-trigger status fetch errors (first grace-tick burn), but the
// button must stay disabled and the poll must stay armed - a transient failure must
// not be indistinguishable from "give up immediately".
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
// scan-status keeps erroring on every poll tick...
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
// ...but the same grace budget burns down on failures too, so persistent failure
// eventually frees the button and stops the interval instead of polling forever.
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
});
expect(clearCount).toBeGreaterThan(0);
});
it('shows Libraries API errors and retries', async () => {
// The backend has no exception middleware, so a real failure is a bare 500 with no
// ProblemDetails body - do not invent one here (mirrors the scan-trigger-404 test below).
mockDashboardApi({
mediaSourcesFailure: {
detail: 'Media source inventory is unavailable',
status: 500,
title: 'Internal error'
},
mediaSourcesFailuresBeforeSuccess: 2,
mediaSources: [mediaSource({ libraries: [] })]
});
@@ -1195,7 +1386,7 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('Media source inventory is unavailable')).toBeInTheDocument();
expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
@@ -1223,6 +1414,37 @@ describe('ChicoryTV SPA scaffold', () => {
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
});
it('scans every library in a source when its Scan-all button is clicked', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({
id: 30,
libraries: [
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
],
name: 'Local'
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Scan all libraries in Local' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/32/scan', expect.objectContaining({ method: 'POST' }));
});
expect(fetchCount('/api/libraries/31/scan')).toBe(1);
expect(fetchCount('/api/libraries/32/scan')).toBe(1);
});
it('shows an empty Libraries state when no media sources exist', async () => {
mockDashboardApi({ mediaSources: [] });
@@ -1837,6 +2059,7 @@ function mockDashboardApi({
confirm = false,
fillerPresets = [],
health = [],
libraryScanStatusFailAfterTrigger = false,
libraryScanStatuses = [],
libraryScanStatusesAfterMutation = null,
libraryScanStatusSequence = null,
@@ -1871,6 +2094,7 @@ function mockDashboardApi({
confirm?: boolean;
fillerPresets?: unknown[];
health?: unknown[];
libraryScanStatusFailAfterTrigger?: boolean;
libraryScanStatuses?: unknown[];
libraryScanStatusesAfterMutation?: unknown[] | null;
libraryScanStatusSequence?: unknown[][] | null;
@@ -1906,6 +2130,7 @@ function mockDashboardApi({
let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess;
let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess;
let scanStatusSequenceIndex = 0;
let scanStatusShouldFail = false;
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
@@ -1982,13 +2207,20 @@ function mockDashboardApi({
if (path === '/api/media-sources') {
if (remainingMediaSourcesFailures > 0) {
remainingMediaSourcesFailures -= 1;
return Promise.resolve(jsonResponse(mediaSourcesFailure, 500));
return Promise.resolve(
mediaSourcesFailure ? jsonResponse(mediaSourcesFailure, 500) : new Response(null, { status: 500 })
);
}
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/libraries/scan-status') {
if (libraryScanStatusFailAfterTrigger && scanStatusShouldFail) {
return Promise.resolve(new Response(null, { status: 500 }));
}
if (libraryScanStatusSequence) {
const sequenceValue = libraryScanStatusSequence[Math.min(scanStatusSequenceIndex, libraryScanStatusSequence.length - 1)];
scanStatusSequenceIndex += 1;
@@ -2012,6 +2244,11 @@ function mockDashboardApi({
}
currentLibraryScanStatuses = libraryScanStatusesAfterMutation ?? currentLibraryScanStatuses;
if (libraryScanStatusFailAfterTrigger) {
scanStatusShouldFail = true;
}
return Promise.resolve(new Response(null, { status: 200 }));
}
+16 -5
View File
@@ -1953,7 +1953,13 @@ function MediaSourceCard({
source: MediaSource;
}) {
const totalItems = source.libraries.reduce((sum, library) => sum + library.itemCount, 0);
const scanning = source.libraries.some((library) => scanStatusesByLibraryId.has(library.id));
const scanning = source.libraries.some((library) => scanningLibraryIds.has(library.id));
const scanSource = () => {
source.libraries.forEach((library) => {
void onScanLibrary(library.id);
});
};
return (
<section className="ctv-library-source-card" aria-label={`${source.name} media source`}>
@@ -1962,13 +1968,18 @@ function MediaSourceCard({
<div>
<div className="ctv-library-source-title">
<h3>{source.name}</h3>
<StatusDot status={scanning ? 'live' : 'ok'} size={7} />
<StatusDot status={scanning ? 'live' : 'ok'} label={scanning ? 'Scanning' : 'Synced'} size={7} />
</div>
<span>{source.connectionAddress ?? 'Local connection'}</span>
</div>
<IconButton disabled size="sm" title={`Settings unavailable for ${source.name}`}>
<Settings aria-hidden="true" size={15} />
</IconButton>
<div className="ctv-library-source-actions">
<IconButton disabled={scanning} onClick={scanSource} size="sm" title={`Scan all libraries in ${source.name}`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled size="sm" title={`Settings unavailable for ${source.name}`}>
<Settings aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
<div className="ctv-library-list" role="list" aria-label={`${source.name} libraries`}>
+140 -36
View File
@@ -24,22 +24,67 @@ export type LibrariesScreenQueryState =
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; scanningLibraryIds: Set<number>; status: 'success' }
| { 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[]> {
return request<LibraryScanStatus[]>('/api/libraries/scan-status');
// 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,
@@ -48,6 +93,11 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
});
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;
@@ -72,7 +122,7 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
scanningLibraryIds: current.scanningLibraryIds,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
@@ -83,29 +133,37 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
}, []);
const loadScanStatuses = useCallback(() => {
getLibraryScanStatus()
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;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set(
[...current.scanningLibraryIds].filter((libraryId) => activeScanIds.has(libraryId))
);
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
scanningLibraryIds,
pendingLibraryIds: nextPending,
status: 'success'
};
});
@@ -115,7 +173,32 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
}
})
.catch(() => {
// Keep source cards visible if a background poll misses.
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]);
@@ -127,10 +210,13 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
}
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,
scanningLibraryIds: new Set(),
pendingLibraryIds: new Set(),
status: 'success'
});
})
@@ -145,8 +231,11 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (state.status !== 'success' || state.data.scanStatuses.length === 0) {
if (!hasActiveScans) {
return undefined;
}
@@ -155,14 +244,22 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
return () => {
window.clearInterval(intervalId);
};
}, [loadScanStatuses, pollMs, state]);
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback(async (libraryId: number) => {
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;
@@ -171,44 +268,51 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
return {
data: current.data,
error: null,
scanningLibraryIds: new Set([...current.scanningLibraryIds, libraryId]),
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
try {
await scanLibrary(libraryId);
await loadScanStatuses();
} catch (error: unknown) {
if (!activeRef.current) {
return;
}
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
setState((current) => {
if (current.status !== 'success') {
return current;
if (!activeRef.current) {
return;
}
const scanningLibraryIds = new Set(current.scanningLibraryIds);
scanningLibraryIds.delete(libraryId);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
scanningLibraryIds,
status: 'success'
};
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: state.scanningLibraryIds,
scanningLibraryIds,
status: 'success'
};
}
+7 -1
View File
@@ -1732,6 +1732,12 @@
flex: 0 0 auto;
}
.ctv-library-source-actions {
display: flex;
align-items: center;
gap: var(--space-2, 4px);
}
.ctv-library-kind-icon {
width: 34px;
height: 34px;
@@ -1796,7 +1802,7 @@
.ctv-library-media-icon {
width: 28px;
height: 28px;
border-radius: var(--radius-xs, 4px);
border-radius: var(--radius-xs, 3px);
background: var(--ctv-bg-sunken);
color: var(--text-secondary);
}