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();
+ }
+}