feat(api): composite create-channel endpoint (#63) #134

Merged
timothy merged 2 commits from feat/63-composite-create into main 2026-07-06 21:28:02 +02:00
Owner

Closes #63.

What changed

  • Added POST /api/channels/from-lineup (CreateChannelFromLineup) for the Channel Builder flow.
  • Request shape: channel basics + templateId + optional advanced overrides + ordered lineup items using /api/library/browse typed scheduling ids.
  • Response shape: { channelId, collectionId, programScheduleId, playoutId } for #89-friendly follow-up/navigation.
  • Regenerated ErsatzTV/wwwroot/openapi/v1.json and web/src/api/generated/v1.d.ts.

Accepted lineup kinds

V1 accepts the browse-backed schedulable kinds: Movie, TelevisionShow, TelevisionSeason, Artist, Collection, SmartCollection, MultiCollection, RerunCollection (first-run), and Playlist. It validates exactly one matching typed id per item and checks target existence.

Transaction strategy

The handler owns one TvContext and one EF transaction. It creates Channel + generated lineup Collection + ProgramSchedule + schedule items + classic Playout together, commits once, and only then queues BuildPlayout(..., Reset), RefreshChannelList, and search-target refresh. This avoids chaining existing create commands because those handlers each create their own context and save eagerly.

Template stamping

The selected ChannelTemplate is stamped at create time, not live-linked. Advanced overrides win over template values. Channel technical defaults, schedule defaults, and schedule-item filler defaults are copied into the created entities.

Verification

  • TZ=UTC dotnet build ErsatzTV.sln passed
  • TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-restore passed: 431 tests
  • TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-restore passed: 493 passed, 1 skipped
  • web: npm run generate:api passed
  • web: npm run typecheck passed
  • web: npm run build passed

Known existing warnings: NU1903 advisories for Scriban.Signed and Microsoft.OpenApi.

Closes #63. ## What changed - Added `POST /api/channels/from-lineup` (`CreateChannelFromLineup`) for the Channel Builder flow. - Request shape: channel basics + `templateId` + optional `advanced` overrides + ordered `lineup` items using `/api/library/browse` typed scheduling ids. - Response shape: `{ channelId, collectionId, programScheduleId, playoutId }` for #89-friendly follow-up/navigation. - Regenerated `ErsatzTV/wwwroot/openapi/v1.json` and `web/src/api/generated/v1.d.ts`. ## Accepted lineup kinds V1 accepts the browse-backed schedulable kinds: Movie, TelevisionShow, TelevisionSeason, Artist, Collection, SmartCollection, MultiCollection, RerunCollection (first-run), and Playlist. It validates exactly one matching typed id per item and checks target existence. ## Transaction strategy The handler owns one `TvContext` and one EF transaction. It creates Channel + generated lineup Collection + ProgramSchedule + schedule items + classic Playout together, commits once, and only then queues `BuildPlayout(..., Reset)`, `RefreshChannelList`, and search-target refresh. This avoids chaining existing create commands because those handlers each create their own context and save eagerly. ## Template stamping The selected `ChannelTemplate` is stamped at create time, not live-linked. Advanced overrides win over template values. Channel technical defaults, schedule defaults, and schedule-item filler defaults are copied into the created entities. ## Verification - `TZ=UTC dotnet build ErsatzTV.sln` passed - `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-restore` passed: 431 tests - `TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-restore` passed: 493 passed, 1 skipped - `web: npm run generate:api` passed - `web: npm run typecheck` passed - `web: npm run build` passed Known existing warnings: NU1903 advisories for Scriban.Signed and Microsoft.OpenApi.
timothy added 1 commit 2026-07-06 20:27:21 +02:00
feat(api): composite create-channel endpoint (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
6857a0d191
timothy added 1 commit 2026-07-06 21:21:21 +02:00
fix(api): rework from-lineup to generated playlist design + review fixes (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
cf36c30997
Adversarial review found the previous multi-flood design non-viable:
PlayoutModeSchedulerFlood never yields to a following Dynamic-start
schedule item, so only the first item ever played, and grouping
media items into a Collection silently dropped the requested order.

Redesign:
- Single-item lineup: one ProgramScheduleItemFlood referencing the
  target directly (media item / collection / smart / multi / rerun /
  playlist); no generated collection or playlist. Response PlaylistId
  is null.
- Multi-item lineup (>= 2): one generated IsSystem Playlist in a
  get-or-created IsSystem PlaylistGroup ("Channel Lineups"), one
  PlaylistItem per entry in lineup order with PlayAll=true, referenced
  by a single Flood schedule item. Rerun collections and playlists are
  rejected (422) in multi lineups (PlaylistItem/CollectionKey lack the
  fields to enumerate them).

Review fixes:
- Normalize + strict-validate MediaType<->CollectionType pairs once up
  front (422 on mismatch / wrong id / not exactly one id).
- Reject MultiCollection with non-Shuffle order (mirrors
  PlayoutModeMustBeValid), Mirror playout source, all via 422.
- OnDemand parity: queue TimeShiftOnDemandPlayout post-commit.
- De-collide generated ProgramSchedule and Playlist names against their
  unique indexes instead of leaking a UNIQUE-constraint DbUpdateException.
- Generic 422 on save failure + ILogger; AnyAsync existence checks;
  Either/Validation unwrap via Match; XML doc on request DTO + endpoint.
- Response model: ChannelId, PlaylistId (nullable), ProgramScheduleId,
  PlayoutId (CollectionId removed). Regenerated OpenAPI v1.json + v1.d.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Fable review + in-session fix (Codex out of usage)

Two parallel review lenses (fable correctness + sonnet contract/tests) on head 6857a0d1 returned needs-work: the core multi-item lineup design was non-viable — every schedule item was a Dynamic-start ProgramScheduleItemFlood, and PlayoutModeSchedulerFlood never yields to a following Dynamic item (PlayoutModeSchedulerFlood.cs:50-53,128-129,163-177), so everything after lineup item 1 never played. Plus 7 more substantial findings: RerunCollection normalized only on a local copy in validation (build trusted the raw request → FK DbUpdateException reachable), advanced.playbackOrder inert for most kinds, MultiCollection bypassing PlayoutModeMustBeValid, missing OnDemand TimeShiftOnDemandPlayout, PlayoutSource.Mirror unhandled, recreate-after-delete leaking a raw UNIQUE-constraint message, and an orphan empty collection for all-collection-like lineups. Atomicity, 404/422 mapping, OpenAPI sync, nullable conventions, and channel-validation parity all checked out.

Fix pushed as cf36c309 (opus implementer subagent, fable fork verification pass — approved):

  • Single-item lineup: one Flood schedule item referencing the target directly (media via MediaItemId, RerunCollection → RerunFirstRun, Collection/Smart/Multi/Playlist via ids). No generated entities; response playlistId null.
  • Multi-item lineup: ONE generated IsSystem Playlist in a get-or-created system PlaylistGroup "Channel Lineups" (Trakt-lists precedent), one PlaylistItem per entry in lineup order with PlayAll=true + effective playbackOrder (genuinely honored by PlaylistEnumerator), referenced by a single Flood item. RerunCollection/Playlist entries rejected with 422 in multi-item lineups (PlaylistItem cannot reference them).
  • Lineup normalized ONCE (strict MediaType↔CollectionType pairs, 422 naming the index); MultiCollection + non-Shuffle/ShuffleInOrder → 422; OnDemand parity post-commit; Mirror → 422; name de-collision (" 2", " 3"…) for ProgramSchedule + Playlist; generated Collection removed entirely; response model collectionId → nullable playlistId.
  • Nits: Either unwrap idiom, stable 422 message + ILogger (no provider internals leaked), AnyAsync existence checks, XML docs, endpoint description states the real semantics.
  • Tests 5 → 21 on the handler (override-beats-template with differing values, multi-item ordering/PlayAll, RerunCollection paths, recreate-after-delete, OnDemand queue order, Mirror/MultiCollection 422s, rollback kept). ErsatzTV.Tests 447 green, Core.Tests 493+1skip, OpenAPI regen zero-diff, web typegen/typecheck/build green.

Backlog nit (LOW, not blocking): a pre-existing user-created (non-system) PlaylistGroup named "Channel Lineups" would break multi-item creates with a generic 422 — a specific pre-check message would be kinder.

## Fable review + in-session fix (Codex out of usage) Two parallel review lenses (fable correctness + sonnet contract/tests) on head 6857a0d1 returned **needs-work**: the core multi-item lineup design was non-viable — every schedule item was a Dynamic-start `ProgramScheduleItemFlood`, and `PlayoutModeSchedulerFlood` never yields to a following Dynamic item (`PlayoutModeSchedulerFlood.cs:50-53,128-129,163-177`), so everything after lineup item 1 never played. Plus 7 more substantial findings: RerunCollection normalized only on a local copy in validation (build trusted the raw request → FK `DbUpdateException` reachable), `advanced.playbackOrder` inert for most kinds, MultiCollection bypassing `PlayoutModeMustBeValid`, missing OnDemand `TimeShiftOnDemandPlayout`, `PlayoutSource.Mirror` unhandled, recreate-after-delete leaking a raw UNIQUE-constraint message, and an orphan empty collection for all-collection-like lineups. Atomicity, 404/422 mapping, OpenAPI sync, nullable conventions, and channel-validation parity all checked out. **Fix pushed as cf36c309** (opus implementer subagent, fable fork verification pass — approved): - **Single-item lineup**: one Flood schedule item referencing the target directly (media via `MediaItemId`, RerunCollection → `RerunFirstRun`, Collection/Smart/Multi/Playlist via ids). No generated entities; response `playlistId` null. - **Multi-item lineup**: ONE generated `IsSystem` Playlist in a get-or-created system PlaylistGroup "Channel Lineups" (Trakt-lists precedent), one `PlaylistItem` per entry in lineup order with `PlayAll=true` + effective `playbackOrder` (genuinely honored by `PlaylistEnumerator`), referenced by a single Flood item. RerunCollection/Playlist entries rejected with 422 in multi-item lineups (`PlaylistItem` cannot reference them). - Lineup normalized ONCE (strict MediaType↔CollectionType pairs, 422 naming the index); MultiCollection + non-Shuffle/ShuffleInOrder → 422; OnDemand parity post-commit; Mirror → 422; name de-collision (" 2", " 3"…) for ProgramSchedule + Playlist; generated Collection removed entirely; response model `collectionId` → nullable `playlistId`. - Nits: Either unwrap idiom, stable 422 message + `ILogger` (no provider internals leaked), `AnyAsync` existence checks, XML docs, endpoint description states the real semantics. - Tests 5 → 21 on the handler (override-beats-template with differing values, multi-item ordering/PlayAll, RerunCollection paths, recreate-after-delete, OnDemand queue order, Mirror/MultiCollection 422s, rollback kept). ErsatzTV.Tests 447 green, Core.Tests 493+1skip, OpenAPI regen zero-diff, web typegen/typecheck/build green. Backlog nit (LOW, not blocking): a pre-existing user-created (non-system) PlaylistGroup named "Channel Lineups" would break multi-item creates with a generic 422 — a specific pre-check message would be kinder.
timothy merged commit a126970dcd into main 2026-07-06 21:28:02 +02:00
Sign in to join this conversation.