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>
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
Move the Channels domain verbatim out of web/src/App.tsx into
web/src/screens/ChannelsScreen.tsx (zero-prop, self-sufficient, mirroring the
SchedulesScreen extraction). Pure structural move: no API, route, CSS, or
visual change. App.tsx retains only the import + the <ChannelsScreen /> dispatch.
- 14 symbols moved (ChannelViewFilter → ChannelTableRow); the Dashboard-owned
progressFromNowPlaying is inlined into the moved progressFromChannelState so
the screen has no import back into App.tsx (behavior-identical).
- 12 Channels behavior tests moved to a colocated ChannelsScreen.test.tsx with
its own scoped fetch mock (renders <ChannelsScreen /> directly, no
mockDashboardApi); App.test.tsx keeps one nav-smoke test for the route.
- Pruned 12 now-dead App.tsx imports; shared symbols (ChannelState,
messageFromError, ApiError, useChannelsQuery) verified still used and kept.
- Docs: spa-conventions §6 (extracted-screen own-fetch-mock convention),
decisions.md (single-file rationale; no web/src/channels/ sibling dir, unlike
Schedules; inlined helper; #238 deferral).
Verified: web vitest 587 passed, eslint clean, tsc/vite build clean,
check:api no drift. #212 empty-lineup bare-create success+failure coverage
preserved. #238 TopBar dead-button left as-is (its owned bug; shell redesign
is epic phase 4 / #247).
refs #244#243
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex re-review of the prior fix commit found the Edit gate closed the exact
repro but two paths remained. One is reachable: Delete was the only
schedule-switch path that guardedSwitch's `saving` guard didn't cover — deleting
mid-save runs applySwitch to the next schedule while the in-flight items PUT is
still outstanding, and that PUT's completion handler then overwrites the next
schedule's draft with the deleted schedule's response. Delete is now
`disabled={saving}`, consistent with the Select, Edit, and guardedSwitch.
Regression: the deferred-PUT test now also asserts Delete is disabled in-flight
and re-enables after the save settles.
Also widens the test mock's onRequest return type to `Response | Promise<Response>
| null` (removes the `as unknown as Response` cast — a test-only type hole the
re-review flagged).
Deferred to #248: the other residual path (properties dialog not focus-trapped, so
keyboard focus can escape to underlying Add/Save mid-save) is a pre-existing,
cross-cutting overlay.tsx a11y gap affecting all dialogs — out of scope for this
targeted blocker fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two findings from the PR #242 adversarial review (Codex + Claude fork):
1. ChannelsEmptyState swallowed mutationError: on the fresh-install path #212
targets, a bare-create can 4xx (e.g. no default ffmpeg profile), but the empty
branch never rendered the error alert the non-empty screen shows — the user saw
only a spinner re-enable. The empty state now renders the same ctv-channels-error
alert. Regression: App.test.tsx asserts the error surfaces + no navigation on an
empty lineup.
2. SchedulesScreen Edit button was not gated on `saving` (Codex): during an items
save PUT the draft is still dirty, so a discard-to-open → shuffle-flip could let
the in-flight PUT resolve AFTER the shuffle reload and clobber the normalized
draft with the pre-shuffle body. Edit is now disabled while saving (consistent
with the schedule Select). Regression: a deferred PUT proves Edit is disabled
in-flight and re-enables once the save settles.
Deferred as nits (both reviews rate low): vetoed navigateToPath leaves two stray
history entries (cosmetic, rare — fixing means refactoring central nav); rapid
double-Back is best-effort (inherent popstate non-cancellability).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review (fork + Codex, both flagged) of PR #241:
- SPA (both reviewers): removing PENDING_GRACE_TICKS wholesale reintroduced a
stuck scan button. A 202'd scan that finishes between 10s polls (short/empty
library) is never observed active, so its optimistic pending flag wedged the
button disabled until reload. Restore a BOUNDED grace net (pruneGraceExpiredPending)
— re-scoped honestly: it absorbs the inherent queue->observed-active lag and the
fast-completion race, NOT the removed lying-200 compensation (the POST now returns
409/404/422 honestly). Bounds pending to PENDING_GRACE_TICKS * pollMs (~30s).
- SPA 409 (Codex): on "already scanning" the button was cleared+reconciled, but a
scan-status still lagging the in-progress scan re-enabled the button and let the
user fire repeated 409s. Keep the pending flag on 409 (no toast) so the button
stays disabled; polling promotes or expires it.
- Scheduler (Codex): the Plex-Shows tail-token batch and the local/Jellyfin/Emby
scan enqueues had no compensating unlock — a WriteAsync failure after LockLibrary
(cancellation on shutdown) stranded the library lock. Wrap each acquired-lock
enqueue in try/catch → UnlockLibrary → rethrow (the Plex catch covers both writes,
since the library message carries Unlock: false and the un-enqueued networks
message was the sole releaser).
Tests: two new App.test.tsx cases — grace-window expiry re-enables the button, and
409 keeps it disabled through the queue->active lag.
Ref #232.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing a schedule's properties to change shuffleScheduleItems for the active
schedule left the open items draft stale: hidden Fixed/Flood start values could
be saved back and the inspector kept offering start-type controls the schedule
no longer supports. onScheduleSaved now detects a shuffle flip on the active
schedule and reloads the items via GET so the server's EnforceProperties
re-normalizes the draft. The schedule-level flags already refresh from the save
response (setBoot maps `saved` into the list).
Decision: chose "block opening the edit dialog while the item draft is dirty"
(confirm-to-discard, guardedSwitch semantics) over confirm-at-reload — the
smaller fully-consistent change. It guarantees the properties editor only ever
opens over a clean baseline draft, so the post-save reload is lossless and
avoids the awkward state where a cancelled discard leaves a now-shuffled
schedule holding Fixed values.
Regression: SchedulesScreen.test.tsx — Fixed item on a non-shuffled schedule →
edit properties to shuffle=true → a fresh items GET fires, the Fixed option is
gone, and a subsequent Save's PUT carries no Fixed startType.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChannelsScreen returned ChannelsEmptyState before the action bar that owns
"New blank channel", so a fresh install could never create its first channel.
The empty state now offers both create paths (bare-create + ChannelBuilder),
reusing the exact createBlankChannel handler (number = max+1 → 1 on empty,
group "ErsatzTV", default ffmpeg profile).
Regression: App.test.tsx bare-creates from a [] lineup, asserts the POST
payload (number "1") + navigation to /app/edit-channel/{id}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scan queue handler now returns a QueueLibraryScanResult enum
(Queued|NotFound|SyncDisabled|AlreadyScanning) instead of a lying bool;
LibrariesController.ScanLibrary maps them to 202/404/422/409 with ProblemDetails.
Guard the lock->enqueue with the EnqueueWithTraktLock compensating-unlock pattern.
ScannerService now releases every library/collection lock in a finally so a handler
exception can't leak the lock. Plex "Shows" scheduler batch (one lock, two messages)
now has only the trailing SynchronizePlexNetworks carry the single release
(Unlock flag), mirroring the scheduler Trakt tail-token precedent.
Guard the other lock->enqueue producers (Create/UpdateLocalLibrary, UpdateTraktList)
with compensating unlock. SPA drops the PENDING_GRACE_TICKS heuristic now that the
POST reports 202/409/404/422 directly: 202 -> pending+poll, 409 -> reconcile (no
error toast), 404/422 -> surface error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>