fix(135): from-lineup advanced overrides can express "clear to none"
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m44s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

CreateChannelFromLineupHandler resolved every advanced override with
advanced.X ?? template.X, so null always meant INHERIT and a channel could
not drop a template-set watermark / filler / preferred language. Add an
optional typed `clear` enum list to CreateChannelFromLineupAdvancedOptions:
omitted/null still inherits (byte-stable for existing clients), a field named
in `clear` is forced to none. Set+clear of the same field is a 422.

The enum (CreateChannelFromLineupClearField) lives in ErsatzTV.Core so the
OpenAPI string-enum scan renders it as a string enum, matching every sibling
advanced-options enum. Handler resolves clearable fields once via
ResolveClearable and validates set/clear conflicts via ValidateClear;
reference validation skips existence checks for cleared (null) refs.

SPA: the shared advancedOptions model re-adds a real "None" option to the five
id selects (watermark + fillers) in both the Channel Builder and the Auto-Tune
DetailPanel, routed through a CLEAR overrides sentinel that applyOverridesToRequest
folds into advanced.clear (never leaking onto the wire as a field value). The
backend enum also covers the preferred audio/subtitle language strings for
machine clients; the SPA text inputs keep "empty = inherit" (tri-state deferred).

Docs: api-conventions.md §2, spa-conventions.md §11, decisions.md record
api.from-lineup-clear-to-none; v1.json + generated TS regenerated.

