From 928784ba485b2d55b54413a8b64da9fdb0205fb8 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 22:10:41 +0200 Subject: [PATCH] fix(135): from-lineup advanced overrides can express "clear to none" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Commands/CreateChannelFromLineup.cs | 5 +- .../CreateChannelFromLineupHandler.cs | 147 +++++++++++++++--- .../CreateChannelFromLineupClearField.cs | 20 +++ .../CreateChannelFromLineupHandlerTests.cs | 104 +++++++++++++ .../CreateChannelFromLineupRequest.cs | 7 +- ErsatzTV/wwwroot/openapi/v1.json | 22 +++ docs/api-conventions.md | 9 ++ docs/decisions.md | 14 ++ docs/decisions/README.md | 1 + docs/spa-conventions.md | 25 ++- web/src/api/channels.ts | 1 + web/src/api/generated/v1.d.ts | 2 + web/src/builder/ChannelBuilder.tsx | 17 +- web/src/builder/advancedOptions.test.ts | 64 ++++++++ web/src/builder/advancedOptions.tsx | 102 +++++++++--- web/src/screens/AutoTuneScreen.tsx | 9 +- 16 files changed, 482 insertions(+), 67 deletions(-) create mode 100644 ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs create mode 100644 web/src/builder/advancedOptions.test.ts diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs index 8b8858931..0281191f2 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineup.cs @@ -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 Clear = null); public record CreateChannelFromLineupItem( LibraryBrowseMediaType MediaType, diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs index d51e8fa8b..8c80b6a97 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs @@ -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 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 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> 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 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 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 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 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> 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); } diff --git a/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs b/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs new file mode 100644 index 000000000..15dfe92a1 --- /dev/null +++ b/ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs @@ -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 +} diff --git a/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs index da34f1400..25c6b842f 100644 --- a/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/CreateChannelFromLineupHandlerTests.cs @@ -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 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 result = + await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + 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() { diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs index f901b72e6..0883fb44e 100644 --- a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs @@ -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? Clear = null) { public CreateChannelFromLineupAdvancedOptions ToCommand() => new( @@ -91,7 +93,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest( IdleBehavior, ShuffleScheduleItems, RandomStartPoint, - FixedStartTimeBehavior); + FixedStartTimeBehavior, + Clear); } public record CreateChannelFromLineupItemRequest( diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 2b5412065..7dd4a1583 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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", diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 8434eca51..717610f3d 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -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: diff --git a/docs/decisions.md b/docs/decisions.md index e41012497..fa3d5c749 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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`). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1abb8ce3a..13f7c2d70 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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) | diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index ed585a12c..aa6972da5 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -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/`) diff --git a/web/src/api/channels.ts b/web/src/api/channels.ts index fd1f6f804..3e7323d0f 100644 --- a/web/src/api/channels.ts +++ b/web/src/api/channels.ts @@ -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 { diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index a5d37c8ed..720f4d6e8 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -347,7 +347,9 @@ export interface components { "shuffleScheduleItems"?: null | boolean; "randomStartPoint"?: null | boolean; "fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"]; + "clear"?: null | Array; }; + "CreateChannelFromLineupClearField": "Watermark" | "FallbackFiller" | "PreRollFiller" | "MidRollFiller" | "PostRollFiller" | "PreferredAudioLanguage" | "PreferredAudioTitle" | "PreferredSubtitleLanguage"; "CreateChannelFromLineupItemRequest": { "mediaType": components["schemas"]["LibraryBrowseMediaType"]; "collectionType": components["schemas"]["CollectionType"]; diff --git a/web/src/builder/ChannelBuilder.tsx b/web/src/builder/ChannelBuilder.tsx index fb402b5f6..fc856b1e4 100644 --- a/web/src/builder/ChannelBuilder.tsx +++ b/web/src/builder/ChannelBuilder.tsx @@ -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)[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, diff --git a/web/src/builder/advancedOptions.test.ts b/web/src/builder/advancedOptions.test.ts new file mode 100644 index 000000000..0f68f7ba3 --- /dev/null +++ b/web/src/builder/advancedOptions.test.ts @@ -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); + }); +}); diff --git a/web/src/builder/advancedOptions.tsx b/web/src/builder/advancedOptions.tsx index 94ef34e9f..e92a1b6f4 100644 --- a/web/src/builder/advancedOptions.tsx +++ b/web/src/builder/advancedOptions.tsx @@ -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>; -// 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> = { + 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)[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>(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)[key] = overrides[key]; + } + const clear = collectClears(overrides); + if (clear.length > 0) { + (base as Record).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)[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) => { const raw = event.target.value; if (raw === INHERIT) { removeOverride(key); + } else if (raw === CLEAR) { + setOverride(key, CLEAR); } else { setOverride(key, map(raw)); } diff --git a/web/src/screens/AutoTuneScreen.tsx b/web/src/screens/AutoTuneScreen.tsx index 59800a8a6..57a596495 100644 --- a/web/src/screens/AutoTuneScreen.tsx +++ b/web/src/screens/AutoTuneScreen.tsx @@ -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; }