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