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
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:
@@ -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
@@ -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, _, _) =>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`}>
|
||||
|
||||
@@ -7,7 +7,7 @@ const sampleChannel = {
|
||||
name: 'Cartoons',
|
||||
group: 'ChicoryTV',
|
||||
categories: '',
|
||||
fFmpegProfileId: 1,
|
||||
ffmpegProfileId: 1,
|
||||
slugSeconds: null,
|
||||
logo: { path: '', contentType: '' },
|
||||
streamSelectorMode: 'Default',
|
||||
|
||||
Vendored
+8
-8
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@ const channel = {
|
||||
name: 'Cartoons',
|
||||
group: 'ChicoryTV',
|
||||
categories: 'Kids',
|
||||
fFmpegProfileId: 1,
|
||||
ffmpegProfileId: 1,
|
||||
slugSeconds: null,
|
||||
logo: { path: '', contentType: '' },
|
||||
streamSelectorMode: 'Default',
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user