diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index ed95b8e5e..046e2ea52 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index b533357d1..bffbafa87 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/web/src/api/libraries.ts b/web/src/api/libraries.ts index 97b66ad98..c9263f8ad 100644 --- a/web/src/api/libraries.ts +++ b/web/src/api/libraries.ts @@ -16,7 +16,7 @@ export type LibrariesScreenQueryState = data: LibrariesScreenData; error: string | null; refresh: () => void; - scanLibrary: (libraryId: number) => Promise; + scanLibrary: (libraryId: number, deep?: boolean) => Promise; scanningLibraryIds: Set; status: 'success'; } @@ -315,9 +315,10 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta }); }, []); - const triggerScan = useCallback((libraryId: number): Promise => { + const triggerScan = useCallback((libraryId: number, deep = false): Promise => { 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; + scanningKeys: Set; +} + +// 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>(new Set()); + const [error, setError] = useState(null); + // Mirror kept in sync synchronously so scan() can guard double-submits without waiting for a render. + const scanningKeysRef = useRef>(new Set()); + const timeoutsRef = useRef>(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 => { + 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; diff --git a/web/src/screens/LibrariesScreen.test.tsx b/web/src/screens/LibrariesScreen.test.tsx index 8eedd24d3..9d17b698e 100644 --- a/web/src/screens/LibrariesScreen.test.tsx +++ b/web/src/screens/LibrariesScreen.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); diff --git a/web/src/screens/LibrariesScreen.tsx b/web/src/screens/LibrariesScreen.tsx index 8bec33cae..3ae7ce712 100644 --- a/web/src/screens/LibrariesScreen.tsx +++ b/web/src/screens/LibrariesScreen.tsx @@ -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 ; @@ -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 (
@@ -97,10 +118,81 @@ export function LibrariesScreen() { /> ))}
+ + {collectionsRows.length > 0 && ( + + )} ); } +// 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; + rows: Array<{ family: CollectionsScanSource; name: string; sourceId: number }>; + scanningKeys: Set; +}) { + return ( +
+
+ +
+
+

External Collections

+
+ Sync Plex, Jellyfin, and Emby collections into ErsatzTV. +
+
+ + {error && ( +
+
+ )} + +
+ {rows.map((row) => { + const scanning = scanningKeys.has(collectionsScanKey(row.family, row.sourceId)); + + return ( +
+ +
+ {row.name} + {familyLabel(row.family)} collections +
+
+ {scanning ? Scanning : Idle} + void onScanCollections(row.family, row.sourceId, true)} size="sm" title={`Deep scan ${row.name} collections`}> + + void onScanCollections(row.family, row.sourceId, false)} size="sm" title={`Scan ${row.name} collections`}> + +
+
+ ); + })} +
+
+ ); +} + // "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; + onScanLibrary: (libraryId: number, deep?: boolean) => Promise; scanStatusesByLibraryId: Map; scanningLibraryIds: Set; source: MediaSource; @@ -250,6 +342,7 @@ function MediaSourceCard({ ) : source.libraries.map((library) => ( Promise; + onScanLibrary: (libraryId: number, deep?: boolean) => Promise; scanStatus: LibraryScanStatus | null; scanning: boolean; }) { @@ -300,6 +395,11 @@ function LibraryRow({ ) : ( Synced )} + {allowDeepScan && ( + void onScanLibrary(library.id, true)} size="sm" title={`Deep scan ${library.name}`}> + + )} void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>