Integrates the six remaining review-gate PRs (#223, #224, #225, #226, #227, #228) in one CI round after #222 merged — they conflicted pairwise on docs (blazor-route-parity.md, decisions.md), resolved here with the planned #227/#228 reconciliation (all #213 items declared closed, stale gap-cells swept). OpenAPI regenerated post-merge: zero diff (auto-merge was consistent).
Integrates the six remaining review-gate PRs (#223, #224, #225, #226, #227, #228) in one CI round after #222 merged — they conflicted pairwise on docs (blazor-route-parity.md, decisions.md), resolved here with the planned #227/#228 reconciliation (all #213 items declared closed, stale gap-cells swept). OpenAPI regenerated post-merge: zero diff (auto-merge was consistent).
Closes #220, closes #215, closes #217, closes #219, closes #218, closes #213.
Verification on the integrated tree: ErsatzTV.Tests 1024/1024 (TZ=UTC), web 504/504 + tsc + check:api clean.
Individual PRs will auto-mark merged when this lands (their heads become reachable from main).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
AddItemsToPlaylistHandler only validated existence for movies/shows/
seasons/episodes, leaving artist/music-video/other-video/song/image/
remote-stream ids unchecked (silently accepted, or in RemoteStream's
case silently dropped entirely - the apply dictionary never included
CollectionType.RemoteStream). Mirror AddItemsToCollectionHandler's
established pattern: add RemoteStream to the apply dictionary, and add
an aggregate existence check (ValidateMediaItems/GetRequestedMediaItemIds)
across all ten kinds against dbContext.MediaItems.
Add ErsatzTV.Tests/Application/MediaCollections/PlaylistHandlerTests.cs
covering: a bogus id of each of the ten kinds fails validation; a valid
RemoteStream id is actually persisted to the playlist (regression test
for the drop bug).
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>
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>
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>
Adversarial review finding 1 on PR #225: decisions.md and api-conventions §3a
read as if the race were eliminated; the guard only narrows it (a queued build
can acquire the lock after the check passes). Also records why true lock
acquisition per mutation was not taken.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document the on-disk media + direct-SQLite LibraryPath + scan recipe for
E2E, since a local library is not API-seedable. Capture two gotchas hit
while verifying the episode-nav PR: deleting search-index/ leaves search
permanently empty (migration doesn't reindex from DB; rescan skips
unchanged files), and /api/search needs field/wildcard queries
(title:Alpha), not bare title words.
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>
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>
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>
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.
Integrates the six remaining review-gate PRs (#223, #224, #225, #226, #227, #228) in one CI round after #222 merged — they conflicted pairwise on docs (blazor-route-parity.md, decisions.md), resolved here with the planned #227/#228 reconciliation (all #213 items declared closed, stale gap-cells swept). OpenAPI regenerated post-merge: zero diff (auto-merge was consistent).
Closes #220, closes #215, closes #217, closes #219, closes #218, closes #213.
Verification on the integrated tree: ErsatzTV.Tests 1024/1024 (TZ=UTC), web 504/504 + tsc + check:api clean.
Individual PRs will auto-mark merged when this lands (their heads become reachable from main).
🤖 Generated with Claude Code
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>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>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>