--- key: api.async-op-contract title: 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235) status: active since: '2026-07-11' supersedes: none superseded-by: none rule: Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. signals: '`QueueShowScanResult`, `ResetAllPlayoutsResponseModel`, `MaintenanceController.EmptyTrash`/`CleanArtwork` · paths: `LibrariesController.ScanShow`, `ChannelController.ResetPlayout`, `PlayoutController.ResetAll` · issues: #235, adversarial-reviewer#20 F7/F8/F9, #232, #215' mechanics: '`docs/api-conventions.md` §3a/§3b' --- Reviewer#20 F7/F8/F9. Normalizes the queue-triggering `/api/*` endpoints onto one contract, closes the two F9 `Libraries.razor` parity gaps, and hardens the Trakt batch-lock lifecycle. Much of the F8 surface was **already normalized** by #232 (library scan → `QueueLibraryScanResult` 202/404/409/422) and #215 (per-id playout mutations + reset → 409 lock guard) — this issue finished the remaining outliers. **Normalized async-op contract** (queue-triggering endpoints): **202 Accepted** = work queued; **404 ProblemDetails** = entity missing (controller pre-check); **409 ProblemDetails** = lock held (the running job, or a mutation racing it — §3a/§3b); **422 ProblemDetails** = domain precondition (sync disabled / unsupported / start failed). Trakt was the reference implementation. Changes made: - `MaintenanceController.EmptyTrash` — error path **500 text/plain → 404/422 ProblemDetails** (`ToErrorResult`). - `MaintenanceController.CleanArtwork` — silent **200 → 202** (fire-and-forget enqueue). No SPA consumer. - `LibrariesController.ScanShow` — conflated **400 `{error}` → 202/404/409/422** via a new `QueueShowScanResult` enum (6 outcomes incl. an honest `ScanFailed`→422, distinct from `Unsupported`). - `ChannelController.ResetPlayout` — **200 → 202** (queue-triggering; 404/409 unchanged). - `PlayoutController.ResetAll` — **202 (no body) → 202 + `ResetAllPlayoutsResponseModel`** reporting `queuedPlayoutIds` / `skippedLocked` / `skippedUnsupported` (replaces the silent skip; still 202, still skips locked/ExternalJson by design per §3a — now it *reports* what it skipped). - `TroubleshootController.TroubleshootPlayback` — bare body-less `NotFound()` → **404/422 ProblemDetails** with distinguishing detail. **Status codes the SPA HLS player depends on were preserved** — verified `HlsPlayer.tsx` never branches on this endpoint's status (playback state comes from the separate `/api/troubleshoot/playback/status` poll); only the error *body* was enriched. **Playout build observability**: the list endpoint (`GET /api/playouts`) already stamped `isLocked` + `BuildStatus` on `PlayoutListItemResponseModel` (#215); this issue adds **`isLocked` to the single-playout `GET /api/playouts/{id}`** (`PlayoutResponseModel`), so the detail poll surface carries the §3a lock flag too. No dedicated `GET /api/playouts/{id}/status` push channel was added — the flag on the existing GETs is the HTTP-observable substitute for Blazor's live lock event, matching the `GET /api/trakt/status` precedent. **F9 parity endpoints** (the `Libraries.razor` deletion gate — #202 did NOT close these): - **Deep scan**: `POST /api/libraries/{id}/scan` gains `?deep=false`, threaded through `QueueLibraryScanByLibraryId(LibraryId, DeepScan=false)` into `ForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep)` (was hardcoded `false`). Non-breaking: existing callers omit it. - **External-collections scan**: new `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false` on the three #202 media-source controllers, dispatching `Synchronize{X}Collections(id, ForceScan:true, deep)`. Each pre-checks source existence (404), acquires the per-source **collections** lock (`Lock{X}Collections()` — the lock *is* the running scan, so a false = **409**), then enqueues and returns 202; the controller compensating-unlocks in a `catch` if the enqueue throws (§3b), and `ScannerService` releases in its `finally`. Thin SPA clients shipped (`scanLibrary(id, deep)`, `scanCollections`); **the SPA deep-scan / collections buttons are the removal PR's remaining parity work** (parity doc §5). **F7 Trakt batch-lock leak fix**: the global Trakt lock was released only when the *terminal* batch message (`Unlock: true`) was processed; a `WorkerService` shutdown/cancellation before that message leaked the lock permanently (subsequent Trakt ops 409 until restart — same class as #231/#233/#234). Fix: `WorkerService` now releases the Trakt lock in a `finally` on read-loop exit if still held. Non-vacuous regression test proven against an inverted-condition control. **Accepted-by-design** (per the issue's decision-record ask): the worker's channels are **unbounded** and there is **no shutdown drain** — messages still queued at process exit are dropped. This is acceptable because the entity locks are **in-memory singletons that die with the process**, so a dropped message can't strand a lock across restarts (the F7 `finally` covers the *within-process* shutdown-break leak, which is the only way a lock outlives its batch while the process keeps running). Adding a bounded-channel backpressure / graceful drain is out of scope and would not fix a correctness bug.