--- key: media.source-mgmt-write-api title: 2026-07-11 — Media-source management REST write API + SPA (#202) status: active since: '2026-07-11' supersedes: none superseded-by: none rule: Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). signals: '`RemoteConnectionResponseModel {hasApiKey}`, per-family identity contracts, Plex pin-flow polling, EntityLocker non-owner-token discipline · paths: `LocalLibrariesController`, `Plex|Jellyfin|EmbyMediaSourcesController` · issues: #202, #197, #231' mechanics: '`docs/handoffs/` session record, issue #202' --- Replaced the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages (14 routes) with SPA screens under `/app/libraries/*` over new write controllers (`LocalLibrariesController`, `Plex|Jellyfin|EmbyMediaSourcesController`), wrapping existing MediatR commands 1:1 (no new commands, no DB migration). Full design + adversarial-review reconciliation: `docs/handoffs/` session record and issue #202. The design surfaced and fixed several pre-existing Application/Infrastructure bugs newly reachable from a programmatic client; each is recorded here because it changes documented behavior, not just adds a route. **Secure `apiKey` contract (Jellyfin/Emby connection).** The connection GET (`RemoteConnectionResponseModel`) returns `{ address, hasApiKey }` — the stored key **never** leaves the server, closing a leak where the old design would have served the raw key from an unauthenticated GET under any-origin CORS. On the connection PUT, a blank/omitted `apiKey` means *retain the existing key*; a non-blank value sets a new one; the key is **required on first connect** (no existing secret) → 422. Rationale: GETs aren't behind `X-Api-Key` (`ApiKeyAuthorizationFilter` only guards mutating verbs), so a secret-bearing GET is a real exposure regardless of how obscure the route is. **Stated explicitly as an input to #197** (the planned read-side-auth review for secret-bearing GETs) — #197 should treat "does any GET return a credential" as one of its checks, not just this one instance. **Three list-replace identity contracts, not one uniform one.** An earlier draft assumed a single "`Id<1`=add / missing=delete / id-preserved" contract across all three PUT-replace families; source inspection proved that false for two of them: - *Remote library sync preferences* (`PUT .../{id}/libraries`) — the command carries no source id and the handler toggles only the ids present in the body; a row **absent** from the request is left untouched, not deleted (libraries are sync-discovered, never created via this PUT, so there are no `Id=0` adds either). The controller validates the submitted id set against `Get{Family}LibrariesBySourceId(id)` (422 on any id not owned by the route's source — closes a cross-source hole). Identity is **not stable across a disable**: `Disable{Family}LibrarySync` removes and re-adds the row with a fresh id, so the SPA keys its draft to `(name, mediaKind)`, never to `Id`, and refetches after every save (the PUT returns the reloaded list). - *Path replacements* (`PUT .../{id}/path-replacements`) — id-based (existing `Id`=update, `Id<1`=add, absent=delete) as documented, **but** the repo UPDATE SQL had no source-id predicate (`WHERE Id = @id`, no `AND {Family}MediaSourceId = @id`), so a PUT to source A could silently overwrite source B's row with the same numeric id. Fixed with a handler-level ownership guard (reject any incoming positive id not in this source's current set → 422, no partial mutation) **and** the repo SQL predicate itself (defense-in-depth for any other caller of that repo method). - *Local library paths* (`PUT /api/libraries/local/{id}`) — identity is the **normalized path string** (full path, trailing-separator/case-insensitive), not `Id`; `Id` in the request is advisory. Renaming a path is delete-old+add-new under the hood (its `LibraryPath.Id` changes). Kept as-is (matches the entrenched, tested Blazor behavior and how the SPA edits by value); not rewritten to id-based identity, which would be a bigger, riskier change out of #202's scope. **Plex pin-flow as REST: poll until the lock releases, exception-safe non-handoff unlock.** The SPA polls `GET /api/media-sources/plex` rather than a per-pin status resource (no pin-addressable server state exists to expose; SSE/push was already rejected, 2026-07-09). Polling contract is `isLocked && !isAuthorized` = waiting on the user; `isLocked && isAuthorized` = finalizing (discovering servers — **do not** stop here, the server list is still empty); `!isLocked && isAuthorized` = success; `!isLocked && !isAuthorized` = timed out/abandoned. This required fixing a **latent lock-leak bug**: `TryCompletePlexPinFlowHandler` threw `OperationCanceledException` on its 2-minute timeout instead of returning `false`, and nothing unlocked on that path — an abandoned sign-in wedged the Plex lock until restart or manual sign-out. Fix releases `UnlockPlex()` on the timeout-throw, a poll-exception, and an enqueue-exception — but **deliberately not** in an unconditional `finally`: on success the lock is handed off to `SynchronizePlexMediaSources`, the sole releaser after server discovery; a blanket `finally` would double-release and release *before* discovery completes, re-opening the same race the fix closes. This is the same non-owner-token discipline as the #231 `EntityLocker` model (2026-07-11 entry above), applied to the pin-flow's handoff-vs-terminal distinction specifically. **404 comes from the controller pre-check, not the handler.** `Apply`/`ToEitherAsync` both `.Join()` errors, which flattens any `NotFoundError` inside a joined `Validation` down to a plain 422. So every id-taking endpoint's real 404 is a controller-side pre-check (`Get...ById(id)`-is-`None` → `ApiResults.NotFoundProblem`, the `TemplateController.DeleteGroup` pattern), not a handler-level conversion — converting the joined validators to `NotFoundError` would be dead code, since the join discards the distinction anyway. This is check-then-act (a delete racing between the pre-check and the command falls through to the handler's own 422, not a 404); accepted and tested against the actual runtime error type rather than a hoped-for handler 404. **"Scan All" dropped, not implemented.** The disabled SPA header button on the libraries hub was speculative UI with **no Blazor equivalent** (`Libraries.razor` only ever supported per-library scan). Removed rather than backed with a new bulk-scan endpoint; per-library scan (#232) and the new per-source refresh-libraries endpoints (P8/J9/E9) cover the real capability set. **App-owned popstate for guarded sub-path routes.** `LocalLibraryEditScreen` and the other `/app/libraries/*` editors are the first screens to both register a dirty-navigation guard *and* track their own sub-path pathname — the combination `spa-conventions.md` §8 had flagged as unvalidated. React commits child passive effects before parent ones, so a sub-path wrapper that self-registers `popstate` would fire (and switch sub-screen) *before* App's guard-restore listener could veto. Resolution: App owns `popstate` centrally for the `libraries` route and only pushes an approved sub-path down to the wrapper (which no longer self-registers `popstate`); on a vetoed pop App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This is scoped to the `libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes (Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar list and the resolved caveat text.