---
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.
