17 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 library browse (PR #130), #64 channel templates (PR #133), #63 composite create-channel (PR #134) — epic #62 COMPLETE.
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).
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. At every milestone merge, FLAG THE USER: is this slice
worth tagging v26.4.0 (reserved for the first app-change release)? Latest sensible tag
point is #91 (cutover); earlier if a stable API slice should reach prod sooner. Tagging
needs explicit user consent; NEVER [skip ci] a commit you'll tag.
Session state (2026-07-06, post-#63): main = a126970d (PR #134 merged): POST /api/channels/from-lineup (operationId CreateChannelFromLineup) — ONE transaction creates
Channel + ProgramSchedule + schedule item(s) + Classic Playout, stamping the selected
ChannelTemplate's defaults with Advanced overrides winning; events (BuildPlayout Reset,
TimeShiftOnDemandPlayout when PlayoutMode=OnDemand, RefreshChannelList, search refresh)
strictly post-commit. Lineup semantics (redesigned after review — the Codex original used
per-item Dynamic floods, which NEVER advance past item 1): single-item lineup → one Flood
schedule item referencing the target directly (Movie/Show/Season/Artist via MediaItemId;
Collection/SmartCollection/MultiCollection via ids; RerunCollection → RerunFirstRun;
Playlist via PlaylistId), NO generated entities, response playlistId null; multi-item
lineup → ONE generated IsSystem Playlist in system PlaylistGroup "Channel Lineups" (Trakt
precedent), one PlaylistItem per entry in lineup order (PlayAll=true, effective
playbackOrder honored per entry by PlaylistEnumerator), referenced by a single Flood item —
RerunCollection/Playlist entries are 422 in multi-item lineups (PlaylistItem cannot reference
them). MultiCollection restricts playbackOrder to Shuffle/ShuffleInOrder (422 otherwise);
PlayoutSource.Mirror → 422; generated ProgramSchedule/Playlist names de-collide (" 2", " 3"…);
mismatched MediaType↔CollectionType pairs / not-exactly-one typed id → 422 naming the index.
Response: channelId, playlistId?, programScheduleId, playoutId. NO "next free channel
number" suggestion in the response (the #89 AUTO badge computes client-side from GET
/api/channels, or file a tiny follow-up). Review: fable+sonnet lenses → 8 SUBSTANTIAL; opus
subagent reworked (cf36c309), fable fork verified, handler tests 5→21. #63 CLOSED; epic #62
backend contract COMPLETE. Baselines: ErsatzTV.Tests 447, Core.Tests 493 (+1 skip);
web tests 80. Main checkout sits on docs/59-ui-redesign-brief; no stale worktrees.
Lessons for all remaining prompts (accumulated):
- 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).
- 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; no Dialog/Modal component yet (needed by #89); 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. Filed: #126 (OpenAPI polymorphism gap).
PROMPT — #89: ChicoryTV Channel Builder (flagship screen)
You are Fable, the ORCHESTRATOR in the main Claude Code session (Codex is retired — Claude Code only). Fable is EXPENSIVE: delegate implementation to fitting subagents (sonnet for components/tests/wiring, haiku for mechanical churn, opus for the judgment-heavy parts — drag-and-drop lineup state, wizard state machine); reserve fable for the final review fork and hard design calls. Read CLAUDE.md and the PROCESS + Lessons sections of this file first.
HARD CONSTRAINTS:
- Worktree:
git worktree add .worktrees/issue-89-channel-builder -b feat/89-channel-builder origin/main; never touch the main checkout (it sits on docs/59-ui-redesign-brief).cd web && npm cifirst. - Max 2–3 concurrent builds machine-wide; ONE dotnet build at a time (only needed if you touch backend — this issue should be FRONTEND-ONLY; a backend need = stop and reassess).
- NEVER set ETV_UPDATE_GOLDENS.
- Review before PR: parallel subagent lenses (fable correctness fork + sonnet contract/tests
- design-system lens vs the prototype), fixes via subagents, fork verification over the fix diff. Merge needs an in-conversation user consent question.
Task
Issue #89 (read the FULL issue body + its 2026-07-02 audit update): recreate the
library-to-lineup Channel Builder — the 3-column flagship screen replacing the
Channel → Schedule → Items → Playout editor chain. Prototype:
design-system/templates/chicorytv-admin/ChannelBuilder.jsx; spec:
design-system/design_handoff_channel_builder/README.md +
docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md §6. Columns: library
browser (search/filter/poster grid, drag + double-click to add) · reorderable lineup ·
settings rail (name/number with AUTO badge, channel image, shuffle, always-playing,
Channel Template picker, Advanced overrides). All 3 themes. Acceptance: creates a working
channel atomically from a lineup; matches prototype.
Backend contract (all on main, epic #62 complete — see Session state above for full semantics)
GET /api/library/browse(#65, PR #130): search/filter/paged picker items with typed ids, duration/counts, artwork.GET /api/channel-templates+/default(#64, PR #133): template picker; default pre-selected.POST /api/artworkupload (#104, PR #117): image dropzone target (>30 MB = bare 413; content-type trusted — keep client-side checks).POST /api/channels/from-lineup(#63, PR #134): THE create call. UI rules that follow from its semantics: RerunCollection/Playlist picks are fine alone but 422 in multi-item lineups — prevent or clearly surface this at add-time, not at submit; MultiCollection + order other than Shuffle/ShuffleInOrder → 422; playbackOrder applies WITHIN each lineup entry; response returns channelId/playlistId?/programScheduleId/playoutId — navigate to the created channel; 422 messages namelineup[i]— map them back to rows. AUTO channel number: compute client-side from GET /api/channels (no server suggestion).- Advanced-rail pickers: filler presets / watermarks / graphics from #105's list endpoints (PR #113; DB order — client-sort). Languages list endpoint does NOT exist — if the rail needs it, file the follow-up and stub gracefully.
- No Dialog/Modal component exists yet in web/ — build it first (it's a known backlog gap).
Process
- Comment approach on #89 before coding (component breakdown, state shape, which subagents).
- Implement via subagents; comment progress.
- Verify: cd web && npm run typecheck && npm run test && npm run build (baseline 80 tests —
expect +new). Honest tests per Lessons (fixtures = real endpoint output under the real
query; truthiness guards, never
=== null). - Review pass (see HARD CONSTRAINTS), then PR → main: "feat(web): Channel Builder (#89)",
closes #89; poll CI by head SHA; ask the user "merge?"; verify main post-merge run. - Update THIS handoff: pop #89, next = #93 Settings (dependency-free) per the queue; record PR + main SHA + baselines. 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 gap) is a good backend slot-filler between screens. Six Renovate/ dependency PRs are open (#21, #48, #49, #61, #131 security, #132) — cheap batch-merge pass when convenient. MCP PR #76 (#58 read-only server foundation) predates most of the API surface — needs a rebase/refresh pass; good parallel track now that the API has stopped moving (epic #62 complete).
- #89 Channel Builder ← PROMPT above (flagship screen; all deps on main: #63 ✓ #64 ✓ #65 ✓ #104 ✓; first Dialog/Modal component; languages endpoint follow-up from #105 when needed). #66/#67 as #89 demands.
- #93 Settings screen is dependency-free — usable as a frontend interleave if a session needs review turnaround.
- Then: #90 rebrand → #91 cutover (+ tag v26.4.0 at the latest here — see RELEASE
CHECKPOINT note).
Cross-refs: #99 seam landed (PR #121), final wiring open; #68 unblocked (templates stamp at
create time, no live link). Done recently: PR #130 (#65 library browse), PR #133 (#64
channel templates), PR #134 (#63 composite create — closed 2026-07-06, main
a126970d, review-fix deltacf36c309). Backend backlog nits added from #134: pre-existing non-system PlaylistGroup named "Channel Lineups" breaks multi-item creates with a generic 422 (add a pre-check message); non-DbUpdateException create failures surface as bare 500; "next free channel number" suggestion endpoint if client-side AUTO proves clunky.