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
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.
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
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.
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.
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.
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.
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.
- 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>
Review fixes pushed in 1a61b89f0425d62c9afebffc924691e9c25067fc.
Per finding:
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.
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.
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.
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 main2026-07-06 19:33:31 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Closes #64.
Adds a first-class
ChannelTemplatedomain 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
FFmpegProfileIdWatermarkIdBuilt-ins and default
IsSystemtemplates:StandardMusic videosDbInitializerdefault-data precedent and runs on upgrades without clobbering existing template rows.ConfigElementKey.ChannelTemplatesDefaultTemplateId.Endpoints
/api/channel-templatesGetChannelTemplates/api/channel-templates/defaultGetDefaultChannelTemplate/api/channel-templates/default/{id:int}SetDefaultChannelTemplate/api/channel-templates/{id:int}GetChannelTemplateById/api/channel-templatesCreateChannelTemplate/api/channel-templates/{id:int}UpdateChannelTemplate/api/channel-templates/{id:int}DeleteChannelTemplateUses the REST foundation patterns: request DTOs,
ApiResults, pre-checks returningNotFoundError, and OpenAPI error-response contract coverage.Migrations
Generated through EF for both providers after running the repository migration flow/build-once scaffold path:
20260706155532_AddChannelTemplates20260706155538_AddChannelTemplatesA startup-project design-time
TvContextDesignTimeFactorywas 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— passedTZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj— passed, 417 testsTZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj— passed, 493 passed / 1 skippeddotnet build --configuration Release --no-restore— passeddotnet ef migrations has-pending-model-changes --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite— passedETV_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— passeddotnet ef migrations has-pending-model-changes --no-build --configuration Release --context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql— passedcd web && npm ci && npm run generate:api && npm run typecheck— passedFollow-up notes
Code review
Found 3 issues:
request.NamewhileApplyTopersistsName.Trim()— creating/updating"Standard "when"Standard"exists passes validation, thenSaveChangesAsynchits the unique indexIX_ChannelTemplate_Nameand throws an unhandledDbUpdateException(a 500 instead of the documented 422). Confirmed empirically with a probe test. Fix: compare againstrequest.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
.OrderBy(t => t.Name)inGetDefaultChannelTemplateHandler's missing-config fallback is dead code:SelectOneAsyncinternally re-applies.OrderBy(keySelector)(heret => t.Id), and a secondOrderByreplaces the first in LINQ. The fallback actually returns the lowest-Id system template, which only equals "Standard" because of the current seed order inDbInitializer. Either drop theOrderBy(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)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 theCommands//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:
ValidateCommonuses sequential early-returnOption<BaseError>instead of contributing.md §2'sValidation<BaseError, T>+.Apply(...)accumulation (clients see one validation error at a time; possibly deliberate to preserveNotFoundErrorsubtypes);GetDefaultTemplateIdis duplicated verbatim across 5 handlers; the newChannelTemplatesDefaultTemplateIdkey is spliced into thepages.*group inConfigElementKey.cs.🤖 Generated with Claude Code
- If this code review was useful, please react with 👍. Otherwise, react with 👎.
Review fixes pushed in
1a61b89f0425d62c9afebffc924691e9c25067fc.Per finding:
Trim/uniqueness mismatch: Done.
ValidateCommonnow normalizes the name once withNormalizeName(...)and uses that value for both blank/length checks and duplicate-name lookup;ApplyTopersists the same normalized value. Added regression coverage for creating"Standard "when"Standard"exists, returning a cleanBaseErrorinstead of throwing.Default-template fallback ordering: Done. The missing-config fallback now uses
.Where(t => t.IsSystem).OrderBy(t => t.Name).FirstOrDefaultAsync(...)directly, soSelectOneAsynccannot replace the intended ordering. Added coverage where name order differs from ID order.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 byChannels.Non-blocking cleanups:
GetDefaultTemplateIdduplication: Done. ExtractedChannelTemplateDefault.GetDefaultTemplateId(...)and reused it across handlers.ConfigElementKey.ChannelTemplatesDefaultTemplateIdplacement: Done. Moved it out of thepages.*block and near related FFmpeg/default configuration keys.Validation<BaseError,T>conversion: Deferred intentionally. The current early-return path preservesNotFoundErrorsubtypes for 404 mapping; converting this to tuple.Apply(...)would risk flattening those into generic validation errors, matching the known #44 gotcha.Verification:
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.ErsatzTV/wwwroot/openapi/v1.json,web/src/api/generated/v1.d.ts, or migration files; no OpenAPI/typegen/migration regeneration performed.1a61b89f0425d62c9afebffc924691e9c25067fc: Build & test (.NET) passed; EF migration integrity (SQLite + MySql) passed; image job skipped for pull_request as expected.