#91b: wire deep-scan + external-collections buttons into LibrariesScreen #272

Merged
timothy merged 1 commits from feat/91b-scan-buttons into main 2026-07-11 19:34:28 +02:00
5 changed files with 403 additions and 15 deletions
+14 -8
View File
@@ -273,14 +273,20 @@ The removal PR is **gated** — it starts only after these clear: ~~#202 (media-
**DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
`POST /api/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
covers external-collections scan (the two `Libraries.razor` parity gaps) — the SPA `Libraries.razor` port can now
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). **Remaining SPA affordance for
the removal PR** (API + thin `web/src/api/libraries.ts` clients — `scanLibrary(id, deep)`, `scanCollections` —
already shipped by #235): `LibrariesScreen` currently exposes only quick-scan; the removal PR must add the
**deep-scan** and **external-collections-scan** buttons (wiring the shipped clients) before deleting
`Libraries.razor`, or that capability is lost. (#204's id-carrying pattern
redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine
executes, in order:
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). ~~**Remaining SPA affordance for
the removal PR**: `LibrariesScreen` exposes only quick-scan; add the **deep-scan** and **external-collections-scan**
buttons before deleting `Libraries.razor`.~~ **SPA affordance DONE 2026-07-11** (landed ahead of the removal PR
so the deletion diff stays pure): `LibrariesScreen` now wires the shipped `scanLibrary(id, deep)` +
`scanCollections(family, id, deep)` clients — a **Deep Scan Library** button on each remote library row and an
**External Collections** section (quick + deep per remote source), matching all four `Libraries.razor` scan actions.
The External Collections rows derive client-side from `getMediaSources()` (no new endpoint): the media-sources API
handler already filters each source's `libraries` to sync-enabled entries, so a remote source with a non-empty
`libraries` list is exactly `GetExternalCollections`'s `Libraries.Any(ShouldSyncItems)` filter. Collections scans
have no scan-status poll surface, so their button pending state is optimistic + timeout-bounded (follow-up #271: a
collections scan-status endpoint would let it reconcile like library scans). (#204's id-carrying
pattern redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
into Step 2 below, since it can only ship when `_Host` is deleted.) With the SPA parity done, the removal PR's
remaining pre-work is just the **mandatory cold adversarial pass**; the runbook then executes, in order:
1. **Cut the rollback tag `blazor-final`** on the pre-deletion `main` commit — the *first* action, before
deleting anything. Exact command + restore path: `docs/decisions.md` 2026-07-11 "Pre-removal Blazor
+26
View File
@@ -777,3 +777,29 @@ the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty cra
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
convention already present on the post-commit enqueues.
## 2026-07-11 — External Collections in the SPA derives from `getMediaSources()`; collections scans are optimistically bounded (#91b Libraries.razor parity)
**Context.** The last SPA pre-work before deleting Blazor `Libraries.razor` was wiring the shipped
`scanLibrary(id, deep)` + `scanCollections(family, id, deep)` clients (F9 API, #235) into `LibrariesScreen`,
matching the page's four scan actions: quick-library (already had it), deep-library, quick-collections,
deep-collections. Landed as its own pure-SPA PR ahead of the removal PR so the deletion diff stays surgical.
**Decision 1 — derive the External Collections rows client-side, no new endpoint.** Blazor's second table
comes from the `GetExternalCollections` MediatR query (one row per Plex/Jellyfin/Emby source with
`Libraries.Any(ShouldSyncItems)`), which has no REST wrapper. Rather than add one, the SPA derives the rows
from the existing `GET /api/media-sources`: `GetAllMediaSourcesForApiHandler` already filters each source's
`libraries` to sync-enabled entries (`ShouldIncludeLibrary` = `ShouldSyncItems` for remote families), so a
remote source with a **non-empty `libraries` list** is *exactly* the `Libraries.Any(ShouldSyncItems)` filter.
No extra endpoint, no extra fetch, single source of truth. (If `MediaSourceLibraryResponseModel` ever stops
filtering to sync-enabled libraries, this equivalence breaks — a test asserting the row set guards it.)
**Decision 2 — collections-scan buttons use optimistic, timeout-bounded pending (no poll reconcile).** Library
scans reconcile their optimistic "pending" flag against `GET /api/libraries/scan-status`. Collections scans
have **no** such surface — that endpoint is library-keyed, and Blazor only ever observed collections locks via
in-process `IEntityLocker` `Are{X}CollectionsLocked` events with no HTTP mirror. So the `useCollectionsScan`
hook disables a button optimistically on click and clears it after a fixed `COLLECTIONS_PENDING_TIMEOUT_MS`
(a 409 "already scanning" is benign and keeps it disabled until the timeout; a 404/network clears it and
surfaces the error). This is the honest ceiling of what the current API exposes. **Follow-up (backlog):** a
collections scan-status endpoint would let the SPA reconcile collections pending against a live active set the
way library scans do; filed as **#271** so the timeout isn't mistaken for the intended end state.
+106 -4
View File
@@ -16,7 +16,7 @@ export type LibrariesScreenQueryState =
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanLibrary: (libraryId: number, deep?: boolean) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
@@ -315,9 +315,10 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
});
}, []);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
const triggerScan = useCallback((libraryId: number, deep = false): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
// Already pending or active - ignore the duplicate submission. Quick and deep scans share the
// same per-library lock, so both affordances gate on (and reconcile through) this one id set.
return Promise.resolve();
}
@@ -338,7 +339,7 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
};
});
return scanLibrary(libraryId)
return scanLibrary(libraryId, deep)
.then(() => {
// 202 Accepted - a scan is genuinely queued. Poll scan-status; the pending flag is
// promoted to "active" once the scan appears there.
@@ -399,6 +400,107 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
return { data: null, error: null, refresh, status: 'loading' };
}
// --- External collections scan (Plex/Jellyfin/Emby) ---
// A pending collections scan re-enables its button after this bound. Unlike library scans, an
// external-collections scan has NO authoritative "in progress" REST surface: /api/libraries/scan-status
// is library-keyed, and Blazor only ever observed collections locks through in-process IEntityLocker
// events (Are{X}CollectionsLocked) that have no HTTP mirror. So we can't reconcile "pending" against a
// live active set the way library scans do - we optimistically disable, then give up after this bound
// so a button can't wedge disabled forever. (A collections scan-status endpoint would let us reconcile
// properly - see the #91b follow-up note.)
const COLLECTIONS_PENDING_TIMEOUT_MS = 30000;
export interface CollectionsScanState {
error: string | null;
scan: (source: CollectionsScanSource, sourceId: number, deep: boolean) => Promise<void>;
scanningKeys: Set<string>;
}
// Stable key for a per-(family, source) collections scan, used by both the hook and the screen so the
// "is this row scanning?" lookup has exactly one definition.
export function collectionsScanKey(source: CollectionsScanSource, sourceId: number): string {
return `${source}:${sourceId}`;
}
export function useCollectionsScan(): CollectionsScanState {
const [scanningKeys, setScanningKeys] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
// Mirror kept in sync synchronously so scan() can guard double-submits without waiting for a render.
const scanningKeysRef = useRef<Set<string>>(new Set());
const timeoutsRef = useRef<Map<string, number>>(new Map());
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
// Identity is stable across the hook's life (we only .set/.delete entries, never reassign the
// Map), so capturing it here is the same instance the cleanup clears at unmount.
const timeouts = timeoutsRef.current;
return () => {
activeRef.current = false;
timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
timeouts.clear();
};
}, []);
const clearKey = useCallback((key: string) => {
const timeoutId = timeoutsRef.current.get(key);
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
timeoutsRef.current.delete(key);
}
const next = new Set(scanningKeysRef.current);
next.delete(key);
scanningKeysRef.current = next;
if (activeRef.current) {
setScanningKeys(next);
}
}, []);
const scan = useCallback(
(source: CollectionsScanSource, sourceId: number, deep: boolean): Promise<void> => {
const key = collectionsScanKey(source, sourceId);
if (scanningKeysRef.current.has(key)) {
// Already pending - ignore the duplicate submission (quick and deep share the source lock).
return Promise.resolve();
}
const pending = new Set(scanningKeysRef.current).add(key);
scanningKeysRef.current = pending;
setScanningKeys(pending);
setError(null);
const timeoutId = window.setTimeout(() => clearKey(key), COLLECTIONS_PENDING_TIMEOUT_MS);
timeoutsRef.current.set(key, timeoutId);
return scanCollections(source, sourceId, deep)
.then(() => {
// 202 Accepted - a scan is genuinely queued. Keep the button disabled until the bounded
// timeout expires (there is no completion signal to reconcile against).
})
.catch((err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
// 409 Conflict - a collections scan is already running for this source. Benign: keep the
// optimistic pending flag (button stays disabled) with no error; it clears on the timeout.
return;
}
// 404 (source missing) / network error - nothing was queued, so re-enable and surface it.
clearKey(key);
if (activeRef.current) {
setError(messageFromLibrariesError(err, 'Unable to scan collections'));
}
});
},
[clearKey]
);
return { error, scan, scanningKeys };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+155 -1
View File
@@ -74,7 +74,14 @@ function mockApi(options: MockOptions = {}): MockHandle {
return Promise.resolve(jsonResponse(options.libraryScanStatuses ?? []));
}
if (/^\/api\/libraries\/\d+\/scan$/.test(url) && method === 'POST') {
if (/^\/api\/libraries\/\d+\/scan(\?deep=true)?$/.test(url) && method === 'POST') {
if (url in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
}
return Promise.resolve(new Response(null, { status: 202 }));
}
if (/^\/api\/media-sources\/(plex|jellyfin|emby)\/\d+\/scan-collections(\?deep=true)?$/.test(url) && method === 'POST') {
if (url in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
}
@@ -369,6 +376,153 @@ describe('LibrariesScreen', () => {
expect(await screen.findByText('No media sources returned')).toBeInTheDocument();
});
describe('deep scan + external collections (#91b Libraries.razor parity)', () => {
// A remote source with two sync-enabled libraries (deep-scan available) plus an External
// Collections row; a Local source (no deep, no collections row); and a remote source with NO
// sync-enabled libraries (no External Collections row).
function paritySources(): unknown[] {
return [
mediaSource({
id: 30,
kind: 'Local',
libraries: [library({ id: 31, mediaKind: 'Movies', name: 'Movies' })],
name: 'Local'
}),
mediaSource({
connectionAddress: 'https://plex.example.test',
id: 40,
kind: 'Plex',
libraries: [library({ id: 41, mediaKind: 'Shows', name: 'TV Shows' })],
name: 'Plex Server'
}),
mediaSource({
connectionAddress: 'https://jellyfin.example.test',
id: 50,
kind: 'Jellyfin',
libraries: [],
name: 'Jellyfin Home'
})
];
}
it('deep-scans a remote library via POST /api/libraries/{id}/scan?deep=true', async () => {
const handle = mockApi({ mediaSources: paritySources() });
render(<LibrariesScreen />);
await screen.findByText('TV Shows');
fireEvent.click(screen.getByRole('button', { name: 'Deep scan TV Shows' }));
await waitFor(() => {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/libraries/41/scan?deep=true', expect.objectContaining({ method: 'POST' }));
});
expect(fetchCount(handle, '/api/libraries/41/scan')).toBe(0);
});
it('offers a deep-scan button only for remote libraries, never Local', async () => {
mockApi({ mediaSources: paritySources() });
render(<LibrariesScreen />);
await screen.findByText('TV Shows');
// Local library "Movies" has a quick scan but no deep scan.
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Deep scan Movies' })).not.toBeInTheDocument();
// Remote library "TV Shows" has both.
expect(screen.getByRole('button', { name: 'Scan TV Shows' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Deep scan TV Shows' })).toBeInTheDocument();
});
it('renders an External Collections row only for remote sources with sync-enabled libraries', async () => {
mockApi({ mediaSources: paritySources() });
render(<LibrariesScreen />);
await screen.findByText('TV Shows');
expect(screen.getByRole('heading', { name: 'External Collections' })).toBeInTheDocument();
// Plex Server has a sync-enabled library → a collections row.
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Deep scan Plex Server collections' })).toBeInTheDocument();
// Jellyfin Home has no sync-enabled library → no collections row; Local never gets one.
expect(screen.queryByRole('button', { name: 'Scan Jellyfin Home collections' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Scan Local collections' })).not.toBeInTheDocument();
});
it('omits the External Collections section entirely when no remote source has sync-enabled libraries', async () => {
mockApi({
mediaSources: [
mediaSource({ id: 30, kind: 'Local', libraries: [library({ id: 31, name: 'Movies' })], name: 'Local' })
]
});
render(<LibrariesScreen />);
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
expect(screen.queryByRole('heading', { name: 'External Collections' })).not.toBeInTheDocument();
});
it('queues a quick collections scan via POST /api/media-sources/{family}/{id}/scan-collections', async () => {
const handle = mockApi({ mediaSources: paritySources() });
render(<LibrariesScreen />);
await screen.findByRole('button', { name: 'Scan Plex Server collections' });
fireEvent.click(screen.getByRole('button', { name: 'Scan Plex Server collections' }));
await waitFor(() => {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/media-sources/plex/40/scan-collections', expect.objectContaining({ method: 'POST' }));
});
// The button is optimistically disabled while the scan is pending.
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).toBeDisabled();
});
it('queues a deep collections scan with ?deep=true', async () => {
const handle = mockApi({ mediaSources: paritySources() });
render(<LibrariesScreen />);
await screen.findByRole('button', { name: 'Deep scan Plex Server collections' });
fireEvent.click(screen.getByRole('button', { name: 'Deep scan Plex Server collections' }));
await waitFor(() => {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/media-sources/plex/40/scan-collections?deep=true', expect.objectContaining({ method: 'POST' }));
});
});
it('treats a 409 "already scanning" collections response as benign (button stays disabled, no error)', async () => {
mockApi({
mediaSources: paritySources(),
mutationFailures: { '/api/media-sources/plex/40/scan-collections': { status: 409 } }
});
render(<LibrariesScreen />);
await screen.findByRole('button', { name: 'Scan Plex Server collections' });
fireEvent.click(screen.getByRole('button', { name: 'Scan Plex Server collections' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).toBeDisabled();
});
expect(screen.queryByText(/Unable to scan collections/)).not.toBeInTheDocument();
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
});
it('surfaces a collections scan failure and re-enables the button', async () => {
mockApi({
mediaSources: paritySources(),
mutationFailures: { '/api/media-sources/plex/40/scan-collections': { status: 404 } }
});
render(<LibrariesScreen />);
await screen.findByRole('button', { name: 'Scan Plex Server collections' });
fireEvent.click(screen.getByRole('button', { name: 'Scan Plex Server collections' }));
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).not.toBeDisabled();
});
});
describe('hub wiring (slice S5)', () => {
it('Add Source menu navigates to the local-new and each family route', async () => {
const pushSpy = vi.spyOn(window.history, 'pushState');
+102 -2
View File
@@ -6,11 +6,13 @@ import {
Film,
Folder,
HardDrive,
Library,
MonitorPlay,
Music,
Plus,
Radio,
RefreshCw,
ScanSearch,
Server,
Settings,
TriangleAlert
@@ -25,7 +27,10 @@ import {
StatusDot
} from '../components';
import {
collectionsScanKey,
useCollectionsScan,
useLibrariesScreenQuery,
type CollectionsScanSource,
type LibraryScanStatus,
type MediaSource,
type MediaSourceLibrary
@@ -41,6 +46,7 @@ import {
export function LibrariesScreen() {
const query = useLibrariesScreenQuery();
const collectionsScan = useCollectionsScan();
if (query.status === 'loading') {
return <LibrariesLoadingState />;
@@ -60,6 +66,21 @@ export function LibrariesScreen() {
);
const scanStatusesByLibraryId = new Map(query.data.scanStatuses.map((status) => [status.libraryId, status]));
// External Collections mirrors Blazor Libraries.razor's second table: one row per Plex/Jellyfin/Emby
// source that has at least one sync-enabled library. GetAllMediaSourcesForApiHandler already filters
// each source's `libraries` to sync-enabled entries (ShouldIncludeLibrary), so a remote source with a
// non-empty `libraries` list is exactly the `Libraries.Any(ShouldSyncItems)` filter GetExternalCollections
// applies - no separate endpoint needed.
const collectionsRows = query.data.sources
.map((source) => {
const family = parseRemoteFamily(source.kind);
if (family === null || source.libraries.length === 0) {
return null;
}
return { family, name: source.name, sourceId: source.id };
})
.filter((row): row is { family: CollectionsScanSource; name: string; sourceId: number } => row !== null);
return (
<div className="ctv-libraries-screen">
<section className="ctv-libraries-header">
@@ -97,10 +118,81 @@ export function LibrariesScreen() {
/>
))}
</div>
{collectionsRows.length > 0 && (
<ExternalCollectionsSection
error={collectionsScan.error}
onScanCollections={collectionsScan.scan}
rows={collectionsRows}
scanningKeys={collectionsScan.scanningKeys}
/>
)}
</div>
);
}
// External Collections table (parity with Blazor Libraries.razor's second table). Each row is a
// remote source with sync-enabled libraries; the quick/deep buttons queue that source's collections
// scan. There is no scan-status poll surface for collections (see useCollectionsScan), so a row's
// "scanning" state is optimistic and bounded by a timeout rather than reconciled against a live set.
function ExternalCollectionsSection({
error,
onScanCollections,
rows,
scanningKeys
}: {
error: string | null;
onScanCollections: (source: CollectionsScanSource, sourceId: number, deep: boolean) => Promise<void>;
rows: Array<{ family: CollectionsScanSource; name: string; sourceId: number }>;
scanningKeys: Set<string>;
}) {
return (
<section className="ctv-library-source-card" aria-label="External collections">
<div className="ctv-library-source-head">
<span className="ctv-library-kind-icon"><Library aria-hidden="true" size={18} /></span>
<div>
<div className="ctv-library-source-title">
<h3>External Collections</h3>
</div>
<span>Sync Plex, Jellyfin, and Emby collections into ErsatzTV.</span>
</div>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<div className="ctv-library-list" role="list" aria-label="External collections list">
{rows.map((row) => {
const scanning = scanningKeys.has(collectionsScanKey(row.family, row.sourceId));
return (
<div className="ctv-library-row" key={collectionsScanKey(row.family, row.sourceId)} role="listitem">
<span className={`ctv-library-kind-icon ctv-library-kind-${row.family}`}><Server aria-hidden="true" size={16} /></span>
<div className="ctv-library-row-main">
<strong>{row.name}</strong>
<span>{familyLabel(row.family)} collections</span>
</div>
<div className="ctv-library-row-status">
{scanning ? <Badge tone="accent" dot>Scanning</Badge> : <Badge tone="ok" dot>Idle</Badge>}
<IconButton disabled={scanning} onClick={() => void onScanCollections(row.family, row.sourceId, true)} size="sm" title={`Deep scan ${row.name} collections`}>
<ScanSearch aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={scanning} onClick={() => void onScanCollections(row.family, row.sourceId, false)} size="sm" title={`Scan ${row.name} collections`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
);
})}
</div>
</section>
);
}
// "Add Source" dropdown: Local library opens the create editor; each remote family opens its screen.
// Mirrors the AddToMenu popover pattern (click-outside + Escape close). Navigation goes through
// navigateToPath, which App's popstate owner turns into the right libraries sub-path (design §D.2).
@@ -203,7 +295,7 @@ function MediaSourceCard({
scanningLibraryIds,
source
}: {
onScanLibrary: (libraryId: number) => Promise<void>;
onScanLibrary: (libraryId: number, deep?: boolean) => Promise<void>;
scanStatusesByLibraryId: Map<number, LibraryScanStatus>;
scanningLibraryIds: Set<number>;
source: MediaSource;
@@ -250,6 +342,7 @@ function MediaSourceCard({
) : source.libraries.map((library) => (
<LibraryRow
key={library.id}
allowDeepScan={!isLocal}
editable={isLocal}
library={library}
onScanLibrary={onScanLibrary}
@@ -268,15 +361,17 @@ function MediaSourceCard({
}
function LibraryRow({
allowDeepScan,
editable,
library,
onScanLibrary,
scanStatus,
scanning
}: {
allowDeepScan: boolean;
editable: boolean;
library: MediaSourceLibrary;
onScanLibrary: (libraryId: number) => Promise<void>;
onScanLibrary: (libraryId: number, deep?: boolean) => Promise<void>;
scanStatus: LibraryScanStatus | null;
scanning: boolean;
}) {
@@ -300,6 +395,11 @@ function LibraryRow({
) : (
<Badge tone="ok" dot>Synced</Badge>
)}
{allowDeepScan && (
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id, true)} size="sm" title={`Deep scan ${library.name}`}>
<ScanSearch aria-hidden="true" size={15} />
</IconButton>
)}
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>