diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs new file mode 100644 index 000000000..7bd4113de --- /dev/null +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Core.Api.Channels; +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"); + + // The only DTO with a [JsonProperty] override (FFmpegProfile -> "ffmpegProfile") — covers + // the attribute path of NewtonsoftSchemaNamingTransformer, which the cases above don't. + yield return new TestCaseData(FullyPopulatedChannelSummary(), "ChannelResponseModel") + .SetName("ChannelResponseModel"); + } + + [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 ChannelResponseModel FullyPopulatedChannelSummary() => new( + 1, + "1", + 1.0, + "Name", + "Group", + "Categories", + "1080p H.264", + "en", + "TransportStream", + true, + true); + + 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..464db31e2 --- /dev/null +++ b/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs @@ -0,0 +1,99 @@ +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. A rename colliding with an existing key + // would silently drop a property from the spec — fail loudly instead (near-unreachable, + // but the generator must never emit a lossy document). + var renamedProperties = new Dictionary(properties.Count, StringComparer.Ordinal); + foreach ((string key, IOpenApiSchema value) in properties) + { + string finalKey = renames.TryGetValue(key, out string newKey) ? newKey : key; + if (!renamedProperties.TryAdd(finalKey, value)) + { + throw new InvalidOperationException( + $"OpenAPI schema property rename collision on '{clrType.FullName}': renaming '{key}' to '{finalKey}' would overwrite an existing property."); + } + } + + 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" }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 33c68a2d8..2a89fe5b0 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -121,6 +121,31 @@ gets this wrong. The SPA works around it with a manual override type; see `web/s `PlayoutAlternateSchedule`, `PlayoutTemplate`, and their request types). Copy this pattern for any new DTO with a `DayOfWeek` (or `DayOfWeek[]`) member — don't trust the generated numeric type. +### 5a. Runtime JSON casing vs the generated spec (the `ffmpegProfileId` wart) + +Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `Startup.cs`), using +`ErsatzTV/Serialization/CustomContractResolver.cs` → `CustomNamingStrategy` (camelCase **plus** a +special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any +`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile` → +`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from +**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` / +`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198). + +Fix (do not remove): `ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs` is an OpenAPI +**schema transformer** registered on all three documents (`options.AddSchemaTransformer(...)` in +`Startup.cs`). For each object schema it resolves the CLR type's Newtonsoft `JsonObjectContract` +through the *same* `CustomContractResolver` the runtime uses and renames `schema.Properties` (and +`schema.Required`) keys to the exact names Newtonsoft would emit. This mirrors the wire format **by +construction**, so future naming-strategy special cases or `[JsonProperty]` renames can't drift. + +Guard: `ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs` serializes fully-populated +DTOs (ChannelViewModel, FFmpegSettingsResponseModel, WatermarkViewModel, +MediaItemInfoResponseModel) through the runtime Newtonsoft settings and asserts the emitted top-level +keys equal the corresponding `v1.json` schema's property set. It fails if spec generation ever drifts +from the MVC serializer again. Note: only exact-match `FFmpegProfileId` gets the special case — +`DefaultFFmpegProfileId` stays `defaultFFmpegProfileId` under both serializers, and non-acronym or +single-leading-cap names (`fFmpegPath`, `fFprobePath`, `zIndex`, `rFrameRate`) already agree. + ## 6. Tests - **Controller tests**: `ErsatzTV.Tests/Controllers/ControllerTests.cs`. NUnit + Shouldly + diff --git a/docs/decisions.md b/docs/decisions.md index cb237a70f..2242b9ccc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -127,3 +127,19 @@ Sample** (alongside Download Results) while a troubleshooting session is startin only gated Download Results. Both downloads compete with the live transcode for I/O and the sample archiver reads the same media file, so gating both during a session is strictly safer and costs nothing (sessions are short). + +## 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198) + +The generated OpenAPI document is made to follow the **runtime** JSON contract, not the reverse. Runtime +`/api/*` responses are serialized by Newtonsoft via `CustomContractResolver`/`CustomNamingStrategy` +(camelCase + a `FFmpegProfileId`→`ffmpegProfileId` special case + `[JsonProperty]` overrides such as +`ChannelResponseModel.FFmpegProfile`→`ffmpegProfile`), while `Microsoft.AspNetCore.OpenApi` generates the +spec from System.Text.Json metadata, whose camelCase drifted (`fFmpegProfileId`, `fFmpegProfile`). That +drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients), +we added `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer registered on all three +documents that renames each schema property through the *same* Newtonsoft contract resolver the runtime uses, +so the spec matches the wire format by construction. A contract test +(`OpenApiSerializerContractTests`) serializes representative DTOs through the real runtime settings and pins +the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the +real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read +`fFmpegProfile` and always showed "Unassigned"). Issue #198. diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 426f53fa7..ffe2d810b 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -76,6 +76,15 @@ One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts - `web/src/api/index.ts` re-exports everything so screens import from `'../api'`, not from the individual domain file directly. +**Trust the generated key casing — it mirrors the runtime.** Since #198 the OpenAPI spec is generated +to match the runtime Newtonsoft serializer exactly (a schema transformer runs the same contract +resolver; see `api-conventions.md` §5a), so the generated types carry the real wire keys — including +Newtonsoft's acronym quirks like `ffmpegProfileId` (channel FFmpeg-profile id) and `ffmpegProfile` +(channel FFmpeg-profile display name). **Do not** hand-cast responses to "fix" a key or dual-read a +spec-cased vs runtime-cased key (the old `PlaybackTroubleshootingScreen` `#198` escape hatch that read +`data.channel.fFmpegProfileId` has been removed — read `ffmpegProfileId` straight off the typed +response). When you mock an API response in a test, use the generated (runtime) casing. + ## 5. Artwork rendering Render `item.artwork` / `item.poster` (or whatever the DTO field is named) **directly as an ` { 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({