Files
ersatztv/docs/handoffs/chicorytv-issue-queue.md
T
timothyandClaude Fable 5 21ede49253
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
docs: advance ChicoryTV issue queue past #91 phase (a) (PR #148); CLAUDE.md reflects SPA-default UI; next prompt = post-cutover housekeeping + parity kickoff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:21:35 +02:00

21 KiB
Raw Blame History

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); #91 phase (a) root flip (PR #148) — SPA is the default UI; phase (b) Blazor removal blocked on parity #140#147.

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-#91 phase a): main = d04769cc (PR #148 merged). #91 phase (a) LANDED: root / + 12 legacy Blazor routes with SPA equivalents 302 to /app/... via ErsatzTV/LegacyUiRedirects.cs (exact-match map, single source of truth for "what has migrated") + middleware in Startup's blazor branch before UseRouting; query strings

  • ETV_BASE_URL PathBase preserved (302 NOT 301 — deliberate, rollback-safe); docker smoke now asserts /app/ serves ChicoryTV. #91 stays OPEN — phase (b) (delete Blazor/MudBlazor) is BLOCKED on SPA parity: recon found ~55 Blazor-only routes; gaps filed as #140 (collections — /app/collections is a placeholder!), #141 (media browse/search/trash), #142 (trakt), #143 (ffmpeg profiles/filler/watermarks), #144 (blocks/decos/templates + playout detail editors), #145 (logs/troubleshooting), #146 (channel edit + numbers), #147 (SPA escape-hatch link to legacy UI). Blazor home escape hatch = /system/health (deliberately un-redirected). OIDC note (correctness fork): default landing changes from challenged-Blazor to open SPA — no NEW exposure (GET /api/* + /app were already unauthenticated), but SPA auth is a phase-(b) design gap. Baselines: ErsatzTV.Tests 527 (495 + 32 redirect tests), Core.Tests 493 (+1 skip), web tests 145 (web/ untouched this session). CLAUDE.md architecture/conventions updated for the SPA-default reality (Blazor sections of docs/contributing.md left for phase (b)). Worktree .worktrees/issue-91-cutover now sits on main (doc commit); issue-90-rebrand worktree removed. Main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.

Lessons for all remaining prompts (accumulated):

  • NEW (#91) — UsePathBase only rewrites the REQUEST side (Request.Path/PathBase); it never touches redirect Location headers — any Response.Redirect to an absolute path must prepend context.Request.PathBase (precedent: IptvController.cs:56,69,305).
  • NEW (#91) — Blazor's MainLayout has a not-ready gate (MainLayout.razor:391): while the DB/search index initializes, EVERY non-root Blazor page prerender 302s to /. Live-E2E probes must wait for FULL readiness (log line "Done migrating search index"), not just /api/health 200 — probing early produces phantom 302 → / results.
  • NEW (#91) — the local-run host guard (Startup.cs:679) matches Host.StartsWith("localhost"); curling 127.0.0.1:8409 404s everything except IPTV — always curl localhost in the #93 live-E2E recipe.
  • NEW (#91) — SPA channel edit is a DEAD END: the Channels pencil navigates to /app/new-channel?edit={id} but ChannelBuilderScreen never reads edit (noted on #146); PlayoutsScreen has no path to playout creation/detail editors (noted on #144).
  • 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 — Post-cutover housekeeping + parity kickoff

You are Fable, the ORCHESTRATOR in the main Claude Code session (Claude Code only). Fable is EXPENSIVE: delegate to fitting subagents (recon → Explore/haiku; mechanical work → sonnet; judgment-heavy code → opus; fable for the hardest calls + review forks). Read CLAUDE.md and the PROCESS + Lessons sections of this file first.

HARD CONSTRAINTS:

  • Work in a worktree off origin/main (git worktree add .worktrees/<name> -b <branch> origin/main); never touch the main checkout (docs/59-ui-redesign-brief). Remove the now-merged .worktrees/issue-91-cutover worktree first (it sits on main after the doc commit). cd web && npm ci in fresh worktrees before web verification.
  • Max 23 concurrent builds; ONE dotnet build at a time. NEVER set ETV_UPDATE_GOLDENS.
  • Merge consent in-conversation per PR. Live-E2E new screens per the #93 recipe (curl localhost, NOT 127.0.0.1 — host guard; wait for "Done migrating search index").

Task (in order; each item is small — batch several into this session)

  1. RELEASE CHECK: if the user hasn't tagged yet, ask about tagging v26.5.0 on main (root-route flip = the UI swap release; d04769cc or later). Verify prod/test containers on bumblebee after any tag (infra side = server-management).
  2. Dep-PR batch pass: open Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) — check freshness, rebase/retrigger, merge the green ones (consent per PR).
  3. MCP PR #76 (#58): rebase/refresh onto current main (post-cutover); it predates the full API surface.
  4. Then START PARITY (unblocks #91 phase b — work top-down by user value): #147 (SPA escape-hatch link — tiny web/ change, do first), #146 (channel edit dead-end — the Channels pencil sends edit= that ChannelBuilderScreen ignores), then #140 (collections screen — biggest gap, /app/collections is a placeholder).
  5. Update THIS handoff: record what merged (PRs + main SHA + baselines), pop done items, write the next prompt (likely: continue parity queue #140#145). Commit to main. Print the next prompt in a fenced block.

Issue queue (work top-down)

  1. HOUSEKEEPING ← PROMPT above (v26.5.0 tag check; dep PRs #21/#48/#49/#61/#131/#132; MCP PR #76 refresh; #99 stays open for /api/channels/state onAir wiring; #126 + #135 remain backend slot-fillers).
  2. SPA parity for #91 phase (b) — order: #147 (escape hatch, tiny) → #146 (channel edit) → #140 (collections) → #144 (blocks/decos/templates + playout editors) → #143 (ffmpeg profiles/filler/watermarks) → #141 (media browse/search/trash) → #145 (logs/ troubleshooting) → #142 (trakt). Each: SPA screen over existing/gap-filling API, then REMOVE the now-covered routes from Blazor-only status by ADDING them to ErsatzTV/LegacyUiRedirects.cs (the map = the single source of truth for migration).
  3. #91 phase (b): delete Blazor/MudBlazor once #140#146 are covered (recon report is in the 2026-07-07 session; key facts: delete Startup.cs:368-381 service regs + MapBlazorHub/MapFallbackToPage only, KEEP MapControllers/MapOpenApi/MapScalarApiReference /OIDC//callback/AccountController/hosted services; drop MudBlazor+BlazorSortable+ Blazored.FluentValidation+Heron.MudCalendar pkg refs, RequiresAspNetWebAssets, razor NoWarn block, Locals/ resx, wwwroot css/lib Blazor assets; update StartupSpaHostingTests + docs/contributing.md Blazor sections; goldens must NOT change). Closes #91; then flag next release tag. Cross-refs: #66/#67 image-pipeline nice-to-haves; #68 independent; #25 (razor Sonar burn-down) becomes MOOT at phase (b) — close it then. Done recently: PR #148 (#91 phase a root flip — merged 2026-07-07, main d04769cc; #91 stays open for phase b), PR #139 (#90 rebrand, TAGGED v26.4.0), PR #138 (#93 Settings), PR #137 (Scriban GHSA CI-unblock).