Move Dashboard rendering, health and on-air helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.
Refs #246
Co-Authored-By: OpenAI Codex <codex@openai.com>
Exercise default-deco persistence and errors plus the complete action, add-dialog, and edit-field kind matrices identified by cold review.
Refs #245
Co-Authored-By: OpenAI Codex <codex@openai.com>
Disable and gate remote connection draft inputs while a save owns the current revision. Exercise the real App-owned route transition after a successful save so the regression proves the editor unmounts without a dirty prompt.
Refs #344
Co-Authored-By: OpenAI Codex <codex@openai.com>
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.
Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.
Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).
Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.
Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.
fixes#286
refs #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-review follow-ups (both non-blocking):
- Low: the TopBar "Add Trakt List" button was a silent no-op on the
/app/trakt-lists/{id} detail sub-route (setAddOpen state isn't rendered by
the editor branch, and the screen is keyed by pathname so the state
wouldn't survive a navigate). Guard on editingId: route back to the list
from the detail view, open the dialog from the list.
- Nit: the invariant test only spot-checked 2 screens. Replaced with a
data-driven it.each over the 4 URL-navigating create screens (channels,
filler, ffmpeg, watermarks) asserting each banner actually navigates — a
typo'd route id now fails red. Docs wording corrected to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shell TopBar rendered a primary-action button (Plus icon) for every
screen, but only SchedulesScreen subscribed to its ctv:primary-action event
— so every other screen's button was dead (a labelled no-op, or a bare "+"
for the ~10 routes whose primaryAction was '').
Resolution (issue #238): the Plus-icon button is a "create new item"
affordance. Keep + wire it only on the 8 list screens with a single create
flow (channels, schedules, multi/rerun collections, trakt lists, filler
presets, ffmpeg profiles, watermarks) via a shared usePrimaryAction hook
(web/src/primaryAction.ts); drop it (primaryAction: '') everywhere else —
where the action isn't a create (Save/Refresh/Play/Validate/Reset/Scan, all
of which have correct in-body controls), is ambiguous (collections tabs), a
silent no-op (builder, playlists), or misplaced (dashboard, libraries). The
TopBar now renders the button only when primaryAction is non-empty.
Also relabels the apiKey route's stale post-#295 "Save key"/description.
Docs: spa-conventions.md §10 + decisions.md 2026-07-12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server returns MachineKeyResponse(string ApiKey) -> JSON { apiKey }, but the
hand-written SPA MachineKey type declared { key } and ApiKeyScreen read result.key,
which would be undefined at runtime (blank key + empty copy). Mocked unit tests
passed against the wrong shape. Align the type, the screen, and both test mocks to
the real { apiKey } contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the API-key SPA model with the session-cookie auth from PR1:
- client.ts: stop sending X-Api-Key; attach X-Csrf on mutating verbs (POST/PUT/
PATCH/DELETE); add suppressUnauthorizedSignal to skip the global 401 signal on
expected wrong-credentials 401s (login / change-password).
- api/auth.ts: hand-written wire types (AuthConfig/AuthSession/MachineKey — the
auth surface is IgnoreApi, deliberately not in generated types) + endpoint fns
(getAuthConfig/getAuthSession/login/setup/logout/changePassword/getMachineKey);
keep the notifyUnauthorized/subscribeUnauthorized 401 signal; add
clearLegacyStoredApiKey. Legacy get/set/clearStoredApiKey retained ONLY so the
still-shipping ApiKeyScreen (machine-key slice) compiles without a cross-slice
conflict — the client no longer reads them.
- AuthGate.tsx: boot gate wrapping <App/> outside the shell (mints no URL, deep
links survive login). checking -> setup | login | ready | error, with a safe
default AuthContext so App.test.tsx renders without a provider; config-fetch
failure lands on an explicit error+Retry, never a blank screen.
- LoginScreen / SetupScreen: shell-less centered cards; inline 401 / 409 handling;
SSO button + local-form gating from AuthConfig.
- UnauthorizedBanner: rewritten to prompt re-login (passive; consults the
unsaved-changes guard before flipping the gate).
- UserMenu: TopBar sign-out (guard -> logout -> signOut), mounted next to
ConnectMenu.
- main.tsx: wrap <AuthGate><App/></AuthGate> inside StrictMode.
Tests: client/auth/AuthGate/LoginScreen/SetupScreen/UnauthorizedBanner/UserMenu
(723 pass). Lint + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-review nit fixes on PR #298:
- useCollectionsScan.scan() now guards on the whole family being busy (active OR
any pending key of that family), not just the exact key — a sibling source of a
family with a scan in flight no longer fires a redundant (benign-409) POST.
- LibrariesScreen ExternalCollectionsSection disables every row of a family that
has a pending or active scan (derives pendingFamilies from pendingKeys), matching
Blazor's instant all-rows-disabled behavior instead of waiting a poll RTT.
- Rewrite the promote test to actually observe the optimistic-pending window via a
deferred POST (was only asserting the promoted end state), and add a test proving
a sibling-source click fires no second POST while the family is pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.
SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.
Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.
Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mint ChannelDetailResponseModel (faithful detail DTO exposing the raw editable
field set the channel editor reads: raw FFmpegProfileId/WatermarkId/FallbackFillerId
ids, the mode enums, logo, playoutCount, id) and route GetById/Create/Update through
it, replacing the lean list ChannelResponseModel that resolved the profile to a name
and dropped the editable ids (a functional regression for draftFromChannel). The lean
ChannelResponseModel stays unchanged for GET /api/channels. webEncodedName dropped
(SPA never reads it). Logo is mirrored as a Core ChannelLogoResponseModel since the
Application ArtworkContentTypeModel can't be referenced from Core.
Repoint the hand-written SPA client aliases now that the VMs are gone from the schema:
Channel -> ChannelDetailResponseModel, MediaCollection/SmartCollection -> *ResponseModel,
ProgramSchedule -> ProgramScheduleResponseModel. Fix#288 honest-nullability test fallout
in search.test.ts (null -> [] for now-non-null id arrays). Include the already-on-disk
playouts.ts WithDayNames removal and regenerate v1.json + v1.d.ts + endpoint-index.md
(authoritative final regen; the reset endpoint's {channelNumber}->{id} re-key surfaces
in the generated docs and the OpenApi error-contract test).
Refs #288#197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetById/Create/Update return ChannelResponseModel via new GetChannelByIdForApi
read-side query; POST /api/channels/{id:int}/playout/reset (new
GetPlayoutIdByChannelId; by-number kept for HlsSessionWorker broadcast).
Refs #288#197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S4 stored-XSS + S9 upload-size DoS from the #197 cold API review.
The artwork path trusted client-supplied content types at both ends: upload
validated only the declared multipart Content-Type (never decoded the bytes),
and serving reflected a client `?contentType=` straight into the response
Content-Type on unauthenticated GET sinks (/iptv/logos, /artwork/watermarks).
Chain: upload <script> bytes as image/png -> GET ...?contentType=text/html
serves them as HTML in-origin. nosniff (#279) does not help because the server
explicitly declares text/html.
- Upload: derive the content type from the bytes via SkiaSharp SKCodec
(header-only, no decode -> no decompression-bomb path); reject non-images 422.
New ErsatzTV.Core/Images/ImageContentTypes as the single allow-list source.
Dropped the untrusted declared Content-Type from the UploadArtwork command.
- Serve: removed the ?contentType= reflection structurally -- dropped ContentType
from GetCachedImagePath and the [FromQuery] binding on GetImage/GetWatermark;
the handler always sniffs the file, defaulting application/octet-stream.
ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA previews
no longer append the query.
- Defense-in-depth: channel-logo / watermark {path, contentType} DTOs run through
ArtworkContentTypeModel.Sanitized(), blanking non-allow-listed types on write.
- S9: Kestrel MaxRequestBodySize from ETV_MAXIMUM_UPLOAD_MB rejects oversized
bodies during read (controller file.Length check kept as friendly-error backstop).
Both serve sinks are IgnoreApi, so no OpenAPI change. Tests: byte-sniff accept/
reject, Sanitized() allow-list, Location no longer carries ?contentType=.
Docs: api-conventions §4a + decisions.md 2026-07-12.
Refs #283#197#66
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundle A SPA slice: the /api surface is now gated behind X-Api-Key on
every request (reads too, RequireKeyForReads defaults true), so a wrong/
missing key 401s everything.
- #282: send X-Api-Key on ALL requests when a key is stored, not only
mutations (removed the mutatingMethods split in api/client.ts).
- #280: new keyless API Key screen (/app/api-key, System nav) that reads/
writes only localStorage via auth.ts and never calls /api, so it works
on a fresh install where every read 401s. Masked key state, Save/Clear,
points at server-generated /config/api.key.
- 401 UX: client emits one app-wide unauthorized signal (auth.ts
notify/subscribeUnauthorized); a shell-level UnauthorizedBanner points
the user at the API Key screen. DRY, no per-screen 401 branches.
- Tests: inverted the GET header assertion (key now sent on reads), added
no-key and 401-signal client tests, auth signal tests, and screen +
banner tests. spa-conventions.md §5e documents the new seams.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The backend (already on this branch) added an optional int? Id to
ScheduleItemRequest so the server reconciles PUT /api/schedules/{id}/items
rows by identity instead of by array position. The SPA previously
discarded the server id on load (only a client-local _key survived) and
never sent one back, so a reorder could misattribute fill-group/shuffle
state onto the wrong persisted row.
- itemRules.ts: fromResponse now captures the response item's id onto
the draft; normalizeForSave emits it back unchanged. newDraftItem and
copyDraftItem explicitly set id: null (a brand-new/copied row was
never persisted under an id, and copyDraftItem must not duplicate the
source's id onto a second row).
- scheduleItem.ts (Add-to-schedule dialog, POST path): id: null for the
same reason — it always creates a new row.
- SchedulesScreen.tsx save(): the PUT-response re-seed already existed
(fromResponse over the response array) but now carries ids through.
This matters because a subtype/playout-mode switch can be a
delete+insert server-side, so the response id for that row can differ
from what was submitted — a second save must use the *response's* id
or the server 422s it as unknown. Added a comment documenting this.
- schedules.ts: replaced the stale "server reuses same-typed rows by
position" comment with the current id-based reconcile contract.
- Added/updated tests in itemRules.test.ts, SchedulesScreen.test.tsx,
and AddToScheduleDialog.test.tsx covering id round-tripping, the
null-id-for-new/copied-item cases, and a second-save-reuses-the-
response-id regression test.
Verified: npm run lint, tsc -b --noEmit, npm run build, and npm test
(680/680) all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add optional `int? Id` to ScheduleItemRequest/ReplaceProgramScheduleItem so
a client can round-trip each existing item's server id. When ids are present,
ReplaceProgramScheduleItemsHandler reconciles by id (not array position), so an
item's persisted fill-group/shuffle state (PlayoutScheduleItemFillGroupIndex,
FK OnDelete Cascade) follows the logical item across reorders/inserts instead of
being inherited by whatever previously occupied its new slot (#259, split from
#252/#253). A fully id-less payload keeps the verbatim positional fallback.
Guards (inside PersistItems, after CheckVersion so 412 precedes 422): duplicate
id -> 422; id not in this schedule -> 422 (a stale id under Phase-1 force-write is
a live lost-update signal, not a new item). Index stays array-position derived.
Regenerated v1.json + TS client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Installs husky git hooks (via web/'s lint-staged + npm, since the JS/TS
project lives in web/ with no root package.json) to catch lint, format,
type, and generated-API-drift errors locally before they reach CI.
Hooks (committed at repo root under .husky/):
- pre-commit: (a) lint-staged runs eslint --fix on staged
web/src/**/*.{ts,tsx} + a project-wide typecheck; (b) if any *.cs are
staged, dotnet format --verify-no-changes on just those files (skipped
when no .cs staged, so web-only commits skip the sln load).
- pre-push: CI-parity gate — cd web && check:api && lint && typecheck &&
build. Blocks pushing drift or a change that breaks an unstaged file.
- commit-msg: requires a Co-Authored-By trailer (merge commits exempt).
Wiring: web/package.json gains husky + lint-staged devDeps, a lint-staged
config, and a `prepare` script (cd .. && husky) that points git's
core.hooksPath at the repo-root .husky dir on npm install. A fresh
`web/` npm install installs all four hooks automatically.
Monorepo/worktree gotchas handled:
- husky init hard-checks for .git in cwd, so `prepare` cd's to the repo
root before invoking husky (npm keeps web/node_modules/.bin on PATH).
- git exports GIT_DIR while running hooks; in a worktree/subdir that made
pre-push's `git diff` (check:api) mislocate the working tree and pass
silently on drift — pre-push now unsets GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE.
docs/ci-cd.md: new "Pre-commit hooks (web/)" section covering all four.
Verified: eslint error blocks commit; clean commit passes; bad-format .cs
blocks (dotnet format ~6-7s scoped), good .cs passes; check:api drift and
a lint error each block `git push --dry-run`, clean state passes; missing
Co-Authored-By blocks commit-msg, present passes; non-web/.cs commits skip
lint/format. npm run lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Last SPA pre-work before deleting Blazor Libraries.razor (#91 phase b):
wire the shipped scanLibrary(id, deep) + scanCollections(family, id, deep)
clients (F9 API, #235) into LibrariesScreen so the SPA reaches parity with
Libraries.razor's four scan actions.
- Deep Scan Library button on each remote (Plex/Jellyfin/Emby) library row,
threading `deep` through the existing optimistic-pending/poll hook (quick +
deep share the per-library lock).
- External Collections section (quick + deep per remote source). Rows derive
client-side from getMediaSources(): the media-sources API handler already
filters each source's `libraries` to sync-enabled entries, so a remote
source with a non-empty libraries list is exactly GetExternalCollections's
Libraries.Any(ShouldSyncItems) filter — no new endpoint.
- useCollectionsScan hook: collections scans have no scan-status poll surface
(the endpoint is library-keyed; Blazor observed collections locks via
in-process IEntityLocker events), so pending is optimistic + timeout-bounded
(409 benign, 404/network surfaces the error). Follow-up #271 for a proper
collections status surface.
Pure SPA change (no backend/OpenAPI). Docs: blazor-route-parity.md §5 (SPA
affordance DONE), decisions.md (derive-vs-endpoint + optimistic-timeout).
Refs #91
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior fix set setItemsLoading/setItemsLoaded at the top of loadItems, but the
activeId effect calls loadItems synchronously → react-hooks/set-state-in-effect lint
error (Main Lint SPA, the real CI failure). Move the not-loaded/loading gating into
reloadAfterConflict (an event handler, lint-clean), which is exactly the 412 conflict-
reload path Codex's Medium-3 targeted; canEdit stays false through that reload window.
Behavior unchanged; the normal switch/initial-load paths (guarded separately by the
#242 dirty-guard) are left untouched. Local: lint clean, vitest 667, check:api no drift,
full dotnet solution test green.
Independent Codex review of #268 found two Blockers the fork missed + two Mediums:
- Blocker: replace PUTs returned the handler's item snapshot but re-queried the root
for the ETag separately, so a racing writer could pair stale items with a newer ETag
(silent overwrite). All four controllers now reload root-then-items (version-first,
fail-safe) and 404 when the root is gone between commit and reload — matching the
Block reference. Fixes the Blocker + the Medium '200 without ETag' case together.
- Blocker: PlaylistsScreen loaded items+root via Promise.all (concurrent), pairing a
stale name with the current ETag; now sequential (items-with-meta first, then root).
- Medium: SchedulesScreen loadItems now marks not-loaded/loading up front so canEdit is
false through the 412 conflict reload (no stale-draft edits lost).
Controller unit-test mocks updated to stub the new reload query. Full suite green
(ErsatzTV.Tests 1334, web 667, check:api no drift).
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:
- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
through from If-Match.
- Handlers introduce the version check as a standalone Either after
validation (never via Apply), bump Version unconditionally before
saving, and persist through SaveChangesWithConcurrencyGuard so a losing
writer maps to 412 instead of 500. DecoTemplate's post-commit playout
Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
ETag of the root's version; PUT parses If-Match (400 on malformed),
threads the expected version into the command, and returns the new
ETag from the refreshed root on success. Both PUT actions now use the
handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
ETag in a ref, read items-with-meta first on load, and open a
"changed elsewhere" ConfirmDialog on a 412 instead of navigating away.
Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fans the frozen ETag/If-Match/412 recipe (Block reference, #253) onto the
Playlist aggregate:
- ReplacePlaylistItems command carries ExpectedVersion; the handler runs
CheckVersion as a standalone Either after validation (so a stale write
survives as 412, not flattened to 422 by Apply/Join), bumps Version
unconditionally before saving, and persists via
SaveChangesWithConcurrencyGuard (EF concurrency-token backstop).
- PlaylistViewModel carries Version; the items GET sets a strong ETag and
the PUT parses If-Match, threads it into the command, and returns the
refreshed ETag on success (400 on a malformed If-Match).
- Sibling item-adding handlers (AddItemsToPlaylist, AddMovie/Episode/
Season/ShowToPlaylist) bump Version too, since they mutate the same
editor-visible item list.
- SPA: playlists.ts exposes getPlaylistItemsWithMeta and an
If-Match-aware updatePlaylist; PlaylistEditor holds the ETag in a ref,
round-trips it on save, and opens a "changed elsewhere" ConfirmDialog on
412 (mirrors BlockEditor).
Tests: new ReplacePlaylistItemsHandlerConcurrencyTests (stale/match/
force-write/no-op-bump/racing-save), new PlaylistController tests
(ETag on GET items, 400/412/thread-version/force-write on PUT), and a
vitest 412-conflict-dialog test for PlaylistsScreen. dotnet test:
1304/1304 green. web: npm run typecheck clean, npm run build clean,
vitest 664/664 green.
Ref #253 PR2.
Codex independent review of #263 surfaced two defects the fork review missed:
- High — client load TOCTOU: BlockEditor read root metadata (getBlock) and items+ETag
(getBlockItemsWithMeta) concurrently, so a concurrent write landing between them (with
the items read resolving last) left a stale root paired with a current ETag → the save
silently overwrote the concurrent change with no 412. Fix: read items+ETag FIRST, then
the root metadata, so the captured ETag is never newer than the root version and any
inconsistency fails safe (save 412s → conflict dialog → reload).
- Medium — `ParseIfMatch` accepted non-canonical strong tags ("03", "+3", " 3 ") as
version 3. An ETag is opaque; only the exact emitted form is valid. Fix: canonical
decimal only (`NumberStyles.None` + no leading zeros) → else 400.
Tests: new `ConcurrencyHeadersTests` (canonical parse + padded/signed/whitespace/weak/
unquoted/list/overflow/empty → malformed); `ApiResultsTests` gains the 412 mapping case.
Existing BlocksScreen tests still green (load reordering is behavior-preserving for the
non-concurrent path).
Refs #253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the shared optimistic-concurrency contract so a stale second tab can no longer
silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate;
PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472).
Contract
- `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule,
Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection,
RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration
`AddAggregateVersions` (nullable:false, default 0).
- Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch →
412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag.
- `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`;
`ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent =
Phase-1 force-write.
Block reference wiring
- Handler: standalone `Either` via `CheckVersion` AFTER validation (never through
`Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`,
`SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412).
- `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the
ETag on GET items and on the successful PUT.
- SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock`
If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412
opens a blocking "changed elsewhere — reload" dialog.
Tests
- Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success
+ bump, no-op save still bumps, and a two-context racing save → 412; proven
non-vacuous (drop `.IsConcurrencyToken()` → the race test fails).
- Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on
GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog.
Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log.
Refs #253
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>
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>