diff --git a/ErsatzTV.Core/Next/Config/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs
index 006fa1bdf..102711b1d 100644
--- a/ErsatzTV.Core/Next/Config/ChannelConfig.cs
+++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs
@@ -1,246 +1,259 @@
//
//
-// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do:
+// To parse this JSON data, add NuGet 'System.Text.Json' then do:
//
// using ErsatzTV.Core.Next.Config;
//
// var channelConfig = ChannelConfig.FromJson(jsonString);
+#nullable enable
+#pragma warning disable CS8618
+#pragma warning disable CS8601
+#pragma warning disable CS8602
+#pragma warning disable CS8603
namespace ErsatzTV.Core.Next.Config
{
using System;
using System.Collections.Generic;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
using System.Globalization;
- using Newtonsoft.Json;
- using Newtonsoft.Json.Converters;
public partial class ChannelConfig
{
- [JsonProperty("ffmpeg")]
+ [JsonPropertyName("ffmpeg")]
public Ffmpeg Ffmpeg { get; set; }
- [JsonProperty("normalization")]
+ [JsonPropertyName("normalization")]
public Normalization Normalization { get; set; }
- [JsonProperty("playout")]
+ [JsonPropertyName("playout")]
public Playout Playout { get; set; }
}
public partial class Ffmpeg
{
- [JsonProperty("disabled_filters", NullValueHandling = NullValueHandling.Ignore)]
- public List DisabledFilters { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("disabled_filters")]
+ public List? DisabledFilters { get; set; }
- [JsonProperty("ffmpeg_path")]
- public string FfmpegPath { get; set; }
+ [JsonPropertyName("ffmpeg_path")]
+ public string? FfmpegPath { get; set; }
- [JsonProperty("ffprobe_path")]
- public string FfprobePath { get; set; }
+ [JsonPropertyName("ffprobe_path")]
+ public string? FfprobePath { get; set; }
- [JsonProperty("preferred_filters", NullValueHandling = NullValueHandling.Ignore)]
- public List PreferredFilters { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("preferred_filters")]
+ public List? PreferredFilters { get; set; }
- [JsonProperty("reports_folder")]
- public string ReportsFolder { get; set; }
+ [JsonPropertyName("reports_folder")]
+ public string? ReportsFolder { get; set; }
}
public partial class Normalization
{
- [JsonProperty("audio")]
+ [JsonPropertyName("audio")]
public Audio Audio { get; set; }
- [JsonProperty("subtitle", NullValueHandling = NullValueHandling.Ignore)]
- public Subtitle Subtitle { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("subtitle")]
+ public Subtitle? Subtitle { get; set; }
- [JsonProperty("video")]
+ [JsonPropertyName("video")]
public Video Video { get; set; }
}
public partial class Audio
{
- [JsonProperty("bitrate_kbps")]
+ [JsonPropertyName("bitrate_kbps")]
public long? BitrateKbps { get; set; }
- [JsonProperty("buffer_kbps")]
+ [JsonPropertyName("buffer_kbps")]
public long? BufferKbps { get; set; }
- [JsonProperty("channels")]
+ [JsonPropertyName("channels")]
public long? Channels { get; set; }
- [JsonProperty("format")]
+ [JsonPropertyName("format")]
public AudioFormat? Format { get; set; }
- [JsonProperty("loudness")]
- public LoudnessClass Loudness { get; set; }
+ [JsonPropertyName("loudness")]
+ public LoudnessClass? Loudness { get; set; }
- [JsonProperty("normalize_loudness", NullValueHandling = NullValueHandling.Ignore)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("normalize_loudness")]
public bool? NormalizeLoudness { get; set; }
- [JsonProperty("sample_rate_hz")]
+ [JsonPropertyName("sample_rate_hz")]
public long? SampleRateHz { get; set; }
}
public partial class LoudnessClass
{
- [JsonProperty("integrated_target")]
+ [JsonPropertyName("integrated_target")]
public double? IntegratedTarget { get; set; }
- [JsonProperty("range_target")]
+ [JsonPropertyName("range_target")]
public double? RangeTarget { get; set; }
- [JsonProperty("true_peak")]
+ [JsonPropertyName("true_peak")]
public double? TruePeak { get; set; }
}
public partial class Subtitle
{
- [JsonProperty("mode", NullValueHandling = NullValueHandling.Ignore)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("mode")]
public Mode? Mode { get; set; }
}
public partial class Video
{
- [JsonProperty("accel")]
+ [JsonPropertyName("accel")]
public AccelEnum? Accel { get; set; }
- [JsonProperty("bit_depth")]
+ [JsonPropertyName("bit_depth")]
public long? BitDepth { get; set; }
- [JsonProperty("bitrate_kbps")]
+ [JsonPropertyName("bitrate_kbps")]
public long? BitrateKbps { get; set; }
- [JsonProperty("buffer_kbps")]
+ [JsonPropertyName("buffer_kbps")]
public long? BufferKbps { get; set; }
- [JsonProperty("deinterlace", NullValueHandling = NullValueHandling.Ignore)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("deinterlace")]
public bool? Deinterlace { get; set; }
- [JsonProperty("filters", NullValueHandling = NullValueHandling.Ignore)]
- public Filters Filters { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("filters")]
+ public Filters? Filters { get; set; }
- [JsonProperty("format")]
+ [JsonPropertyName("format")]
public VideoFormat? Format { get; set; }
- [JsonProperty("height")]
+ [JsonPropertyName("height")]
public long? Height { get; set; }
- [JsonProperty("scaling_mode", NullValueHandling = NullValueHandling.Ignore)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("scaling_mode")]
public ScalingMode? ScalingMode { get; set; }
- [JsonProperty("vaapi_device")]
- public string VaapiDevice { get; set; }
+ [JsonPropertyName("vaapi_device")]
+ public string? VaapiDevice { get; set; }
- [JsonProperty("vaapi_driver")]
+ [JsonPropertyName("vaapi_driver")]
public VaapiDriverEnum? VaapiDriver { get; set; }
- [JsonProperty("width")]
+ [JsonPropertyName("width")]
public long? Width { get; set; }
}
public partial class Filters
{
- [JsonProperty("bwdif")]
- public BwdifClass Bwdif { get; set; }
+ [JsonPropertyName("bwdif")]
+ public BwdifClass? Bwdif { get; set; }
- [JsonProperty("bwdif_cuda")]
- public BwdifCudaClass BwdifCuda { get; set; }
+ [JsonPropertyName("bwdif_cuda")]
+ public BwdifCudaClass? BwdifCuda { get; set; }
- [JsonProperty("deinterlace_qsv")]
- public DeinterlaceQsvClass DeinterlaceQsv { get; set; }
+ [JsonPropertyName("deinterlace_qsv")]
+ public DeinterlaceQsvClass? DeinterlaceQsv { get; set; }
- [JsonProperty("deinterlace_vaapi")]
- public DeinterlaceVaapiClass DeinterlaceVaapi { get; set; }
+ [JsonPropertyName("deinterlace_vaapi")]
+ public DeinterlaceVaapiClass? DeinterlaceVaapi { get; set; }
- [JsonProperty("libplacebo")]
- public LibplaceboClass Libplacebo { get; set; }
+ [JsonPropertyName("libplacebo")]
+ public LibplaceboClass? Libplacebo { get; set; }
- [JsonProperty("tonemap")]
- public TonemapClass Tonemap { get; set; }
+ [JsonPropertyName("tonemap")]
+ public TonemapClass? Tonemap { get; set; }
- [JsonProperty("tonemap_opencl")]
- public TonemapOpenclClass TonemapOpencl { get; set; }
+ [JsonPropertyName("tonemap_opencl")]
+ public TonemapOpenclClass? TonemapOpencl { get; set; }
- [JsonProperty("w3fdif")]
- public W3FdifClass W3Fdif { get; set; }
+ [JsonPropertyName("w3fdif")]
+ public W3FdifClass? W3Fdif { get; set; }
- [JsonProperty("yadif")]
- public YadifClass Yadif { get; set; }
+ [JsonPropertyName("yadif")]
+ public YadifClass? Yadif { get; set; }
- [JsonProperty("yadif_cuda")]
- public YadifCudaClass YadifCuda { get; set; }
+ [JsonPropertyName("yadif_cuda")]
+ public YadifCudaClass? YadifCuda { get; set; }
}
public partial class BwdifClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class BwdifCudaClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class DeinterlaceQsvClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class DeinterlaceVaapiClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class LibplaceboClass
{
- [JsonProperty("tonemapping")]
- public string Tonemapping { get; set; }
+ [JsonPropertyName("tonemapping")]
+ public string? Tonemapping { get; set; }
}
public partial class TonemapClass
{
- [JsonProperty("tonemap")]
- public string Tonemap { get; set; }
+ [JsonPropertyName("tonemap")]
+ public string? Tonemap { get; set; }
}
public partial class TonemapOpenclClass
{
- [JsonProperty("tonemap")]
- public string Tonemap { get; set; }
+ [JsonPropertyName("tonemap")]
+ public string? Tonemap { get; set; }
}
public partial class W3FdifClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class YadifClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class YadifCudaClass
{
- [JsonProperty("mode")]
- public string Mode { get; set; }
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
}
public partial class Playout
{
- [JsonProperty("folder")]
+ [JsonPropertyName("folder")]
public string Folder { get; set; }
///
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
///
- [JsonProperty("virtual_start")]
- public string VirtualStart { get; set; }
+ [JsonPropertyName("virtual_start")]
+ public string? VirtualStart { get; set; }
}
public enum AudioFormat { Aac, Ac3 };
@@ -257,21 +270,19 @@ namespace ErsatzTV.Core.Next.Config
public partial class ChannelConfig
{
- public static ChannelConfig FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Config.Converter.Settings);
+ public static ChannelConfig FromJson(string json) => JsonSerializer.Deserialize(json, ErsatzTV.Core.Next.Config.Converter.Settings);
}
public static class Serialize
{
- public static string ToJson(this ChannelConfig self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Config.Converter.Settings);
+ public static string ToJson(this ChannelConfig self) => JsonSerializer.Serialize(self, ErsatzTV.Core.Next.Config.Converter.Settings);
}
internal static class Converter
{
- public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
+ public static readonly JsonSerializerOptions Settings = new(JsonSerializerDefaults.General)
{
- NullValueHandling = NullValueHandling.Ignore,
- MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
- DateParseHandling = DateParseHandling.None,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
AudioFormatConverter.Singleton,
@@ -280,19 +291,20 @@ namespace ErsatzTV.Core.Next.Config
VideoFormatConverter.Singleton,
ScalingModeConverter.Singleton,
VaapiDriverEnumConverter.Singleton,
- new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
+ new DateOnlyConverter(),
+ new TimeOnlyConverter(),
+ IsoDateTimeOffsetConverter.Singleton
},
};
}
- internal class AudioFormatConverter : JsonConverter
+ internal class AudioFormatConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(AudioFormat) || t == typeof(AudioFormat?);
+ public override bool CanConvert(Type t) => t == typeof(AudioFormat);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override AudioFormat Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "aac":
@@ -303,21 +315,15 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type AudioFormat");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, AudioFormat value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (AudioFormat)untypedValue;
switch (value)
{
case AudioFormat.Aac:
- serializer.Serialize(writer, "aac");
+ JsonSerializer.Serialize(writer, "aac", options);
return;
case AudioFormat.Ac3:
- serializer.Serialize(writer, "ac3");
+ JsonSerializer.Serialize(writer, "ac3", options);
return;
}
throw new Exception("Cannot marshal type AudioFormat");
@@ -326,14 +332,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly AudioFormatConverter Singleton = new AudioFormatConverter();
}
- internal class ModeConverter : JsonConverter
+ internal class ModeConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(Mode) || t == typeof(Mode?);
+ public override bool CanConvert(Type t) => t == typeof(Mode);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override Mode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "burn":
@@ -344,21 +349,15 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type Mode");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, Mode value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (Mode)untypedValue;
switch (value)
{
case Mode.Burn:
- serializer.Serialize(writer, "burn");
+ JsonSerializer.Serialize(writer, "burn", options);
return;
case Mode.Convert:
- serializer.Serialize(writer, "convert");
+ JsonSerializer.Serialize(writer, "convert", options);
return;
}
throw new Exception("Cannot marshal type Mode");
@@ -367,14 +366,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly ModeConverter Singleton = new ModeConverter();
}
- internal class AccelEnumConverter : JsonConverter
+ internal class AccelEnumConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(AccelEnum) || t == typeof(AccelEnum?);
+ public override bool CanConvert(Type t) => t == typeof(AccelEnum);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override AccelEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "amf":
@@ -395,36 +393,30 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type AccelEnum");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, AccelEnum value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (AccelEnum)untypedValue;
switch (value)
{
case AccelEnum.Amf:
- serializer.Serialize(writer, "amf");
+ JsonSerializer.Serialize(writer, "amf", options);
return;
case AccelEnum.Cuda:
- serializer.Serialize(writer, "cuda");
+ JsonSerializer.Serialize(writer, "cuda", options);
return;
case AccelEnum.Qsv:
- serializer.Serialize(writer, "qsv");
+ JsonSerializer.Serialize(writer, "qsv", options);
return;
case AccelEnum.Rkmpp:
- serializer.Serialize(writer, "rkmpp");
+ JsonSerializer.Serialize(writer, "rkmpp", options);
return;
case AccelEnum.Vaapi:
- serializer.Serialize(writer, "vaapi");
+ JsonSerializer.Serialize(writer, "vaapi", options);
return;
case AccelEnum.Videotoolbox:
- serializer.Serialize(writer, "videotoolbox");
+ JsonSerializer.Serialize(writer, "videotoolbox", options);
return;
case AccelEnum.Vulkan:
- serializer.Serialize(writer, "vulkan");
+ JsonSerializer.Serialize(writer, "vulkan", options);
return;
}
throw new Exception("Cannot marshal type AccelEnum");
@@ -433,14 +425,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly AccelEnumConverter Singleton = new AccelEnumConverter();
}
- internal class VideoFormatConverter : JsonConverter
+ internal class VideoFormatConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(VideoFormat) || t == typeof(VideoFormat?);
+ public override bool CanConvert(Type t) => t == typeof(VideoFormat);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override VideoFormat Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "h264":
@@ -451,21 +442,15 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type VideoFormat");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, VideoFormat value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (VideoFormat)untypedValue;
switch (value)
{
case VideoFormat.H264:
- serializer.Serialize(writer, "h264");
+ JsonSerializer.Serialize(writer, "h264", options);
return;
case VideoFormat.Hevc:
- serializer.Serialize(writer, "hevc");
+ JsonSerializer.Serialize(writer, "hevc", options);
return;
}
throw new Exception("Cannot marshal type VideoFormat");
@@ -474,14 +459,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly VideoFormatConverter Singleton = new VideoFormatConverter();
}
- internal class ScalingModeConverter : JsonConverter
+ internal class ScalingModeConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(ScalingMode) || t == typeof(ScalingMode?);
+ public override bool CanConvert(Type t) => t == typeof(ScalingMode);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override ScalingMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "crop":
@@ -494,24 +478,18 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type ScalingMode");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, ScalingMode value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (ScalingMode)untypedValue;
switch (value)
{
case ScalingMode.Crop:
- serializer.Serialize(writer, "crop");
+ JsonSerializer.Serialize(writer, "crop", options);
return;
case ScalingMode.ScaleAndPad:
- serializer.Serialize(writer, "scale_and_pad");
+ JsonSerializer.Serialize(writer, "scale_and_pad", options);
return;
case ScalingMode.Stretch:
- serializer.Serialize(writer, "stretch");
+ JsonSerializer.Serialize(writer, "stretch", options);
return;
}
throw new Exception("Cannot marshal type ScalingMode");
@@ -520,14 +498,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly ScalingModeConverter Singleton = new ScalingModeConverter();
}
- internal class VaapiDriverEnumConverter : JsonConverter
+ internal class VaapiDriverEnumConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(VaapiDriverEnum) || t == typeof(VaapiDriverEnum?);
+ public override bool CanConvert(Type t) => t == typeof(VaapiDriverEnum);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override VaapiDriverEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "i965":
@@ -540,24 +517,18 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type VaapiDriverEnum");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, VaapiDriverEnum value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (VaapiDriverEnum)untypedValue;
switch (value)
{
case VaapiDriverEnum.I965:
- serializer.Serialize(writer, "i965");
+ JsonSerializer.Serialize(writer, "i965", options);
return;
case VaapiDriverEnum.Ihd:
- serializer.Serialize(writer, "ihd");
+ JsonSerializer.Serialize(writer, "ihd", options);
return;
case VaapiDriverEnum.Radeonsi:
- serializer.Serialize(writer, "radeonsi");
+ JsonSerializer.Serialize(writer, "radeonsi", options);
return;
}
throw new Exception("Cannot marshal type VaapiDriverEnum");
@@ -565,4 +536,118 @@ namespace ErsatzTV.Core.Next.Config
public static readonly VaapiDriverEnumConverter Singleton = new VaapiDriverEnumConverter();
}
+
+ public class DateOnlyConverter : JsonConverter
+ {
+ private readonly string serializationFormat;
+ public DateOnlyConverter() : this(null) { }
+
+ public DateOnlyConverter(string? serializationFormat)
+ {
+ this.serializationFormat = serializationFormat ?? "yyyy-MM-dd";
+ }
+
+ public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ return DateOnly.Parse(value!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString(serializationFormat));
+ }
+
+ public class TimeOnlyConverter : JsonConverter
+ {
+ private readonly string serializationFormat;
+
+ public TimeOnlyConverter() : this(null) { }
+
+ public TimeOnlyConverter(string? serializationFormat)
+ {
+ this.serializationFormat = serializationFormat ?? "HH:mm:ss.fff";
+ }
+
+ public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ return TimeOnly.Parse(value!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString(serializationFormat));
+ }
+
+ internal class IsoDateTimeOffsetConverter : JsonConverter
+ {
+ public override bool CanConvert(Type t) => t == typeof(DateTimeOffset);
+
+ private const string DefaultDateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK";
+
+ private DateTimeStyles _dateTimeStyles = DateTimeStyles.RoundtripKind;
+ private string? _dateTimeFormat;
+ private CultureInfo? _culture;
+
+ public DateTimeStyles DateTimeStyles
+ {
+ get => _dateTimeStyles;
+ set => _dateTimeStyles = value;
+ }
+
+ public string? DateTimeFormat
+ {
+ get => _dateTimeFormat ?? string.Empty;
+ set => _dateTimeFormat = (string.IsNullOrEmpty(value)) ? null : value;
+ }
+
+ public CultureInfo Culture
+ {
+ get => _culture ?? CultureInfo.CurrentCulture;
+ set => _culture = value;
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
+ {
+ string text;
+
+
+ if ((_dateTimeStyles & DateTimeStyles.AdjustToUniversal) == DateTimeStyles.AdjustToUniversal
+ || (_dateTimeStyles & DateTimeStyles.AssumeUniversal) == DateTimeStyles.AssumeUniversal)
+ {
+ value = value.ToUniversalTime();
+ }
+
+ text = value.ToString(_dateTimeFormat ?? DefaultDateTimeFormat, Culture);
+
+ writer.WriteStringValue(text);
+ }
+
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? dateText = reader.GetString();
+
+ if (string.IsNullOrEmpty(dateText) == false)
+ {
+ if (!string.IsNullOrEmpty(_dateTimeFormat))
+ {
+ return DateTimeOffset.ParseExact(dateText, _dateTimeFormat, Culture, _dateTimeStyles);
+ }
+ else
+ {
+ return DateTimeOffset.Parse(dateText, Culture, _dateTimeStyles);
+ }
+ }
+ else
+ {
+ return default(DateTimeOffset);
+ }
+ }
+
+
+ public static readonly IsoDateTimeOffsetConverter Singleton = new IsoDateTimeOffsetConverter();
+ }
}
+#pragma warning restore CS8618
+#pragma warning restore CS8601
+#pragma warning restore CS8602
+#pragma warning restore CS8603
diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs
index 5014a83b4..6c114f866 100644
--- a/ErsatzTV.Core/Next/Playout.cs
+++ b/ErsatzTV.Core/Next/Playout.cs
@@ -1,19 +1,24 @@
//
//
-// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do:
+// To parse this JSON data, add NuGet 'System.Text.Json' then do:
//
// using ErsatzTV.Core.Next;
//
// var playout = Playout.FromJson(jsonString);
+#nullable enable
+#pragma warning disable CS8618
+#pragma warning disable CS8601
+#pragma warning disable CS8602
+#pragma warning disable CS8603
namespace ErsatzTV.Core.Next
{
using System;
using System.Collections.Generic;
+ using System.Text.Json;
+ using System.Text.Json.Serialization;
using System.Globalization;
- using Newtonsoft.Json;
- using Newtonsoft.Json.Converters;
///
/// A playout schedule for a single time window.
@@ -27,13 +32,13 @@ namespace ErsatzTV.Core.Next
///
/// Ordered list of scheduled items for this window.
///
- [JsonProperty("items")]
+ [JsonPropertyName("items")]
public List Items { get; set; }
///
/// URI identifying the schema version, e.g. "https://ersatztv.org/playout/version/0.0.1".
///
- [JsonProperty("version")]
+ [JsonPropertyName("version")]
public string Version { get; set; }
}
@@ -56,41 +61,41 @@ namespace ErsatzTV.Core.Next
///
/// RFC3339 formatted finish time, e.g. 2026-04-13T00:54:21.527-05:00.
///
- [JsonProperty("finish")]
+ [JsonPropertyName("finish")]
public DateTimeOffset Finish { get; set; }
///
/// Stable identifier for this item, unique within the playout.
///
- [JsonProperty("id")]
+ [JsonPropertyName("id")]
public string Id { get; set; }
///
/// The default source shared by any track that doesn't specify its own. Required unless
/// every selected track in `tracks` provides its own `source`.
///
- [JsonProperty("source")]
- public Source Source { get; set; }
+ [JsonPropertyName("source")]
+ public Source? Source { get; set; }
///
/// RFC3339 formatted start time, e.g. 2026-04-13T00:24:21.527-05:00.
///
- [JsonProperty("start")]
+ [JsonPropertyName("start")]
public DateTimeOffset Start { get; set; }
///
/// Per-track selection. Omit to let the server pick the first video and first audio track of
/// the item's `source`.
///
- [JsonProperty("tracks")]
- public PlayoutItemTracks Tracks { get; set; }
+ [JsonPropertyName("tracks")]
+ public PlayoutItemTracks? Tracks { get; set; }
///
/// Watermark (image/video overlay) to composite on top of the primary content for the
/// duration of this item. Omit for no watermark.
///
- [JsonProperty("watermark")]
- public Watermark Watermark { get; set; }
+ [JsonPropertyName("watermark")]
+ public Watermark? Watermark { get; set; }
}
///
@@ -131,20 +136,21 @@ namespace ErsatzTV.Core.Next
///
/// Optional start offset into the source, in milliseconds.
///
- [JsonProperty("in_point_ms")]
+ [JsonPropertyName("in_point_ms")]
public long? InPointMs { get; set; }
///
/// Optional end offset into the source, in milliseconds.
///
- [JsonProperty("out_point_ms")]
+ [JsonPropertyName("out_point_ms")]
public long? OutPointMs { get; set; }
///
/// Absolute path to the media file.
///
- [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)]
- public string Path { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
///
/// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is
@@ -154,17 +160,18 @@ namespace ErsatzTV.Core.Next
/// read only once (at playback). Especially useful here: a scripted source (e.g. a yt-dlp
/// pipeline) is otherwise opened twice, once to probe and once to play. See `ProbeHint`.
///
- [JsonProperty("probe_hint")]
- public ProbeHint ProbeHint { get; set; }
+ [JsonPropertyName("probe_hint")]
+ public ProbeHint? ProbeHint { get; set; }
- [JsonProperty("source_type")]
+ [JsonPropertyName("source_type")]
public SourceType SourceType { get; set; }
///
/// The lavfi filter graph parameters, passed verbatim to ffmpeg's `-f lavfi -i`.
///
- [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)]
- public string Params { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("params")]
+ public string? Params { get; set; }
///
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"].
@@ -172,25 +179,25 @@ namespace ErsatzTV.Core.Next
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. Supports {{TEMPLATE}}
/// expansion.
///
- [JsonProperty("headers")]
- public List Headers { get; set; }
+ [JsonPropertyName("headers")]
+ public List? Headers { get; set; }
///
/// Enable persistent connections in ffmpeg. Default: false.
///
- [JsonProperty("keep_alive")]
+ [JsonPropertyName("keep_alive")]
public bool? KeepAlive { get; set; }
///
/// Enable reconnect on failure. Default: true.
///
- [JsonProperty("reconnect")]
+ [JsonPropertyName("reconnect")]
public bool? Reconnect { get; set; }
///
/// Maximum reconnect delay in seconds. Maps to ffmpeg's `reconnect_delay_max`.
///
- [JsonProperty("reconnect_delay_max")]
+ [JsonPropertyName("reconnect_delay_max")]
public long? ReconnectDelayMax { get; set; }
///
@@ -198,7 +205,7 @@ namespace ErsatzTV.Core.Next
///
/// Request timeout in microseconds. Defaults to 10 seconds.
///
- [JsonProperty("timeout_us")]
+ [JsonPropertyName("timeout_us")]
public long? TimeoutUs { get; set; }
///
@@ -207,36 +214,39 @@ namespace ErsatzTV.Core.Next
/// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream".
///
/// URI template for the resolver endpoint, e.g.
- /// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON
+ /// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON
/// `PlayoutItem`.
///
- [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
- public string Uri { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("uri")]
+ public string? Uri { get; set; }
///
/// Custom User-Agent string.
///
/// Custom User-Agent string. Supports {{TEMPLATE}} expansion.
///
- [JsonProperty("user_agent")]
- public string UserAgent { get; set; }
+ [JsonPropertyName("user_agent")]
+ public string? UserAgent { get; set; }
///
/// Optional arguments for the command. Supports {{TEMPLATE}} expansion. Defaults to [].
///
- [JsonProperty("args", NullValueHandling = NullValueHandling.Ignore)]
- public List Args { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("args")]
+ public List? Args { get; set; }
///
/// Command that writes an MPEG-TS stream to its stdout. Supports {{TEMPLATE}} expansion.
///
- [JsonProperty("command", NullValueHandling = NullValueHandling.Ignore)]
- public string Command { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("command")]
+ public string? Command { get; set; }
///
/// Whether the content is live and therefore cannot work ahead. Default: false.
///
- [JsonProperty("is_live")]
+ [JsonPropertyName("is_live")]
public bool? IsLive { get; set; }
}
@@ -256,13 +266,14 @@ namespace ErsatzTV.Core.Next
///
/// Audio streams in the source. Omit (or use `[]`) for video-only sources; defaults to empty.
///
- [JsonProperty("audio", NullValueHandling = NullValueHandling.Ignore)]
- public List Audio { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("audio")]
+ public List? Audio { get; set; }
///
/// Source duration in milliseconds. Omit for live or unbounded sources.
///
- [JsonProperty("duration_ms")]
+ [JsonPropertyName("duration_ms")]
public long? DurationMs { get; set; }
///
@@ -270,23 +281,25 @@ namespace ErsatzTV.Core.Next
/// "png_pipe"). Used to detect still images (a single video stream in an `image2` or
/// `*_pipe` container). Defaults to "mpegts" when omitted.
///
- [JsonProperty("format_name")]
- public string FormatName { get; set; }
+ [JsonPropertyName("format_name")]
+ public string? FormatName { get; set; }
///
/// Subtitle streams in the source. Omit (or use `[]`) for sources without subtitles;
/// defaults to empty. Provide an entry for any subtitle stream a track selects, otherwise
/// the requested subtitle stream cannot be located and subtitles are dropped.
///
- [JsonProperty("subtitle", NullValueHandling = NullValueHandling.Ignore)]
- public List Subtitle { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("subtitle")]
+ public List? Subtitle { get; set; }
///
/// Video (and still-image) streams in the source. Omit (or use `[]`) for audio-only sources;
/// defaults to empty.
///
- [JsonProperty("video", NullValueHandling = NullValueHandling.Ignore)]
- public List Video { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("video")]
+ public List? Video { get; set; }
}
///
@@ -297,19 +310,19 @@ namespace ErsatzTV.Core.Next
///
/// Number of audio channels.
///
- [JsonProperty("channels")]
+ [JsonPropertyName("channels")]
public long Channels { get; set; }
///
/// Codec name as reported by ffprobe (e.g. "aac", "ac3", "mp2"). Compared case-insensitively.
///
- [JsonProperty("codec")]
+ [JsonPropertyName("codec")]
public string Codec { get; set; }
///
/// Zero-based index of this stream within the source.
///
- [JsonProperty("stream_index")]
+ [JsonPropertyName("stream_index")]
public long StreamIndex { get; set; }
}
@@ -323,13 +336,13 @@ namespace ErsatzTV.Core.Next
/// "dvd_subtitle"). Compared case-insensitively; image-based codecs are handled differently
/// from text-based ones.
///
- [JsonProperty("codec")]
+ [JsonPropertyName("codec")]
public string Codec { get; set; }
///
/// Zero-based index of this stream within the source.
///
- [JsonProperty("stream_index")]
+ [JsonPropertyName("stream_index")]
public long StreamIndex { get; set; }
}
@@ -344,88 +357,88 @@ namespace ErsatzTV.Core.Next
/// Codec name as reported by ffprobe (e.g. "h264", "hevc", "mpeg2video", "png"). Compared
/// case-insensitively.
///
- [JsonProperty("codec")]
+ [JsonPropertyName("codec")]
public string Codec { get; set; }
///
/// Color primaries (e.g. "bt709", "bt2020"). Omit if unknown.
///
- [JsonProperty("color_primaries")]
- public string ColorPrimaries { get; set; }
+ [JsonPropertyName("color_primaries")]
+ public string? ColorPrimaries { get; set; }
///
/// Color range (e.g. "tv", "pc"). Omit if unknown.
///
- [JsonProperty("color_range")]
- public string ColorRange { get; set; }
+ [JsonPropertyName("color_range")]
+ public string? ColorRange { get; set; }
///
/// Color space / matrix coefficients (e.g. "bt709", "bt2020nc"). Omit if unknown.
///
- [JsonProperty("color_space")]
- public string ColorSpace { get; set; }
+ [JsonPropertyName("color_space")]
+ public string? ColorSpace { get; set; }
///
/// Color transfer characteristics (e.g. "bt709", "smpte2084", "arib-std-b67"). The values
/// "smpte2084" and "arib-std-b67" mark the stream as HDR. Omit for SDR.
///
- [JsonProperty("color_transfer")]
- public string ColorTransfer { get; set; }
+ [JsonPropertyName("color_transfer")]
+ public string? ColorTransfer { get; set; }
///
/// Display aspect ratio, e.g. "16:9". Omit if unknown.
///
- [JsonProperty("display_aspect_ratio")]
- public string DisplayAspectRatio { get; set; }
+ [JsonPropertyName("display_aspect_ratio")]
+ public string? DisplayAspectRatio { get; set; }
///
/// Interlacing field order as reported by ffprobe ("progressive", "tt", "bb", "tb", "bt").
/// Omit for progressive.
///
- [JsonProperty("field_order")]
- public string FieldOrder { get; set; }
+ [JsonPropertyName("field_order")]
+ public string? FieldOrder { get; set; }
///
/// Frame rate as a number or rational string (e.g. "24", "30000/1001"). Defaults to 24 when
/// omitted.
///
- [JsonProperty("frame_rate")]
- public string FrameRate { get; set; }
+ [JsonPropertyName("frame_rate")]
+ public string? FrameRate { get; set; }
///
/// Coded frame height in pixels.
///
- [JsonProperty("height")]
+ [JsonPropertyName("height")]
public long Height { get; set; }
///
/// Pixel format (e.g. "yuv420p", "yuv420p10le").
///
- [JsonProperty("pix_fmt")]
+ [JsonPropertyName("pix_fmt")]
public string PixFmt { get; set; }
///
/// Codec profile (e.g. "high", "main 10"). Compared case-insensitively. Omit if unknown.
///
- [JsonProperty("profile")]
- public string Profile { get; set; }
+ [JsonPropertyName("profile")]
+ public string? Profile { get; set; }
///
/// Sample (pixel) aspect ratio, e.g. "1:1". Omit for square pixels.
///
- [JsonProperty("sample_aspect_ratio")]
- public string SampleAspectRatio { get; set; }
+ [JsonPropertyName("sample_aspect_ratio")]
+ public string? SampleAspectRatio { get; set; }
///
/// Zero-based index of this stream within the source.
///
- [JsonProperty("stream_index")]
+ [JsonPropertyName("stream_index")]
public long StreamIndex { get; set; }
///
/// Coded frame width in pixels.
///
- [JsonProperty("width")]
+ [JsonPropertyName("width")]
public long Width { get; set; }
}
@@ -438,20 +451,20 @@ namespace ErsatzTV.Core.Next
///
/// Audio track selection.
///
- [JsonProperty("audio")]
- public TrackSelection Audio { get; set; }
+ [JsonPropertyName("audio")]
+ public TrackSelection? Audio { get; set; }
///
/// Subtitle track selection.
///
- [JsonProperty("subtitle")]
- public TrackSelection Subtitle { get; set; }
+ [JsonPropertyName("subtitle")]
+ public TrackSelection? Subtitle { get; set; }
///
/// Video track selection.
///
- [JsonProperty("video")]
- public TrackSelection Video { get; set; }
+ [JsonPropertyName("video")]
+ public TrackSelection? Video { get; set; }
}
///
@@ -468,14 +481,14 @@ namespace ErsatzTV.Core.Next
///
/// Source to pull this track from. If omitted, inherits from the parent `PlayoutItem.source`.
///
- [JsonProperty("source")]
- public Source Source { get; set; }
+ [JsonPropertyName("source")]
+ public Source? Source { get; set; }
///
/// Zero-based stream index within the effective source. If omitted, the server picks the
/// first stream of this track's kind.
///
- [JsonProperty("stream_index")]
+ [JsonPropertyName("stream_index")]
public long? StreamIndex { get; set; }
}
@@ -489,20 +502,20 @@ namespace ErsatzTV.Core.Next
/// Horizontal offset from the anchor `location`, as a percent of primary content width
/// (0–100). Omit for 0.
///
- [JsonProperty("horizontal_margin_percent")]
+ [JsonPropertyName("horizontal_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? HorizontalMarginPercent { get; set; }
///
/// Anchor position within the primary content frame.
///
- [JsonProperty("location")]
+ [JsonPropertyName("location")]
public WatermarkLocation Location { get; set; }
///
/// Opacity as a percent (0–100). Omit for fully opaque (100).
///
- [JsonProperty("opacity_percent")]
+ [JsonPropertyName("opacity_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? OpacityPercent { get; set; }
@@ -510,27 +523,27 @@ namespace ErsatzTV.Core.Next
/// The source providing the watermark media (typically an image, but any `PlayoutItemSource`
/// is accepted).
///
- [JsonProperty("source")]
+ [JsonPropertyName("source")]
public PlayoutItemSource Source { get; set; }
///
/// Zero-based stream index within the source. If omitted, the server picks the first video
/// stream.
///
- [JsonProperty("stream_index")]
+ [JsonPropertyName("stream_index")]
public long? StreamIndex { get; set; }
///
/// Visibility schedule for the watermark. Omit for an always-on watermark.
///
- [JsonProperty("timing")]
- public Timing Timing { get; set; }
+ [JsonPropertyName("timing")]
+ public Timing? Timing { get; set; }
///
/// Vertical offset from the anchor `location`, as a percent of primary content height
/// (0–100). Omit for 0.
///
- [JsonProperty("vertical_margin_percent")]
+ [JsonPropertyName("vertical_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? VerticalMarginPercent { get; set; }
@@ -538,7 +551,7 @@ namespace ErsatzTV.Core.Next
/// Scale the watermark to this percent of the primary content width (0–100). Omit to use the
/// watermark's actual size.
///
- [JsonProperty("width_percent")]
+ [JsonPropertyName("width_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? WidthPercent { get; set; }
@@ -549,7 +562,7 @@ namespace ErsatzTV.Core.Next
/// so a 0% margin can land inside the bars. Has no effect when the primary content fills the
/// output (crop/stretch). Omit for false.
///
- [JsonProperty("within_source_content")]
+ [JsonPropertyName("within_source_content")]
public bool? WithinSourceContent { get; set; }
}
@@ -596,20 +609,21 @@ namespace ErsatzTV.Core.Next
///
/// Optional start offset into the source, in milliseconds.
///
- [JsonProperty("in_point_ms")]
+ [JsonPropertyName("in_point_ms")]
public long? InPointMs { get; set; }
///
/// Optional end offset into the source, in milliseconds.
///
- [JsonProperty("out_point_ms")]
+ [JsonPropertyName("out_point_ms")]
public long? OutPointMs { get; set; }
///
/// Absolute path to the media file.
///
- [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)]
- public string Path { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("path")]
+ public string? Path { get; set; }
///
/// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is
@@ -619,17 +633,18 @@ namespace ErsatzTV.Core.Next
/// read only once (at playback). Especially useful here: a scripted source (e.g. a yt-dlp
/// pipeline) is otherwise opened twice, once to probe and once to play. See `ProbeHint`.
///
- [JsonProperty("probe_hint")]
- public ProbeHint ProbeHint { get; set; }
+ [JsonPropertyName("probe_hint")]
+ public ProbeHint? ProbeHint { get; set; }
- [JsonProperty("source_type")]
+ [JsonPropertyName("source_type")]
public SourceType SourceType { get; set; }
///
/// The lavfi filter graph parameters, passed verbatim to ffmpeg's `-f lavfi -i`.
///
- [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)]
- public string Params { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("params")]
+ public string? Params { get; set; }
///
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"].
@@ -637,25 +652,25 @@ namespace ErsatzTV.Core.Next
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. Supports {{TEMPLATE}}
/// expansion.
///
- [JsonProperty("headers")]
- public List Headers { get; set; }
+ [JsonPropertyName("headers")]
+ public List? Headers { get; set; }
///
/// Enable persistent connections in ffmpeg. Default: false.
///
- [JsonProperty("keep_alive")]
+ [JsonPropertyName("keep_alive")]
public bool? KeepAlive { get; set; }
///
/// Enable reconnect on failure. Default: true.
///
- [JsonProperty("reconnect")]
+ [JsonPropertyName("reconnect")]
public bool? Reconnect { get; set; }
///
/// Maximum reconnect delay in seconds. Maps to ffmpeg's `reconnect_delay_max`.
///
- [JsonProperty("reconnect_delay_max")]
+ [JsonPropertyName("reconnect_delay_max")]
public long? ReconnectDelayMax { get; set; }
///
@@ -663,7 +678,7 @@ namespace ErsatzTV.Core.Next
///
/// Request timeout in microseconds. Defaults to 10 seconds.
///
- [JsonProperty("timeout_us")]
+ [JsonPropertyName("timeout_us")]
public long? TimeoutUs { get; set; }
///
@@ -672,36 +687,39 @@ namespace ErsatzTV.Core.Next
/// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream".
///
/// URI template for the resolver endpoint, e.g.
- /// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON
+ /// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON
/// `PlayoutItem`.
///
- [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
- public string Uri { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("uri")]
+ public string? Uri { get; set; }
///
/// Custom User-Agent string.
///
/// Custom User-Agent string. Supports {{TEMPLATE}} expansion.
///
- [JsonProperty("user_agent")]
- public string UserAgent { get; set; }
+ [JsonPropertyName("user_agent")]
+ public string? UserAgent { get; set; }
///
/// Optional arguments for the command. Supports {{TEMPLATE}} expansion. Defaults to [].
///
- [JsonProperty("args", NullValueHandling = NullValueHandling.Ignore)]
- public List Args { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("args")]
+ public List? Args { get; set; }
///
/// Command that writes an MPEG-TS stream to its stdout. Supports {{TEMPLATE}} expansion.
///
- [JsonProperty("command", NullValueHandling = NullValueHandling.Ignore)]
- public string Command { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonPropertyName("command")]
+ public string? Command { get; set; }
///
/// Whether the content is live and therefore cannot work ahead. Default: false.
///
- [JsonProperty("is_live")]
+ [JsonPropertyName("is_live")]
public bool? IsLive { get; set; }
}
@@ -718,7 +736,7 @@ namespace ErsatzTV.Core.Next
///
/// Reference clock used to position cycles.
///
- [JsonProperty("clock")]
+ [JsonPropertyName("clock")]
public PeriodicClock Clock { get; set; }
///
@@ -726,26 +744,26 @@ namespace ErsatzTV.Core.Next
/// which new appearances are allowed to begin. An appearance already in progress at the cap
/// is allowed to fade out cleanly. Omit for no cap.
///
- [JsonProperty("disable_after_ms")]
+ [JsonPropertyName("disable_after_ms")]
public long? DisableAfterMs { get; set; }
///
/// Fade-in and fade-out duration in milliseconds (symmetric). Must be ≤ `hold_ms`. Omit for
/// 1000; set to 0 for hard cuts.
///
- [JsonProperty("fade_ms")]
+ [JsonPropertyName("fade_ms")]
public long? FadeMs { get; set; }
///
/// Period of the cycle, start-to-start, in milliseconds.
///
- [JsonProperty("frequency_ms")]
+ [JsonPropertyName("frequency_ms")]
public long FrequencyMs { get; set; }
///
/// Time held fully visible between fade-in and fade-out, in milliseconds.
///
- [JsonProperty("hold_ms")]
+ [JsonPropertyName("hold_ms")]
public long HoldMs { get; set; }
///
@@ -753,10 +771,10 @@ namespace ErsatzTV.Core.Next
/// an offset of 0 puts an appearance start at every wall-clock 5-minute boundary; 120000
/// shifts to :02, :07, :12, etc. Omit for 0.
///
- [JsonProperty("phase_offset_ms")]
+ [JsonPropertyName("phase_offset_ms")]
public long? PhaseOffsetMs { get; set; }
- [JsonProperty("timing_type")]
+ [JsonPropertyName("timing_type")]
public TimingType TimingType { get; set; }
}
@@ -783,40 +801,39 @@ namespace ErsatzTV.Core.Next
public partial class Playout
{
- public static Playout FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Converter.Settings);
+ public static Playout FromJson(string json) => JsonSerializer.Deserialize(json, ErsatzTV.Core.Next.Converter.Settings);
}
public static class Serialize
{
- public static string ToJson(this Playout self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Converter.Settings);
+ public static string ToJson(this Playout self) => JsonSerializer.Serialize(self, ErsatzTV.Core.Next.Converter.Settings);
}
public static class Converter
{
- public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
+ public static readonly JsonSerializerOptions Settings = new(JsonSerializerDefaults.General)
{
- NullValueHandling = NullValueHandling.Ignore,
- MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
- DateParseHandling = DateParseHandling.None,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
SourceTypeConverter.Singleton,
WatermarkLocationConverter.Singleton,
PeriodicClockConverter.Singleton,
TimingTypeConverter.Singleton,
- new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
+ new DateOnlyConverter(),
+ new TimeOnlyConverter(),
+ IsoDateTimeOffsetConverter.Singleton
},
};
}
- internal class SourceTypeConverter : JsonConverter
+ internal class SourceTypeConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(SourceType) || t == typeof(SourceType?);
+ public override bool CanConvert(Type t) => t == typeof(SourceType);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override SourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "dynamic":
@@ -835,33 +852,27 @@ namespace ErsatzTV.Core.Next
throw new Exception("Cannot unmarshal type SourceType");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, SourceType value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (SourceType)untypedValue;
switch (value)
{
case SourceType.Dynamic:
- serializer.Serialize(writer, "dynamic");
+ JsonSerializer.Serialize(writer, "dynamic", options);
return;
case SourceType.Http:
- serializer.Serialize(writer, "http");
+ JsonSerializer.Serialize(writer, "http", options);
return;
case SourceType.Lavfi:
- serializer.Serialize(writer, "lavfi");
+ JsonSerializer.Serialize(writer, "lavfi", options);
return;
case SourceType.Local:
- serializer.Serialize(writer, "local");
+ JsonSerializer.Serialize(writer, "local", options);
return;
case SourceType.Rtsp:
- serializer.Serialize(writer, "rtsp");
+ JsonSerializer.Serialize(writer, "rtsp", options);
return;
case SourceType.Script:
- serializer.Serialize(writer, "script");
+ JsonSerializer.Serialize(writer, "script", options);
return;
}
throw new Exception("Cannot marshal type SourceType");
@@ -870,14 +881,13 @@ namespace ErsatzTV.Core.Next
public static readonly SourceTypeConverter Singleton = new SourceTypeConverter();
}
- internal class MinMaxValueCheckConverter : JsonConverter
+ internal class MinMaxValueCheckConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(double) || t == typeof(double?);
+ public override bool CanConvert(Type t) => t == typeof(double);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetDouble();
if (value >= 0 && value <= 100)
{
return value;
@@ -885,17 +895,11 @@ namespace ErsatzTV.Core.Next
throw new Exception("Cannot unmarshal type double");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (double)untypedValue;
if (value >= 0 && value <= 100)
{
- serializer.Serialize(writer, value);
+ JsonSerializer.Serialize(writer, value, options);
return;
}
throw new Exception("Cannot marshal type double");
@@ -904,14 +908,13 @@ namespace ErsatzTV.Core.Next
public static readonly MinMaxValueCheckConverter Singleton = new MinMaxValueCheckConverter();
}
- internal class WatermarkLocationConverter : JsonConverter
+ internal class WatermarkLocationConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(WatermarkLocation) || t == typeof(WatermarkLocation?);
+ public override bool CanConvert(Type t) => t == typeof(WatermarkLocation);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override WatermarkLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "bottom_center":
@@ -936,42 +939,36 @@ namespace ErsatzTV.Core.Next
throw new Exception("Cannot unmarshal type WatermarkLocation");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, WatermarkLocation value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (WatermarkLocation)untypedValue;
switch (value)
{
case WatermarkLocation.BottomCenter:
- serializer.Serialize(writer, "bottom_center");
+ JsonSerializer.Serialize(writer, "bottom_center", options);
return;
case WatermarkLocation.BottomLeft:
- serializer.Serialize(writer, "bottom_left");
+ JsonSerializer.Serialize(writer, "bottom_left", options);
return;
case WatermarkLocation.BottomRight:
- serializer.Serialize(writer, "bottom_right");
+ JsonSerializer.Serialize(writer, "bottom_right", options);
return;
case WatermarkLocation.Center:
- serializer.Serialize(writer, "center");
+ JsonSerializer.Serialize(writer, "center", options);
return;
case WatermarkLocation.CenterLeft:
- serializer.Serialize(writer, "center_left");
+ JsonSerializer.Serialize(writer, "center_left", options);
return;
case WatermarkLocation.CenterRight:
- serializer.Serialize(writer, "center_right");
+ JsonSerializer.Serialize(writer, "center_right", options);
return;
case WatermarkLocation.TopCenter:
- serializer.Serialize(writer, "top_center");
+ JsonSerializer.Serialize(writer, "top_center", options);
return;
case WatermarkLocation.TopLeft:
- serializer.Serialize(writer, "top_left");
+ JsonSerializer.Serialize(writer, "top_left", options);
return;
case WatermarkLocation.TopRight:
- serializer.Serialize(writer, "top_right");
+ JsonSerializer.Serialize(writer, "top_right", options);
return;
}
throw new Exception("Cannot marshal type WatermarkLocation");
@@ -980,14 +977,13 @@ namespace ErsatzTV.Core.Next
public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter();
}
- internal class PeriodicClockConverter : JsonConverter
+ internal class PeriodicClockConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(PeriodicClock) || t == typeof(PeriodicClock?);
+ public override bool CanConvert(Type t) => t == typeof(PeriodicClock);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override PeriodicClock Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
switch (value)
{
case "content":
@@ -998,21 +994,15 @@ namespace ErsatzTV.Core.Next
throw new Exception("Cannot unmarshal type PeriodicClock");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, PeriodicClock value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (PeriodicClock)untypedValue;
switch (value)
{
case PeriodicClock.Content:
- serializer.Serialize(writer, "content");
+ JsonSerializer.Serialize(writer, "content", options);
return;
case PeriodicClock.Wall:
- serializer.Serialize(writer, "wall");
+ JsonSerializer.Serialize(writer, "wall", options);
return;
}
throw new Exception("Cannot marshal type PeriodicClock");
@@ -1021,14 +1011,13 @@ namespace ErsatzTV.Core.Next
public static readonly PeriodicClockConverter Singleton = new PeriodicClockConverter();
}
- internal class TimingTypeConverter : JsonConverter
+ internal class TimingTypeConverter : JsonConverter
{
- public override bool CanConvert(Type t) => t == typeof(TimingType) || t == typeof(TimingType?);
+ public override bool CanConvert(Type t) => t == typeof(TimingType);
- public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
+ public override TimingType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonToken.Null) return null;
- var value = serializer.Deserialize(reader);
+ var value = reader.GetString();
if (value == "periodic")
{
return TimingType.Periodic;
@@ -1036,17 +1025,11 @@ namespace ErsatzTV.Core.Next
throw new Exception("Cannot unmarshal type TimingType");
}
- public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
+ public override void Write(Utf8JsonWriter writer, TimingType value, JsonSerializerOptions options)
{
- if (untypedValue == null)
- {
- serializer.Serialize(writer, null);
- return;
- }
- var value = (TimingType)untypedValue;
if (value == TimingType.Periodic)
{
- serializer.Serialize(writer, "periodic");
+ JsonSerializer.Serialize(writer, "periodic", options);
return;
}
throw new Exception("Cannot marshal type TimingType");
@@ -1054,4 +1037,118 @@ namespace ErsatzTV.Core.Next
public static readonly TimingTypeConverter Singleton = new TimingTypeConverter();
}
+
+ public class DateOnlyConverter : JsonConverter
+ {
+ private readonly string serializationFormat;
+ public DateOnlyConverter() : this(null) { }
+
+ public DateOnlyConverter(string? serializationFormat)
+ {
+ this.serializationFormat = serializationFormat ?? "yyyy-MM-dd";
+ }
+
+ public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ return DateOnly.Parse(value!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString(serializationFormat));
+ }
+
+ public class TimeOnlyConverter : JsonConverter
+ {
+ private readonly string serializationFormat;
+
+ public TimeOnlyConverter() : this(null) { }
+
+ public TimeOnlyConverter(string? serializationFormat)
+ {
+ this.serializationFormat = serializationFormat ?? "HH:mm:ss.fff";
+ }
+
+ public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ return TimeOnly.Parse(value!);
+ }
+
+ public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString(serializationFormat));
+ }
+
+ internal class IsoDateTimeOffsetConverter : JsonConverter
+ {
+ public override bool CanConvert(Type t) => t == typeof(DateTimeOffset);
+
+ private const string DefaultDateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK";
+
+ private DateTimeStyles _dateTimeStyles = DateTimeStyles.RoundtripKind;
+ private string? _dateTimeFormat;
+ private CultureInfo? _culture;
+
+ public DateTimeStyles DateTimeStyles
+ {
+ get => _dateTimeStyles;
+ set => _dateTimeStyles = value;
+ }
+
+ public string? DateTimeFormat
+ {
+ get => _dateTimeFormat ?? string.Empty;
+ set => _dateTimeFormat = (string.IsNullOrEmpty(value)) ? null : value;
+ }
+
+ public CultureInfo Culture
+ {
+ get => _culture ?? CultureInfo.CurrentCulture;
+ set => _culture = value;
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
+ {
+ string text;
+
+
+ if ((_dateTimeStyles & DateTimeStyles.AdjustToUniversal) == DateTimeStyles.AdjustToUniversal
+ || (_dateTimeStyles & DateTimeStyles.AssumeUniversal) == DateTimeStyles.AssumeUniversal)
+ {
+ value = value.ToUniversalTime();
+ }
+
+ text = value.ToString(_dateTimeFormat ?? DefaultDateTimeFormat, Culture);
+
+ writer.WriteStringValue(text);
+ }
+
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? dateText = reader.GetString();
+
+ if (string.IsNullOrEmpty(dateText) == false)
+ {
+ if (!string.IsNullOrEmpty(_dateTimeFormat))
+ {
+ return DateTimeOffset.ParseExact(dateText, _dateTimeFormat, Culture, _dateTimeStyles);
+ }
+ else
+ {
+ return DateTimeOffset.Parse(dateText, Culture, _dateTimeStyles);
+ }
+ }
+ else
+ {
+ return default(DateTimeOffset);
+ }
+ }
+
+
+ public static readonly IsoDateTimeOffsetConverter Singleton = new IsoDateTimeOffsetConverter();
+ }
}
+#pragma warning restore CS8618
+#pragma warning restore CS8601
+#pragma warning restore CS8602
+#pragma warning restore CS8603
diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs
index a8f3134ad..b2929a84f 100644
--- a/ErsatzTV/Controllers/InternalController.cs
+++ b/ErsatzTV/Controllers/InternalController.cs
@@ -22,7 +22,6 @@ using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
-using Newtonsoft.Json;
namespace ErsatzTV.Controllers;
@@ -320,7 +319,7 @@ public class InternalController : StreamingControllerBase
foreach (Core.Next.PlayoutItem nextPlayoutItem in maybeNextPlayoutItem)
{
return Content(
- JsonConvert.SerializeObject(nextPlayoutItem, Core.Next.Converter.Settings),
+ System.Text.Json.JsonSerializer.Serialize(nextPlayoutItem, Core.Next.Converter.Settings),
"application/json");
}
}