270 lines
21 KiB
Markdown
270 lines
21 KiB
Markdown
# 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 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 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.percent` 0–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
|
||
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).
|