feat(api): channel templates entity + CRUD (#64) #133

Merged
timothy merged 2 commits from feat/64-channel-templates into main 2026-07-06 19:33:31 +02:00
Owner

Summary

Closes #64.

Adds a first-class ChannelTemplate domain entity plus REST CRUD under /api/channel-templates, default-template selection, built-in system templates, dual-provider EF migrations, OpenAPI regeneration, and generated web API typings.

Entity design

  • Templates compose existing domain objects by reference rather than duplicating values:
    • required FFmpegProfileId
    • optional WatermarkId
    • optional filler preset references for fallback/pre-roll/mid-roll/post-roll
  • The template stores the core technical/behavioral knobs needed by #63 create-channel defaults:
    • stream selector mode/expression and preferred audio fields
    • playout source/mode, streaming mode, transcode mode, idle behavior
    • subtitle language/mode
    • music-video credits mode/template, song-video mode
    • schedule defaults: shuffle schedule items, random start point, fixed-start-time behavior
  • Templates are applied at channel creation time by the future #63 endpoint; channels are not live-linked to templates, so deleting a custom template does not affect existing channels.

Built-ins and default

  • Seeds non-deletable/non-editable IsSystem templates:
    • Standard
    • Music videos
  • Seeding follows existing DbInitializer default-data precedent and runs on upgrades without clobbering existing template rows.
  • Stores the selected default template in ConfigElementKey.ChannelTemplatesDefaultTemplateId.
  • Delete rejects system templates and the currently selected default template.

Endpoints

Method Route Name
GET /api/channel-templates GetChannelTemplates
GET /api/channel-templates/default GetDefaultChannelTemplate
PUT /api/channel-templates/default/{id:int} SetDefaultChannelTemplate
GET /api/channel-templates/{id:int} GetChannelTemplateById
POST /api/channel-templates CreateChannelTemplate
PUT /api/channel-templates/{id:int} UpdateChannelTemplate
DELETE /api/channel-templates/{id:int} DeleteChannelTemplate

Uses the REST foundation patterns: request DTOs, ApiResults, pre-checks returning NotFoundError, and OpenAPI error-response contract coverage.

Migrations

Generated through EF for both providers after running the repository migration flow/build-once scaffold path:

  • SQLite: 20260706155532_AddChannelTemplates
  • MySQL: 20260706155538_AddChannelTemplates

A startup-project design-time TvContextDesignTimeFactory was added so EF tooling uses the provider migration assemblies reliably and honors CI env vars (ETV_CONFIG_FOLDER, MySql__ConnectionString).

Verification

  • TZ=UTC dotnet build ErsatzTV.sln — passed
  • TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj — passed, 417 tests
  • TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj — passed, 493 passed / 1 skipped
  • dotnet build --configuration Release --no-restore — passed
  • SQLite model drift: dotnet ef migrations has-pending-model-changes --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite — passed
  • SQLite fresh DB apply: ETV_CONFIG_FOLDER=$(mktemp -d) ETV_TRANSCODE_FOLDER=$(mktemp -d) dotnet ef database update --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite — passed
  • MySQL model drift: dotnet ef migrations has-pending-model-changes --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql — passed
  • MySQL fresh DB apply — not run locally; no MySQL service or Docker binary available. CI migrations job should cover this.
  • cd web && npm ci && npm run generate:api && npm run typecheck — passed

Follow-up notes

  • #63 can consume this DTO shape by selecting a template plus advanced overrides, then copying values into the new channel/playout/schedule at create time.
  • #68-style per-channel behavior flags are not blocked; templates carry defaults but do not create a live template dependency.
## Summary Closes #64. Adds a first-class `ChannelTemplate` domain entity plus REST CRUD under `/api/channel-templates`, default-template selection, built-in system templates, dual-provider EF migrations, OpenAPI regeneration, and generated web API typings. ## Entity design - Templates compose existing domain objects by reference rather than duplicating values: - required `FFmpegProfileId` - optional `WatermarkId` - optional filler preset references for fallback/pre-roll/mid-roll/post-roll - The template stores the core technical/behavioral knobs needed by #63 create-channel defaults: - stream selector mode/expression and preferred audio fields - playout source/mode, streaming mode, transcode mode, idle behavior - subtitle language/mode - music-video credits mode/template, song-video mode - schedule defaults: shuffle schedule items, random start point, fixed-start-time behavior - Templates are applied at channel creation time by the future #63 endpoint; channels are not live-linked to templates, so deleting a custom template does not affect existing channels. ## Built-ins and default - Seeds non-deletable/non-editable `IsSystem` templates: - `Standard` - `Music videos` - Seeding follows existing `DbInitializer` default-data precedent and runs on upgrades without clobbering existing template rows. - Stores the selected default template in `ConfigElementKey.ChannelTemplatesDefaultTemplateId`. - Delete rejects system templates and the currently selected default template. ## Endpoints | Method | Route | Name | | --- | --- | --- | | GET | `/api/channel-templates` | `GetChannelTemplates` | | GET | `/api/channel-templates/default` | `GetDefaultChannelTemplate` | | PUT | `/api/channel-templates/default/{id:int}` | `SetDefaultChannelTemplate` | | GET | `/api/channel-templates/{id:int}` | `GetChannelTemplateById` | | POST | `/api/channel-templates` | `CreateChannelTemplate` | | PUT | `/api/channel-templates/{id:int}` | `UpdateChannelTemplate` | | DELETE | `/api/channel-templates/{id:int}` | `DeleteChannelTemplate` | Uses the REST foundation patterns: request DTOs, `ApiResults`, pre-checks returning `NotFoundError`, and OpenAPI error-response contract coverage. ## Migrations Generated through EF for both providers after running the repository migration flow/build-once scaffold path: - SQLite: `20260706155532_AddChannelTemplates` - MySQL: `20260706155538_AddChannelTemplates` A startup-project design-time `TvContextDesignTimeFactory` was added so EF tooling uses the provider migration assemblies reliably and honors CI env vars (`ETV_CONFIG_FOLDER`, `MySql__ConnectionString`). ## Verification - `TZ=UTC dotnet build ErsatzTV.sln` — passed - `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj` — passed, 417 tests - `TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj` — passed, 493 passed / 1 skipped - `dotnet build --configuration Release --no-restore` — passed - SQLite model drift: `dotnet ef migrations has-pending-model-changes --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite` — passed - SQLite fresh DB apply: `ETV_CONFIG_FOLDER=$(mktemp -d) ETV_TRANSCODE_FOLDER=$(mktemp -d) dotnet ef database update --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite` — passed - MySQL model drift: `dotnet ef migrations has-pending-model-changes --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql` — passed - MySQL fresh DB apply — not run locally; no MySQL service or Docker binary available. CI migrations job should cover this. - `cd web && npm ci && npm run generate:api && npm run typecheck` — passed ## Follow-up notes - #63 can consume this DTO shape by selecting a template plus advanced overrides, then copying values into the new channel/playout/schedule at create time. - #68-style per-channel behavior flags are not blocked; templates carry defaults but do not create a live template dependency.
timothy added 1 commit 2026-07-06 18:44:22 +02:00
feat(api): add channel templates
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
7858ac002a
Author
Owner

Code review

Found 3 issues:

  1. Duplicate-name validation compares the raw request.Name while ApplyTo persists Name.Trim() — creating/updating "Standard " when "Standard" exists passes validation, then SaveChangesAsync hits the unique index IX_ChannelTemplate_Name and throws an unhandled DbUpdateException (a 500 instead of the documented 422). Confirmed empirically with a probe test. Fix: compare against request.Name.Trim() in the uniqueness check.

http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates/ChannelTemplateCommandBase.cs#L58-L60

http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates/ChannelTemplateCommandBase.cs#L103-L105

  1. The .OrderBy(t => t.Name) in GetDefaultChannelTemplateHandler's missing-config fallback is dead code: SelectOneAsync internally re-applies .OrderBy(keySelector) (here t => t.Id), and a second OrderBy replaces the first in LINQ. The fallback actually returns the lowest-Id system template, which only equals "Standard" because of the current seed order in DbInitializer. Either drop the OrderBy(Name) and rely on Id ordering intentionally, or use .Where(t => t.IsSystem).OrderBy(t => t.Name).FirstOrDefaultAsync(...) directly. The fallback branch is also untested.

http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates/GetDefaultChannelTemplateHandler.cs#L31-L36

(cf. the re-ordering in SelectOneAsync: http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Infrastructure/Extensions/QueryableExtensions.cs#L13-L15)

  1. All 14 command/query records and handlers sit flat in ErsatzTV.Application/ChannelTemplates/ (docs/contributing.md §2 says requests are records "living under <Domain>/Queries/ or <Domain>/Commands/ next to their handler"). Every other Application domain (Channels, FFmpegProfiles, Filler, …) uses the Commands//Queries/ subfolders; only the mapper/DTO belong at the domain root.

http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates

Not blocking, for awareness: ValidateCommon uses sequential early-return Option<BaseError> instead of contributing.md §2's Validation<BaseError, T> + .Apply(...) accumulation (clients see one validation error at a time; possibly deliberate to preserve NotFoundError subtypes); GetDefaultTemplateId is duplicated verbatim across 5 handlers; the new ChannelTemplatesDefaultTemplateId key is spliced into the pages.* group in ConfigElementKey.cs.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

### Code review Found 3 issues: 1. Duplicate-name validation compares the raw `request.Name` while `ApplyTo` persists `Name.Trim()` — creating/updating `"Standard "` when `"Standard"` exists passes validation, then `SaveChangesAsync` hits the unique index `IX_ChannelTemplate_Name` and throws an unhandled `DbUpdateException` (a 500 instead of the documented 422). Confirmed empirically with a probe test. Fix: compare against `request.Name.Trim()` in the uniqueness check. http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates/ChannelTemplateCommandBase.cs#L58-L60 http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates/ChannelTemplateCommandBase.cs#L103-L105 2. The `.OrderBy(t => t.Name)` in `GetDefaultChannelTemplateHandler`'s missing-config fallback is dead code: `SelectOneAsync` internally re-applies `.OrderBy(keySelector)` (here `t => t.Id`), and a second `OrderBy` replaces the first in LINQ. The fallback actually returns the lowest-Id system template, which only equals "Standard" because of the current seed order in `DbInitializer`. Either drop the `OrderBy(Name)` and rely on Id ordering intentionally, or use `.Where(t => t.IsSystem).OrderBy(t => t.Name).FirstOrDefaultAsync(...)` directly. The fallback branch is also untested. http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates/GetDefaultChannelTemplateHandler.cs#L31-L36 (cf. the re-ordering in `SelectOneAsync`: http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Infrastructure/Extensions/QueryableExtensions.cs#L13-L15) 3. All 14 command/query records and handlers sit flat in `ErsatzTV.Application/ChannelTemplates/` (docs/contributing.md §2 says requests are records "living under `<Domain>/Queries/` or `<Domain>/Commands/` next to their handler"). Every other Application domain (Channels, FFmpegProfiles, Filler, …) uses the `Commands/`/`Queries/` subfolders; only the mapper/DTO belong at the domain root. http://192.168.1.95:3000/timothy/ersatztv/src/commit/7858ac002a1c70ab8845e333b871497520a335f0/ErsatzTV.Application/ChannelTemplates Not blocking, for awareness: `ValidateCommon` uses sequential early-return `Option<BaseError>` instead of contributing.md §2's `Validation<BaseError, T>` + `.Apply(...)` accumulation (clients see one validation error at a time; possibly deliberate to preserve `NotFoundError` subtypes); `GetDefaultTemplateId` is duplicated verbatim across 5 handlers; the new `ChannelTemplatesDefaultTemplateId` key is spliced into the `pages.*` group in `ConfigElementKey.cs`. 🤖 Generated with [Claude Code](https://claude.ai/code) <sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
timothy added 1 commit 2026-07-06 19:22:50 +02:00
fix(api): address channel template review
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
1a61b89f04
Author
Owner

Review fixes pushed in 1a61b89f0425d62c9afebffc924691e9c25067fc.

Per finding:

  1. Trim/uniqueness mismatch: Done. ValidateCommon now normalizes the name once with NormalizeName(...) and uses that value for both blank/length checks and duplicate-name lookup; ApplyTo persists the same normalized value. Added regression coverage for creating "Standard " when "Standard" exists, returning a clean BaseError instead of throwing.

  2. Default-template fallback ordering: Done. The missing-config fallback now uses .Where(t => t.IsSystem).OrderBy(t => t.Name).FirstOrDefaultAsync(...) directly, so SelectOneAsync cannot replace the intended ordering. Added coverage where name order differs from ID order.

  3. Folder layout: Done. Moved ChannelTemplate commands/handlers to ErsatzTV.Application/ChannelTemplates/Commands/ and queries/handlers to .../Queries/, preserving the existing domain-root namespace convention used by Channels.

Non-blocking cleanups:

  • GetDefaultTemplateId duplication: Done. Extracted ChannelTemplateDefault.GetDefaultTemplateId(...) and reused it across handlers.
  • ConfigElementKey.ChannelTemplatesDefaultTemplateId placement: Done. Moved it out of the pages.* block and near related FFmpeg/default configuration keys.
  • Validation<BaseError,T> conversion: Deferred intentionally. The current early-return path preserves NotFoundError subtypes for 404 mapping; converting this to tuple .Apply(...) would risk flattening those into generic validation errors, matching the known #44 gotcha.

Verification:

  • Focused: TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~ChannelTemplate" -v minimal — passed, 30 tests.
  • TZ=UTC dotnet build ErsatzTV.sln — passed.
  • TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj — passed, 419 tests.
  • TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj — passed, 493 passed / 1 skipped.
  • API/migration check: no diff in ErsatzTV/wwwroot/openapi/v1.json, web/src/api/generated/v1.d.ts, or migration files; no OpenAPI/typegen/migration regeneration performed.
  • CI run 260 at head 1a61b89f0425d62c9afebffc924691e9c25067fc: Build & test (.NET) passed; EF migration integrity (SQLite + MySql) passed; image job skipped for pull_request as expected.
Review fixes pushed in `1a61b89f0425d62c9afebffc924691e9c25067fc`. Per finding: 1. **Trim/uniqueness mismatch**: Done. `ValidateCommon` now normalizes the name once with `NormalizeName(...)` and uses that value for both blank/length checks and duplicate-name lookup; `ApplyTo` persists the same normalized value. Added regression coverage for creating `"Standard "` when `"Standard"` exists, returning a clean `BaseError` instead of throwing. 2. **Default-template fallback ordering**: Done. The missing-config fallback now uses `.Where(t => t.IsSystem).OrderBy(t => t.Name).FirstOrDefaultAsync(...)` directly, so `SelectOneAsync` cannot replace the intended ordering. Added coverage where name order differs from ID order. 3. **Folder layout**: Done. Moved ChannelTemplate commands/handlers to `ErsatzTV.Application/ChannelTemplates/Commands/` and queries/handlers to `.../Queries/`, preserving the existing domain-root namespace convention used by `Channels`. Non-blocking cleanups: - `GetDefaultTemplateId` duplication: Done. Extracted `ChannelTemplateDefault.GetDefaultTemplateId(...)` and reused it across handlers. - `ConfigElementKey.ChannelTemplatesDefaultTemplateId` placement: Done. Moved it out of the `pages.*` block and near related FFmpeg/default configuration keys. - `Validation<BaseError,T>` conversion: Deferred intentionally. The current early-return path preserves `NotFoundError` subtypes for 404 mapping; converting this to tuple `.Apply(...)` would risk flattening those into generic validation errors, matching the known #44 gotcha. Verification: - Focused: `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~ChannelTemplate" -v minimal` — passed, 30 tests. - `TZ=UTC dotnet build ErsatzTV.sln` — passed. - `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj` — passed, 419 tests. - `TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj` — passed, 493 passed / 1 skipped. - API/migration check: no diff in `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, or migration files; no OpenAPI/typegen/migration regeneration performed. - CI run 260 at head `1a61b89f0425d62c9afebffc924691e9c25067fc`: Build & test (.NET) passed; EF migration integrity (SQLite + MySql) passed; image job skipped for pull_request as expected.
timothy merged commit 4854c45a89 into main 2026-07-06 19:33:31 +02:00
Sign in to join this conversation.