diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index a78a706b7..ebb46331a 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.CommandLine.Parsing; using System.Globalization; using System.IO.Abstractions; using System.Runtime.InteropServices; @@ -18,6 +19,7 @@ using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using CommandResult = CliWrap.CommandResult; using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem; using WatermarkLocation = ErsatzTV.FFmpeg.State.WatermarkLocation; @@ -187,6 +189,15 @@ public partial class SyncNextPlayoutHandler( .Include(i => i.MediaItem) .ThenInclude(i => (i as MusicVideo).MediaVersions) .ThenInclude(mv => mv.Streams) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as RemoteStream).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as RemoteStream).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata) + .ThenInclude(em => em.Subtitles) .AsSplitQuery() .ToListAsync(cancellationToken); @@ -211,7 +222,8 @@ public partial class SyncNextPlayoutHandler( foreach (PlayoutItem playoutItem in group) { if (playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && - playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo) + playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo && + playoutItem.MediaItem is not RemoteStream) { continue; } @@ -468,6 +480,53 @@ public partial class SyncNextPlayoutHandler( PlayoutItem playoutItem, CancellationToken cancellationToken) { + if (playoutItem.MediaItem is RemoteStream remoteStream) + { + if (!string.IsNullOrWhiteSpace(remoteStream.Url)) + { + if (remoteStream.Url.StartsWith("rtsp://", StringComparison.OrdinalIgnoreCase)) + { + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Rtsp, + Uri = remoteStream.Url + }; + } + + return new Core.Next.Source + { + SourceType = Core.Next.SourceType.Http, + Uri = remoteStream.Url, + IsLive = remoteStream.IsLive, + KeepAlive = true, + Reconnect = true + }; + } + + if (!string.IsNullOrWhiteSpace(remoteStream.Script)) + { + var split = CommandLineParser.SplitCommandLine(remoteStream.Script).ToList(); + if (split.Count > 0) + { + var source = new Core.Next.Source + { + SourceType = Core.Next.SourceType.Script, + Command = split.Head(), + IsLive = remoteStream.IsLive + }; + + if (split.Count > 1) + { + source.Args = split.Tail().ToList(); + } + + return source; + } + } + + return Option.None; + } + string path = await playoutItem.MediaItem.GetLocalPath( plexPathReplacementService, jellyfinPathReplacementService, diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs index e009b9846..4d4df1b0a 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -34,7 +34,6 @@ public class StartFFmpegNextSessionHandler( ILogger sessionWorkerLogger) : IRequestHandler> { - public Task> Handle( StartFFmpegNextSession request, CancellationToken cancellationToken) => @@ -302,6 +301,19 @@ public class StartFFmpegNextSessionHandler( ffmpeg.FfprobePath = path; } + Option maybeSaveReports = await configElementRepository.GetValue( + ConfigElementKey.FFmpegSaveReports, + cancellationToken); + + foreach (bool saveReports in maybeSaveReports) + { + if (saveReports) + { + ffmpeg.SaveReports = true; + ffmpeg.ReportsFolder = FileSystemLayout.FFmpegReportsFolder; + } + } + var audioNormalization = new Audio { Format = ffmpegProfile.AudioFormat switch diff --git a/ErsatzTV.Core/Next/Config/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs index 175044d05..3766b8a07 100644 --- a/ErsatzTV.Core/Next/Config/ChannelConfig.cs +++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs @@ -40,6 +40,12 @@ namespace ErsatzTV.Core.Next.Config [JsonProperty("preferred_filters", NullValueHandling = NullValueHandling.Ignore)] public List PreferredFilters { get; set; } + + [JsonProperty("reports_folder")] + public string ReportsFolder { get; set; } + + [JsonProperty("save_reports", NullValueHandling = NullValueHandling.Ignore)] + public bool? SaveReports { get; set; } } public partial class Normalization diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 9d4dfba6b..0a2ef79ff 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -99,6 +99,12 @@ namespace ErsatzTV.Core.Next /// 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 { @@ -161,6 +167,8 @@ namespace ErsatzTV.Core.Next /// /// 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; } @@ -170,6 +178,24 @@ namespace ErsatzTV.Core.Next /// [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; } } /// @@ -307,6 +333,12 @@ namespace ErsatzTV.Core.Next /// 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 { @@ -369,6 +401,8 @@ namespace ErsatzTV.Core.Next /// /// 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; } @@ -378,6 +412,24 @@ namespace ErsatzTV.Core.Next /// [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; } } /// @@ -435,7 +487,7 @@ namespace ErsatzTV.Core.Next public TimingType TimingType { get; set; } } - public enum SourceType { Http, Lavfi, Local }; + public enum SourceType { Http, Lavfi, Local, Rtsp, Script }; /// /// Anchor position within the primary content frame. @@ -500,6 +552,10 @@ namespace ErsatzTV.Core.Next 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"); } @@ -523,6 +579,12 @@ namespace ErsatzTV.Core.Next 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"); }