diff --git a/CHANGELOG.md b/CHANGELOG.md index 839e34343..a9dccc4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix case where block playouts would occasionally get stuck building forever - Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes +- Pass extracted subtitle paths to next engine; this fixes text subtitle burn-in when extraction is enabled + - Embedded text subtitles will otherwise be ignored and unused (when extraction is disabled) +- Fix next engine music video playback when music video credits are disabled +- Fix playback when seeking into content beyond final text subtitle cue ## [26.6.0] - 2026-07-09 ### Added diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 61e68dca4..c55e6a7a3 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -49,7 +49,9 @@ public class UpdateChannelHandler( bool hasPlayoutChange = hasEpgChange || c.WatermarkId != update.WatermarkId || c.PreferredAudioLanguageCode != update.PreferredAudioLanguageCode || c.PreferredAudioTitle != update.PreferredAudioTitle || - c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode; + c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode || + c.MusicVideoCreditsMode != update.MusicVideoCreditsMode || + c.SubtitleMode != update.SubtitleMode; c.Name = update.Name; c.Number = update.Number; diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs index 43c93161b..c2504bee2 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -353,7 +353,8 @@ public class StartFFmpegNextSessionHandler( { NextEngineTextSubtitleMode.Convert => Mode.Convert, _ => Mode.Burn - } + }, + FontsFolder = FileSystemLayout.FontsCacheFolder }; string playoutFolder = _fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channel.Number, "current"); diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs index 4c08b0a6c..fcc721a93 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Application.Maintenance; +using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; @@ -168,6 +169,7 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa .Map(pi => pi.MediaItemId) .ToList(); + bool extractedAnything = false; foreach (int mediaItemId in toUpdate) { if (cancellationToken.IsCancellationRequested) @@ -176,8 +178,16 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa } // extract subtitles and fonts for each item and update db - await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken); - await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken); + extractedAnything |= await ExtractSubtitles( + dbContext, + mediaItemId, + ffmpegPath, + cancellationToken); + extractedAnything |= await ExtractFonts( + dbContext, + mediaItemId, + ffmpegPath, + cancellationToken); } _logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); @@ -186,6 +196,21 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa { await _entityLocker.UnlockPlayout(playoutId); } + + if (extractedAnything) + { + List channelNumbers = await dbContext.Channels + .AsNoTracking() + .Where(c => c.StreamingEngine == StreamingEngine.Next && + c.Playouts.Any(p => playoutIdsToCheck.Contains(p.Id))) + .Select(c => c.Number) + .ToListAsync(cancellationToken); + + foreach (string channelNumber in channelNumbers) + { + await _workerChannel.WriteAsync(new SyncNextPlayout(channelNumber), cancellationToken); + } + } } catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs index 52776461e..7f70d6de3 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs @@ -89,12 +89,14 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem return result; } - protected async Task ExtractSubtitles( + protected async Task ExtractSubtitles( TvContext dbContext, int mediaItemId, string ffmpegPath, CancellationToken cancellationToken) { + bool extractedAnything = false; + foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) { foreach (List allSubtitles in GetSubtitles(mediaItem)) @@ -133,9 +135,10 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem .Add("-hide_banner") .Add("-i").Add(mediaItemPath); - var subtitleIndexes = subtitlesToExtract - .OrderBy(s => s.Subtitle.StreamIndex) - .Select(s => s.Subtitle.StreamIndex) + var subtitleIndexes = allSubtitles + .Filter(s => s.SubtitleKind is SubtitleKind.Embedded) + .OrderBy(s => s.StreamIndex) + .Select(s => s.StreamIndex) .ToList(); foreach (SubtitleToExtract subtitle in subtitlesToExtract) @@ -172,6 +175,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem "Successfully extracted {Count} subtitles in {Duration}", subtitlesToExtract.Count, sw.Elapsed.Humanize()); + + extractedAnything = true; } else { @@ -182,14 +187,18 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem } } } + + return extractedAnything; } - protected async Task ExtractFonts( + protected async Task ExtractFonts( TvContext dbContext, int mediaItemId, string ffmpegPath, CancellationToken cancellationToken) { + bool extractedAnything = false; + foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) { MediaVersion headVersion = mediaItem.GetHeadVersion(); @@ -232,6 +241,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem if (fileSystem.File.Exists(fullOutputPath)) { logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); + extractedAnything = true; } else { @@ -242,6 +252,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem } } } + + return extractedAnything; } diff --git a/ErsatzTV.Core/Next/Config/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs index 006fa1bdf..1da6b968b 100644 --- a/ErsatzTV.Core/Next/Config/ChannelConfig.cs +++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs @@ -1,246 +1,262 @@ // // -// 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)] + [JsonPropertyName("fonts_folder")] + public string? FontsFolder { get; set; } + + [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 +273,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 +294,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 +318,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 +335,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 +352,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 +369,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 +396,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 +428,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 +445,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 +462,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 +481,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 +501,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 +520,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 +539,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.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index 9d07bded9..89923a48e 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -103,20 +103,7 @@ public class PlayoutItemConverter( foreach (Core.Next.Source source in maybeSource) { - if (playoutItem is not DynamicPlayoutItem) - { - if (playoutItem.InPoint > TimeSpan.Zero) - { - source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; - } - - var duration = playoutItem.MediaItem.GetDurationForPlayout(); - if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) - { - source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; - } - } - + SetInOutPoints(playoutItem, source); nextPlayoutItem.Source = source; } @@ -158,7 +145,7 @@ public class PlayoutItemConverter( Codec = s.Codec }).ToList(); - nextPlayoutItem.Source.ProbeHint = new Core.Next.ProbeHint + nextPlayoutItem.Source!.ProbeHint = new Core.Next.ProbeHint { Audio = sourceAudioHints, Video = sourceVideoHints, @@ -375,7 +362,7 @@ public class PlayoutItemConverter( ChannelSubtitleMode subtitleMode, CancellationToken cancellationToken) { - List allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); + List allSubtitles = await GetSubtitles(channel, audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); Option maybeAudioStream = Option.None; Option maybeSubtitle = Option.None; @@ -428,11 +415,30 @@ public class PlayoutItemConverter( { if (subtitle.SubtitleKind is SubtitleKind.Embedded) { - if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) + if (subtitle.IsImage) { - nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); - nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); - nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; + if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) + { + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); + nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; + } + } + // next only supports sidecar text subtitles at the moment; ignore non-extracted text subs + else if (subtitle.IsExtracted && !string.IsNullOrWhiteSpace(subtitle.Path)) + { + if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) + { + nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); + nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); + nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Local, + Path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path), + }; + + SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); + } } } else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) @@ -446,6 +452,8 @@ public class PlayoutItemConverter( SourceType = Core.Next.SourceType.Local, Path = subtitle.Path, }; + + SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); } } else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) @@ -548,6 +556,7 @@ public class PlayoutItemConverter( } private static async Task> GetSubtitles( + Channel channel, MediaItem mediaItem, int playoutItemId, TimeSpan playoutItemInPoint) @@ -560,7 +569,7 @@ public class PlayoutItemConverter( Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), - MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), + MusicVideo => GetMusicVideoSubtitles(channel, playoutItemId, playoutItemInPoint), OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), @@ -582,8 +591,13 @@ public class PlayoutItemConverter( return allSubtitles; } - private static List GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) + private static List GetMusicVideoSubtitles(Channel channel, int playoutItemId, TimeSpan playoutItemInPoint) { + if (channel.MusicVideoCreditsMode is not ChannelMusicVideoCreditsMode.GenerateSubtitles) + { + return []; + } + string seekToMs = playoutItemInPoint > TimeSpan.Zero ? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" : string.Empty; @@ -602,4 +616,21 @@ public class PlayoutItemConverter( } ]; } + + private static void SetInOutPoints(PlayoutItem playoutItem, Core.Next.Source source) + { + if (playoutItem is not DynamicPlayoutItem) + { + if (playoutItem.InPoint > TimeSpan.Zero) + { + source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; + } + + var duration = playoutItem.MediaItem.GetDurationForPlayout(); + if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) + { + source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; + } + } + } } diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index a8f3134ad..9840be53f 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -1,5 +1,6 @@ using System.CommandLine.Parsing; using System.Diagnostics; +using System.Text; using CliWrap; using ErsatzTV.Application.Emby; using ErsatzTV.Application.Jellyfin; @@ -22,7 +23,6 @@ using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; namespace ErsatzTV.Controllers; @@ -64,7 +64,8 @@ public class InternalController : StreamingControllerBase [HttpGet("ffmpeg/music-video-credits/{playoutItemId:int}")] public async Task GetMusicVideoCredits( int playoutItemId, - [FromQuery] long? seekToMs, + [FromQuery] + long? seekToMs, CancellationToken cancellationToken) { Option maybeCreditsFile = await _mediator.Send( @@ -75,7 +76,7 @@ public class InternalController : StreamingControllerBase return new PhysicalFileResult(creditsFile, "text/x-ssa"); } - return NotFound(); + return File(Encoding.UTF8.GetBytes(EmptySubtitleDocument("text/x-ssa")), "text/x-ssa"); } [HttpGet("ffmpeg/remote-stream/{remoteStreamId}")] @@ -219,9 +220,14 @@ public class InternalController : StreamingControllerBase } [HttpGet("/media/subtitle/{id:int}")] - public async Task GetSubtitle(int id, [FromQuery] long? seekToMs) + public async Task GetSubtitle( + int id, + [FromQuery] long? seekToMs, + CancellationToken cancellationToken) { - Either maybePath = await _mediator.Send(new GetSubtitlePathById(id)); + Either maybePath = await _mediator.Send( + new GetSubtitlePathById(id), + cancellationToken); foreach (SubtitlePathAndCodec pathAndCodec in maybePath.RightToSeq()) { @@ -237,7 +243,8 @@ public class InternalController : StreamingControllerBase if (seekToMs is > 0) { Either maybeProcess = await _mediator.Send( - new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value))); + new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value)), + cancellationToken); foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq()) { Command command = processModel.Process; @@ -265,10 +272,20 @@ public class InternalController : StreamingControllerBase } process.Start(); - return new FileStreamResult(process.StandardOutput.BaseStream, mimeType); + using var buffer = new MemoryStream(); + await process.StandardOutput.BaseStream.CopyToAsync(buffer, cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + byte[] bytes = buffer.ToArray(); + if (bytes.Length == 0) + { + return Content(EmptySubtitleDocument(mimeType), mimeType); + } + + return File(bytes, mimeType); } - return new NotFoundResult(); + return Content(EmptySubtitleDocument(mimeType), mimeType); } if (pathAndCodec.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) @@ -320,7 +337,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"); } } @@ -348,4 +365,13 @@ public class InternalController : StreamingControllerBase return GetProcessResponse(result, channelNumber, StreamingMode.TransportStream); } + + private static string EmptySubtitleDocument(string mimeType) => mimeType switch + { + "text/x-ssa" => "[Script Info]\nScriptType: v4.00+\n\n" + + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20\n\n" + + "[Events]\nFormat: Layer, Start, End, Style, Text\n", + "text/vtt" => "WEBVTT\n\n", + _ => "1\n00:00:00,000 --> 00:00:00,001\n \n\n" + }; }