21 KiB
ChicoryTV issue-queue handoff (living document)
Paste the prompt below into a fresh session to work the next item. Each session ends by
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
filed backend gap issues #100–#111; a 7–9-way parallel workflow build once exhausted RAM, so
builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
(PR #128), #85 Guide/EPG (PR #129); #62 prerequisites: #65 (PR #130), #64 (PR #133), #63
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); #93 Settings (PR #138) —
first screen through the full design-first workflow; #92 design-sync round-trip verified and
documented (docs/design-sync.md); #90 rebrand (PR #139) — SPA fully presents as
ChicoryTV; v26.4.0 tagged at this merge (first app-change release → prod).
PROCESS (2026-07-06, binding — supersedes 07-05): Claude Code ONLY — Codex is retired
(usage exhausted). Fable is the orchestrator in the main session and is EXPENSIVE — use it
SPARINGLY: delegate implementation to the best-fitting subagent models (haiku for mechanical
churn, sonnet for standard components/tests, opus for judgment-heavy logic/orchestration
code; fable only for the hardest design calls and the final review fork). Reviews stay
multi-lens via subagents (fable correctness fork + cheaper contract/tests lens + a
design-system lens for frontend work), plus a fork verification pass over any fix diff.
Review fixes are applied by fitting subagents, never inline. npm ci in each fresh worktree
before web/ verification. Merges need explicit user consent per PR — NOTE: the permission
classifier requires consent IN-CONVERSATION; the standing consent written here does not
satisfy it, so ask a quick merge question each time (learned #134). Subagents killed by
transient API errors CAN be resumed via SendMessage with their agentId — resume instead of
relaunching (their edits are saved; learned #89). NEW (#93): LIVE-E2E the new screen against
a real fresh local server BEFORE the review lenses — it caught two ship-blockers jsdom
can't see (see Lessons: local-run recipe).
RELEASE CHECKPOINT (standing, added 2026-07-06): prod cutover to the fork is DONE —
prod container ersatztv on bumblebee runs 192.168.1.95:3000/timothy/ersatztv:prod
(= v26.3.1, app-identical to upstream 26.3.0); ersatztv-test tracks :latest (main).
Prod only advances on v* tags. v26.4.0 TAGGED 2026-07-07 on 65b1a5e3 (the #90 merge,
user-consented) — first app-change release; prod image = full API + all SPA screens +
ChicoryTV branding. At future milestone merges, flag the user for the NEXT tag
(v26.4.1/v26.5.0 — #91 cutover is the obvious next tag point). Tagging needs explicit user
consent; NEVER [skip ci] a commit you'll tag.
Session state (2026-07-07, post-#90): main = 65b1a5e3 (PR #139 merged, tagged
v26.4.0). #90 rebrand landed: inventory showed most SPA naming was ALREADY ChicoryTV from
earlier screen work; the actual gaps were (a) NO favicon at all → web/public/favicon.svg
(byte-copy of design-system/assets/chicorytv-icon.svg; Vite rebases the absolute
/favicon.svg href to /app/favicon.svg at build — verified in built output, served by
Startup.cs static files), (b) no description/theme-color meta (theme-color = #12100E =
--ctv-bg, NOT the icon's #171A21), (c) sidebar used the dark-square chicorytv-icon.svg
→ switched to the canonical transparent chicory-mark.svg per the design-system lockup
(brand-logo.card.html / admin template Shell.jsx), (d) Channel Builder default group
'ErsatzTV'→'ChicoryTV' (+2 test assertions). Backend/Blazor strings untouched. Remaining
intentional "Ersatz" in web/: vite.config outDir path + one backend-file code comment
(App.tsx). Review: single fable fork (ship; it live-verified the favicon path by building).
Baselines unchanged: ErsatzTV.Tests 495, Core.Tests 493 (+1 skip), web tests 145.
Worktree .worktrees/issue-90-rebrand now sits on main (used for the doc commit) — remove it
next session. Main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
Lessons for all remaining prompts (accumulated):
- NEW (#93) — Local live-E2E recipe:
npm run build(outputs to gitignoredErsatzTV/wwwroot/app/), thenln -sfn <worktree>/ErsatzTV/wwwroot/app ErsatzTV/bin/Debug/net10.0/wwwroot/app(Program.cs sets ContentRoot to the ASSEMBLY dir, so the SPA static-file provider reads bin's wwwroot — publish/Docker copy it,dotnet rundoesn't), thenETV_CONFIG_FOLDER=<scratch> ASPNETCORE_URLS=http://127.0.0.1:8409 dotnet run --project ErsatzTV. Fresh DB migrates in seconds; ffmpeg autodetected from PATH. update-openapi.sh FAILS while an instance runs (single-instance mutex) — kill it first. - NEW (#93) — Dapper + Microsoft.Data.Sqlite infers expression columns (COUNT(*)) as BLOB when the result set is EMPTY → incompatible deserializer → 500 on every fresh DB. Prefer EF LINQ GroupBy for aggregates in Api handlers; regression-test the empty-DB path.
- NEW (#93) — Screen hooks must TIER their loads: the screen's own resources gate loading/error; reference data (pickers, sources, health, version) settles per-resource (allSettled) with inline "Couldn't load X" notes. Precedent: settings.ts. Also: render the error branch BEFORE the loading branch — a draft-null loading guard ahead of the error check made the error state unreachable (infinite spinner).
- NEW (#93) — ApiResults maps ONLY NotFoundError→404; plain BaseError→422. Handlers that
collapse "missing" and "invalid state" into one query filter can't 404 — split the lookup
(precedent: DeleteCustomResolutionHandler). Request DTOs in Controllers/Api/Requests
deliberately have NO
#nullable enable(only RESPONSE DTOs get it). Startup's UseStringEnumSchemas registers non-Core enums (OutputFormatKind, LogEventLevel) INDIVIDUALLY — assembly-wide reflection over ErsatzTV.FFmpeg throws ReflectionTypeLoadException (optional NvEncSharp natives). Core↔Application enum twins bridge via exhaustive switch expressions, never int casts. - NEW (#93) —
npm run check:apidiff-guards v1.d.ts against the LAST COMMIT — it fails mid-branch after a backend OpenAPI change until the regenerated file is committed; regen-idempotence (running generate:api twice → no diff) is the real sync check. - NEW (#93) — OSV advisories can break ALL CI overnight: NuGetAudit + warnings-as-errors turns a fresh critical advisory into NU1904 restore failures on every branch. Fix = a one-line central bump PR straight off main, merged before feature PRs (PR #137, Scriban 6→7 validated by both suites + unchanged XMLTV goldens).
- NEW (#89) — Dialog/portal components: key open-effects on
[open]ONLY and read callbacks through a latest-ref; an effect depending on an inlineonClosere-runs (and re-focuses) on every parent render — the focus-steal makes dialog inputs untypeable, and jsdom tests can't catch it (fireEvent.change needs no focus). - NEW (#89) — before offering a "None"/clear affordance for any field the backend resolves
with
x ?? fallback, check whether null actually MEANS clear — for from-lineup advanced overrides null = INHERIT (see #135), so honest UI is "Inherit from template", not "None". - NEW (#89) —
<label onClick={...}>wrapping a labelable control (button/input) double-fires in real browsers (label activation forwarding + bubble); jsdom does not emulate it, tests stay green. Use a<div>row with the control as the single accessible element. - NEW (#89) —
/api/library/browsemediaTypeis single-valued: a Collections-style picker needs 5 typed parallel calls (Collection/Smart/Multi/Rerun/Playlist) merged client-side. ApiResults 422 title is ALWAYS "Validation failed" — fixtures must not invent titles. - Multiple Dynamic-start Flood schedule items are NON-VIABLE (#134): PlayoutModeSchedulerFlood
only yields to a next item with StartType.Fixed (
PlayoutModeSchedulerFlood.cs:50-53) and never advances on the hard stop — an ordered multi-source lineup must be ONE generatedIsSystemPlaylist (PlayAll=true per entry, entries in Index order) behind a single Flood item. PlaylistItem supports Movie/Show/Season/Artist/Collection/Smart/Multi but has NO RerunCollectionId and NO nested-playlist support (CollectionKey.ForPlaylistItem + MediaCollectionRepository.GetPlaylistItemMap are the two switches that define support). - Validation must see the SAME data the build path uses (#134): normalizing on a
with {}copy inside the validator let raw request values reach persistence (FK violation → opaque 422). Normalize the whole input once up front; both validation and build consume the normalized form. - Any handler that SYNTHESIZES names into unique-indexed columns needs de-collision (" 2", " 3"…, max-length-safe) — deleting a channel doesn't cascade its generated schedule/playlist, so recreate-after-delete is a routine path, not an edge case (#134).
SelectOneAsyncre-applies.OrderBy(keySelector)INTERNALLY, which REPLACES any ordering the caller composed before it (#133) — never pre-OrderByinto SelectOneAsync; write the explicit.Where(...).OrderBy(...).FirstOrDefaultAsync(...)when ordering matters.- Normalize user input ONCE (#133): validate uniqueness/lengths against the SAME normalized (e.g. trimmed) value you persist, or a whitespace variant slips past validation and dies on the unique index as an unhandled 500.
- Application command/query records + handlers live in
<Domain>/Commands/and<Domain>/Queries/subfolders (contributing §2); namespace staysErsatzTV.Application.<Domain>regardless of subfolder (#133). - Deferral wording must ENUMERATE what is deferred (#130): "aggregate collection metadata is deferred" quietly swallowed manual collections, which are a cheap direct join — the review had to split the deferral. Cheap-vs-expensive is per collection kind, not per feature.
- Merged-source paging pattern (#130): Lucene supplies media ids+total, EF supplies
collection-likes; page = media first, then a skip cascade through each collection type
(
remainingSkip/takethreading). Stale Lucene entries can drift collection paging for a scan window — accepted, documented in-code. Any similar dual-source endpoint should copy the GetLibraryBrowseItemsHandler pattern AND its multi-type-overflow paging test. - User text into BOTH Lucene and SQL needs per-side treatment (#130): raw query text is the
established Lucene idiom (parser falls back to escaped-literal on ParseException — malformed
input degrades to empty/literal results, never throws), but the same text in EF LIKE needs
%/_/escape-char escaping or semantics diverge between the two halves. - Direct
*MetadataDbSet queries need a deterministic winner (#130): items can carry >1 metadata row; either go through the navigation + HeadOrNone() idiom or GroupBy(itemId).OrderBy(Id).First(). - NULL FIELDS ARE OMITTED ON THE WIRE (#129): Startup.cs sets Newtonsoft
NullValueHandling.Ignoreglobally, so any null DTO property is ABSENT from the JSON →undefinedin the browser, even though generated types say| null. Frontend guards must use truthiness (!x), NEVER=== null; fixtures for null cases must OMIT the key (test precedent: "renders the Guide screen when an on-air channel omits nowPlaying"). - Cross-endpoint correlation needs shared ids (#129): /api/guide titles are show-only (ChannelGuideMetadata.GetTitle) while /api/channels/state nowPlaying uses GetDisplayTitle ("Show - s01e01 - Ep") — string matching across endpoints can never work for episodes. Live match is now timestamps-only; the real fix is a shared programme/playout-item id on both endpoints (backlog).
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS.
percentis a 0–1 fraction despite its name. Enum-with-None fields are never truthiness-checked. Verify UNITS/scale of numeric wire fields against the producing code, not the field name. - Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; poll while pending∪active nonempty with a grace window; drain grace on persistent errors.
- setState updaters must be PURE — no fetches (#127), no ref mutations (#128); StrictMode double-invokes updaters and the test renderer doesn't, so reviewers must catch it.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback = the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't; #93: handoff said --status-warn-soft, the SPA token is --ctv-warn-soft — Toast precedent).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped.
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based double-submit guards; mutations never refetch the world (#125/#127).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures are BARE 4xx/5xx unless the controller returns ProblemDetails); mount call-counts assert the DELTA across navigation; status-dot state never color-only (StatusDot has a label).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need
#nullable enable; Application has NO nullable context. NSubstitute+ConfigElementKey:Arg.Any<ConfigElementKey>()+<T>.Option<T>.ToNullable()→MatchUnsafe. update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check. Validation.Apply ERASES NotFoundError subtypes (#44 gotcha) — multi-check validation that must 404 stays early-return. - Backlog nits (unfiled): unclamped pageSize (browse is clamped; older endpoints aren't);
PlayoutController Create/Delete lack Name=; heavy GetItems pre-check; >30 MB uploads →
bare 413; artwork content-type trusted (#66); schedule estimator materializes collections
per GET; /api/health TTL cache;
LibraryScanStatusResponseModel.percent0–1 under a percent name; 1 pre-existing --text-faint usage in shell.css. From #129: shared programme/playout-item id on /api/guide + /api/channels/state; extract the duplicated channel-state poll loop into a shared helper; EPG grid re-renders unmemoized on every tick; /api/guide 21-include eager-load untrimmed. From #130: the two manual-collection metadata helpers each fetch CollectionItems (share one fetch); very large manual collections make the browse duration sum heavy. From #134: pre-existing non-system PlaylistGroup named "Channel Lineups" breaks multi-item creates with a generic 422; non-DbUpdateException create failures surface as bare 500. From #89: undefined-vs-null lineup keys in the create body; reduced-motion block lists a now-no-op .ctv-builder-libcard. From #93: enumeration endpoints missing for MPEG-TS scripts / audio language codes / UI cultures (settings fields are free-text meanwhile); media sources have no status/reachability signal in the API (Settings omits the StatusDot); full FFmpeg profile editor screen; edits made to an already-saved group DURING an in-flight save can be overwritten by the returned DTO (narrow race, noted by the verification fork). Filed: #126, #135.
PROMPT — #91: Blazor → SPA cutover (retire old UI)
You are Fable, the ORCHESTRATOR in the main Claude Code session (Codex is retired — Claude Code only). Fable is EXPENSIVE: delegate to fitting subagents (recon → Explore/haiku; mechanical deletions → sonnet; routing/Startup surgery → opus; fable for the hardest design calls + review forks). Read CLAUDE.md and the PROCESS + Lessons sections of this file first.
HARD CONSTRAINTS:
git worktree add .worktrees/issue-91-cutover -b feat/91-cutover origin/main; never touch the main checkout (docs/59-ui-redesign-brief). Also REMOVE the stale merged worktree .worktrees/issue-90-rebrand first (git worktree remove).cd web && npm ci.- Max 2–3 concurrent builds; ONE dotnet build at a time. NEVER set ETV_UPDATE_GOLDENS.
- This issue is BIG (deleting the Blazor UI + MudBlazor + root-route change). STAGE IT: recon may conclude it should split into 2 PRs — (a) root-route flip + legacy redirects, (b) Blazor/MudBlazor removal. Prefer the split; land (a) first — it's small, reversible, and immediately user-visible.
- Full review before each PR (3 lenses: fable correctness fork + contract/tests lens +
design/UX lens for the routing UX) — this is NOT a mechanical issue. Live-E2E per the
#93 recipe (build SPA → symlink wwwroot/app into bin → run fresh server) and manually hit
/,/app/..., a legacy Blazor route,/iptv/*,/api/*, swagger. Merge consent in-conversation per PR.
Task
Issue #91: complete the Blazor→SPA transition. Make the SPA the default UI (root route); redirect legacy Blazor page routes; remove Blazor pages/components + MudBlazor deps once nothing user-facing depends on them; update CLAUDE.md/docs (infra → server-management). Acceptance: no user-facing MudBlazor screens remain; ChicoryTV SPA is THE UI.
Approach notes
- RECON [Explore]: how Startup.cs/Program.cs wire Blazor (MapBlazorHub, fallback page,
/appstatic files + SPA fallback); every Blazor @page route and whether an SPA equivalent exists (screens shipped: dashboard/channels/schedule/playouts/libraries/EPG/ builder/settings); what NON-page infrastructure lives in ErsatzTV/ that must SURVIVE (controllers, SignalR?, background services, OIDC/auth wiring); MudBlazor/BlazorSortable package references; what/iptv,/api, swagger, and the v1.json generator depend on. Decide: is anything still Blazor-ONLY (no SPA equivalent)? If yes → those routes keep a legacy escape hatch (e.g./legacy/...) or the gap gets filed as a blocking sub-issue. - Phase (a): root
/serves/redirects to the SPA; legacy Blazor routes 301 to SPA equivalents (map table from recon); keep Blazor reachable under a legacy prefix ONLY if recon found Blazor-only functionality. Mind deep links + the SPA's own client routing. - Phase (b): delete Pages/Shared/component dirs, drop MudBlazor+BlazorSortable from Directory.Packages.props + csproj, prune Startup DI, _Imports, CSS/static assets. Expect dotnet test fallout in ErsatzTV.Tests (495 baseline) — anything referencing Blazor bits. Goldens must NOT change.
- Docs: CLAUDE.md says "Blazor Server UI (MudBlazor)" + "Keep Blazor pages thin" — update architecture/conventions inline with the PR; docs/contributing.md Blazor sections too. ~/homelab-docs/Docker/ErsatzTV.md mentions the UI → update after merge.
- PRs → main: "feat(web): SPA cutover — root route (#91)" then "feat!: remove Blazor UI (#91)" (closes #91 on the second). Poll CI by head SHA; consent per merge; verify main post-merge runs.
- RELEASE CHECKPOINT: v26.4.0 shipped at #90 (prod now runs the branded SPA at /app, root still Blazor). After #91 lands, flag the user: tag v26.5.0 (next release-seq — the breaking UI swap deserves its own release, not a patch).
- Update THIS handoff: pop #91; next = post-cutover housekeeping (queue item 2). Record PRs + main SHA + new baselines (ErsatzTV.Tests count will DROP with Blazor tests gone — record the new number as baseline). Commit to main. Print the next prompt in a fenced block.
Issue queue (work top-down)
- HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126 (OpenAPI polymorphism) + #135 (advanced clear-to-none) are backend slot-fillers between screens. Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) — cheap batch-merge pass when convenient (note: Scriban already bumped to 7.2.5 by PR #137). MCP PR #76 (#58) still needs its rebase/refresh pass — good parallel track.
- #91 cutover ← PROMPT above (BIG; stage into root-flip PR + Blazor-removal PR; flag v26.5.0 after it lands).
- Post-cutover housekeeping: verify prod/test containers on the tagged image (v26.4.0 built
by run on
65b1a5e3→:prod; watchtower or manual pull on bumblebee — infra side = server-management); dep-PR batch pass; MCP PR #76 refresh; then triage epic #59 remainder (backend rebrand scope, #66/#67 image pipeline, #68). Cross-refs: #66/#67 remain open image-pipeline nice-to-haves; #68 unblocked-independent. Done recently: PR #139 (#90 rebrand — closed 2026-07-07, main65b1a5e3, TAGGED v26.4.0), PR #138 (#93 Settings, first design-first screen), PR #137 (Scriban GHSA CI-unblock), #92 design-sync closed (docs/design-sync.md), PR #136 (#89 Channel Builder).