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"); } }