Files
ersatztv/docs/handoffs/chicorytv-issue-queue.md
T
timothyandClaude Fable 5 b564545ff7
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
docs: advance ChicoryTV issue queue past #90 (PR #139, tagged v26.4.0); next prompt = #91 cutover
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:20:36 +02:00

270 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 79-way parallel workflow build once exhausted RAM, so
builds are limited to 23 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 gitignored
`ErsatzTV/wwwroot/app/`), then `ln -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 run`
doesn't), then `ETV_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:api` diff-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 inline `onClose` re-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/browse` `mediaType` is 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 generated
`IsSystem` Playlist (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).
- `SelectOneAsync` re-applies `.OrderBy(keySelector)` INTERNALLY, which REPLACES any ordering
the caller composed before it (#133) — never pre-`OrderBy` into 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 stays
`ErsatzTV.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`/`take` threading). 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 `*Metadata` DbSet 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.Ignore` globally, so any null DTO property is ABSENT from the JSON →
`undefined` in 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. `percent` is a 01 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
pendingactive 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.percent` 01 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 23 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
1. RECON [Explore]: how Startup.cs/Program.cs wire Blazor (MapBlazorHub, fallback page,
`/app` static 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.
2. 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.
3. 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.
4. 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.
5. 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.
6. 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).
7. 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)
0. 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.
1. #91 cutover ← PROMPT above (BIG; stage into root-flip PR + Blazor-removal PR; flag
v26.5.0 after it lands).
2. 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, main 65b1a5e3, 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).