From bb49367053caeaafa0999e96ce8c0f41ae7c6a8a Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:25:00 -0500 Subject: [PATCH] feat: support media server items in next streaming engine (#2865) * fix next playback of items with no audio * support media server items through next engine --- .../Commands/SyncNextPlayoutHandler.cs | 153 +++++++++++++-- ErsatzTV.Core/Next/Playout.cs | 174 ++++++++++-------- 2 files changed, 231 insertions(+), 96 deletions(-) diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index 47592e0dc..9ac42766b 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -6,18 +6,24 @@ using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Next; +using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem; namespace ErsatzTV.Application.Playouts; public partial class SyncNextPlayoutHandler( IFileSystem fileSystem, ILocalFileSystem localFileSystem, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, IDbContextFactory dbContextFactory, ILogger logger) : IRequestHandler @@ -112,27 +118,37 @@ public partial class SyncNextPlayoutHandler( playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; } - List localLibraryIds = await dbContext.LocalLibraries - .AsNoTracking() - .Map(l => l.Id) - .ToListAsync(cancellationToken); - List playoutItems = await dbContext.PlayoutItems .AsNoTracking() .Where(i => i.Playout.Channel.Number == (mirrorChannelNumber ?? channelNumber)) - .Where(i => localLibraryIds.Contains(i.MediaItem.LibraryPath.LibraryId)) + .Include(i => i.MediaItem) + .ThenInclude(mi => mi.LibraryPath) + .ThenInclude(lp => lp.Library) .Include(i => i.MediaItem) .ThenInclude(i => (i as Episode).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) + .ThenInclude(i => (i as Episode).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => i.MediaItem) .ThenInclude(i => (i as Movie).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => i.MediaItem) .ThenInclude(i => (i as OtherVideo).MediaVersions) .ThenInclude(mv => mv.MediaFiles) .Include(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => i.MediaItem) .ThenInclude(i => (i as MusicVideo).MediaVersions) .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) + .AsSplitQuery() .ToListAsync(cancellationToken); logger.LogDebug("Located {Count} local playout items", playoutItems.Count); @@ -156,31 +172,132 @@ public partial class SyncNextPlayoutHandler( continue; } - string path = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head().Path; + MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); playoutItem.Start += playoutOffset; playoutItem.Finish += playoutOffset; - var nextPlayoutItem = new ItemElement + var nextPlayoutItem = new Core.Next.PlayoutItem { Id = playoutItem.Id.ToString(CultureInfo.InvariantCulture), - Start = playoutItem.StartOffset.ToString("O"), - Finish = playoutItem.FinishOffset.ToString("O"), - Source = new ItemSource - { - SourceType = SourceType.Local, - Path = path, - } + Start = playoutItem.StartOffset, + Finish = playoutItem.FinishOffset }; + Option maybeSource = await SourceForItem(playoutItem, cancellationToken); + if (maybeSource.IsNone) + { + continue; + } + + foreach (Core.Next.Source source in maybeSource) + { + nextPlayoutItem.Source = source; + } + + // if no audio streams, use lavfi to insert silence + if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio)) + { + var videoSource = nextPlayoutItem.Source; + + nextPlayoutItem.Source = null; + nextPlayoutItem.Tracks = new Core.Next.PlayoutItemTracks + { + Audio = new Core.Next.TrackSelection + { + Source = + new Core.Next.Source + { + SourceType = Core.Next.SourceType.Lavfi, + Params = "anullsrc=channel_layout=stereo:sample_rate=48000" + } + }, + Video = new Core.Next.TrackSelection + { + Source = new Core.Next.Source + { + SourceType = videoSource.SourceType, + Path = videoSource.Path, + } + } + }; + } + playout.Items.Add(nextPlayoutItem); } - await fileSystem.File.WriteAllTextAsync(fileName, playout.ToJson(), cancellationToken); + await fileSystem.File.WriteAllTextAsync(fileName, Core.Next.Serialize.ToJson(playout), cancellationToken); + } + } + + private async Task> SourceForItem( + PlayoutItem playoutItem, + CancellationToken cancellationToken) + { + string path = await playoutItem.MediaItem.GetLocalPath( + plexPathReplacementService, + jellyfinPathReplacementService, + embyPathReplacementService, + cancellationToken); + + // check filesystem first + if (fileSystem.File.Exists(path)) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Local, + Path = path, + }; + } + + MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); + int mediaSourceId = playoutItem.MediaItem.LibraryPath.Library.MediaSourceId; + if (file is PlexMediaFile pmf) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = $"http://localhost:{Settings.StreamingPort}/media/plex/{mediaSourceId}/{pmf.Key}" + }; + } + + Option jellyfinItemId = playoutItem.MediaItem switch + { + JellyfinEpisode e => e.ItemId, + JellyfinMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in jellyfinItemId) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}" + }; + } + + // attempt to remotely stream emby + Option embyItemId = playoutItem.MediaItem switch + { + EmbyEpisode e => e.ItemId, + EmbyMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in embyItemId) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}" + }; } + + return Option.None; } - public void CleanOldVersions( + private void CleanOldVersions( string playoutRoot, string currentLinkPath, int keepVersions = 2, diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 44680c975..7a05e7227 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -18,171 +18,189 @@ namespace ErsatzTV.Core.Next /// /// 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. + /// 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; } + public List Items { get; set; } /// - /// URI identifying the schema version, e.g. "https://ersatztv.org/playout/version/0.0.1" + /// 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 + /// + /// 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 date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// RFC3339 formatted finish time, e.g. 2026-04-13T00:54:21.527-05:00. /// [JsonProperty("finish")] - public string Finish { get; set; } + 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 ItemSource Source { get; set; } + public Source Source { get; set; } /// - /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// RFC3339 formatted start time, e.g. 2026-04-13T00:24:21.527-05:00. /// [JsonProperty("start")] - public string Start { get; set; } + 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 TracksClass Tracks { get; set; } + public PlayoutItemTracks Tracks { get; set; } } - public partial class ItemSource + /// + /// 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). + /// + 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}}"] + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. /// [JsonProperty("headers")] public List Headers { get; set; } /// - /// Enable reconnect on failure (default: true) + /// Enable reconnect on failure. Default: true. /// [JsonProperty("reconnect")] public bool? Reconnect { get; set; } /// - /// Max reconnect delay in seconds + /// 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 + /// Socket timeout in microseconds. /// [JsonProperty("timeout_us")] public long? TimeoutUs { get; set; } /// - /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}" + /// 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 + /// 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 + /// + /// 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 { - [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 + /// Audio track selection. /// - [JsonProperty("reconnect_delay_max")] - public long? ReconnectDelayMax { get; set; } + [JsonProperty("audio")] + public TrackSelection Audio { get; set; } /// - /// Socket timeout in microseconds + /// Video track selection. /// - [JsonProperty("timeout_us")] - public long? TimeoutUs { get; set; } + [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 + { /// - /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}" + /// Source to pull this track from. If omitted, inherits from the parent `PlayoutItem.source`. /// - [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] - public string Uri { get; set; } + [JsonProperty("source")] + public Source Source { get; set; } /// - /// Custom user-agent string + /// Zero-based stream index within the effective source. If omitted, the server picks the + /// first stream of this track's kind. /// - [JsonProperty("user_agent")] - public string UserAgent { get; set; } + [JsonProperty("stream_index")] + public long? StreamIndex { get; set; } } public enum SourceType { Http, Lavfi, Local };