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>
This commit is contained in:
+251
-14
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user