External-collections scan has no status/progress REST surface — SPA collections buttons are optimistic + timeout-bounded #271

Closed
opened 2026-07-11 19:25:08 +02:00 by timothy · 3 comments
Owner

Backlog follow-up from #91b (SPA Libraries.razor parity, landed 2026-07-11).

LibrariesScreen now exposes External Collections quick/deep scan buttons wired to POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections. Unlike library scans — which reconcile their optimistic "pending" flag against GET /api/libraries/scan-status — collections scans have no equivalent status/progress endpoint. The legacy Blazor page observed collections locks purely via in-process IEntityLocker.Are{X}CollectionsLocked events, which have no HTTP mirror.

Consequence today: useCollectionsScan (web/src/api/libraries.ts) disables a collections button optimistically on click and re-enables it after a fixed COLLECTIONS_PENDING_TIMEOUT_MS (a 409 "already scanning" is benign and keeps it disabled until the timeout). So the button's "scanning" state is not authoritative — a long deep-collections scan re-enables the button before it finishes, and a fast scan holds the button disabled for the full timeout.

Fix (proposed): add a collections scan-status/lock REST surface (e.g. GET /api/media-sources/{family}/{id}/collections-scan-status, or fold collections locks into a unified /api/scan-status) so the SPA can reconcile collections pending against a live active set, matching the library-scan UX. Then drop the timeout fallback in favor of authoritative reconcile.

Severity: Low — capability parity is met (the scan is genuinely queued/triggered); only the live progress affordance is approximate. Not a #91b gate.

Refs: docs/decisions.md 2026-07-11 "External Collections in the SPA…"; docs/blazor-route-parity.md §5.

**Backlog follow-up from #91b (SPA Libraries.razor parity, landed 2026-07-11).** `LibrariesScreen` now exposes External Collections quick/deep scan buttons wired to `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections`. Unlike **library** scans — which reconcile their optimistic "pending" flag against `GET /api/libraries/scan-status` — **collections** scans have no equivalent status/progress endpoint. The legacy Blazor page observed collections locks purely via in-process `IEntityLocker.Are{X}CollectionsLocked` events, which have no HTTP mirror. **Consequence today:** `useCollectionsScan` (`web/src/api/libraries.ts`) disables a collections button optimistically on click and re-enables it after a fixed `COLLECTIONS_PENDING_TIMEOUT_MS` (a 409 "already scanning" is benign and keeps it disabled until the timeout). So the button's "scanning" state is not authoritative — a long deep-collections scan re-enables the button before it finishes, and a fast scan holds the button disabled for the full timeout. **Fix (proposed):** add a collections scan-status/lock REST surface (e.g. `GET /api/media-sources/{family}/{id}/collections-scan-status`, or fold collections locks into a unified `/api/scan-status`) so the SPA can reconcile collections pending against a live active set, matching the library-scan UX. Then drop the timeout fallback in favor of authoritative reconcile. **Severity:** Low — capability parity is met (the scan is genuinely queued/triggered); only the live progress affordance is approximate. Not a #91b gate. Refs: `docs/decisions.md` 2026-07-11 "External Collections in the SPA…"; `docs/blazor-route-parity.md` §5.
timothy added the reviewfrontend labels 2026-07-11 19:25:08 +02:00
Author
Owner

