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.
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)
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>
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 main2026-07-12 15:08:24 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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-processIEntityLockerevents with no HTTP mirror. SouseCollectionsScandisabled a button optimistically on click and cleared it after a fixedCOLLECTIONS_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→GetCollectionsScanStatushandler) returns oneCollectionsScanStatusResponseModel { family }per media-source family (plex/jellyfin/emby) whose collections lock is currently held, readingIEntityLocker.Are{X}CollectionsLocked(). Counterpart toGET /api/libraries/scan-status, with two deliberate shape differences dictated by the lock: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).Returns only active families (empty list = nothing running), mirroring the queue op's own lock. (api-conventions §3b, new "status counterpart" note.)
SPA —
useCollectionsScannow 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 samepruneGraceExpiredPendinggrace-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_MSis removed. A row shows "Scanning" when its family is active or it has an in-grace optimistic pending key.Tests
getCollectionsScanStatusGET; hook mount-seed, 202-promote, 409-keeps-disabled, 404-error+clear (fetch mock models the family-global lock the controller acquires synchronously).v1.json+v1.d.tsregenerated (idempotent);check:apiclean.[]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
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>