diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index 92713aa13..7831cda4c 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -199,12 +199,12 @@ public partial class SyncNextPlayoutHandler( logger.LogDebug("Located {Count} local playout items", playoutItems.Count); // fill in all gaps - List newPlayoutItems = []; - Option lastFinish = Option.None; playoutItems.Sort((a, b) => a.StartOffset.CompareTo(b.StartOffset)); - foreach (var playoutItem in playoutItems) + if (playoutItems.Count > 0) { - foreach (var finish in lastFinish) + List newPlayoutItems = []; + DateTimeOffset finish = DateTimeOffset.Now; + foreach (var playoutItem in playoutItems) { if (finish < playoutItem.StartOffset) { @@ -215,12 +215,13 @@ public partial class SyncNextPlayoutHandler( Finish = playoutItem.Start }); } + + finish = playoutItem.FinishOffset; + newPlayoutItems.Add(playoutItem); } - lastFinish = playoutItem.FinishOffset; - newPlayoutItems.Add(playoutItem); + playoutItems = newPlayoutItems; } - playoutItems = newPlayoutItems; Option maybeGlobalWatermark = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) @@ -244,7 +245,7 @@ public partial class SyncNextPlayoutHandler( targetFolder, $"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json"); - var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] }; + var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.2", Items = [] }; foreach (PlayoutItem playoutItem in group) { Option maybeNextPlayoutItem = await playoutItemConverter.ToNext( diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index ed7709edc..89bade4b8 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -111,18 +111,19 @@ namespace ErsatzTV.Core.Next /// transcode position and its `finish` is clamped to the placeholder's `finish`. Because /// `start` advances each tick, the resolver is re-hit while the transcode position remains /// inside the placeholder window, allowing a sequence of distinct items to be returned for a - /// single placeholder. The resolved item's `source` may not itself be `dynamic`. + /// single placeholder. The resolved item's `source` may not itself be `dynamic`, but it may + /// carry its own `probe_hint`, which is honored exactly as for a directly-specified source. /// /// The channel automatically injects the following request headers (in addition to any /// configured via `headers`), all formatted per RFC 3339 for timestamps: /// - /// - `X-Etv-Channel` — the channel number this request is for. - /// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to + /// - `x-etv-channel` — the channel number this request is for. + /// - `x-etv-dynamic-id` — a stable identifier for the placeholder; resolvers can use it to /// coalesce or cache decisions across the multiple calls that occur while transcoding stays /// inside one placeholder window. - /// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced + /// - `x-etv-now` — the current transcode position; this is the `start` that will be forced /// onto the resolved item. - /// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that + /// - `x-etv-until` — the placeholder's `finish`; the resolver should not return an item that /// extends beyond this (it will be clamped if it does). /// public partial class Source @@ -145,6 +146,17 @@ namespace ErsatzTV.Core.Next [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] public string Path { get; set; } + /// + /// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is + /// read only once (at playback). See `ProbeHint`. + /// + /// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is + /// 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; } + [JsonProperty("source_type")] public SourceType SourceType { get; set; } @@ -228,6 +240,166 @@ namespace ErsatzTV.Core.Next public bool? IsLive { get; set; } } + /// + /// Pre-supplied probe metadata for a source. When present, the server trusts these values + /// and skips running ffprobe entirely, so the source is opened only once (at playback) + /// instead of twice. This matters most for slow or expensive inputs such as scripted yt-dlp + /// pipelines. + /// + /// The hint fully replaces probing: values are not validated up front, so incorrect metadata + /// surfaces as an ffmpeg error during playback rather than a probe failure. Provide an entry + /// for every stream the pipeline needs to select — typically one video and one audio. + /// + public partial class ProbeHint + { + /// + /// 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; } + + /// + /// Source duration in milliseconds. Omit for live or unbounded sources. + /// + [JsonProperty("duration_ms")] + public long? DurationMs { get; set; } + + /// + /// Container format name as reported by ffprobe's `format_name` (e.g. "mpegts", "image2", + /// "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; } + + /// + /// 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; } + } + + /// + /// Probe metadata for a single audio stream. + /// + public partial class AudioHint + { + /// + /// Number of audio channels. + /// + [JsonProperty("channels")] + public long Channels { get; set; } + + /// + /// Codec name as reported by ffprobe (e.g. "aac", "ac3", "mp2"). Compared case-insensitively. + /// + [JsonProperty("codec")] + public string Codec { get; set; } + + /// + /// Zero-based index of this stream within the source. + /// + [JsonProperty("stream_index")] + public long StreamIndex { get; set; } + } + + /// + /// Probe metadata for a single video (or still-image) stream. Optional fields left out + /// assume progressive, square-pixel, SDR content at 24 fps — the same fallbacks used when + /// ffprobe omits a field. + /// + public partial class VideoHint + { + /// + /// Codec name as reported by ffprobe (e.g. "h264", "hevc", "mpeg2video", "png"). Compared + /// case-insensitively. + /// + [JsonProperty("codec")] + public string Codec { get; set; } + + /// + /// Color primaries (e.g. "bt709", "bt2020"). Omit if unknown. + /// + [JsonProperty("color_primaries")] + public string ColorPrimaries { get; set; } + + /// + /// Color range (e.g. "tv", "pc"). Omit if unknown. + /// + [JsonProperty("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; } + + /// + /// 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; } + + /// + /// Display aspect ratio, e.g. "16:9". Omit if unknown. + /// + [JsonProperty("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; } + + /// + /// 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; } + + /// + /// Coded frame height in pixels. + /// + [JsonProperty("height")] + public long Height { get; set; } + + /// + /// Pixel format (e.g. "yuv420p", "yuv420p10le"). + /// + [JsonProperty("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; } + + /// + /// Sample (pixel) aspect ratio, e.g. "1:1". Omit for square pixels. + /// + [JsonProperty("sample_aspect_ratio")] + public string SampleAspectRatio { get; set; } + + /// + /// Zero-based index of this stream within the source. + /// + [JsonProperty("stream_index")] + public long StreamIndex { get; set; } + + /// + /// Coded frame width in pixels. + /// + [JsonProperty("width")] + public long Width { 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). @@ -375,18 +547,19 @@ namespace ErsatzTV.Core.Next /// transcode position and its `finish` is clamped to the placeholder's `finish`. Because /// `start` advances each tick, the resolver is re-hit while the transcode position remains /// inside the placeholder window, allowing a sequence of distinct items to be returned for a - /// single placeholder. The resolved item's `source` may not itself be `dynamic`. + /// single placeholder. The resolved item's `source` may not itself be `dynamic`, but it may + /// carry its own `probe_hint`, which is honored exactly as for a directly-specified source. /// /// The channel automatically injects the following request headers (in addition to any /// configured via `headers`), all formatted per RFC 3339 for timestamps: /// - /// - `X-Etv-Channel` — the channel number this request is for. - /// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to + /// - `x-etv-channel` — the channel number this request is for. + /// - `x-etv-dynamic-id` — a stable identifier for the placeholder; resolvers can use it to /// coalesce or cache decisions across the multiple calls that occur while transcoding stays /// inside one placeholder window. - /// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced + /// - `x-etv-now` — the current transcode position; this is the `start` that will be forced /// onto the resolved item. - /// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that + /// - `x-etv-until` — the placeholder's `finish`; the resolver should not return an item that /// extends beyond this (it will be clamped if it does). /// public partial class PlayoutItemSource @@ -409,6 +582,17 @@ namespace ErsatzTV.Core.Next [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] public string Path { get; set; } + /// + /// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is + /// read only once (at playback). See `ProbeHint`. + /// + /// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is + /// 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; } + [JsonProperty("source_type")] public SourceType SourceType { get; set; } diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index 1d7c429ba..98b17433c 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -122,8 +122,43 @@ public class PlayoutItemConverter( if (playoutItem is not DynamicPlayoutItem) { - // if no audio streams, use lavfi to insert silence MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); + var sourceVideoHints = headVersion.Streams + .Where(s => s.MediaStreamKind is MediaStreamKind.Video) + .Select(s => new Core.Next.VideoHint + { + StreamIndex = s.Index, + Codec = s.Codec, + Height = headVersion.Height, + Width = headVersion.Width, + Profile = s.Profile, + FieldOrder = headVersion.VideoScanKind is VideoScanKind.Interlaced ? "tt" : "progressive", + PixFmt = string.IsNullOrWhiteSpace(s.PixelFormat) ? PixelFormatForBitDepth(s.BitsPerRawSample) : s.PixelFormat, + FrameRate = headVersion.RFrameRate, + SampleAspectRatio = headVersion.SampleAspectRatio, + DisplayAspectRatio = headVersion.DisplayAspectRatio, + ColorPrimaries = s.ColorPrimaries, + ColorRange = s.ColorRange, + ColorSpace = s.ColorSpace, + ColorTransfer = s.ColorTransfer + }).ToList(); + var sourceAudioHints = headVersion.Streams + .Where(s => s.MediaStreamKind is MediaStreamKind.Audio) + .Select(s => new Core.Next.AudioHint + { + StreamIndex = s.Index, + Codec = s.Codec, + Channels = s.Channels + }).ToList(); + + nextPlayoutItem.Source.ProbeHint = new Core.Next.ProbeHint + { + Audio = sourceAudioHints, + Video = sourceVideoHints, + DurationMs = (long)headVersion.Duration.TotalMilliseconds + }; + + // if no audio streams, use lavfi to insert silence if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio)) { var videoSource = nextPlayoutItem.Source; @@ -137,7 +172,18 @@ public class PlayoutItemConverter( new Core.Next.Source { SourceType = Core.Next.SourceType.Lavfi, - Params = "anullsrc=channel_layout=stereo:sample_rate=48000" + Params = "anullsrc=channel_layout=stereo:sample_rate=48000", + ProbeHint = new Core.Next.ProbeHint + { + Audio = [ + new Core.Next.AudioHint + { + StreamIndex = 0, + Codec = "pcm_s16le", + Channels = 2 + } + ] + } } }, Video = new Core.Next.TrackSelection @@ -171,6 +217,15 @@ public class PlayoutItemConverter( return nextPlayoutItem; } + private static string PixelFormatForBitDepth(int bitDepth) + { + return bitDepth switch + { + 10 => "yuv420p10le", + _ => "yuv420p" + }; + } + private async Task> SourceForItem( PlayoutItem playoutItem, CancellationToken cancellationToken)