Related nuance (surfaced by the PR #272 adversarial review): the backend collections lock is family-global, not per-source — LockPlexCollections() / Are{X}CollectionsLocked() take no source id (PlexMediaSourcesController.cs:301, IEntityLocker). Blazor disabled all same-family collection rows while any one scanned. The SPA useCollectionsScan keys optimistic pending by ${family}:${sourceId}, so with two Plex sources, scanning A leaves B's button enabled; clicking B yields a benign 409 (correctly handled — B goes pending until the timeout). No correctness harm, just a visual divergence from Blazor's all-rows-disabled behavior.

A proper collections status/lock surface (this issue) should expose the lock at whatever granularity the backend actually enforces (family-global today), letting the SPA disable the matching row set authoritatively instead of per-source-optimistically.

Related nuance (surfaced by the PR #272 adversarial review): the backend collections lock is **family-global**, not per-source — `LockPlexCollections()` / `Are{X}CollectionsLocked()` take no source id (`PlexMediaSourcesController.cs:301`, `IEntityLocker`). Blazor disabled *all* same-family collection rows while any one scanned. The SPA `useCollectionsScan` keys optimistic pending by `${family}:${sourceId}`, so with **two Plex sources**, scanning A leaves B's button enabled; clicking B yields a benign 409 (correctly handled — B goes pending until the timeout). No correctness harm, just a visual divergence from Blazor's all-rows-disabled behavior. A proper collections status/lock surface (this issue) should expose the lock at whatever granularity the backend actually enforces (family-global today), letting the SPA disable the matching row set authoritatively instead of per-source-optimistically.
timothy added the in-progress label 2026-07-12 14:06:24 +02:00
Author
Owner

Claiming — session started 2026-07-12 (afternoon). Picking this up to unblock #91b (arc item 4): the external-collections scan needs an HTTP status/lock surface so Libraries.razor can be deleted and the SPA collections buttons can reconcile authoritatively instead of relying on the fixed timeout.

Plan (following the library-scan-status precedent + api-conventions §3b): add a collections scan-status REST surface at the granularity the backend actually enforces (family-global collections lock, per the PR #272 review note), then drop the COLLECTIONS_PENDING_TIMEOUT_MS fallback in useCollectionsScan in favor of authoritative reconcile. Will confirm the exact endpoint shape after recon.

Claiming — session started 2026-07-12 (afternoon). Picking this up to unblock #91b (arc item 4): the external-collections scan needs an HTTP status/lock surface so `Libraries.razor` can be deleted and the SPA collections buttons can reconcile authoritatively instead of relying on the fixed timeout. Plan (following the library-scan-status precedent + api-conventions §3b): add a collections scan-status REST surface at the granularity the backend actually enforces (family-global collections lock, per the PR #272 review note), then drop the `COLLECTIONS_PENDING_TIMEOUT_MS` fallback in `useCollectionsScan` in favor of authoritative reconcile. Will confirm the exact endpoint shape after recon.
Author
Owner

Closed — PR #298 (FF-merged to main 4112413c; PR CI green, branch deleted)

What was done. Added an authoritative HTTP status surface for external-collections scans and rewired the SPA to reconcile against it, retiring the fixed timeout.

  • Backend: new GET /api/media-sources/collections-scan-status (MediaSourcesController → GetCollectionsScanStatus handler) returns one CollectionsScanStatusResponseModel { family } per media-source family (plex/jellyfin/emby) whose collections lock is currently held, reading IEntityLocker.Are{X}CollectionsLocked(). Returns only active families (empty = nothing running) — the counterpart to GET /api/libraries/scan-status. Two deliberate shape differences dictated by the lock: family-global (no source id — an entry means every source of that family is scanning, matching Blazor's all-rows-disabled) and no percent (collections scans expose only a boolean lock).
  • SPA: useCollectionsScan now polls the endpoint and reconciles optimistic pending against the active-family set, seeding on mount so an already-running scan disables the buttons immediately (the old timeout couldn't). Reuses the library hook's pruneGraceExpiredPending grace-tick helper (generalized over the key type) to absorb the click→observed-active lag / fast-scan race. COLLECTIONS_PENDING_TIMEOUT_MS removed. A row shows Scanning when its family is active or has an in-grace pending key; a cold-review nit fix also disables all rows of a family on click (and drops the redundant sibling-source POST), matching Blazor.

Root cause (of the gap, not a bug). Collections locks were family-global in-process IEntityLocker state with no HTTP mirror — a known limitation logged when the SPA Libraries.razor port landed (decisions.md 2026-07-11), filed as this issue so the timeout wasn't mistaken for the end state. This closes that follow-up.

Files changed. ErsatzTV/Controllers/Api/MediaSourcesController.cs, ErsatzTV.Application/MediaSources/Queries/GetCollectionsScanStatus{,Handler}.cs, ErsatzTV.Core/Api/MediaSources/CollectionsScanStatusResponseModel.cs, web/src/api/libraries.ts, web/src/screens/LibrariesScreen.tsx, tests (handler ×3, controller ×2, SPA api-fn + hook reconcile incl. deferred-POST optimistic-window + sibling-guard), regenerated v1.json/v1.d.ts/endpoint-index.md, docs (api-conventions.md §3b, blazor-route-parity.md §5, decisions.md 2026-07-12).

Verification. Full local pass: ErsatzTV.Tests 1477, Core.Tests 507, Architecture.Tests 5, web 697. Live smoke: 401 without key, 200 + [] with key (matches /api/libraries/scan-status). Cold adversarial fork review (MERGEABLE-WITH-NITS → both nits fixed) + fix-commit re-review (MERGEABLE). PR CI green (Build & test, EF migration integrity, Docs reminder).

Deferred / follow-up. None. The endpoint reports lock state regardless of who holds it (API scan, scheduled sync, another tab), so it's more correct than the click-scoped timeout it replaces.

Impact. Removes the last F9 API gap that gated deleting Libraries.razor → unblocks #91b (arc item 4).

## Closed — PR #298 (FF-merged to `main` `4112413c`; PR CI green, branch deleted) **What was done.** Added an authoritative HTTP status surface for external-collections scans and rewired the SPA to reconcile against it, retiring the fixed timeout. - **Backend**: new `GET /api/media-sources/collections-scan-status` (`MediaSourcesController` → `GetCollectionsScanStatus` handler) returns one `CollectionsScanStatusResponseModel { family }` per media-source family (`plex`/`jellyfin`/`emby`) whose collections lock is currently held, reading `IEntityLocker.Are{X}CollectionsLocked()`. Returns only active families (empty = nothing running) — the counterpart to `GET /api/libraries/scan-status`. Two deliberate shape differences dictated by the lock: **family-global** (no source id — an entry means every source of that family is scanning, matching Blazor's all-rows-disabled) and **no percent** (collections scans expose only a boolean lock). - **SPA**: `useCollectionsScan` now polls the endpoint and reconciles optimistic pending against the active-family set, **seeding on mount** so an already-running scan disables the buttons immediately (the old timeout couldn't). Reuses the library hook's `pruneGraceExpiredPending` grace-tick helper (generalized over the key type) to absorb the click→observed-active lag / fast-scan race. `COLLECTIONS_PENDING_TIMEOUT_MS` removed. A row shows Scanning when its family is active or has an in-grace pending key; a cold-review nit fix also disables **all** rows of a family on click (and drops the redundant sibling-source POST), matching Blazor. **Root cause (of the gap, not a bug).** Collections locks were family-global in-process `IEntityLocker` state with no HTTP mirror — a known limitation logged when the SPA `Libraries.razor` port landed (decisions.md 2026-07-11), filed as this issue so the timeout wasn't mistaken for the end state. This closes that follow-up. **Files changed.** `ErsatzTV/Controllers/Api/MediaSourcesController.cs`, `ErsatzTV.Application/MediaSources/Queries/GetCollectionsScanStatus{,Handler}.cs`, `ErsatzTV.Core/Api/MediaSources/CollectionsScanStatusResponseModel.cs`, `web/src/api/libraries.ts`, `web/src/screens/LibrariesScreen.tsx`, tests (handler ×3, controller ×2, SPA api-fn + hook reconcile incl. deferred-POST optimistic-window + sibling-guard), regenerated `v1.json`/`v1.d.ts`/`endpoint-index.md`, docs (`api-conventions.md` §3b, `blazor-route-parity.md` §5, `decisions.md` 2026-07-12). **Verification.** Full local pass: ErsatzTV.Tests 1477, Core.Tests 507, Architecture.Tests 5, web 697. Live smoke: 401 without key, 200 + `[]` with key (matches `/api/libraries/scan-status`). Cold adversarial fork review (MERGEABLE-WITH-NITS → both nits fixed) + fix-commit re-review (MERGEABLE). PR CI green (Build & test, EF migration integrity, Docs reminder). **Deferred / follow-up.** None. The endpoint reports lock state regardless of who holds it (API scan, scheduled sync, another tab), so it's more correct than the click-scoped timeout it replaces. **Impact.** Removes the last F9 API gap that gated deleting `Libraries.razor` → **unblocks #91b (arc item 4)**.
timothy removed the in-progress label 2026-07-12 15:09:22 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#271