Browse Source

feat: support remote streams with next engine (#2899)

pull/2900/head
Jason Dove 4 days ago committed by GitHub
parent
commit
7c4a5e3a77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 61
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  2. 14
      ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
  3. 6
      ErsatzTV.Core/Next/Config/ChannelConfig.cs
  4. 64
      ErsatzTV.Core/Next/Playout.cs

61
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -1,4 +1,5 @@ @@ -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; @@ -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( @@ -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( @@ -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( @@ -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<Core.Next.Source>.None;
}
string path = await playoutItem.MediaItem.GetLocalPath(
plexPathReplacementService,
jellyfinPathReplacementService,

14
ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs

@ -34,7 +34,6 @@ public class StartFFmpegNextSessionHandler( @@ -34,7 +34,6 @@ public class StartFFmpegNextSessionHandler(
ILogger<NextSessionWorker> sessionWorkerLogger)
: IRequestHandler<StartFFmpegNextSession, Either<BaseError, string>>
{
public Task<Either<BaseError, string>> Handle(
StartFFmpegNextSession request,
CancellationToken cancellationToken) =>
@ -302,6 +301,19 @@ public class StartFFmpegNextSessionHandler( @@ -302,6 +301,19 @@ public class StartFFmpegNextSessionHandler(
ffmpeg.FfprobePath = path;
}
Option<bool> maybeSaveReports = await configElementRepository.GetValue<bool>(
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

6
ErsatzTV.Core/Next/Config/ChannelConfig.cs

@ -40,6 +40,12 @@ namespace ErsatzTV.Core.Next.Config @@ -40,6 +40,12 @@ namespace ErsatzTV.Core.Next.Config
[JsonProperty("preferred_filters", NullValueHandling = NullValueHandling.Ignore)]
public List<string> 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

64
ErsatzTV.Core/Next/Playout.cs

@ -99,6 +99,12 @@ namespace ErsatzTV.Core.Next @@ -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.
/// </summary>
public partial class Source
{
@ -161,6 +167,8 @@ namespace ErsatzTV.Core.Next @@ -161,6 +167,8 @@ namespace ErsatzTV.Core.Next
/// <summary>
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}".
///
/// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream".
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
@ -170,6 +178,24 @@ namespace ErsatzTV.Core.Next @@ -170,6 +178,24 @@ namespace ErsatzTV.Core.Next
/// </summary>
[JsonProperty("user_agent")]
public string UserAgent { get; set; }
/// <summary>
/// Optional arguments for the command. Supports {{TEMPLATE}} expansion. Defaults to [].
/// </summary>
[JsonProperty("args", NullValueHandling = NullValueHandling.Ignore)]
public List<string> Args { get; set; }
/// <summary>
/// Command that writes an MPEG-TS stream to its stdout. Supports {{TEMPLATE}} expansion.
/// </summary>
[JsonProperty("command", NullValueHandling = NullValueHandling.Ignore)]
public string Command { get; set; }
/// <summary>
/// Whether the content is live and therefore cannot work ahead. Default: false.
/// </summary>
[JsonProperty("is_live")]
public bool? IsLive { get; set; }
}
/// <summary>
@ -307,6 +333,12 @@ namespace ErsatzTV.Core.Next @@ -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.
/// </summary>
public partial class PlayoutItemSource
{
@ -369,6 +401,8 @@ namespace ErsatzTV.Core.Next @@ -369,6 +401,8 @@ namespace ErsatzTV.Core.Next
/// <summary>
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}".
///
/// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream".
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
@ -378,6 +412,24 @@ namespace ErsatzTV.Core.Next @@ -378,6 +412,24 @@ namespace ErsatzTV.Core.Next
/// </summary>
[JsonProperty("user_agent")]
public string UserAgent { get; set; }
/// <summary>
/// Optional arguments for the command. Supports {{TEMPLATE}} expansion. Defaults to [].
/// </summary>
[JsonProperty("args", NullValueHandling = NullValueHandling.Ignore)]
public List<string> Args { get; set; }
/// <summary>
/// Command that writes an MPEG-TS stream to its stdout. Supports {{TEMPLATE}} expansion.
/// </summary>
[JsonProperty("command", NullValueHandling = NullValueHandling.Ignore)]
public string Command { get; set; }
/// <summary>
/// Whether the content is live and therefore cannot work ahead. Default: false.
/// </summary>
[JsonProperty("is_live")]
public bool? IsLive { get; set; }
}
/// <summary>
@ -435,7 +487,7 @@ namespace ErsatzTV.Core.Next @@ -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 };
/// <summary>
/// Anchor position within the primary content frame.
@ -500,6 +552,10 @@ namespace ErsatzTV.Core.Next @@ -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 @@ -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");
}

Loading…
Cancel
Save