From f26790aa35ad06be9fb8e8b638a285a0f255158a Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 20:00:23 +0200 Subject: [PATCH 1/4] fix(api): mirror runtime Newtonsoft JSON casing in OpenAPI spec (#198) The OpenAPI document is generated from System.Text.Json metadata, whose camelCase drifted from the runtime MVC serializer (Newtonsoft via CustomContractResolver/CustomNamingStrategy): the spec said "fFmpegProfileId" and "fFmpegProfile" while the wire emits "ffmpegProfileId" (naming-strategy special case) and "ffmpegProfile" (ChannelResponseModel's [JsonProperty] override). That fed the SPA the wrong keys. Add NewtonsoftSchemaNamingTransformer, an OpenAPI schema transformer registered on all three documents that renames each object schema's Properties (and Required) keys through the SAME Newtonsoft contract resolver the runtime uses, so the spec matches the wire format by construction. Regenerate v1.json. Guard with OpenApiSerializerContractTests: serializes fully-populated DTOs through the runtime Newtonsoft settings and pins the v1.json schema property sets to the emitted keys, failing if generation drifts again. Co-Authored-By: Claude Fable 5 --- .../OpenApiSerializerContractTests.cs | 169 ++++++++++++++++++ .../NewtonsoftSchemaNamingTransformer.cs | 92 ++++++++++ ErsatzTV/Startup.cs | 8 +- ErsatzTV/wwwroot/openapi/v1.json | 30 ++-- 4 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs create mode 100644 ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs new file mode 100644 index 000000000..7213d7341 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Core.Api.MediaItems; +using ErsatzTV.Core.Api.Settings; +using ErsatzTV.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +/// +/// Guards that the generated OpenAPI document's property names for a set of DTOs exactly match the +/// JSON keys the runtime MVC serializer (Newtonsoft, via + +/// ) actually emits. The spec is generated from System.Text.Json +/// metadata, whose camelCase can drift from Newtonsoft's (e.g. "ffmpegProfileId" special case, or a +/// [JsonProperty] override). See issue #198 — a schema transformer now mirrors the runtime resolver, +/// and this test fails if that mirroring is ever removed or broken. +/// +[TestFixture] +public class OpenApiSerializerContractTests +{ + // Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly. + private static readonly JsonSerializerSettings RuntimeSettings = new() + { + NullValueHandling = NullValueHandling.Ignore, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + ContractResolver = new CustomContractResolver(), + Converters = { new StringEnumConverter() } + }; + + private static IEnumerable Cases() + { + yield return new TestCaseData(FullyPopulatedChannel(), "ChannelViewModel").SetName("ChannelViewModel"); + yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel") + .SetName("FFmpegSettingsResponseModel"); + yield return new TestCaseData(FullyPopulatedWatermark(), "WatermarkViewModel").SetName("WatermarkViewModel"); + yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel") + .SetName("MediaItemInfoResponseModel"); + } + + [TestCaseSource(nameof(Cases))] + public void Runtime_Serialized_Keys_Should_Match_OpenApi_Schema_Properties(object dto, string schemaName) + { + // Every member of dto is non-null, so NullValueHandling.Ignore drops nothing: the emitted + // top-level keys are the complete runtime property set for this type. + var serialized = JObject.Parse(JsonConvert.SerializeObject(dto, RuntimeSettings)); + List runtimeKeys = serialized.Properties().Select(p => p.Name).OrderBy(n => n).ToList(); + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); + JsonElement properties = document.RootElement + .GetProperty("components") + .GetProperty("schemas") + .GetProperty(schemaName) + .GetProperty("properties"); + + List schemaKeys = properties.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToList(); + + runtimeKeys.ShouldBe( + schemaKeys, + $"OpenAPI schema '{schemaName}' property names must match the runtime Newtonsoft JSON keys."); + } + + private static ChannelViewModel FullyPopulatedChannel() => new( + 1, + "1", + "Name", + "Group", + "Categories", + 1, + 1.0, + new ArtworkContentTypeModel("path", "image/png"), + default, + "selector", + "en", + "Audio Title", + default, + default, + 1, + TimeSpan.Zero, + default, + 1, + 1, + 1, + "en", + default, + default, + "template", + default, + default, + default, + true, + true); + + private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new( + "/usr/bin/ffmpeg", + "/usr/bin/ffprobe", + 1, + "en", + true, + true, + true, + true, + 1, + 1, + 1, + 1, + 1, + default, + "script"); + + private static WatermarkViewModel FullyPopulatedWatermark() => new( + 1, + new ArtworkContentTypeModel("path", "image/png"), + "Name", + default, + default, + default, + default, + 1.0, + 1.0, + 1.0, + 1, + 1, + 1, + true, + "expression", + 1); + + private static MediaItemInfoResponseModel FullyPopulatedMediaItemInfo() => new( + 1, + "Title", + "Movie", + "Local", + "Server", + "Library", + default, + TimeSpan.Zero, + "1:1", + "16:9", + "30/1", + default, + 1.0, + 1, + 1, + [], + []); + + private static string FindOpenApiDocument() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "ErsatzTV", "wwwroot", "openapi", "v1.json"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("Could not find ErsatzTV/wwwroot/openapi/v1.json"); + } +} diff --git a/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs b/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs new file mode 100644 index 000000000..d5e90599b --- /dev/null +++ b/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using Newtonsoft.Json.Serialization; + +namespace ErsatzTV.Serialization; + +/// +/// OpenAPI schema transformer that renames each schema property to the exact JSON key the runtime +/// Newtonsoft serializer (configured with ) would actually emit. +/// The OpenAPI document is generated from System.Text.Json metadata, whose camelCase naming can drift +/// from Newtonsoft's — e.g. STJ emits "fFmpegProfileId" while the MVC pipeline emits "ffmpegProfileId" +/// (the special case). Mirroring the real contract resolver keeps +/// the spec in lockstep with the wire format by construction, so future [JsonProperty] renames or +/// naming-strategy special cases cannot drift. See issue #198. +/// +public static class NewtonsoftSchemaNamingTransformer +{ + private static readonly CustomContractResolver ContractResolver = new(); + + public static Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + if (schema.Properties is not { Count: > 0 } properties) + { + return Task.CompletedTask; + } + + Type clrType = context.JsonTypeInfo.Type; + if (ContractResolver.ResolveContract(clrType) is not JsonObjectContract contract) + { + return Task.CompletedTask; + } + + // CLR member name -> Newtonsoft-emitted JSON property name. + var memberToNewtonsoftName = new Dictionary(StringComparer.Ordinal); + foreach (JsonProperty jsonProperty in contract.Properties) + { + if (jsonProperty.UnderlyingName is { } underlyingName && jsonProperty.PropertyName is { } propertyName) + { + memberToNewtonsoftName[underlyingName] = propertyName; + } + } + + // Current schema key (STJ name) -> desired key (Newtonsoft name), only where they differ. + var renames = new Dictionary(StringComparer.Ordinal); + foreach (var jsonPropertyInfo in context.JsonTypeInfo.Properties) + { + string schemaKey = jsonPropertyInfo.Name; + if (!properties.ContainsKey(schemaKey)) + { + continue; + } + + if (jsonPropertyInfo.AttributeProvider is MemberInfo member && + memberToNewtonsoftName.TryGetValue(member.Name, out string newtonsoftName) && + !string.Equals(newtonsoftName, schemaKey, StringComparison.Ordinal)) + { + renames[schemaKey] = newtonsoftName; + } + } + + if (renames.Count == 0) + { + return Task.CompletedTask; + } + + // Rebuild Properties preserving insertion order. + var renamedProperties = new Dictionary(properties.Count, StringComparer.Ordinal); + foreach ((string key, IOpenApiSchema value) in properties) + { + renamedProperties[renames.TryGetValue(key, out string newKey) ? newKey : key] = value; + } + + schema.Properties = renamedProperties; + + if (schema.Required is { Count: > 0 } required) + { + var renamedRequired = new System.Collections.Generic.HashSet(required.Count, StringComparer.Ordinal); + foreach (string key in required) + { + renamedRequired.Add(renames.TryGetValue(key, out string newKey) ? newKey : key); + } + + schema.Required = renamedRequired; + } + + return Task.CompletedTask; + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 8edf58221..c90399a8d 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -180,6 +180,7 @@ public class Startup options => { options.ShouldInclude += a => a.GroupName == "general"; + options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync); options.AddDocumentTransformer((document, _, _) => { UseStringEnumSchemas(document); @@ -189,13 +190,18 @@ public class Startup services.AddOpenApi( "scripted-schedule-tagged", - options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; }); + options => + { + options.ShouldInclude += a => a.GroupName == "scripted-schedule"; + options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync); + }); services.AddOpenApi( "scripted-schedule", options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; + options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync); var tag = new OpenApiTag { Name = "ScriptedSchedule" }; var tagReference = new OpenApiTagReference("ScriptedSchedule"); options.AddOperationTransformer((operation, _, _) => diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 669f39879..6696380b7 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -14225,7 +14225,7 @@ "name", "group", "categories", - "fFmpegProfile", + "ffmpegProfile", "language", "streamingMode", "isEnabled", @@ -14253,7 +14253,7 @@ "categories": { "type": "string" }, - "fFmpegProfile": { + "ffmpegProfile": { "type": "string" }, "language": { @@ -14332,7 +14332,7 @@ "description", "isSystem", "isDefault", - "fFmpegProfileId", + "ffmpegProfileId", "watermarkId", "fallbackFillerId", "preRollFillerId", @@ -14374,7 +14374,7 @@ "isDefault": { "type": "boolean" }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": "integer", "format": "int32" }, @@ -14494,7 +14494,7 @@ "name", "group", "categories", - "fFmpegProfileId", + "ffmpegProfileId", "slugSeconds", "logo", "streamSelectorMode", @@ -14549,7 +14549,7 @@ "string" ] }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": "integer", "format": "int32" }, @@ -14810,7 +14810,7 @@ } ] }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": [ "null", "integer" @@ -15153,7 +15153,7 @@ "number", "group", "categories", - "fFmpegProfileId", + "ffmpegProfileId", "slugSeconds", "logo", "streamSelectorMode", @@ -15203,7 +15203,7 @@ "string" ] }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": "integer", "format": "int32" }, @@ -15314,7 +15314,7 @@ "required": [ "name", "description", - "fFmpegProfileId", + "ffmpegProfileId", "watermarkId", "fallbackFillerId", "preRollFillerId", @@ -15346,7 +15346,7 @@ "description": { "type": "string" }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": "integer", "format": "int32" }, @@ -21094,7 +21094,7 @@ "number", "group", "categories", - "fFmpegProfileId", + "ffmpegProfileId", "slugSeconds", "logo", "streamSelectorMode", @@ -21144,7 +21144,7 @@ "string" ] }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": "integer", "format": "int32" }, @@ -21255,7 +21255,7 @@ "required": [ "name", "description", - "fFmpegProfileId", + "ffmpegProfileId", "watermarkId", "fallbackFillerId", "preRollFillerId", @@ -21287,7 +21287,7 @@ "description": { "type": "string" }, - "fFmpegProfileId": { + "ffmpegProfileId": { "type": "integer", "format": "int32" }, From 7ffde6225f2b22bde50ec75abac786a435523098 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 20:00:38 +0200 Subject: [PATCH 2/4] fix(web): read runtime-cased ffmpegProfileId/ffmpegProfile keys (#198) Regenerate v1.d.ts from the aligned spec (fFmpegProfileId->ffmpegProfileId, fFmpegProfile->ffmpegProfile) and update every SPA reader/writer and test mock to the runtime casing: - ChannelEditScreen: read channel.ffmpegProfileId; draft/set/select use ffmpegProfileId. - ChannelBuilder: template reads/writes + ADVANCED_KEYS use ffmpegProfileId. - App.tsx channel list: read channel.ffmpegProfile (was fFmpegProfile, a latent bug that always rendered "Unassigned" since the runtime never sent that key). - PlaybackTroubleshootingScreen: drop the #198 escape hatch (rawChannel cast + dual-read) and read data.channel.ffmpegProfileId directly. - Test mocks now use runtime casing; pinning comments updated. Co-Authored-By: Claude Fable 5 --- web/src/App.test.tsx | 32 +++++++++---------- web/src/App.tsx | 2 +- web/src/api/channels.test.ts | 2 +- web/src/api/generated/v1.d.ts | 16 +++++----- web/src/builder/ChannelBuilder.tsx | 16 +++++----- web/src/screens/ChannelEditScreen.test.tsx | 2 +- web/src/screens/ChannelEditScreen.tsx | 6 ++-- .../PlaybackTroubleshootingScreen.test.tsx | 12 ++++--- .../screens/PlaybackTroubleshootingScreen.tsx | 8 +---- 9 files changed, 46 insertions(+), 50 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 2c7d36b92..47aa024da 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -320,7 +320,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', id: 1, language: 'en', name: 'Retro Cartoons', @@ -328,7 +328,7 @@ describe('ChicoryTV SPA scaffold', () => { streamingMode: 'HLS Direct' }, { - fFmpegProfile: 'MPEG-TS', + ffmpegProfile: 'MPEG-TS', id: 2, language: 'fr', name: 'News 24', @@ -426,7 +426,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -438,7 +438,7 @@ describe('ChicoryTV SPA scaffold', () => { streamingMode: 'HLS Direct' }, { - fFmpegProfile: 'MPEG-TS', + ffmpegProfile: 'MPEG-TS', group: 'News', id: 2, isEnabled: false, @@ -500,7 +500,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -535,7 +535,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -562,7 +562,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -574,7 +574,7 @@ describe('ChicoryTV SPA scaffold', () => { streamingMode: 'HLS Direct' }, { - fFmpegProfile: 'MPEG-TS', + ffmpegProfile: 'MPEG-TS', group: 'News', id: 2, isEnabled: false, @@ -610,7 +610,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -641,7 +641,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -691,7 +691,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -703,7 +703,7 @@ describe('ChicoryTV SPA scaffold', () => { streamingMode: 'HLS Direct' }, { - fFmpegProfile: 'MPEG-TS', + ffmpegProfile: 'MPEG-TS', group: 'News', id: 2, isEnabled: false, @@ -746,7 +746,7 @@ describe('ChicoryTV SPA scaffold', () => { mockDashboardApi({ channels: [ { - fFmpegProfile: 'HLS Direct', + ffmpegProfile: 'HLS Direct', group: 'Kids', id: 1, isEnabled: true, @@ -758,7 +758,7 @@ describe('ChicoryTV SPA scaffold', () => { streamingMode: 'HLS Direct' }, { - fFmpegProfile: 'MPEG-TS', + ffmpegProfile: 'MPEG-TS', group: 'News', id: 2, isEnabled: true, @@ -3239,7 +3239,7 @@ function omitNullKeys(fixture: Record): Record function channelSummary(overrides: Record = {}): Record { return { categories: '', - fFmpegProfile: '1080p H.264', + ffmpegProfile: '1080p H.264', group: 'ChicoryTV', id: 1, isEnabled: true, @@ -3259,7 +3259,7 @@ function channelSummary(overrides: Record = {}): Record = {}): Record { return omitNullKeys({ description: 'General-purpose 1080p H.264.', - fFmpegProfileId: 100, + ffmpegProfileId: 100, fixedStartTimeBehavior: 'Strict', id: 10, idleBehavior: 'StopOnDisconnect', diff --git a/web/src/App.tsx b/web/src/App.tsx index 092942e5f..d6e75683c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1564,7 +1564,7 @@ function ChannelTableRow({ )} {channel.streamingMode} - {channel.fFmpegProfile || 'Unassigned'} + {channel.ffmpegProfile || 'Unassigned'}
diff --git a/web/src/api/channels.test.ts b/web/src/api/channels.test.ts index 0b005762b..81b73e8d6 100644 --- a/web/src/api/channels.test.ts +++ b/web/src/api/channels.test.ts @@ -7,7 +7,7 @@ const sampleChannel = { name: 'Cartoons', group: 'ChicoryTV', categories: '', - fFmpegProfileId: 1, + ffmpegProfileId: 1, slugSeconds: null, logo: { path: '', contentType: '' }, streamSelectorMode: 'Default', diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 07d8940be..9896c2f9f 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -157,7 +157,7 @@ export interface components { "name": string; "group": string; "categories": string; - "fFmpegProfile": string; + "ffmpegProfile": string; "language": string; "streamingMode": string; "isEnabled": boolean; @@ -178,7 +178,7 @@ export interface components { "description": string; "isSystem": boolean; "isDefault": boolean; - "fFmpegProfileId": number; + "ffmpegProfileId": number; "watermarkId": null | number; "fallbackFillerId": null | number; "preRollFillerId": null | number; @@ -209,7 +209,7 @@ export interface components { "name": null | string; "group": null | string; "categories": null | string; - "fFmpegProfileId": number; + "ffmpegProfileId": number; "slugSeconds": null | number; "logo": components["schemas"]["ArtworkContentTypeModel"]; "streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"]; @@ -259,7 +259,7 @@ export interface components { }; "CreateChannelFromLineupAdvancedOptionsRequest": { "playbackOrder"?: null | components["schemas"]["PlaybackOrder"]; - "fFmpegProfileId"?: null | number; + "ffmpegProfileId"?: null | number; "watermarkId"?: null | number; "fallbackFillerId"?: null | number; "preRollFillerId"?: null | number; @@ -316,7 +316,7 @@ export interface components { "number": null | string; "group": null | string; "categories": null | string; - "fFmpegProfileId": number; + "ffmpegProfileId": number; "slugSeconds": null | number; "logo": components["schemas"]["ArtworkContentTypeModel"]; "streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"]; @@ -343,7 +343,7 @@ export interface components { "CreateChannelTemplateRequest": { "name": string; "description": string; - "fFmpegProfileId": number; + "ffmpegProfileId": number; "watermarkId": null | number; "fallbackFillerId": null | number; "preRollFillerId": null | number; @@ -1432,7 +1432,7 @@ export interface components { "number": null | string; "group": null | string; "categories": null | string; - "fFmpegProfileId": number; + "ffmpegProfileId": number; "slugSeconds": null | number; "logo": components["schemas"]["ArtworkContentTypeModel"]; "streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"]; @@ -1459,7 +1459,7 @@ export interface components { "UpdateChannelTemplateRequest": { "name": string; "description": string; - "fFmpegProfileId": number; + "ffmpegProfileId": number; "watermarkId": null | number; "fallbackFillerId": null | number; "preRollFillerId": null | number; diff --git a/web/src/builder/ChannelBuilder.tsx b/web/src/builder/ChannelBuilder.tsx index aad213d87..85a2b73e1 100644 --- a/web/src/builder/ChannelBuilder.tsx +++ b/web/src/builder/ChannelBuilder.tsx @@ -647,7 +647,7 @@ function templatePlaybackOrder(template: ChannelTemplate): PlaybackOrder { // Advanced field keys wired into request.advanced (playbackOrder + playoutMode // live in dedicated state and are ALWAYS sent, so they are excluded here). const ADVANCED_KEYS = [ - 'fFmpegProfileId', + 'ffmpegProfileId', 'watermarkId', 'fallbackFillerId', 'preRollFillerId', @@ -1091,7 +1091,7 @@ function ChannelBuilder({ const requestBody: CreateChannelTemplateRequest = { name: saveName.trim(), description: saveDesc.trim(), - fFmpegProfileId: eff('fFmpegProfileId'), + ffmpegProfileId: eff('ffmpegProfileId'), watermarkId: eff('watermarkId'), fallbackFillerId: eff('fallbackFillerId'), preRollFillerId: eff('preRollFillerId'), @@ -1768,7 +1768,7 @@ function templateChips( ): string[] { const chips: string[] = []; chips.push(STREAMING_MODE_LABELS[template.streamingMode as StreamingMode] ?? template.streamingMode); - const profile = ffmpegProfiles.find((candidate) => candidate.id === template.fFmpegProfileId); + const profile = ffmpegProfiles.find((candidate) => candidate.id === template.ffmpegProfileId); if (profile?.name) { chips.push(profile.name); } @@ -1956,15 +1956,15 @@ function AdvancedPanel({ - p.id === template.fFmpegProfileId)?.name ?? `#${template.fFmpegProfileId}`} /> + p.id === template.ffmpegProfileId)?.name ?? `#${template.ffmpegProfileId}`} /> diff --git a/web/src/screens/ChannelEditScreen.test.tsx b/web/src/screens/ChannelEditScreen.test.tsx index afa5ea441..e12e0f46c 100644 --- a/web/src/screens/ChannelEditScreen.test.tsx +++ b/web/src/screens/ChannelEditScreen.test.tsx @@ -8,7 +8,7 @@ const channel = { name: 'Cartoons', group: 'ChicoryTV', categories: 'Kids', - fFmpegProfileId: 1, + ffmpegProfileId: 1, slugSeconds: null, logo: { path: '', contentType: '' }, streamSelectorMode: 'Default', diff --git a/web/src/screens/ChannelEditScreen.tsx b/web/src/screens/ChannelEditScreen.tsx index 44da62e7a..3d1904cb3 100644 --- a/web/src/screens/ChannelEditScreen.tsx +++ b/web/src/screens/ChannelEditScreen.tsx @@ -119,7 +119,7 @@ function draftFromChannel(channel: Channel): UpdateChannelRequest { number: channel.number, group: channel.group, categories: channel.categories, - fFmpegProfileId: channel.fFmpegProfileId, + ffmpegProfileId: channel.ffmpegProfileId, slugSeconds: channel.slugSeconds, logo: channel.logo, streamSelectorMode: channel.streamSelectorMode, @@ -407,13 +407,13 @@ function StreamingPane({