---
key: scan.collections-scan-status
title: 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: '`GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout.'
signals: '`CollectionsScanStatusResponseModel`, `useCollectionsScan`, `pruneGraceExpiredPending`, `COLLECTIONS_PENDING_TIMEOUT_MS` removed · paths: `MediaSourcesController.GetCollectionsScanStatus` · issues: #271'
mechanics: '`GET /api/v1/media-sources/collections-scan-status`'
---

**External Collections rows stay client-derived.** The SPA derives them from `GET
/api/v1/media-sources`: remote sources expose only sync-enabled libraries, so a non-empty `libraries` list is
equivalent to Blazor's `Libraries.Any(ShouldSyncItems)` filter. No second listing endpoint or fetch is needed.

The first scan-button implementation used a bounded optimistic timeout because collections locks had no HTTP
mirror. #271 replaces that temporary ceiling with the authoritative status contract below.

**Decision 1 — one family-global status endpoint, reading `IEntityLocker`.** New
`GET /api/v1/media-sources/collections-scan-status` (`MediaSourcesController` → `GetCollectionsScanStatus`
handler) returns one `CollectionsScanStatusResponseModel { family }` entry per media-source family
(`plex`/`jellyfin`/`emby`) whose collections lock is currently held, and only for active ones — the direct
counterpart to `GET /api/v1/libraries/scan-status`. It reads `IEntityLocker.Are{X}CollectionsLocked()` (the lock
*is* the running scan — the scan-collections controllers acquire it before enqueueing and the scanner releases
it on completion), analogous to how the library endpoint reads `IScannerProxyService.GetActiveScans()`. Two
deliberate shape differences from libraries: (a) **family-global, not per-source** — the collections lock takes
no source id (`LockPlexCollections()`), so an entry means *every* source of that family is scanning, matching
Blazor's all-rows-disabled behavior (per the PR #272 review note); (b) **no percent** — collections scans
expose only a boolean lock, not progress.

**Decision 2 — the SPA reconciles authoritatively; the fixed timeout is removed.** `useCollectionsScan` now
polls the new endpoint (seeding on mount, so a scan already running when the screen opens disables the buttons
immediately — the old timeout couldn't) and reconciles optimistic pending against the active-family set using
the **same `pruneGraceExpiredPending` grace-tick helper** the library hook uses (now generic over the pending
key type). A row shows "Scanning" when its family is in the active set **or** it has a still-in-grace optimistic
pending key. The grace window is kept (not the old wholesale timeout) to absorb the click→observed-active lag and
the fast-scan-between-polls race — the same bounded-pending discipline #232/#230 established for library scans.
`COLLECTIONS_PENDING_TIMEOUT_MS` is gone.
