diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs index a6372dd01..e401f6e8f 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -14,7 +14,7 @@ using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Core.Next; +using ErsatzTV.Core.Next.Config; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -351,7 +351,7 @@ public class StartFFmpegNextSessionHandler( return new ChannelConfig { - Playout = new Core.Next.Playout + Playout = new Core.Next.Config.Playout { Folder = playoutFolder }, diff --git a/ErsatzTV.Application/Streaming/NextSessionWorker.cs b/ErsatzTV.Application/Streaming/NextSessionWorker.cs index f898902b2..3dd0bc309 100644 --- a/ErsatzTV.Application/Streaming/NextSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/NextSessionWorker.cs @@ -1,11 +1,10 @@ using System.IO.Abstractions; -using System.Text; using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Next; +using ErsatzTV.Core.Next.Config; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index 4959a351e..7656ee9a2 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -45,7 +45,7 @@ - + diff --git a/ErsatzTV.Core/Next/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs similarity index 98% rename from ErsatzTV.Core/Next/ChannelConfig.cs rename to ErsatzTV.Core/Next/Config/ChannelConfig.cs index 31052879a..5d0c5eeb8 100644 --- a/ErsatzTV.Core/Next/ChannelConfig.cs +++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs @@ -2,11 +2,11 @@ // // To parse this JSON data, add NuGet 'Newtonsoft.Json' then do: // -// using ErsatzTV.Core.Next; +// using ErsatzTV.Core.Next.Config; // // var channelConfig = ChannelConfig.FromJson(jsonString); -namespace ErsatzTV.Core.Next +namespace ErsatzTV.Core.Next.Config { using System; using System.Collections.Generic; @@ -139,12 +139,12 @@ namespace ErsatzTV.Core.Next public partial class ChannelConfig { - public static ChannelConfig FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Converter.Settings); + public static ChannelConfig FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Config.Converter.Settings); } public static class Serialize { - public static string ToJson(this ChannelConfig self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Converter.Settings); + public static string ToJson(this ChannelConfig self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Config.Converter.Settings); } internal static class Converter diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs new file mode 100644 index 000000000..0642b33f2 --- /dev/null +++ b/ErsatzTV.Core/Next/Playout.cs @@ -0,0 +1,259 @@ +// +// +// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do: +// +// using ErsatzTV.Core.Next; +// +// var playout = Playout.FromJson(jsonString); + +namespace ErsatzTV.Core.Next +{ + using System; + using System.Collections.Generic; + + using System.Globalization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + /// + /// A playout schedule for a single time window. + /// + /// Files should be named `{start}_{finish}.json` using compact ISO 8601 + /// (no separators), e.g. + /// `20260413T000000.000000000-0500_20260414T002131.620000000-0500.json`, + /// so that the channel can locate the correct file for the current time. + /// + public partial class Playout + { + [JsonProperty("items")] + public List Items { get; set; } + + /// + /// URI identifying the schema version, e.g. "https://ersatztv.org/playout/version/0.0.1" + /// + [JsonProperty("version")] + public string Version { get; set; } + } + + public partial class ItemElement + { + /// + /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// + [JsonProperty("finish")] + public string Finish { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("source")] + public ItemSource Source { get; set; } + + /// + /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// + [JsonProperty("start")] + public string Start { get; set; } + + [JsonProperty("tracks")] + public TracksClass Tracks { get; set; } + } + + public partial class ItemSource + { + [JsonProperty("in_point_ms")] + public long? InPointMs { get; set; } + + [JsonProperty("out_point_ms")] + public long? OutPointMs { get; set; } + + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + [JsonProperty("source_type")] + public SourceType SourceType { get; set; } + + [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] + public string Params { get; set; } + + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"] + /// + [JsonProperty("headers")] + public List Headers { get; set; } + + /// + /// Enable reconnect on failure (default: true) + /// + [JsonProperty("reconnect")] + public bool? Reconnect { get; set; } + + /// + /// Max reconnect delay in seconds + /// + [JsonProperty("reconnect_delay_max")] + public long? ReconnectDelayMax { get; set; } + + /// + /// Socket timeout in microseconds + /// + [JsonProperty("timeout_us")] + public long? TimeoutUs { get; set; } + + /// + /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}" + /// + [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] + public string Uri { get; set; } + + /// + /// Custom user-agent string + /// + [JsonProperty("user_agent")] + public string UserAgent { get; set; } + } + + public partial class TracksClass + { + [JsonProperty("audio")] + public AudioClass Audio { get; set; } + + [JsonProperty("video")] + public AudioClass Video { get; set; } + } + + public partial class AudioClass + { + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public AudioSource Source { get; set; } + + [JsonProperty("stream_index", NullValueHandling = NullValueHandling.Ignore)] + public long? StreamIndex { get; set; } + } + + public partial class AudioSource + { + [JsonProperty("in_point_ms")] + public long? InPointMs { get; set; } + + [JsonProperty("out_point_ms")] + public long? OutPointMs { get; set; } + + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + [JsonProperty("source_type")] + public SourceType SourceType { get; set; } + + [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] + public string Params { get; set; } + + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"] + /// + [JsonProperty("headers")] + public List Headers { get; set; } + + /// + /// Enable reconnect on failure (default: true) + /// + [JsonProperty("reconnect")] + public bool? Reconnect { get; set; } + + /// + /// Max reconnect delay in seconds + /// + [JsonProperty("reconnect_delay_max")] + public long? ReconnectDelayMax { get; set; } + + /// + /// Socket timeout in microseconds + /// + [JsonProperty("timeout_us")] + public long? TimeoutUs { get; set; } + + /// + /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}" + /// + [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] + public string Uri { get; set; } + + /// + /// Custom user-agent string + /// + [JsonProperty("user_agent")] + public string UserAgent { get; set; } + } + + public enum SourceType { Http, Lavfi, Local }; + + public partial class Playout + { + public static Playout FromJson(string json) => JsonConvert.DeserializeObject(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); + } + + internal static class Converter + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + Converters = + { + SourceTypeConverter.Singleton, + new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } + }, + }; + } + + internal class SourceTypeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(SourceType) || t == typeof(SourceType?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "http": + return SourceType.Http; + case "lavfi": + return SourceType.Lavfi; + case "local": + return SourceType.Local; + } + throw new Exception("Cannot unmarshal type SourceType"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (SourceType)untypedValue; + switch (value) + { + case SourceType.Http: + serializer.Serialize(writer, "http"); + return; + case SourceType.Lavfi: + serializer.Serialize(writer, "lavfi"); + return; + case SourceType.Local: + serializer.Serialize(writer, "local"); + return; + } + throw new Exception("Cannot marshal type SourceType"); + } + + public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); + } +}