feat(api): #271 collections scan-status REST surface + authoritative SPA reconcile #298

Merged
timothy merged 2 commits from worktree-271-collections-scan-status into main 2026-07-12 15:08:24 +02:00
Owner

Closes #271. Unblocks #91b (arc item 4): the External Collections scan affordance now has full authoritative parity with Libraries.razor.

Problem

Library scans reconcile their optimistic "pending" flag against GET /api/libraries/scan-status. External-collections scans had no HTTP status surface — Blazor only observed collections locks via in-process IEntityLocker events with no HTTP mirror. So useCollectionsScan disabled a button optimistically on click and cleared it after a fixed COLLECTIONS_PENDING_TIMEOUT_MS: a long deep scan re-enabled the button before it finished, and a fast scan held it disabled for the full timeout.

Change

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(). Counterpart to GET /api/libraries/scan-status, with two deliberate shape differences dictated by the lock:

  • family-global, not per-source — the collections lock takes no source id (LockPlexCollections()), so an entry means every source of that family is scanning (matches Blazor's all-rows-disabled behavior; per the PR #272 review note).
  • no percent — collections scans expose only a boolean lock, not progress.

Returns only active families (empty list = nothing running), mirroring the queue op's own lock. (api-conventions §3b, new "status counterpart" note.)

SPA — useCollectionsScan now polls the endpoint and reconciles optimistic pending against the active-family set, seeding on mount so a scan already running when the screen opens disables the buttons immediately (the old timeout couldn't). Uses the same pruneGraceExpiredPending grace-tick helper as library scans (now generic over the pending key type) to absorb the click→observed-active lag / fast-scan-between-polls race — the bounded-pending discipline from #232/#230. COLLECTIONS_PENDING_TIMEOUT_MS is removed. A row shows "Scanning" when its family is active or it has an in-grace optimistic pending key.

Tests

  • Handler (3): empty / only-locked-families / all-locked.
  • Controller (2): idiomatic route assertion + mediator delegation.
  • SPA (5): getCollectionsScanStatus GET; hook mount-seed, 202-promote, 409-keeps-disabled, 404-error+clear (fetch mock models the family-global lock the controller acquires synchronously).
  • Full local pass: ErsatzTV.Tests 1477, Core.Tests 507, Architecture.Tests 5, web 696. OpenAPI v1.json + v1.d.ts regenerated (idempotent); check:api clean.
  • Live smoke: 401 without key, 200 + [] with key (matches /api/libraries/scan-status).

Docs

docs/api-conventions.md §3b (status-counterpart pattern), docs/blazor-route-parity.md §5, docs/decisions.md (2026-07-12 entry), docs/endpoint-index.md (regenerated).

🤖 Generated with Claude Code

Closes #271. Unblocks #91b (arc item 4): the External Collections scan affordance now has full authoritative parity with `Libraries.razor`. ## Problem Library scans reconcile their optimistic "pending" flag against `GET /api/libraries/scan-status`. External-collections scans had **no** HTTP status surface — Blazor only observed collections locks via in-process `IEntityLocker` events with no HTTP mirror. So `useCollectionsScan` disabled a button optimistically on click and cleared it after a fixed `COLLECTIONS_PENDING_TIMEOUT_MS`: a long deep scan re-enabled the button before it finished, and a fast scan held it disabled for the full timeout. ## Change **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()`. Counterpart to `GET /api/libraries/scan-status`, with two deliberate shape differences dictated by the lock: - **family-global, not per-source** — the collections lock takes no source id (`LockPlexCollections()`), so an entry means every source of that family is scanning (matches Blazor's all-rows-disabled behavior; per the PR #272 review note). - **no percent** — collections scans expose only a boolean lock, not progress. Returns only active families (empty list = nothing running), mirroring the queue op's own lock. (api-conventions §3b, new "status counterpart" note.) **SPA** — `useCollectionsScan` now polls the endpoint and reconciles optimistic pending against the active-family set, **seeding on mount** so a scan already running when the screen opens disables the buttons immediately (the old timeout couldn't). Uses the same `pruneGraceExpiredPending` grace-tick helper as library scans (now generic over the pending key type) to absorb the click→observed-active lag / fast-scan-between-polls race — the bounded-pending discipline from #232/#230. `COLLECTIONS_PENDING_TIMEOUT_MS` is removed. A row shows "Scanning" when its family is active **or** it has an in-grace optimistic pending key. ## Tests - Handler (3): empty / only-locked-families / all-locked. - Controller (2): idiomatic route assertion + mediator delegation. - SPA (5): `getCollectionsScanStatus` GET; hook mount-seed, 202-promote, 409-keeps-disabled, 404-error+clear (fetch mock models the family-global lock the controller acquires synchronously). - Full local pass: ErsatzTV.Tests 1477, Core.Tests 507, Architecture.Tests 5, web 696. OpenAPI `v1.json` + `v1.d.ts` regenerated (idempotent); `check:api` clean. - Live smoke: 401 without key, 200 + `[]` with key (matches `/api/libraries/scan-status`). ## Docs `docs/api-conventions.md` §3b (status-counterpart pattern), `docs/blazor-route-parity.md` §5, `docs/decisions.md` (2026-07-12 entry), `docs/endpoint-index.md` (regenerated). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-07-12 14:25:38 +02:00
feat(api): #271 collections scan-status REST surface + authoritative SPA reconcile
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
6d31758cca
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.

SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.

Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.

Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 14:34:38 +02:00
fix(spa): #271 disable all family rows on click (family-global lock parity)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
4112413ca5
Cold-review nit fixes on PR #298:
- useCollectionsScan.scan() now guards on the whole family being busy (active OR
  any pending key of that family), not just the exact key — a sibling source of a
  family with a scan in flight no longer fires a redundant (benign-409) POST.
- LibrariesScreen ExternalCollectionsSection disables every row of a family that
  has a pending or active scan (derives pendingFamilies from pendingKeys), matching
  Blazor's instant all-rows-disabled behavior instead of waiting a poll RTT.
- Rewrite the promote test to actually observe the optimistic-pending window via a
  deferred POST (was only asserting the promoted end state), and add a test proving
  a sibling-source click fires no second POST while the family is pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy merged commit 4112413ca5 into main 2026-07-12 15:08:24 +02:00
timothy deleted branch worktree-271-collections-scan-status 2026-07-12 15:08:24 +02:00
Sign in to join this conversation.