diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs index 499707e81..a767b8234 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs @@ -2,6 +2,12 @@ namespace ErsatzTV.Application.Streaming; -public record StartFFmpegSession(string ChannelNumber, string Mode, string Scheme, string Host) : - IRequest>, +public record StartFFmpegSession( + string ChannelNumber, + string Mode, + string Scheme, + string Host, + string PathBase, + string AccessTokenQuery) : + IRequest>, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs index ecb944b0f..c8322bf96 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions; +using System.Globalization; +using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Graphics; @@ -19,7 +20,7 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Streaming; -public class StartFFmpegSessionHandler : IRequestHandler> +public class StartFFmpegSessionHandler : IRequestHandler> { private readonly IFileSystem _fileSystem; private readonly IConfigElementRepository _configElementRepository; @@ -65,15 +66,15 @@ public class StartFFmpegSessionHandler : IRequestHandler> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => + public Task> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => Validate(request) .MapT(_ => StartProcess(request, cancellationToken)) // this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError) #pragma warning disable VSTHRD103 - .Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync, Unit>(identity)); + .Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync, string>(identity)); #pragma warning restore VSTHRD103 - private async Task StartProcess(StartFFmpegSession request, CancellationToken cancellationToken) + private async Task StartProcess(StartFFmpegSession request, CancellationToken cancellationToken) { Option idleTimeout = await _configElementRepository .GetValue(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) @@ -116,7 +117,7 @@ public class StartFFmpegSessionHandler : IRequestHandler targetFramerate) => @@ -139,12 +140,12 @@ public class StartFFmpegSessionHandler : IRequestHandler FolderMustBeEmpty(request)); - private Task> SessionMustBeInactive(StartFFmpegSession request) + private async Task> SessionMustBeInactive(StartFFmpegSession request) { var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null)) .Where(success => success) .Map(_ => Unit.Default) - .ToValidation(new ChannelSessionAlreadyActive()); + .ToValidation(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request))); if (result.IsFail && _ffmpegSegmenterService.TryGetWorker( request.ChannelNumber, @@ -153,7 +154,7 @@ public class StartFFmpegSessionHandler : IRequestHandler.None); } - return result.AsTask(); + return result; } private Task> FolderMustBeEmpty(StartFFmpegSession request) @@ -166,4 +167,52 @@ public class StartFFmpegSessionHandler : IRequestHandler>(Unit.Default); } + + private async Task GetMultiVariantPlaylist(StartFFmpegSession request) + { + var variantPlaylist = + $"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/hls.m3u8{request.AccessTokenQuery}"; + + Option maybeStreamingSpecs = + await _mediator.Send(new GetChannelStreamingSpecs(request.ChannelNumber)); + string resolution = string.Empty; + var bitrate = "10000000"; + foreach (ChannelStreamingSpecsViewModel streamingSpecs in maybeStreamingSpecs) + { + string videoCodec = streamingSpecs.VideoFormat switch + { + FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08", + FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0", + FFmpegProfileVideoFormat.H264 => "avc1.4D4028", + _ => string.Empty + }; + + string audioCodec = streamingSpecs.AudioFormat switch + { + FFmpegProfileAudioFormat.Ac3 => "ac-3", + FFmpegProfileAudioFormat.Aac or FFmpegProfileAudioFormat.AacLatm => "mp4a.40.2", + _ => string.Empty + }; + + List codecStrings = []; + if (!string.IsNullOrWhiteSpace(videoCodec)) + { + codecStrings.Add(videoCodec); + } + + if (!string.IsNullOrWhiteSpace(audioCodec)) + { + codecStrings.Add(audioCodec); + } + + string codecs = codecStrings.Count > 0 ? $",CODECS=\"{string.Join(",", codecStrings)}\"" : string.Empty; + resolution = $",RESOLUTION={streamingSpecs.Width}x{streamingSpecs.Height}{codecs}"; + bitrate = streamingSpecs.Bitrate.ToString(CultureInfo.InvariantCulture); + } + + return $@"#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} +{variantPlaylist}"; + } } diff --git a/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs b/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs index 6d321d4d6..66c4cba02 100644 --- a/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs +++ b/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs @@ -1,8 +1,6 @@ namespace ErsatzTV.Core.Errors; -public class ChannelSessionAlreadyActive : BaseError +public class ChannelSessionAlreadyActive(string multiVariantPlaylist) : BaseError("Channel already has HLS session") { - public ChannelSessionAlreadyActive() : base("Channel already has HLS session") - { - } + public string MultiVariantPlaylist { get; } = multiVariantPlaylist; } diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 21bf3ec93..aa334de9e 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Globalization; -using System.Text; using CliWrap; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Images; @@ -93,6 +91,15 @@ public class IptvController : StreamingControllerBase return NotFound(); } + foreach (ChannelViewModel channel in maybeChannel) + { + // NEXT: MPEG-TS streams are not (yet?) supported + if (!channel.IsEnabled || channel.StreamingEngine is StreamingEngine.Next) + { + return NotFound(); + } + } + // if mode is "unspecified" - find the configured mode and set it or redirect if (string.IsNullOrWhiteSpace(mode) || mode == "mixed") { @@ -224,11 +231,16 @@ public class IptvController : StreamingControllerBase "Maybe starting ffmpeg session for channel {Channel}, mode {Mode}", channelNumber, mode); - var request = new StartFFmpegSession(channelNumber, mode, Request.Scheme, Request.Host.ToString()); - Either result = await _mediator.Send(request); - string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber); + var request = new StartFFmpegSession( + channelNumber, + mode, + Request.Scheme, + Request.Host.ToString(), + Request.PathBase, + AccessTokenQuery()); + Either result = await _mediator.Send(request); return result.Match( - _ => + multiVariantPlaylist => { _logger.LogDebug( "Session started; returning multi-variant playlist for channel {Channel}", @@ -241,12 +253,12 @@ public class IptvController : StreamingControllerBase { switch (error) { - case ChannelSessionAlreadyActive: + case ChannelSessionAlreadyActive active: _logger.LogDebug( "Session is already active; returning multi-variant playlist for channel {Channel}", channelNumber); - return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl"); + return Content(active.MultiVariantPlaylist, "application/vnd.apple.mpegurl"); // return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8"); default: _logger.LogWarning( @@ -289,54 +301,6 @@ public class IptvController : StreamingControllerBase public async Task GetStream(string channelNumber) => await GetHlsDirectStream(channelNumber); - private async Task GetMultiVariantPlaylist(string channelNumber) - { - var variantPlaylist = - $"{Request.Scheme}://{Request.Host}{Request.PathBase}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}"; - - Option maybeStreamingSpecs = - await _mediator.Send(new GetChannelStreamingSpecs(channelNumber)); - string resolution = string.Empty; - var bitrate = "10000000"; - foreach (ChannelStreamingSpecsViewModel streamingSpecs in maybeStreamingSpecs) - { - string videoCodec = streamingSpecs.VideoFormat switch - { - FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08", - FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0", - FFmpegProfileVideoFormat.H264 => "avc1.4D4028", - _ => string.Empty - }; - - string audioCodec = streamingSpecs.AudioFormat switch - { - FFmpegProfileAudioFormat.Ac3 => "ac-3", - FFmpegProfileAudioFormat.Aac or FFmpegProfileAudioFormat.AacLatm => "mp4a.40.2", - _ => string.Empty - }; - - List codecStrings = []; - if (!string.IsNullOrWhiteSpace(videoCodec)) - { - codecStrings.Add(videoCodec); - } - - if (!string.IsNullOrWhiteSpace(audioCodec)) - { - codecStrings.Add(audioCodec); - } - - string codecs = codecStrings.Count > 0 ? $",CODECS=\"{string.Join(",", codecStrings)}\"" : string.Empty; - resolution = $",RESOLUTION={streamingSpecs.Width}x{streamingSpecs.Height}{codecs}"; - bitrate = streamingSpecs.Bitrate.ToString(CultureInfo.InvariantCulture); - } - - return $@"#EXTM3U -#EXT-X-VERSION:3 -#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} -{variantPlaylist}"; - } - private async Task GetHlsDirectStream(string channelNumber) { var request = new GetPlayoutItemProcessByChannelNumber(