// // // 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 { /// /// Ordered list of scheduled items for this window. /// [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; } } /// /// A single scheduled item in the playout. /// /// An item must supply media for its tracks. You can do this two ways, and they can be /// combined: /// /// 1. Set `source` to use one shared source for every track. Track details (which stream, /// etc.) are chosen naively, or may be refined via `tracks`. /// 2. Set `tracks` to specify each track (video, audio) individually. A track may provide /// its own `source`; if it doesn't, the item's top-level `source` is used. /// /// At least one of `source` or `tracks` must be present, and every track that is selected /// must have an effective source (either its own or the item's). /// public partial class PlayoutItem { /// /// RFC3339 formatted finish time, e.g. 2026-04-13T00:54:21.527-05:00. /// [JsonProperty("finish")] public DateTimeOffset Finish { get; set; } /// /// Stable identifier for this item, unique within the playout. /// [JsonProperty("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; } /// /// RFC3339 formatted start time, e.g. 2026-04-13T00:24:21.527-05:00. /// [JsonProperty("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; } /// /// 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; } } /// /// A file on the local filesystem reachable by the server. /// /// A synthetic source produced by an ffmpeg lavfi filter graph. /// /// A remote source fetched over HTTP(S). /// /// A live stream pulled from an RTSP server (e.g. an IP camera). Always treated as live: it /// is never seeked and cannot work ahead. /// /// An external command whose stdout is an MPEG-TS stream, proxied to ffmpeg over loopback /// HTTP. /// public partial class Source { /// /// Optional start offset into the source, in milliseconds. /// [JsonProperty("in_point_ms")] public long? InPointMs { get; set; } /// /// Optional end offset into the source, in milliseconds. /// [JsonProperty("out_point_ms")] public long? OutPointMs { get; set; } /// /// Absolute path to the media file. /// [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] public string Path { get; set; } [JsonProperty("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; } /// /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. /// [JsonProperty("headers")] public List Headers { get; set; } /// /// Enable persistent connections in ffmpeg. Default: false. /// [JsonProperty("keep_alive")] public bool? KeepAlive { get; set; } /// /// Enable reconnect on failure. Default: true. /// [JsonProperty("reconnect")] public bool? Reconnect { get; set; } /// /// Maximum reconnect delay in seconds. Maps to ffmpeg's `reconnect_delay_max`. /// [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}}". /// /// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream". /// [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] public string Uri { get; set; } /// /// Custom User-Agent string. /// [JsonProperty("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; } /// /// Command that writes an MPEG-TS stream to its stdout. Supports {{TEMPLATE}} expansion. /// [JsonProperty("command", NullValueHandling = NullValueHandling.Ignore)] public string Command { get; set; } /// /// Whether the content is live and therefore cannot work ahead. Default: false. /// [JsonProperty("is_live")] public bool? IsLive { get; set; } } /// /// Per-track overrides for a playout item. Omit a field to use the server default for that /// track kind (first stream of that kind in the item's `source`, if any). /// public partial class PlayoutItemTracks { /// /// Audio track selection. /// [JsonProperty("audio")] public TrackSelection Audio { get; set; } /// /// Subtitle track selection. /// [JsonProperty("subtitle")] public TrackSelection Subtitle { get; set; } /// /// Video track selection. /// [JsonProperty("video")] public TrackSelection Video { get; set; } } /// /// Selects a single track (video or audio). /// /// - `source` absent: inherit the parent `PlayoutItem.source`. /// - `source` present: use this source for this track, overriding the parent. /// - `stream_index` absent: server picks the first stream of the track's kind in the /// effective source. /// - `stream_index` present: use this specific stream within the effective source. /// public partial class TrackSelection { /// /// Source to pull this track from. If omitted, inherits from the parent `PlayoutItem.source`. /// [JsonProperty("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")] public long? StreamIndex { get; set; } } /// /// An image or video overlay composited on top of the primary content. Sized and positioned /// relative to the primary content's frame. /// public partial class Watermark { /// /// Horizontal offset from the anchor `location`, as a percent of primary content width /// (0–100). Omit for 0. /// [JsonProperty("horizontal_margin_percent")] [JsonConverter(typeof(MinMaxValueCheckConverter))] public double? HorizontalMarginPercent { get; set; } /// /// Anchor position within the primary content frame. /// [JsonProperty("location")] public WatermarkLocation Location { get; set; } /// /// Opacity as a percent (0–100). Omit for fully opaque (100). /// [JsonProperty("opacity_percent")] [JsonConverter(typeof(MinMaxValueCheckConverter))] public double? OpacityPercent { get; set; } /// /// The source providing the watermark media (typically an image, but any `PlayoutItemSource` /// is accepted). /// [JsonProperty("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")] public long? StreamIndex { get; set; } /// /// Visibility schedule for the watermark. Omit for an always-on watermark. /// [JsonProperty("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")] [JsonConverter(typeof(MinMaxValueCheckConverter))] public double? VerticalMarginPercent { get; set; } /// /// Scale the watermark to this percent of the primary content width (0–100). Omit to use the /// watermark's actual size. /// [JsonProperty("width_percent")] [JsonConverter(typeof(MinMaxValueCheckConverter))] public double? WidthPercent { get; set; } /// /// When true, position margins are measured from the edges of the source content rather than /// the padded output frame, so letterbox/pillarbox bars push the watermark inward and keep /// it inside the visible content. When false, margins are relative to the full padded frame, /// 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")] public bool? WithinSourceContent { get; set; } } /// /// A media source. Exactly one variant, distinguished by `source_type`. /// /// The source providing the watermark media (typically an image, but any `PlayoutItemSource` /// is accepted). /// /// A file on the local filesystem reachable by the server. /// /// A synthetic source produced by an ffmpeg lavfi filter graph. /// /// A remote source fetched over HTTP(S). /// /// A live stream pulled from an RTSP server (e.g. an IP camera). Always treated as live: it /// is never seeked and cannot work ahead. /// /// An external command whose stdout is an MPEG-TS stream, proxied to ffmpeg over loopback /// HTTP. /// public partial class PlayoutItemSource { /// /// Optional start offset into the source, in milliseconds. /// [JsonProperty("in_point_ms")] public long? InPointMs { get; set; } /// /// Optional end offset into the source, in milliseconds. /// [JsonProperty("out_point_ms")] public long? OutPointMs { get; set; } /// /// Absolute path to the media file. /// [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] public string Path { get; set; } [JsonProperty("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; } /// /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. /// [JsonProperty("headers")] public List Headers { get; set; } /// /// Enable persistent connections in ffmpeg. Default: false. /// [JsonProperty("keep_alive")] public bool? KeepAlive { get; set; } /// /// Enable reconnect on failure. Default: true. /// [JsonProperty("reconnect")] public bool? Reconnect { get; set; } /// /// Maximum reconnect delay in seconds. Maps to ffmpeg's `reconnect_delay_max`. /// [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}}". /// /// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream". /// [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] public string Uri { get; set; } /// /// Custom User-Agent string. /// [JsonProperty("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; } /// /// Command that writes an MPEG-TS stream to its stdout. Supports {{TEMPLATE}} expansion. /// [JsonProperty("command", NullValueHandling = NullValueHandling.Ignore)] public string Command { get; set; } /// /// Whether the content is live and therefore cannot work ahead. Default: false. /// [JsonProperty("is_live")] public bool? IsLive { get; set; } } /// /// Controls when the watermark is shown. Exactly one variant, distinguished by /// `timing_type`. /// /// Cyclical visibility: the watermark fades in, holds, and fades out once every /// `frequency_ms`. Cycle length is start-to-start, so `2 * fade_ms + hold_ms` must be ≤ /// `frequency_ms`, and `fade_ms` must be ≤ `hold_ms`. /// public partial class Timing { /// /// Reference clock used to position cycles. /// [JsonProperty("clock")] public PeriodicClock Clock { get; set; } /// /// Cap on the time, measured from the start of the playout item in milliseconds, during /// 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")] 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")] public long? FadeMs { get; set; } /// /// Period of the cycle, start-to-start, in milliseconds. /// [JsonProperty("frequency_ms")] public long FrequencyMs { get; set; } /// /// Time held fully visible between fade-in and fade-out, in milliseconds. /// [JsonProperty("hold_ms")] public long HoldMs { get; set; } /// /// Shift the cycle by this many milliseconds. With `clock: wall` and `frequency_ms: 300000`, /// 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")] public long? PhaseOffsetMs { get; set; } [JsonProperty("timing_type")] public TimingType TimingType { get; set; } } public enum SourceType { Http, Lavfi, Local, Rtsp, Script }; /// /// Anchor position within the primary content frame. /// /// Nine-position anchor within the primary content frame. Read like a 3×3 grid: rows /// top/center/bottom, columns left/center/right; the dead center is `center`. /// public enum WatermarkLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight }; /// /// Reference clock used to position cycles. /// /// Reference clock for periodic timing. `wall` aligns cycles to wall-clock time (so a viewer /// tuning in at any moment sees the same phase). `content` measures from the start of the /// containing playout item. /// public enum PeriodicClock { Content, Wall }; public enum TimingType { Periodic }; 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 { NullValueHandling = NullValueHandling.Ignore, MetadataPropertyHandling = MetadataPropertyHandling.Ignore, DateParseHandling = DateParseHandling.None, Converters = { SourceTypeConverter.Singleton, WatermarkLocationConverter.Singleton, PeriodicClockConverter.Singleton, TimingTypeConverter.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; case "rtsp": return SourceType.Rtsp; case "script": return SourceType.Script; } 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; case SourceType.Rtsp: serializer.Serialize(writer, "rtsp"); return; case SourceType.Script: serializer.Serialize(writer, "script"); return; } throw new Exception("Cannot marshal type SourceType"); } public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); } internal class MinMaxValueCheckConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(double) || t == typeof(double?); public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) { if (reader.TokenType == JsonToken.Null) return null; var value = serializer.Deserialize(reader); if (value >= 0 && value <= 100) { return value; } throw new Exception("Cannot unmarshal type double"); } public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) { if (untypedValue == null) { serializer.Serialize(writer, null); return; } var value = (double)untypedValue; if (value >= 0 && value <= 100) { serializer.Serialize(writer, value); return; } throw new Exception("Cannot marshal type double"); } public static readonly MinMaxValueCheckConverter Singleton = new MinMaxValueCheckConverter(); } internal class WatermarkLocationConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(WatermarkLocation) || t == typeof(WatermarkLocation?); 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 "bottom_center": return WatermarkLocation.BottomCenter; case "bottom_left": return WatermarkLocation.BottomLeft; case "bottom_right": return WatermarkLocation.BottomRight; case "center": return WatermarkLocation.Center; case "center_left": return WatermarkLocation.CenterLeft; case "center_right": return WatermarkLocation.CenterRight; case "top_center": return WatermarkLocation.TopCenter; case "top_left": return WatermarkLocation.TopLeft; case "top_right": return WatermarkLocation.TopRight; } throw new Exception("Cannot unmarshal type WatermarkLocation"); } public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) { if (untypedValue == null) { serializer.Serialize(writer, null); return; } var value = (WatermarkLocation)untypedValue; switch (value) { case WatermarkLocation.BottomCenter: serializer.Serialize(writer, "bottom_center"); return; case WatermarkLocation.BottomLeft: serializer.Serialize(writer, "bottom_left"); return; case WatermarkLocation.BottomRight: serializer.Serialize(writer, "bottom_right"); return; case WatermarkLocation.Center: serializer.Serialize(writer, "center"); return; case WatermarkLocation.CenterLeft: serializer.Serialize(writer, "center_left"); return; case WatermarkLocation.CenterRight: serializer.Serialize(writer, "center_right"); return; case WatermarkLocation.TopCenter: serializer.Serialize(writer, "top_center"); return; case WatermarkLocation.TopLeft: serializer.Serialize(writer, "top_left"); return; case WatermarkLocation.TopRight: serializer.Serialize(writer, "top_right"); return; } throw new Exception("Cannot marshal type WatermarkLocation"); } public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter(); } internal class PeriodicClockConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(PeriodicClock) || t == typeof(PeriodicClock?); 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 "content": return PeriodicClock.Content; case "wall": return PeriodicClock.Wall; } throw new Exception("Cannot unmarshal type PeriodicClock"); } public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) { if (untypedValue == null) { serializer.Serialize(writer, null); return; } var value = (PeriodicClock)untypedValue; switch (value) { case PeriodicClock.Content: serializer.Serialize(writer, "content"); return; case PeriodicClock.Wall: serializer.Serialize(writer, "wall"); return; } throw new Exception("Cannot marshal type PeriodicClock"); } public static readonly PeriodicClockConverter Singleton = new PeriodicClockConverter(); } internal class TimingTypeConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(TimingType) || t == typeof(TimingType?); public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) { if (reader.TokenType == JsonToken.Null) return null; var value = serializer.Deserialize(reader); if (value == "periodic") { return TimingType.Periodic; } throw new Exception("Cannot unmarshal type TimingType"); } public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) { if (untypedValue == null) { serializer.Serialize(writer, null); return; } var value = (TimingType)untypedValue; if (value == TimingType.Periodic) { serializer.Serialize(writer, "periodic"); return; } throw new Exception("Cannot marshal type TimingType"); } public static readonly TimingTypeConverter Singleton = new TimingTypeConverter(); } }