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;
using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Api.LibraryBrowse;
@@ -43,7 +43,8 @@ public record CreateChannelFromLineupAdvancedOptions(
ChannelIdleBehavior? IdleBehavior = null, ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null, bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null, bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null); FixedStartTimeBehavior? FixedStartTimeBehavior = null,
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
public record CreateChannelFromLineupItem( public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType, LibraryBrowseMediaType MediaType,
@@ -190,11 +190,21 @@ public class CreateChannelFromLineupHandler(
return new NotFoundError($"Channel template {request.TemplateId} does not exist."); 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 ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId; int? fallbackFillerId = resolved.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId; int? preRollFillerId = resolved.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId; int? midRollFillerId = resolved.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId; int? postRollFillerId = resolved.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological; PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource; ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
@@ -207,8 +217,8 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> referenceValidation = await ValidateReferences( Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext, dbContext,
advanced, ffmpegProfileId,
template, resolved,
cancellationToken); cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq()) foreach (BaseError error in referenceValidation.LeftToSeq())
{ {
@@ -272,6 +282,7 @@ public class CreateChannelFromLineupHandler(
request, request,
template, template,
advanced, advanced,
resolved,
name, name,
number, number,
group, group,
@@ -291,6 +302,7 @@ public class CreateChannelFromLineupHandler(
playbackOrder, playbackOrder,
advanced, advanced,
template, template,
resolved,
fallbackFillerId, fallbackFillerId,
preRollFillerId, preRollFillerId,
midRollFillerId, midRollFillerId,
@@ -383,6 +395,7 @@ public class CreateChannelFromLineupHandler(
CreateChannelFromLineup request, CreateChannelFromLineup request,
ChannelTemplate template, ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced, CreateChannelFromLineupAdvancedOptions advanced,
ResolvedClearableOptions resolved,
string name, string name,
string number, string number,
string group, string group,
@@ -421,16 +434,14 @@ public class CreateChannelFromLineupHandler(
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource, PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode, PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode, StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId, WatermarkId = resolved.WatermarkId,
FallbackFillerId = fallbackFillerId, FallbackFillerId = fallbackFillerId,
Artwork = artwork, Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode, StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty, StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode = PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode, SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode, MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = MusicVideoCreditsTemplate =
@@ -462,6 +473,7 @@ public class CreateChannelFromLineupHandler(
PlaybackOrder playbackOrder, PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced, CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template, ChannelTemplate template,
ResolvedClearableOptions resolved,
int? fallbackFillerId, int? fallbackFillerId,
int? preRollFillerId, int? preRollFillerId,
int? midRollFillerId, int? midRollFillerId,
@@ -478,11 +490,9 @@ public class CreateChannelFromLineupHandler(
MidRollFillerId = midRollFillerId, MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId, PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId, FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode = PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty, PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty, PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
}; };
@@ -526,20 +536,21 @@ public class CreateChannelFromLineupHandler(
private static async Task<Either<BaseError, Unit>> ValidateReferences( private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext, TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced, int ffmpegProfileId,
ChannelTemplate template, ResolvedClearableOptions resolved,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken)) if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{ {
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist."); 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( Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext, dbContext,
advanced.WatermarkId ?? template.WatermarkId, resolved.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId, resolved.FallbackFillerId,
cancellationToken); cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq()) foreach (BaseError error in channelReferences.LeftToSeq())
{ {
@@ -548,9 +559,9 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> itemFillers = await ValidateItemFillers( Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext, dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId, resolved.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId, resolved.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId, resolved.PostRollFillerId,
cancellationToken); cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq()) foreach (BaseError error in itemFillers.LeftToSeq())
{ {
@@ -560,6 +571,80 @@ public class CreateChannelFromLineupHandler(
return Unit.Default; 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( private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext, TvContext dbContext,
int? watermarkId, int? watermarkId,
@@ -803,4 +888,16 @@ public class CreateChannelFromLineupHandler(
Playlist Playlist, Playlist Playlist,
ProgramSchedule ProgramSchedule, ProgramSchedule ProgramSchedule,
Playout Playout); 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); 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] [Test]
public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided() public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided()
{ {
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@@ -64,7 +65,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
ChannelIdleBehavior? IdleBehavior = null, ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null, bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null, bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null) FixedStartTimeBehavior? FixedStartTimeBehavior = null,
List<CreateChannelFromLineupClearField>? Clear = null)
{ {
public CreateChannelFromLineupAdvancedOptions ToCommand() => public CreateChannelFromLineupAdvancedOptions ToCommand() =>
new( new(
@@ -91,7 +93,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
IdleBehavior, IdleBehavior,
ShuffleScheduleItems, ShuffleScheduleItems,
RandomStartPoint, RandomStartPoint,
FixedStartTimeBehavior); FixedStartTimeBehavior,
Clear);
} }
public record CreateChannelFromLineupItemRequest( public record CreateChannelFromLineupItemRequest(
+22
View File
@@ -24544,9 +24544,31 @@
"$ref": "#/components/schemas/FixedStartTimeBehavior" "$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": { "CreateChannelFromLineupItemRequest": {
"required": [ "required": [
"mediaType", "mediaType",
+9
View File
@@ -102,6 +102,15 @@ Exemplars:
(`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL (`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` result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md`
2026-07-19, #431). 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 - **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 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: 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. **`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). **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.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.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.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-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.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) | | `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, `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. 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`). - **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
The 24-field `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs, The `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the **INHERIT/omit** field adapters (`useAdvancedOverrides`) — `ADVANCED_KEYS`, `effectiveValue`, and the field adapters (`useAdvancedOverrides`) — is one module
is one module consumed by both the Channel Builder and the Auto-Tune DetailPanel. The contract consumed by both the Channel Builder and the Auto-Tune DetailPanel, and must not be re-implemented
(a select on `INHERIT` or a cleared text input **omits** the field so the create handler coalesces per screen. Each screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode`
it with the template value; there is no "None" — #135) must not be re-implemented per screen. Each are surfaced as dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and
screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode` are surfaced as merged into `advanced` at create, not carried in the override map.
dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and merged into - **Three per-field states: INHERIT / set / CLEAR** (#135, `api.from-lineup-clear-to-none`). A select on
`advanced` at create, not carried in the override map. `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/`) ## 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 BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest']; export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel']; export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
export type CreateChannelFromLineupClearField = components['schemas']['CreateChannelFromLineupClearField'];
export type CreateChannelRequest = components['schemas']['CreateChannelRequest']; export type CreateChannelRequest = components['schemas']['CreateChannelRequest'];
export interface ChannelsScreenData { export interface ChannelsScreenData {
+2
View File
@@ -347,7 +347,9 @@ export interface components {
"shuffleScheduleItems"?: null | boolean; "shuffleScheduleItems"?: null | boolean;
"randomStartPoint"?: null | boolean; "randomStartPoint"?: null | boolean;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"]; "fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
"clear"?: null | Array<components["schemas"]["CreateChannelFromLineupClearField"]>;
}; };
"CreateChannelFromLineupClearField": "Watermark" | "FallbackFiller" | "PreRollFiller" | "MidRollFiller" | "PostRollFiller" | "PreferredAudioLanguage" | "PreferredAudioTitle" | "PreferredSubtitleLanguage";
"CreateChannelFromLineupItemRequest": { "CreateChannelFromLineupItemRequest": {
"mediaType": components["schemas"]["LibraryBrowseMediaType"]; "mediaType": components["schemas"]["LibraryBrowseMediaType"];
"collectionType": components["schemas"]["CollectionType"]; "collectionType": components["schemas"]["CollectionType"];
+8 -9
View File
@@ -76,6 +76,7 @@ import {
} from '../components'; } from '../components';
import { import {
ADVANCED_KEYS, ADVANCED_KEYS,
applyOverridesToRequest,
FIXED_START_TIME_BEHAVIORS, FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS, IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES, MUSIC_VIDEO_CREDITS_MODES,
@@ -940,15 +941,13 @@ function ChannelBuilder({
} }
} }
// advanced null/omitted = inherit template value (handler coalesces with ??); // advanced null/omitted = inherit template value (handler coalesces with ??); a field set to
// clear-to-none is not expressible — see #135. Overrides only ever holds // the CLEAR sentinel is translated into advanced.clear (force to none) — see #135.
// real values (inherited fields are absent), so we copy present keys as-is. // applyOverridesToRequest copies present set-values and attaches `clear`.
const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode }; const advanced: CreateChannelFromLineupRequest['advanced'] = applyOverridesToRequest(
for (const key of ADVANCED_KEYS) { { playbackOrder, playoutMode },
if (key in overrides) { overrides
(advanced as Record<string, unknown>)[key] = overrides[key]; );
}
}
const body: CreateChannelFromLineupRequest = { const body: CreateChannelFromLineupRequest = {
name: trimmedName, 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 // a channel template's baseline, used by BOTH the manual Channel Builder
// (ChannelBuilder.tsx) and the Auto-Tune per-channel DetailPanel (AutoTuneScreen.tsx). // (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 // The tricky, bug-prone part is the INHERIT/omit vs CLEAR contract (see #135). Three states
// on "Inherit from template" is OMITTED from the request entirely (not sent as null), // per advanced field:
// because the create handler coalesces a missing value with the template's value and // - INHERIT: the field is OMITTED from the request; the create handler coalesces the missing
// the API cannot express "clear to none". Both consumers MUST honour that, so it lives // value with the template's value (the historical default for every existing client).
// here once. Consumers render their own field JSX; only the model + adapters are shared. // - 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) -------- // ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) --------
export const PLAYBACK_ORDERS = [ export const PLAYBACK_ORDERS = [
@@ -93,19 +99,74 @@ export type AdvancedKey = (typeof ADVANCED_KEYS)[number];
export type Overrides = Partial<Record<AdvancedKey, unknown>>; export type Overrides = Partial<Record<AdvancedKey, unknown>>;
// The current effective value for an advanced field = user override (if any) // Sentinel select value meaning "inherit from the template" — the field is
// else the selected template's value. // 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 { export function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown {
if (key in overrides) { if (key in overrides) {
return overrides[key]; return overrides[key] === CLEAR ? null : overrides[key];
} }
return (template as unknown as Record<string, unknown>)[key]; return (template as unknown as Record<string, unknown>)[key];
} }
// Sentinel select value meaning "inherit from the template" — the field is // The backend clear-field enum values for every key set to CLEAR in the overrides map.
// omitted from `advanced` entirely (not sent as null). The API cannot express export function collectClears(overrides: Overrides): CreateChannelFromLineupClearField[] {
// clear-to-none, so there is no "None" choice; see #135. const clears: CreateChannelFromLineupClearField[] = [];
export const INHERIT = '__inherit__'; 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 // Templates carry no playbackOrder; the Shuffle toggle *is* the template's
// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological). // 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]; const templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
// Override selects: the first option is always "Inherit from template" (the // Override selects: the first option is always "Inherit from template" (the INHERIT sentinel ->
// INHERIT sentinel -> field omitted from the request). There is deliberately // field omitted from the request), followed by an explicit "None" (the CLEAR sentinel -> field
// NO "None" option for filler/watermark ids: the create handler coalesces // added to advanced.clear, forcing it to none even when the template sets one) for clearable
// null with the template value, so clear-to-none is not expressible (#135). // 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 idSelectOptions = (key: AdvancedKey, rows: Array<{ id: number; name: string | null }>) => {
const inherited = templateValueOf(key) as number | null; const inherited = templateValueOf(key) as number | null;
return [ return [
inheritOption(inherited == null ? null : fillerName(rows, inherited)), 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}` })) ...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 selValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key]) : INHERIT);
const onSelect = const onSelect =
(key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => { (key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => {
const raw = event.target.value; const raw = event.target.value;
if (raw === INHERIT) { if (raw === INHERIT) {
removeOverride(key); removeOverride(key);
} else if (raw === CLEAR) {
setOverride(key, CLEAR);
} else { } else {
setOverride(key, map(raw)); setOverride(key, map(raw));
} }
+6 -3
View File
@@ -48,6 +48,7 @@ import {
} from '../api'; } from '../api';
import { registerNavigationGuard } from '../navigationGuard'; import { registerNavigationGuard } from '../navigationGuard';
import { import {
applyOverridesToRequest,
FIXED_START_TIME_BEHAVIORS, FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS, IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES, MUSIC_VIDEO_CREDITS_MODES,
@@ -325,9 +326,11 @@ export function AutoTuneScreen() {
} }
channel.logo = logo; channel.logo = logo;
} }
const advanced: Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode } = { // applyOverridesToRequest copies set overrides and folds any CLEAR sentinels into
...(override?.advanced ?? {}) // 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) { if (override?.playbackOrder != null) {
advanced.playbackOrder = override.playbackOrder; advanced.playbackOrder = override.playbackOrder;
} }