fixes #135

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:25:58 +02:00
co-authored by Claude Opus 4.8
parent 44d9e47e1c
commit 928784ba48
16 changed files with 482 additions and 67 deletions
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
@@ -43,7 +43,8 @@ public record CreateChannelFromLineupAdvancedOptions(
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
@@ -190,11 +190,21 @@ public class CreateChannelFromLineupHandler(
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
// template sets one; both setting and clearing the same field is contradictory.
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
foreach (BaseError error in clearValidation.LeftToSeq())
{
return error;
}
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
int? fallbackFillerId = resolved.FallbackFillerId;
int? preRollFillerId = resolved.PreRollFillerId;
int? midRollFillerId = resolved.MidRollFillerId;
int? postRollFillerId = resolved.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
@@ -207,8 +217,8 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
ffmpegProfileId,
resolved,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
@@ -272,6 +282,7 @@ public class CreateChannelFromLineupHandler(
request,
template,
advanced,
resolved,
name,
number,
group,
@@ -291,6 +302,7 @@ public class CreateChannelFromLineupHandler(
playbackOrder,
advanced,
template,
resolved,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
@@ -383,6 +395,7 @@ public class CreateChannelFromLineupHandler(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
ResolvedClearableOptions resolved,
string name,
string number,
string group,
@@ -421,16 +434,14 @@ public class CreateChannelFromLineupHandler(
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
WatermarkId = resolved.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
@@ -462,6 +473,7 @@ public class CreateChannelFromLineupHandler(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
ResolvedClearableOptions resolved,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
@@ -478,11 +490,9 @@ public class CreateChannelFromLineupHandler(
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
@@ -526,20 +536,21 @@ public class CreateChannelFromLineupHandler(
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int ffmpegProfileId,
ResolvedClearableOptions resolved,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
// existence check (there is nothing to point at).
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
resolved.WatermarkId,
resolved.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
@@ -548,9 +559,9 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
resolved.PreRollFillerId,
resolved.MidRollFillerId,
resolved.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
@@ -560,6 +571,80 @@ public class CreateChannelFromLineupHandler(
return Unit.Default;
}
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
{
if (advanced.Clear is null || advanced.Clear.Count == 0)
{
return Unit.Default;
}
var cleared = advanced.Clear.ToHashSet();
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
[
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
(CreateChannelFromLineupClearField.PreferredAudioTitle,
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
];
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
{
if (cleared.Contains(field) && hasSetValue)
{
return BaseError.New(
$"Advanced option '{field}' cannot be both set and cleared in the same request");
}
}
return Unit.Default;
}
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
private static ResolvedClearableOptions ResolveClearable(
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template)
{
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
? []
: advanced.Clear.ToHashSet();
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
cleared.Contains(field) ? null : adv ?? tmpl;
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
return new ResolvedClearableOptions(
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
Str(
CreateChannelFromLineupClearField.PreferredAudioLanguage,
advanced.PreferredAudioLanguageCode,
template.PreferredAudioLanguageCode),
Str(
CreateChannelFromLineupClearField.PreferredAudioTitle,
advanced.PreferredAudioTitle,
template.PreferredAudioTitle),
Str(
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
advanced.PreferredSubtitleLanguageCode,
template.PreferredSubtitleLanguageCode));
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
@@ -803,4 +888,16 @@ public class CreateChannelFromLineupHandler(
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
// Effective values for the clearable advanced fields after applying advanced.Clear + template
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
private sealed record ResolvedClearableOptions(
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode);
}
@@ -0,0 +1,20 @@
namespace ErsatzTV.Core.Api.Channels;
// The "clear to none" signal for POST /api/v1/channels/from-lineup (#135). For these
// template-inheritable advanced fields a null/omitted override means INHERIT the template value;
// naming the field here forces it to NONE on the new channel even when the template sets one.
// Omitting the field entirely keeps the historical omitted=inherit behavior stable for existing
// clients. Sending both a set value and a clear for the same field is a validation error (see
// CreateChannelFromLineupHandler). Lives in Core so the OpenAPI string-enum scan (Startup
// UseStringEnumSchemas) renders it as a string enum, matching every sibling advanced-options enum.
public enum CreateChannelFromLineupClearField
{
Watermark,
FallbackFiller,
PreRollFiller,
MidRollFiller,
PostRollFiller,
PreferredAudioLanguage,
PreferredAudioTitle,
PreferredSubtitleLanguage
}
@@ -304,6 +304,110 @@ public class CreateChannelFromLineupHandlerTests
item.MidRollFillerId.ShouldBe(3);
}
[Test]
public async Task Clear_Should_Force_Template_Inherited_Values_To_None()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
// Give the template a watermark + preferred audio language so "clear" has something to drop
// (the base template has fillers 2-5 but no watermark / audio language).
await using (TvContext context = _db.CreateContext())
{
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" });
ChannelTemplate template = await context.ChannelTemplates.SingleAsync();
template.WatermarkId = 21;
template.PreferredAudioLanguageCode = "eng";
await context.SaveChangesAsync();
}
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
Clear:
[
CreateChannelFromLineupClearField.Watermark,
CreateChannelFromLineupClearField.FallbackFiller,
CreateChannelFromLineupClearField.PreRollFiller,
CreateChannelFromLineupClearField.MidRollFiller,
CreateChannelFromLineupClearField.PostRollFiller,
CreateChannelFromLineupClearField.PreferredAudioLanguage
]);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
RightOf(result);
await using TvContext assert = _db.CreateContext();
DomainChannel channel = await assert.Channels.SingleAsync();
// Cleared -> none, even though the template supplies a value.
channel.WatermarkId.ShouldBeNull();
channel.FallbackFillerId.ShouldBeNull();
channel.PreferredAudioLanguageCode.ShouldBe(string.Empty);
ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync();
item.PreRollFillerId.ShouldBeNull();
item.MidRollFillerId.ShouldBeNull();
item.PostRollFillerId.ShouldBeNull();
item.FallbackFillerId.ShouldBeNull();
item.PreferredAudioLanguageCode.ShouldBe(string.Empty);
}
[Test]
public async Task Clear_And_Set_Same_Field_Should_Be_Validation_Error()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.FillerPresets.Add(MakeFiller(6, FillerKind.Fallback));
await context.SaveChangesAsync();
}
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
FallbackFillerId: 6,
Clear: [CreateChannelFromLineupClearField.FallbackFiller]);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("cannot be both set and cleared");
}
[Test]
public async Task Clear_And_Set_String_Field_Conflicts_But_Empty_String_Is_Redundant()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
// A real value + clear on the same string field is contradictory.
var conflicting = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PreferredAudioLanguageCode: "eng",
Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]);
BaseError error = LeftOf(
await MakeHandler().Handle(MakeRequest(advanced: conflicting), CancellationToken.None));
error.Value.ShouldContain("cannot be both set and cleared");
// An empty string + clear is redundant, not conflicting -> the create still succeeds. (This
// runs second: the conflicting request above returned Left without persisting, so number "12"
// is still free.)
var redundant = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PreferredAudioLanguageCode: "",
Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]);
RightOf(await MakeHandler().Handle(MakeRequest(advanced: redundant), CancellationToken.None));
}
[Test]
public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided()
{
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -64,7 +65,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null)
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
List<CreateChannelFromLineupClearField>? Clear = null)
{
public CreateChannelFromLineupAdvancedOptions ToCommand() =>
new(
@@ -91,7 +93,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
FixedStartTimeBehavior,
Clear);
}
public record CreateChannelFromLineupItemRequest(
+22
View File
@@ -24544,9 +24544,31 @@
"$ref": "#/components/schemas/FixedStartTimeBehavior"
}
]
},
"clear": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/CreateChannelFromLineupClearField"
}
}
}
},
"CreateChannelFromLineupClearField": {
"enum": [
"Watermark",
"FallbackFiller",
"PreRollFiller",
"MidRollFiller",
"PostRollFiller",
"PreferredAudioLanguage",
"PreferredAudioTitle",
"PreferredSubtitleLanguage"
],
"type": "string"
},
"CreateChannelFromLineupItemRequest": {
"required": [
"mediaType",
+9
View File
@@ -102,6 +102,15 @@ Exemplars:
(`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL
result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md`
2026-07-19, #431).
- **"Clear to none" vs "inherit" on a coalescing DTO: a typed `clear` enum list, not null.** When a
create/patch DTO resolves a field as `request.X ?? inherited.X` (e.g. `advanced.X ?? template.X`),
`null` already means *inherit*, so it cannot also mean *set to none*. Add an optional `clear` field
typed as a **list of a string enum** naming the fields to force to none — additive, so omitted =
inherit stays byte-stable for existing clients. Validate that a field is not both set and cleared
(reject as 422). **Define the enum in `ErsatzTV.Core`** (not the Application command) so
`Startup.UseStringEnumSchemas` renders it as a string enum in the spec — an Application-layer enum
shows as a bare `integer`. Exemplar: `CreateChannelFromLineupClearField` on
`POST /api/v1/channels/from-lineup` (`decisions.md` 2026-07-21, `api.from-lineup-clear-to-none`, #135).
- **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is
frozen-additive (#286), so when a response field's shape needs to grow, keep the old member
populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar:
+14
View File
@@ -3427,3 +3427,17 @@ leaving the shared tree dirty is the one outcome that would make this script a n
**`UnbalancedReleases` can under-count, and that is documented rather than fixed.** It only increments when a release finds the pool already empty. An over-release while the count is positive — e.g. one cancelling out a coexisting leak — decrements a real-looking slot and is never recorded, so the two bugs hide each other. There are no false positives (non-zero still means the contract broke), but zero does not prove correctness. Exact accounting would need per-owner tokens, which the #536 "ownership is a discipline, not a token" decision deliberately avoids; the docstring now states the limitation instead.
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
## 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)
`key: api.from-lineup-clear-to-none` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error.
**Signals:** clear to none, inherit vs none, advanced override, watermark/filler clear, template-minus-one-setting · paths: `ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs`, `ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs`, `web/src/builder/advancedOptions.tsx` · issues: #135, #89, #385, #386
**Mechanics:** `CreateChannelFromLineupHandler.ResolveClearable`/`ValidateClear`; SPA `applyOverridesToRequest`/`collectClears`/`CLEAR` sentinel; api-conventions.md §2, spa-conventions.md
**Why a `clear` list, not a sentinel or per-field flags.** The gap (found in #89 review) was that the handler resolved every advanced override with `advanced.X ?? template.X`, so a client sending `null` always *inherited*. That is correct for the common path but leaves "this channel should have NO watermark / pre-roll filler even though the template has one" inexpressible. The fix had to keep `omitted = inherit` byte-stable for existing clients (`/api/v1` is frozen-additive, #286), so it is a new optional field, not a reshaping of the existing ones. A `{set, value}` wrapper per field would have rewritten every field's wire type; a reserved `0` sentinel is magic and asymmetric between int ids and strings; parallel `clearX` bools add one field per clearable. A single **typed enum list** is additive, self-documenting, type-checked (an invalid value is a 400 at model binding), covers ids and strings with one mechanism, and extends by adding an enum member. The clearable set is the eight template-inheritable fields where "none" is meaningful: watermark, the four fillers, and the preferred audio/subtitle language + audio title.
**Set + clear of the same field is rejected, not silently resolved.** The SPA never produces that state (a select is inherit, a value, or None), so the check exists to keep hand-crafted / machine-client requests unambiguous rather than picking a winner. A null/empty set value alongside a clear is fine (redundant, not conflicting).
**The enum lives in `ErsatzTV.Core`, not the Application command, on purpose.** The OpenAPI string-enum pass (`Startup.UseStringEnumSchemas`) scans the Core assembly wholesale; an enum defined in `ErsatzTV.Application` renders as a bare `integer` in the spec while every sibling advanced-options enum (`PlaybackOrder`, `ChannelSubtitleMode`, …) is a string enum. Placing `CreateChannelFromLineupClearField` in `ErsatzTV.Core/Api/Channels/` makes the wire contract a string enum by construction, matching its siblings.
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
+1
View File
@@ -12,6 +12,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](../decisions.md#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) |
| `api.channel-health-signal` | Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). | 2026-07-17 | [link](../decisions.md#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) |
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](../decisions.md#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) |
| `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](../decisions.md#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135) |
| `api.healthcheck-remediation-dto` | Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. | 2026-07-17 | [link](../decisions.md#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) |
| `api.healthcheck-ttl-cache` | Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh. | 2026-07-19 | [link](../decisions.md#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431) |
| `api.logs-sort-params` | `GET /api/logs` takes allow-listed `sortField` (`timestamp`\|`level`) and `sortDirection` (`asc`\|`desc`) query params, normalized (not rejected) on an unrecognized value. | 2026-07-11 | [link](../decisions.md#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs) |
+17 -8
View File
@@ -431,14 +431,23 @@ string-keyed dispatcher, or generic screen-action framework participates in norm
`window.confirm` guard belongs on *screen navigation / unload* while any uncommitted edit exists,
not on panel close. Show an "Edited" badge on customised rows so the pending edits are visible.
- **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
The 24-field `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the **INHERIT/omit** field adapters (`useAdvancedOverrides`) —
is one module consumed by both the Channel Builder and the Auto-Tune DetailPanel. The contract
(a select on `INHERIT` or a cleared text input **omits** the field so the create handler coalesces
it with the template value; there is no "None" — #135) must not be re-implemented per screen. Each
screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode` are surfaced as
dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and merged into
`advanced` at create, not carried in the override map.
The `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the field adapters (`useAdvancedOverrides`) — is one module
consumed by both the Channel Builder and the Auto-Tune DetailPanel, and must not be re-implemented
per screen. Each screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode`
are surfaced as dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and
merged into `advanced` at create, not carried in the override map.
- **Three per-field states: INHERIT / set / CLEAR** (#135, `api.from-lineup-clear-to-none`). A select on
`INHERIT` (or a cleared text input) **omits** the field so the create handler coalesces it with the
template value. A concrete value overrides the template. The **`CLEAR` sentinel** ("None") is stored in
the overrides map like any override but is folded into the request's `advanced.clear` list at build
time by `applyOverridesToRequest`/`collectClears` — so the sentinel never reaches the wire as a field
value, and `effectiveValue` reads a `CLEAR` override as none. **Build the request only through
`applyOverridesToRequest`**, never a raw spread of the overrides map (a spread leaks the `CLEAR`
sentinel). Today only the five id selects (watermark + fillers) expose a "None" option; the backend
`clear` enum also covers the preferred audio/subtitle language strings for machine clients, but the SPA
text inputs keep "empty = inherit" (a tri-state text control is deferred) — API-ahead-of-UI, matching
the "REST API is a real audience" posture.
## 12. Reusable rule builder (`web/src/builder/rules/`)
+1
View File
@@ -11,6 +11,7 @@ export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChan
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
export type CreateChannelFromLineupClearField = components['schemas']['CreateChannelFromLineupClearField'];
export type CreateChannelRequest = components['schemas']['CreateChannelRequest'];
export interface ChannelsScreenData {
+2
View File
@@ -347,7 +347,9 @@ export interface components {
"shuffleScheduleItems"?: null | boolean;
"randomStartPoint"?: null | boolean;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
"clear"?: null | Array<components["schemas"]["CreateChannelFromLineupClearField"]>;
};
"CreateChannelFromLineupClearField": "Watermark" | "FallbackFiller" | "PreRollFiller" | "MidRollFiller" | "PostRollFiller" | "PreferredAudioLanguage" | "PreferredAudioTitle" | "PreferredSubtitleLanguage";
"CreateChannelFromLineupItemRequest": {
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
"collectionType": components["schemas"]["CollectionType"];
+8 -9
View File
@@ -76,6 +76,7 @@ import {
} from '../components';
import {
ADVANCED_KEYS,
applyOverridesToRequest,
FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES,
@@ -940,15 +941,13 @@ function ChannelBuilder({
}
}
// advanced null/omitted = inherit template value (handler coalesces with ??);
// clear-to-none is not expressible — see #135. Overrides only ever holds
// real values (inherited fields are absent), so we copy present keys as-is.
const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode };
for (const key of ADVANCED_KEYS) {
if (key in overrides) {
(advanced as Record<string, unknown>)[key] = overrides[key];
}
}
// advanced null/omitted = inherit template value (handler coalesces with ??); a field set to
// the CLEAR sentinel is translated into advanced.clear (force to none) — see #135.
// applyOverridesToRequest copies present set-values and attaches `clear`.
const advanced: CreateChannelFromLineupRequest['advanced'] = applyOverridesToRequest(
{ playbackOrder, playoutMode },
overrides
);
const body: CreateChannelFromLineupRequest = {
name: trimmedName,
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
applyOverridesToRequest,
CLEAR,
collectClears,
effectiveValue,
type Overrides
} from './advancedOptions';
import type { ChannelTemplate } from '../api';
// Minimal template stub: only the fields the assertions read.
const template = {
watermarkId: 21,
preRollFillerId: 2,
preferredAudioLanguageCode: 'eng'
} as unknown as ChannelTemplate;
describe('advancedOptions clear-to-none (#135)', () => {
it('effectiveValue reads a CLEAR override as none, an absent key as the template value', () => {
const overrides: Overrides = { watermarkId: CLEAR, preRollFillerId: 7 };
// Cleared -> null (renders "None").
expect(effectiveValue('watermarkId', template, overrides)).toBeNull();
// Set -> the override value.
expect(effectiveValue('preRollFillerId', template, overrides)).toBe(7);
// Absent -> inherits the template value.
expect(effectiveValue('preferredAudioLanguageCode', template, overrides)).toBe('eng');
});
it('collectClears maps CLEAR-valued clearable keys to their backend enum values', () => {
const overrides: Overrides = {
watermarkId: CLEAR,
fallbackFillerId: CLEAR,
preferredAudioLanguageCode: CLEAR,
preRollFillerId: 7
};
expect(collectClears(overrides).sort()).toEqual(
['FallbackFiller', 'PreferredAudioLanguage', 'Watermark'].sort()
);
});
it('applyOverridesToRequest copies set values and folds CLEAR into advanced.clear (never as a value)', () => {
const overrides: Overrides = { watermarkId: CLEAR, preRollFillerId: 7, midRollFillerId: 3 };
const advanced = applyOverridesToRequest({ playbackOrder: 'Shuffle' as const }, overrides);
// Set values copied through.
expect(advanced).toMatchObject({ playbackOrder: 'Shuffle', preRollFillerId: 7, midRollFillerId: 3 });
// The CLEAR sentinel never leaks onto the wire as a field value.
expect('watermarkId' in advanced).toBe(false);
// It becomes a clear-list entry instead.
expect((advanced as { clear?: string[] }).clear).toEqual(['Watermark']);
});
it('applyOverridesToRequest omits the clear key entirely when nothing is cleared', () => {
const advanced = applyOverridesToRequest({ playbackOrder: 'Chronological' as const }, { preRollFillerId: 7 });
expect('clear' in advanced).toBe(false);
});
it('a non-clearable key holding the CLEAR sentinel is copied through, never silently dropped', () => {
// Only reachable if a user literally typed the sentinel into a non-clearable text input.
const advanced = applyOverridesToRequest({}, { streamSelector: CLEAR });
expect((advanced as { streamSelector?: unknown }).streamSelector).toBe(CLEAR);
expect('clear' in advanced).toBe(false);
});
});
+84 -18
View File
@@ -2,13 +2,19 @@
// a channel template's baseline, used by BOTH the manual Channel Builder
// (ChannelBuilder.tsx) and the Auto-Tune per-channel DetailPanel (AutoTuneScreen.tsx).
//
// The tricky, bug-prone part is the INHERIT/omit contract (see #135): a field left
// on "Inherit from template" is OMITTED from the request entirely (not sent as null),
// because the create handler coalesces a missing value with the template's value and
// the API cannot express "clear to none". Both consumers MUST honour that, so it lives
// here once. Consumers render their own field JSX; only the model + adapters are shared.
// The tricky, bug-prone part is the INHERIT/omit vs CLEAR contract (see #135). Three states
// per advanced field:
// - INHERIT: the field is OMITTED from the request; the create handler coalesces the missing
// value with the template's value (the historical default for every existing client).
// - a set value: sent as-is, overriding the template.
// - CLEAR ("None"): the field is added to `advanced.clear`, forcing it to none on the new
// channel even when the template sets one. Only the id fields (watermark + the four fillers)
// expose a "None" option in the SPA today; the backend `clear` enum also covers the preferred
// audio/subtitle language strings for machine clients (API-ahead-of-UI — see spa-conventions).
// Both consumers MUST honour that, so it lives here once. Consumers render their own field JSX;
// only the model + adapters are shared.
import type { ChannelTemplate } from '../api';
import type { ChannelTemplate, CreateChannelFromLineupClearField } from '../api';
// ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) --------
export const PLAYBACK_ORDERS = [
@@ -93,19 +99,74 @@ export type AdvancedKey = (typeof ADVANCED_KEYS)[number];
export type Overrides = Partial<Record<AdvancedKey, unknown>>;
// The current effective value for an advanced field = user override (if any)
// else the selected template's value.
// Sentinel select value meaning "inherit from the template" — the field is
// omitted from `advanced` entirely (not sent as null). See #135.
export const INHERIT = '__inherit__';
// Sentinel override value meaning "clear to none" — the field is added to the request's
// `advanced.clear` list, forcing it to none even when the template sets one (#135). Stored in the
// overrides map (a clear IS a divergence from the template) and translated at request-build time.
export const CLEAR = '__clear__';
// AdvancedKeys whose "None" maps to a backend clear-field. Only these can be cleared; every other
// key set to CLEAR is ignored by collectClears. The id keys drive the SPA "None" option today; the
// string keys are covered so a machine client (MCP) can clear them via the same enum.
export const CLEARABLE_FIELDS: Partial<Record<AdvancedKey, CreateChannelFromLineupClearField>> = {
watermarkId: 'Watermark',
fallbackFillerId: 'FallbackFiller',
preRollFillerId: 'PreRollFiller',
midRollFillerId: 'MidRollFiller',
postRollFillerId: 'PostRollFiller',
preferredAudioLanguageCode: 'PreferredAudioLanguage',
preferredAudioTitle: 'PreferredAudioTitle',
preferredSubtitleLanguageCode: 'PreferredSubtitleLanguage'
};
// The current effective value for an advanced field = user override (if any) else the selected
// template's value. A CLEAR override reads as none (null), so pickers/previews render "None".
export function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown {
if (key in overrides) {
return overrides[key];
return overrides[key] === CLEAR ? null : overrides[key];
}
return (template as unknown as Record<string, unknown>)[key];
}
// Sentinel select value meaning "inherit from the template" — the field is
// omitted from `advanced` entirely (not sent as null). The API cannot express
// clear-to-none, so there is no "None" choice; see #135.
export const INHERIT = '__inherit__';
// The backend clear-field enum values for every key set to CLEAR in the overrides map.
export function collectClears(overrides: Overrides): CreateChannelFromLineupClearField[] {
const clears: CreateChannelFromLineupClearField[] = [];
for (const key of ADVANCED_KEYS) {
if (overrides[key] === CLEAR) {
const field = CLEARABLE_FIELDS[key];
if (field) {
clears.push(field);
}
}
}
return clears;
}
// Fold an overrides map into an `advanced` request object: copy every set (non-CLEAR) override, then
// attach `clear` for any CLEAR-valued key. Shared by both consumers so the CLEAR sentinel never leaks
// onto the wire as a field value. `base` carries consumer-specific extras (playbackOrder/playoutMode).
export function applyOverridesToRequest<T extends Record<string, unknown>>(base: T, overrides: Overrides): T {
for (const key of ADVANCED_KEYS) {
if (!(key in overrides)) {
continue;
}
// A CLEAR sentinel is folded into `clear` below, but only for clearable fields. Any other key
// holding the sentinel is only reachable if a user literally typed it into a text input — copy
// it through as its value rather than silently dropping the field from the request.
if (overrides[key] === CLEAR && key in CLEARABLE_FIELDS) {
continue;
}
(base as Record<string, unknown>)[key] = overrides[key];
}
const clear = collectClears(overrides);
if (clear.length > 0) {
(base as Record<string, unknown>).clear = clear;
}
return base;
}
// Templates carry no playbackOrder; the Shuffle toggle *is* the template's
// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological).
@@ -146,25 +207,30 @@ export function useAdvancedOverrides(
const templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
// Override selects: the first option is always "Inherit from template" (the
// INHERIT sentinel -> field omitted from the request). There is deliberately
// NO "None" option for filler/watermark ids: the create handler coalesces
// null with the template value, so clear-to-none is not expressible (#135).
// Override selects: the first option is always "Inherit from template" (the INHERIT sentinel ->
// field omitted from the request), followed by an explicit "None" (the CLEAR sentinel -> field
// added to advanced.clear, forcing it to none even when the template sets one) for clearable
// fields, then the concrete rows. "None" is distinct from "Inherit": inherit keeps the template
// value, None drops it (#135).
const idSelectOptions = (key: AdvancedKey, rows: Array<{ id: number; name: string | null }>) => {
const inherited = templateValueOf(key) as number | null;
return [
inheritOption(inherited == null ? null : fillerName(rows, inherited)),
...(key in CLEARABLE_FIELDS ? [{ value: CLEAR, label: 'None' }] : []),
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
];
};
// Selected value for an override select: the override when present, else INHERIT.
// Selected value for an override select: the override when present (CLEAR renders as the "None"
// option since String(CLEAR) === CLEAR), else INHERIT.
const selValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key]) : INHERIT);
const onSelect =
(key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => {
const raw = event.target.value;
if (raw === INHERIT) {
removeOverride(key);
} else if (raw === CLEAR) {
setOverride(key, CLEAR);
} else {
setOverride(key, map(raw));
}
+6 -3
View File
@@ -48,6 +48,7 @@ import {
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import {
applyOverridesToRequest,
FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES,
@@ -325,9 +326,11 @@ export function AutoTuneScreen() {
}
channel.logo = logo;
}
const advanced: Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode } = {
...(override?.advanced ?? {})
};
// applyOverridesToRequest copies set overrides and folds any CLEAR sentinels into
// advanced.clear, so the sentinel never leaks onto the wire as a field value (#135).
const advanced = applyOverridesToRequest<
Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode }
>({}, override?.advanced ?? {});
if (override?.playbackOrder != null) {
advanced.playbackOrder = override.playbackOrder;
}