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
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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+2
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user