Merge pull request 'fix(openapi): spec property naming now mirrors the runtime Newtonsoft serializer (#198)' (#201) from fix/198-openapi-casing into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled

This commit was merged in pull request #201.
This commit is contained in:
2026-07-09 18:19:20 +00:00
16 changed files with 405 additions and 66 deletions
@@ -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;
/// <summary>
/// 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 <see cref="CustomContractResolver" /> +
/// <see cref="StringEnumConverter" />) 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.
/// </summary>
[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<TestCaseData> 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<string> 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<string> 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");
}
}
@@ -0,0 +1,99 @@
using System.Reflection;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Newtonsoft.Json.Serialization;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI schema transformer that renames each schema property to the exact JSON key the runtime
/// Newtonsoft serializer (configured with <see cref="CustomContractResolver" />) 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 <see cref="CustomNamingStrategy" /> 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.
/// </summary>
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<string, string>(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<string, string>(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<string, IOpenApiSchema>(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<string>(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;
}
}
+7 -1
View File
@@ -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, _, _) =>
+15 -15
View File
@@ -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"
},
+25
View File
@@ -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/<Domain>ControllerTests.cs`. NUnit + Shouldly +
+16
View File
@@ -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.
+9
View File
@@ -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 `<img
+16 -16
View File
@@ -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<string, unknown>): Record<string, unknown>
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
categories: '',
fFmpegProfile: '1080p H.264',
ffmpegProfile: '1080p H.264',
group: 'ChicoryTV',
id: 1,
isEnabled: true,
@@ -3259,7 +3259,7 @@ function channelSummary(overrides: Record<string, unknown> = {}): Record<string,
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
description: 'General-purpose 1080p H.264.',
fFmpegProfileId: 100,
ffmpegProfileId: 100,
fixedStartTimeBehavior: 'Strict',
id: 10,
idleBehavior: 'StopOnDisconnect',
+1 -1
View File
@@ -1564,7 +1564,7 @@ function ChannelTableRow({
)}
</td>
<td><Badge tone={streamingTone}>{channel.streamingMode}</Badge></td>
<td className="ctv-channel-ffmpeg">{channel.fFmpegProfile || 'Unassigned'}</td>
<td className="ctv-channel-ffmpeg">{channel.ffmpegProfile || 'Unassigned'}</td>
<td>
<div className="ctv-channel-actions">
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
+1 -1
View File
@@ -7,7 +7,7 @@ const sampleChannel = {
name: 'Cartoons',
group: 'ChicoryTV',
categories: '',
fFmpegProfileId: 1,
ffmpegProfileId: 1,
slugSeconds: null,
logo: { path: '', contentType: '' },
streamSelectorMode: 'Default',
+8 -8
View File
@@ -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;
+8 -8
View File
@@ -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({
<Select
size="sm"
label="FFmpeg profile"
value={selValue('fFmpegProfileId')}
value={selValue('ffmpegProfileId')}
options={[
inheritOption(
ffmpegProfiles.find((p) => p.id === template.fFmpegProfileId)?.name ??
`#${template.fFmpegProfileId}`
ffmpegProfiles.find((p) => p.id === template.ffmpegProfileId)?.name ??
`#${template.ffmpegProfileId}`
),
...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }))
]}
onChange={onSelect('fFmpegProfileId', Number)}
onChange={onSelect('ffmpegProfileId', Number)}
/>
<Select
size="sm"
@@ -2153,7 +2153,7 @@ function AdvancedPanel({
) : (
<>
<AdvGroup label="Streaming">
<ReadOnlyField label="FFmpeg profile" value={ffmpegProfiles.find((p) => p.id === template.fFmpegProfileId)?.name ?? `#${template.fFmpegProfileId}`} />
<ReadOnlyField label="FFmpeg profile" value={ffmpegProfiles.find((p) => p.id === template.ffmpegProfileId)?.name ?? `#${template.ffmpegProfileId}`} />
<ReadOnlyField label="Streaming mode" value={STREAMING_MODE_LABELS[template.streamingMode as StreamingMode] ?? template.streamingMode} />
<ReadOnlyField label="Transcode mode" value={template.transcodeMode} />
</AdvGroup>
+1 -1
View File
@@ -8,7 +8,7 @@ const channel = {
name: 'Cartoons',
group: 'ChicoryTV',
categories: 'Kids',
fFmpegProfileId: 1,
ffmpegProfileId: 1,
slugSeconds: null,
logo: { path: '', contentType: '' },
streamSelectorMode: 'Default',
+3 -3
View File
@@ -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({
<Select
disabled={hlsDirect}
fullWidth
onChange={(event) => set({ fFmpegProfileId: Number(event.target.value) })}
onChange={(event) => set({ ffmpegProfileId: Number(event.target.value) })}
options={data.ffmpegProfiles.map((profile) => ({
label: profile.name ?? `Profile ${profile.id}`,
value: String(profile.id)
}))}
size="sm"
value={String(draft.fFmpegProfileId)}
value={String(draft.ffmpegProfileId)}
/>
</Row>
<Row help="Black video / silent audio inserted between every playout item." label="Slug seconds">
@@ -68,9 +68,10 @@ function installFetch(statusRef: { current: Status }) {
);
}
if (url.startsWith('/api/channels/')) {
// Pin the RUNTIME shape, not the OpenAPI-spec shape: the server serializes with Newtonsoft,
// so the profile key is "ffmpegProfileId" (the generated types wrongly say "fFmpegProfileId"
// — see #198). streamSelectorMode/streamSelector match the spec (no leading acronym).
// Pin the runtime shape: the server serializes with Newtonsoft, so the profile key is
// "ffmpegProfileId". Since #198 the spec generator mirrors the runtime contract resolver, so
// the generated types now agree ("ffmpegProfileId"); this mock stays runtime-shaped to guard
// the fix. streamSelectorMode/streamSelector match too (no leading acronym).
return Promise.resolve(
json({ id: 7, name: 'Channel Seven', ffmpegProfileId: 2, streamSelectorMode: 'Default', streamSelector: null })
);
@@ -173,8 +174,9 @@ describe('PlaybackTroubleshootingScreen', () => {
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
const params = new URLSearchParams(String(hlsMock.loadSource.mock.calls[0][0]).split('?')[1]);
// The regression this pins: the runtime channel JSON key is ffmpegProfileId (#198); reading the
// spec-cased fFmpegProfileId left the profile null and the URL fell back to ffmpegProfile=0.
// The regression this pins: the runtime channel JSON key is ffmpegProfileId (#198). Before the
// spec was aligned, reading the spec-cased fFmpegProfileId left the profile null and the URL
// fell back to ffmpegProfile=0.
expect(params.get('ffmpegProfile')).toBe('2');
expect(params.get('channel')).toBe('7');
expect(params.get('start')).toBe(new Date('2026-07-09T20:00').toISOString());
@@ -302,13 +302,7 @@ export function PlaybackTroubleshootingScreen() {
// LoadChannel logic).
let defaultProfileId: null | number = data.profiles[0]?.id ?? null;
if (mode.kind === 'channel' && data.channel) {
// Runtime JSON is Newtonsoft-cased ("ffmpegProfileId"); generated types say
// "fFmpegProfileId" — see #198. Read the runtime key first, fall back to the spec key,
// then to the first profile; never let this silently become 0.
// (streamSelectorMode / streamSelector below have no leading acronym, so their runtime
// keys match the generated types — verified against a live GET /api/channels/{id}.)
const rawChannel = data.channel as typeof data.channel & { ffmpegProfileId?: null | number };
defaultProfileId = rawChannel.ffmpegProfileId ?? data.channel.fFmpegProfileId ?? defaultProfileId;
defaultProfileId = data.channel.ffmpegProfileId ?? defaultProfileId;
}
const isRemoteStream = (data.rawKind ?? '').toLowerCase() === 'remotestream';