Replaces the Blazor /media/sources/* pages (Local / Plex / Jellyfin / Emby) with ChicoryTV SPA screens over a new REST write surface, unblocking the #91 phase (b) Blazor removal. Fixes#202.
What's here
REST write API (4 new controllers, ~26 endpoints, zero new MediatR commands, zero DB migrations — wraps existing commands):
SPA (/app/libraries/*): LibrariesScreen hub (extracted from App.tsx, now with a live Add-Source menu + per-source gear), LocalLibraryEditScreen, PlexSourceScreen (pin-flow poll), RemoteSourceScreen, RemoteConnectionEditScreen, RemoteLibrariesEditScreen (sortable), PathReplacementsEditScreen. Draft/save + dirty-guard throughout; App-owned popstate for the guarded sub-path route.
Security & correctness (owned within this PR)
Secure apiKey contract: connection GET returns { address, hasApiKey } — the stored key never crosses the wire; PUT: blank = retain, non-blank = set, required on first connect. Explicit input to the #197 security review.
Pre-existing bugs fixed (found in pre-review, filed for record): #255 cross-source path-replacement overwrite (repo UPDATE had no source predicate → predicate + handler ownership guard), #256 sign-out/disconnect lock-wedge on exception (now finally-released), #257 Plex post-save Unlock ordering (was releasing before the network scan).
Plex pin-flow lock leak on abandon/timeout → released on non-handoff exits (exception-safe); success path still hands off to SynchronizePlexMediaSources.
Three honest source-scoped list-replace identity contracts (library-prefs recreate ids on disable → SPA refetches); 404 via controller pre-check (.Join() flattens NotFoundError→422); 409 lock parity (#215).
Design provenance
[PLAN-MODE] design → independent Codex adversarial pre-review (4 blockers/6 highs, all reconciled) → fresh independent re-review (MERGEABLE) before any code. Recorded on adversarial-reviewer#19 slice 2.
Tests & docs
656 web tests + full .NET suite green; lint / build / check:api clean.
Docs updated same-PR: blazor-route-parity.md (media-source rows → PARITY-OK), domain-model.md (SPA edit routes), decisions.md (the design decisions), api-conventions.md §3c (durable-save PUT + post-save background sync). Capability matrix (mutation-depth #203 evidence) will be posted on #202.
Rollback
No DB migration. Code revert restores the prior UI/API completely; E2E mutations are undone by the pre-E2E SQLite backup. Redirect entries for /media/sources/* deferred to the #91b removal PR (matches every other Section-2 row).
Response DTOs (ErsatzTV.Core/Api/MediaSources) and request DTOs
(ErsatzTV/Controllers/Api/Requests) per the #202 design doc §B — the
shared shapes that backend slices S1 (local libraries), S2 (Plex), and
S3 (Jellyfin/Emby) will consume. No controllers or handler changes;
DTOs are unused so far.
Notable: RemoteConnectionResponseModel deliberately never carries the
raw API key (secure connection contract); SaveRemoteConnectionRequest's
To{Jellyfin,Emby}Command(existingApiKey) retains the existing key when
the incoming ApiKey is blank/omitted.
No conventions changed; nothing to update in docs/api-conventions.md.
Move the Libraries domain verbatim out of web/src/App.tsx into
web/src/screens/LibrariesScreen.tsx (zero-prop, self-sufficient), mirroring the
ChannelsScreen extraction (#244). Pure structural move: no API, route, CSS, or
visual change. App.tsx retains only the import + the <LibrariesScreen /> dispatch.
- 10 symbols moved (LibrariesLoadingState -> sourceLastScanLabel); App.tsx's
formatDateTime is inlined into the moved screen so it has no import back into
App.tsx (behavior-identical), matching the Channels precedent.
- Libraries behavior tests moved to a colocated LibrariesScreen.test.tsx with its
own scoped fetch mock (renders <LibrariesScreen /> directly); App.test.tsx keeps
one nav-smoke test for the route.
- Pruned now-dead App.tsx imports (Server, MonitorPlay, Music, FileImage, Folder,
HardDrive icons; useLibrariesScreenQuery, LibraryScanStatus/MediaSource/
MediaSourceLibrary types) and the now-dead runPollTick test helper (its doc
comment named it Libraries-specific).
- Disabled "Add Source"/gear/"Scan All" affordances are unchanged (wired in later
S5/S6 slices, not here).
Verified: web vitest 584 passed, eslint clean, tsc/vite build clean, check:api no
drift.
refs #202
New PlexMediaSourcesController (/api/media-sources/plex) P1-P8 wrapping
existing MediatR commands: state GET, pin-flow, sign-out, per-server
libraries/path-replacements GET+PUT, and refresh — VMs projected to the
S0 shared DTOs, ApiResults mapping, 404 controller pre-checks, #215-style
409 lock guards, [EndpointGroupName("general")].
Lock-lifecycle hardening (the tricky part):
- TryCompletePlexPinFlowHandler now releases the Plex lock ONLY on its
non-handoff exits (timeout-throw, poll exception, enqueue exception, the
dead return-false) via try/catch — NOT an unconditional finally. On
success the lock is handed off to SynchronizePlexMediaSources (the sole
releaser after discovery); a finally would double-release and release
before discovery, re-opening the finding-5 poll race. Fixes the latent
leak where an abandoned pin flow wedged Plex locked until restart.
- StartPlexPinFlow controller compensates UnlockPlex on the Left branch AND
any thrown dispatch/enqueue; only the Right/200 path holds the lock.
- SignOutOfPlexHandler wraps its work in try/finally { UnlockPlex() } — a
terminal handler with no handoff, so unconditional release is correct.
- Post-save library sync enqueues SynchronizePlexLibraryByIdIfNeeded
(Unlock:false) then SynchronizePlexNetworks (Unlock:true) — one lock, one
release on the last message, compensating-unlock if the 2nd enqueue throws
(corrects the Blazor Unlock-ordering bug, finding 6).
Data-integrity hardening:
- UpdatePlexPathReplacementsHandler rejects (422, no mutation) any positive
Id not owned by the route source, blank RemotePath/LocalPath, and null
list/items (findings 2c/8).
- MediaSourceRepository Plex path-replacement UPDATE gains
AND PlexMediaSourceId = @id (Jellyfin/Emby untouched — slice S3).
- ReplaceLibraryPreferences controller validates the id set against the
source's libraries (rejects unowned + Id=0), returns the reloaded list
(ids change on disable).
Tests (NUnit/Shouldly/NSubstitute), 34 new, all green: pin-flow lock
released on thrown-cancellation/poll-throw/enqueue-throw AND held on
success (no double-release); sign-out finally-release under a throwing
dependency; cross-source/nonblank/null path-replacement 422s; library-prefs
id-not-owned 422; post-save enqueue exact messages + Unlock flags; full
controller route/404/409/422 coverage.
Refs #202
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds LocalLibrariesController (L1-L7: list/get/create/update/delete/move-path/
path-exists) wrapping the existing local-library MediatR commands, mapping to
the shared S0 response DTOs. Per design #202 §A.1/§C5/§C6:
- 404 for L4/L5/L6 comes from a controller pre-check (GetLocalLibraryById is
None), not the handler -- .Apply/.ToEitherAsync both .Join() a NotFoundError
into a plain 422, so relying on the handler would be dead code. This is
check-then-act; a delete racing the pre-check falls through to the handler's
422, documented in the controller.
- L4/L5 409 via IEntityLocker.IsLibraryLocked(id); L6 resolves the source
library from the path id (new ILibraryRepository.GetLibraryIdForPath) before
its own lock check.
- MoveLocalLibraryPathHandler gains same-MediaKind and different-library
validation (finding 3) -- Blazor only filtered these client-side in the move
dialog, so an API/MCP client could bypass them.
- CreateLocalLibraryHandler/UpdateLocalLibraryHandler gain a shared
NewPathsMustExist validation (LocalLibraryHandlerBase) that Directory.Exists-
checks only new paths (Id < 1); existing rows stay exempt so an unmounted
share doesn't block a rename. L7 (path-exists) is a controller-local
IFileSystem check with no command.
Tests: controller route/mediator-arg tests incl. 404-pre-check vs
fall-through-422 and 409-lock cases; handler tests for the move-path
cross-kind/same-library 422s, new-path 422 (missing/mixed), and a lossless
round-trip proving local paths are identified by normalized path string, not
id. Full solution test suite (Scanner/Core/Architecture/Tests/Infrastructure)
green, 0 regressions.
Deviations: none from the S1 slice description. Did not touch
MediaSourceRepository.cs or any Plex/Jellyfin/Emby file (S2/S3 scope). Did not
run the OpenAPI regen scripts (separate gate after S1-S3 merge per design §E).
New JellyfinMediaSourcesController (/api/media-sources/jellyfin, J1-J9) and
EmbyMediaSourcesController (/api/media-sources/emby, E1-E9), wrapping the
existing Jellyfin/Emby MediatR commands per the #202 design doc §A.3/§A.4.
Secure connection contract (§C3/§B, finding 1): the connection GET returns
only { address, hasApiKey } — the API key never crosses the wire. The PUT
retains the existing key when the incoming key is blank, sets a new one when
non-blank, and 422s "API key is required" on a blank first connect.
Finding 7 (lock-release discipline): DisconnectJellyfinHandler and
DisconnectEmbyHandler now wrap their work in try/finally so a throw from any
awaited dependency (repo delete, search-index commit, secret store) still
releases the family lock instead of wedging every future disconnect at 409.
Findings 2c/8 (path-replacement cross-source guard): UpdateJellyfinPathReplacementsHandler
and UpdateEmbyPathReplacementsHandler now reject, before any write, an incoming
positive Id that isn't owned by the route's media source, a null item, or a
blank RemotePath/LocalPath — all 422 with no partial mutation. Defense-in-depth
repo fix: the Jellyfin/Emby path-replacement UPDATE SQL in MediaSourceRepository
now scopes by {Jellyfin,Emby}MediaSourceId (was previously unscoped by Id alone,
allowing a PUT to one source to silently overwrite another source's row). The
Plex path-replacement method (~line 397) is untouched — that's slice S2's file.
Library preferences (§C4a): the controller validates the incoming id set
against the source's known libraries (reject foreign ids, require full
coverage, no Id=0) before dispatch, then — for §C7 — LockLibrary + enqueues
the SynchronizeXLibraries/SynchronizeXLibraryByIdIfNeeded pair per enabled
library (compensating unlock if the enqueue throws), and returns the reloaded
list (ids are not stable across a disable).
404s on id-taking endpoints come from a controller pre-check (GetXMediaSourceById
is None), not a handler NotFoundError, since Either.Apply/ToEitherAsync join any
NotFoundError into a flat 422 (finding 9).
Tests: controller route/404/409/422 tests for both families; disconnect
fault-injection tests proving the lock releases even when a dependency throws;
path-replacement handler tests for cross-source-id/blank/null-item rejection
and correct add/update/delete merge; a repository-level test proving the SQL
fix stops a same-family cross-source path-replacement overwrite.
No new commands, no DB migration, no OpenAPI regen (gated until S1-S3 merge
per the design doc's build-slice plan).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Local library create/edit editor (create at /app/libraries/local/new,
edit at /app/libraries/local/{id}) wired into the S5-built LibrariesRouteScreen
dispatch switch, replacing MediaSourceEditorPlaceholder for the local-new and
local-edit sub-routes only. Remote (Plex/Jellyfin/Emby) branches are untouched
(S6b).
- Name (required) + Media Kind (create-only, disabled+annotated on edit)
- Add Path: path-exists pre-check (L7) + in-draft duplicate detection
(mediaSources/paths.ts normalizePath)
- Delete path: draft-local removal with a media-item-count confirm dialog
- Move path: dialog filtered to same-MediaKind libraries excluding the source,
including "(New Library)" which composes createLocalLibrary + moveLocalLibraryPath
(surfaces the error and leaves the new empty library on a failed move, matching
Blazor); gated on !dirty to avoid clobbering unsaved edits with the post-move
refetch
- Draft/saved model with explicit Save (POST L3 / PUT L4), draft retained on
422/network error, dirty-guard (registerNavigationGuard + beforeunload)
- Delete library (L5) with a media-item-count confirm; 409 refetches detail
Extended the existing App.test.tsx App-owned-popstate regression test (design
§D.2) to exercise the real screen's dirty guard instead of a manually-armed
stand-in, now that S6a has landed the editor it was stubbing out for.
Verification (web/): vitest (632 passed), eslint clean, tsc -b + vite build
clean, check:api reports no drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the Remote media-source SPA screens over the S5 foundation, replacing
the MediaSourceEditorPlaceholder for the plex/jellyfin/emby dispatch branches
only (Local branches left for S6a):
- PlexSourceScreen: pin-flow sign-in / fix-credentials / sign-out with the
§C1 poll state machine — polls GET /api/media-sources/plex every 2s up to
150s and keeps polling while authorized-but-locked ("finalizing"); the
terminal success is the lock releasing. Popup-blocked fallback link. Server
table (Refresh disabled while locked / Edit Libraries / Edit Path
Replacements) + sign-out content-removal confirm dialog.
- RemoteSourceScreen (shared Jellyfin/Emby): connect / edit-connection /
disconnect (warning dialog) + server table.
- RemoteConnectionEditScreen (shared): secure key affordance (§C3/finding 1)
— address prefilled, "leave blank to keep" when hasApiKey, required on first
connect; stored key never rendered or requested.
- RemoteLibrariesEditScreen (shared): client-side sortable Name + MediaKind
columns, per-library sync Switch, one Save; draft keyed by (name,mediaKind)
not id, refetch after save (ids change on disable, §C4a).
- PathReplacementsEditScreen (shared): row list + selected-row edit form,
add/remove, one Save; both fields required; family remote-path column label.
All editors use the ChannelEditScreen draft/save model + a shared useDirtyGuard
(registerNavigationGuard + beforeunload), Save gated !valid||!dirty||saving,
draft retained on 422/network, destructive actions gated on saving, 409 →
refetch. Colocated tests cover the poll (waiting→finalizing→success asserting
it does NOT stop at authorized&locked, timeout, budget-exhausted), the secure
key affordance, sortable columns, draft-retained-on-422, dirty-guard veto, and
the disconnect/sign-out dialogs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Replaces the Blazor
/media/sources/*pages (Local / Plex / Jellyfin / Emby) with ChicoryTV SPA screens over a new REST write surface, unblocking the #91 phase (b) Blazor removal. Fixes #202.What's here
REST write API (4 new controllers, ~26 endpoints, zero new MediatR commands, zero DB migrations — wraps existing commands):
LocalLibrariesController— list/detail/create/update(PUT-replace)/delete/move-path/path-existsPlexMediaSourcesController— state, pin-flow, sign-out, libraries, path-replacements, refreshJellyfin/EmbyMediaSourcesController— state, connection GET/PUT, disconnect, libraries, path-replacements, refreshSPA (
/app/libraries/*):LibrariesScreenhub (extracted from App.tsx, now with a live Add-Source menu + per-source gear),LocalLibraryEditScreen,PlexSourceScreen(pin-flow poll),RemoteSourceScreen,RemoteConnectionEditScreen,RemoteLibrariesEditScreen(sortable),PathReplacementsEditScreen. Draft/save + dirty-guard throughout; App-owned popstate for the guarded sub-path route.Security & correctness (owned within this PR)
{ address, hasApiKey }— the stored key never crosses the wire; PUT: blank = retain, non-blank = set, required on first connect. Explicit input to the #197 security review.finally-released), #257 Plex post-saveUnlockordering (was releasing before the network scan).SynchronizePlexMediaSources..Join()flattensNotFoundError→422); 409 lock parity (#215).Design provenance
[PLAN-MODE]design → independent Codex adversarial pre-review (4 blockers/6 highs, all reconciled) → fresh independent re-review (MERGEABLE) before any code. Recorded on adversarial-reviewer#19 slice 2.Tests & docs
check:apiclean.blazor-route-parity.md(media-source rows → PARITY-OK),domain-model.md(SPA edit routes),decisions.md(the design decisions),api-conventions.md§3c (durable-save PUT + post-save background sync). Capability matrix (mutation-depth #203 evidence) will be posted on #202.Rollback
No DB migration. Code revert restores the prior UI/API completely; E2E mutations are undone by the pre-E2E SQLite backup. Redirect entries for
/media/sources/*deferred to the #91b removal PR (matches every other Section-2 row).🤖 Generated with Claude Code
New PlexMediaSourcesController (/api/media-sources/plex) P1-P8 wrapping existing MediatR commands: state GET, pin-flow, sign-out, per-server libraries/path-replacements GET+PUT, and refresh — VMs projected to the S0 shared DTOs, ApiResults mapping, 404 controller pre-checks, #215-style 409 lock guards, [EndpointGroupName("general")]. Lock-lifecycle hardening (the tricky part): - TryCompletePlexPinFlowHandler now releases the Plex lock ONLY on its non-handoff exits (timeout-throw, poll exception, enqueue exception, the dead return-false) via try/catch — NOT an unconditional finally. On success the lock is handed off to SynchronizePlexMediaSources (the sole releaser after discovery); a finally would double-release and release before discovery, re-opening the finding-5 poll race. Fixes the latent leak where an abandoned pin flow wedged Plex locked until restart. - StartPlexPinFlow controller compensates UnlockPlex on the Left branch AND any thrown dispatch/enqueue; only the Right/200 path holds the lock. - SignOutOfPlexHandler wraps its work in try/finally { UnlockPlex() } — a terminal handler with no handoff, so unconditional release is correct. - Post-save library sync enqueues SynchronizePlexLibraryByIdIfNeeded (Unlock:false) then SynchronizePlexNetworks (Unlock:true) — one lock, one release on the last message, compensating-unlock if the 2nd enqueue throws (corrects the Blazor Unlock-ordering bug, finding 6). Data-integrity hardening: - UpdatePlexPathReplacementsHandler rejects (422, no mutation) any positive Id not owned by the route source, blank RemotePath/LocalPath, and null list/items (findings 2c/8). - MediaSourceRepository Plex path-replacement UPDATE gains AND PlexMediaSourceId = @id (Jellyfin/Emby untouched — slice S3). - ReplaceLibraryPreferences controller validates the id set against the source's libraries (rejects unowned + Id=0), returns the reloaded list (ids change on disable). Tests (NUnit/Shouldly/NSubstitute), 34 new, all green: pin-flow lock released on thrown-cancellation/poll-throw/enqueue-throw AND held on success (no double-release); sign-out finally-release under a throwing dependency; cross-source/nonblank/null path-replacement 422s; library-prefs id-not-owned 422; post-save enqueue exact messages + Unlock flags; full controller route/404/409/422 coverage. Refs #202 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Owns the shared single-files so the S6a (Local) / S6b (Remote) editor slices touch disjoint files. Editor screens are stubbed (MediaSourceEditorPlaceholder) for S6. - web/src/api/mediaSources.ts (+test): client module over the new media-source write endpoints (local CRUD + move/path-exists; Plex pin-flow/sign-out; shared remote state/connection/libraries/path-replacements/refresh; family = only URL variance), DTOs re-exported from generated v1, messageFromMediaSourcesError; barrel export. - web/src/mediaSources/{familyMeta,paths,pinFlowPoll}.ts (+tests): family labels/routes/ remote-path column naming (owns RemoteFamily); client-side NormalizePath mirror for in-draft dup detection; pure §C1 pin-flow poll state machine (waiting/finalizing/ success/timeout/budget-exhausted), timer-free and fully unit-tested. - routing.ts parseLibrariesSubRoute + LibrariesSubRoute union split Local vs Remote. - App.tsx: libraries route allowSubPaths; LibrariesRouteScreen wrapper dispatching a flat switch to placeholders; App-owned popstate (finding 4) — App is the single popstate owner, consults canLeaveCurrentScreen() and only on approval updates librariesSubPath passed DOWN to the wrapper (wrapper never self-listens); state write scoped to the libraries route so Playouts/Media pops stay byte-identical (nit 3). - LibrariesScreen hub wiring: Add-Source menu (Local/Plex/Jellyfin/Emby), remote source gear -> family screen, local library row gear -> edit route; removed the disabled Scan-All button + the deferred-sources card (§C7/§D.1). - Tests: App-owned-popstate dirty-guard case (confirm false keeps URL+sub-screen; true navigates); mediaSources client URL/verb mapping; pinFlowPoll transitions; familyMeta/ paths units; hub-wiring navigation. - docs/spa-conventions.md §8 (resolved sub-path+dirty-guard caveat -> App-owned popstate) + §2 exemplar list (guarded-route exception). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Adds the Local library create/edit editor (create at /app/libraries/local/new, edit at /app/libraries/local/{id}) wired into the S5-built LibrariesRouteScreen dispatch switch, replacing MediaSourceEditorPlaceholder for the local-new and local-edit sub-routes only. Remote (Plex/Jellyfin/Emby) branches are untouched (S6b). - Name (required) + Media Kind (create-only, disabled+annotated on edit) - Add Path: path-exists pre-check (L7) + in-draft duplicate detection (mediaSources/paths.ts normalizePath) - Delete path: draft-local removal with a media-item-count confirm dialog - Move path: dialog filtered to same-MediaKind libraries excluding the source, including "(New Library)" which composes createLocalLibrary + moveLocalLibraryPath (surfaces the error and leaves the new empty library on a failed move, matching Blazor); gated on !dirty to avoid clobbering unsaved edits with the post-move refetch - Draft/saved model with explicit Save (POST L3 / PUT L4), draft retained on 422/network error, dirty-guard (registerNavigationGuard + beforeunload) - Delete library (L5) with a media-item-count confirm; 409 refetches detail Extended the existing App.test.tsx App-owned-popstate regression test (design §D.2) to exercise the real screen's dirty guard instead of a manually-armed stand-in, now that S6a has landed the editor it was stubbing out for. Verification (web/): vitest (632 passed), eslint clean, tsc -b + vite build clean, check:api reports no drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Build the Remote media-source SPA screens over the S5 foundation, replacing the MediaSourceEditorPlaceholder for the plex/jellyfin/emby dispatch branches only (Local branches left for S6a): - PlexSourceScreen: pin-flow sign-in / fix-credentials / sign-out with the §C1 poll state machine — polls GET /api/media-sources/plex every 2s up to 150s and keeps polling while authorized-but-locked ("finalizing"); the terminal success is the lock releasing. Popup-blocked fallback link. Server table (Refresh disabled while locked / Edit Libraries / Edit Path Replacements) + sign-out content-removal confirm dialog. - RemoteSourceScreen (shared Jellyfin/Emby): connect / edit-connection / disconnect (warning dialog) + server table. - RemoteConnectionEditScreen (shared): secure key affordance (§C3/finding 1) — address prefilled, "leave blank to keep" when hasApiKey, required on first connect; stored key never rendered or requested. - RemoteLibrariesEditScreen (shared): client-side sortable Name + MediaKind columns, per-library sync Switch, one Save; draft keyed by (name,mediaKind) not id, refetch after save (ids change on disable, §C4a). - PathReplacementsEditScreen (shared): row list + selected-row edit form, add/remove, one Save; both fields required; family remote-path column label. All editors use the ChannelEditScreen draft/save model + a shared useDirtyGuard (registerNavigationGuard + beforeunload), Save gated !valid||!dirty||saving, draft retained on 422/network, destructive actions gated on saving, 409 → refetch. Colocated tests cover the poll (waiting→finalizing→success asserting it does NOT stop at authorized&locked, timeout, budget-exhausted), the secure key affordance, sortable columns, draft-retained-on-422, dirty-guard veto, and the disconnect/sign-out dialogs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>