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