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>
Bug 2 (client side): array position becomes the persisted index on the next
PUT-replace, so the schedules editor must ingest strictly by the server-provided
`index` rather than trusting response row order — otherwise a reload + re-save could
silently reshuffle the lineup. Applied at both ingest points (GET load and the
replace response). Pinned by a shuffled-response-order test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1: switching schedules synchronously clears items/baseline/selection/dirty
before setActiveId (applySwitch) and gates every mutation surface on a
successful items load for the CURRENT activeId (itemsLoaded) — a failed items
GET for schedule B can no longer leave B's header over A's dirty draft and PUT
A's lineup into B.
F3: key={selectedItem._key} on ScheduleItemInspector so per-item child state
(PlaylistPicker groupId, SearchPicker query) resets on selection change.
F4: mutate() no-ops and all edit surfaces disable while saving, so edits during
an in-flight Save can't be silently discarded by the Save .then.
F5: create-schedule auto-switch routes through guardedSwitch so a dirty draft
gets the same discard confirm.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After a collection-type change, snap playbackOrder to the new type's first
offered order when the current one is no longer valid (e.g. Collection+Marathon
-> TelevisionShow left a stale 'Marathon' while the native <select> displayed
'Chronological' and Marathon fields stayed visible), and reconcile multipleMode
into the valid set for the new (type, order) state (e.g. CollectionSize
surviving a switch into Playlist). Replaces the narrow MultiCollection/Playlist
special-cases with a general invariant. Adds repro + a from/to-pair invariant
test; updates the Playlist multipleMode test to the corrected behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract ScheduleScreen from App.tsx into screens/SchedulesScreen.tsx +
schedules/ domain folder (itemRules, pickers, inspector, ScheduleForm).
Draft model with explicit Save (single destructive PUT), Discard, dirty
guard (navigationGuard + beforeunload), schedule CRUD, and all Blazor
item fields/gates/resets. Rewrite api/schedules.ts to the flat DTO +
CRUD + languages/filler-by-kind pickers. Live TopBar Add Schedule via a
window CustomEvent. Screen + nav-guard tests; App.test updated for the
extracted screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a "New blank channel" action next to "Add Channel" on ChannelsScreen
(web/src/App.tsx) that POSTs CreateChannelRequest with Blazor's add-mode
defaults (ChannelEditor.razor's else branch) via the new createChannel client,
then navigates to the channel's editor. Distinct from "Add Channel", which
remains the library-to-lineup ChannelBuilder flow and is untouched.
New web/src/api/languages.ts module (getLanguages) plus channels.ts additions
(getMusicVideoCreditsTemplates, getChannelStreamSelectors, createChannel) for the
channel-editor gaps in #212. Each has URL-building tests.
Blazor parity for the remaining #213 conveniences:
- GET /api/logs gains sortField (timestamp|level) and sortDirection
(asc|desc) query params, allow-listed and normalized (unrecognized
values fall back to the pre-existing timestamp-desc default) rather
than rejected with a 422. LogsScreen.tsx renders clickable, sortable
column headers with a chevron direction indicator.
- LogsScreen.tsx now persists the chosen page size to localStorage
(ctv-logs-page-size) and restores it on mount, following the
existing designSystem.ts localStorage-preference pattern. This is a
client-local UI preference, not the Blazor ConfigElement-backed
server setting — see docs/decisions.md.
- TrashScreen.tsx adds a per-kind "See all N ..." affordance that
pages past the 100/kind /api/search cap using the already-paginated
GET /api/library/browse (mediaType + pageNum), appending results
client-side. No new API surface was needed since that endpoint
already supports the paging the trash screen needed.
docs/decisions.md, docs/blazor-route-parity.md, docs/spa-conventions.md
and docs/api-conventions.md updated in this same commit. OpenAPI spec
regenerated (v1.d.ts unchanged: query params aren't part of the
generated components/schemas surface).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blazor-parity gating, option lists and forced-reset transforms for the
schedules editor, exhaustively unit-tested (44 cases). No React/fetch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blazor parity conveniences: BlockPlayoutTroubleshootingScreen now persists the block-history
page-size selector to localStorage (ctv-block-history-page-size, same ctv- namespace as
ctv-theme) and restores it on mount, and gates the per-block History action on block.id >= 0
(mirrors BlockPlayoutTroubleshooting.razor, which hides it for synthesized/virtual blocks).
BlocksScreen and TemplatesScreen list screens gain a client-side name/group search filter box,
matching the filter already present on the troubleshooting blocks list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidebar "Playouts 3" badge on a fresh empty DB was design-mock
scaffolding (badge: 3 hard-coded in the routes array) never wired to
live data; Blazor had no equivalent. Removed the value but kept the
nav-badge mechanism (ScreenRoute.badge, NavItem badge/badgeTone props)
in place since it's a plausible future home for a live warnings count.
The footer "1 failing" chip reported in the same issue is NOT a bug:
summarizeHealth renders live GET /api/health data, and on a fresh
local dev instance the genuinely failing check is FFmpeg Capabilities
(local Homebrew ffmpeg lacks the subtitles/zscale filters that prod's
ffmpeg image has). No code change for that half.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review of #220 found in-grid episode card clicks never
scrolled/highlighted: navigateToPath() (routing.ts) does pushState +
a synthetic popstate, not a real hash change, so the anchor effect's
hashchange-only listener never fired for same-pathname navigation.
Now listens to both hashchange and popstate.
Also: track the last anchor value actually scrolled to so a
refetch/pagination that recreates the items array (anchor unchanged)
doesn't hijack scroll position; document the known CHILD_PAGE_SIZE
deep-link limitation (parity with the Blazor fragment link); and fix
the MediaPosterCard/shell.css comments that described the highlight
ring as "temporary" when only its glow pulse fades, not the ring
itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three PR #222 adversarial-review findings fixed:
1. SearchScreen's `refreshing` derivation compared the last success `state.query`
against the current query even when the query was cleared to empty — `load()`
early-returns on a blank query, so `state` never updates and the "Refreshing…"
cue got stuck forever over the empty-query card. Gate on `hasQuery`.
2. `MediaPosterCard` falls back to `onOpen` whenever `onToggleSelect` is
undefined, so `selectMode && refreshing` (onToggleSelect withheld but onOpen
still derived from `!canSelect`) made a mid-select click navigate away
instead of no-op'ing. Both screens now withhold `onOpen` for the whole of
select mode, not just the "live" part of it.
3. The Select/Done toggle was `disabled={refreshing}`, which also blocked
*exiting* select mode — but exiting only clears selection, it isn't a
mutation against the stale result set. Disable only when entering
(`refreshing && !selectMode`).
Also corrected the "can never get stuck" over-claim in docs/spa-conventions.md
§3a: the param-keyed refreshing derivation is only self-correcting when every
param value actually triggers a fetch; params that suppress fetching (like an
empty search query) must be excluded from the comparison or the whole flag
gated on the same condition.
Tests added: query-cleared-to-empty shows no refreshing cue (both screens'
existing 3 race tests still green); select-mode+refreshing card click neither
selects nor navigates; select toggle disabled only while entering, not exiting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was
in flight (EntityLocker.IsPlayoutLocked); the REST API had no equivalent, so a
client could race an in-flight build with a destructive ExecuteDelete and leave
a half-built playout. After Blazor removal this safety invariant would vanish
entirely (adversarial-reviewer#18 removal gate).
Server:
- Add public ApiResults.ConflictProblem(title, detail) (409, mirrors NotFoundProblem).
- Inject IEntityLocker into PlayoutController; guard every id-keyed mutation
(PUT {id}, PUT .../deco, PUT .../alternate-schedules, PUT .../templates,
POST .../erase-items, POST .../erase-items-and-history, DELETE {id}) → 409
when IsPlayoutLocked(id); add [ProducesResponseType(...409)] to each.
- Guard ChannelController.ResetPlayout the same way after resolving the id.
- reset-all stays 202 (ResetAllPlayoutsHandler already skips locked playouts).
- Stamp IsLocked onto PlayoutListItemResponseModel from IsPlayoutLocked.
SPA:
- Disable Reset/Erase/Erase-and-history/Delete for a locked row + show a
"Building…" Badge; on a 409 surface the error and refresh the list.
Tests: controller-level 409 guard tests (delete/erase/PUT/deco/channel-reset)
+ IsLocked projection test; new OpenAPI contract + metadata 409 rows.
Docs: api-conventions §3a, blazor-route-parity playouts verdict, decisions.md.
Regenerated v1.json + web types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /api/libraries/{id}/scan-show resolved the target show via
GetShowIdByTitle, an EF.Functions.Like "%title%" substring match with
no OrderBy - non-deterministic under duplicate/overlapping titles and
capable of scanning the wrong show. The Blazor UI never had this bug
(it always passed the exact show id); this endpoint shipped days ago
in PR #216 with no external consumers, so the contract break is safe.
BREAKING CHANGE: ScanShowRequest now takes `showId: int` instead of
`showTitle: string`. Replaced ITelevisionRepository.GetShowIdByTitle
with GetShowTitle(libraryId, showId), which also enforces the show
belongs to the given library. LibrariesController.ScanShow now returns
a genuine 404 ProblemDetails (via ApiResults.NotFoundProblem, the
established pre-check pattern from TemplateController.DeleteGroup)
when the show id doesn't exist in that library, then queues
QueueShowScanByLibraryId with the DB-resolved title.
SPA: libraries.ts ScanShowParams.showId replaces showTitle;
MediaDetailScreen.tsx passes show.id. Extended
ApiErrorResponseMetadataTests and OpenApiErrorResponseContractTests
with the new 404 contract for ScanShow. Regenerated v1.json / v1.d.ts
via scripts/update-openapi.sh + npm run generate:api.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Search and Media browse keep the previous successful result set rendered
during a refetch (query on Search; kind/query/page on Media browse) with no
gating, so per-card Add-to, Select/select-mode, the selection action bar, Add
all, and Save-as-smart-collection stayed live over stale, about-to-be-replaced
items. Worst path: SearchScreen.addAll only checked activeRef, so a late
GET /api/search/all-items could open a bulk-add dialog scoped to the previous
query's entire result set.
Key the success state to the request params that produced it and derive a
`refreshing` flag; while refreshing, keep cards visible but disable every
mutation surface, show a "Refreshing…" cue, and dim the grid. Card navigation
stays live. Bind addAll's completion to its query via lastQueryRef so a stale
all-items result is discarded. Same pattern applied to both screens.
Docs: spa-conventions §3a (refreshing/gating pattern) + §8 (temporal-semantics
review checklist); blazor-route-parity search/media-browse verdicts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Episode cards in the SPA search and media-browse screens were inert (mediaDetailPath had no
Episode case, and LibraryBrowseItemResponseModel carried no parent-season id to route with).
- API: add nullable SeasonId to LibraryBrowseItemResponseModel; populate it in
LibraryBrowseItemMapper.GetEpisodes (the single shared hydration site used by both the
library-browse search/browse handler and the season episode drill-in), leave it null for
every other kind. Regenerated v1.json + v1.d.ts per docs/api-conventions.md §5.
- SPA: mediaDetailPath now routes Episode items with a seasonId to
/app/media/seasons/{seasonId}#episode-{id} (matching Blazor's Search.razor:241 link), null
otherwise. MediaPosterCard accepts an id/highlighted pair; SeasonDetailScreen's episode grid
gives each card a stable `episode-{id}` anchor and scrolls/highlights it on mount and on
hashchange (deep-link support).
- Tests: GetLibraryBrowseItemsHandlerTests asserts SeasonId is populated for episode drill-in
results and null for other kinds; web tests cover mediaDetailPath's episode cases and the
anchor/scroll/highlight behavior (jsdom scrollIntoView stub).
- Docs: blazor-route-parity.md's episode-browse row and the Search cluster verdict updated —
the standalone SPA episode browse exists and episode cards now navigate, closing the
adversarial-reviewer#18 finding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>