Browse Source

return multi variant playlist from start ffmpeg session

pull/2854/head
Jason Dove 3 months ago
parent
commit
0b03436ad3
No known key found for this signature in database
  1. 10
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs
  2. 67
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  3. 6
      ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs
  4. 76
      ErsatzTV/Controllers/IptvController.cs

10
ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs

@ -2,6 +2,12 @@
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public record StartFFmpegSession(string ChannelNumber, string Mode, string Scheme, string Host) : public record StartFFmpegSession(
IRequest<Either<BaseError, Unit>>, string ChannelNumber,
string Mode,
string Scheme,
string Host,
string PathBase,
string AccessTokenQuery) :
IRequest<Either<BaseError, string>>,
IFFmpegWorkerRequest; IFFmpegWorkerRequest;

67
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 System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics; using ErsatzTV.Application.Graphics;
@ -19,7 +20,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>> public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, string>>
{ {
private readonly IFileSystem _fileSystem; private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
@ -65,15 +66,15 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_workerChannel = workerChannel; _workerChannel = workerChannel;
} }
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => public Task<Either<BaseError, string>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
Validate(request) Validate(request)
.MapT(_ => StartProcess(request, cancellationToken)) .MapT(_ => StartProcess(request, cancellationToken))
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError) // this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
#pragma warning disable VSTHRD103 #pragma warning disable VSTHRD103
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity)); .Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<string>, string>(identity));
#pragma warning restore VSTHRD103 #pragma warning restore VSTHRD103
private async Task<Unit> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken) private async Task<string> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken)
{ {
Option<TimeSpan> idleTimeout = await _configElementRepository Option<TimeSpan> idleTimeout = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) .GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
@ -116,7 +117,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken); await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
return Unit.Default; return await GetMultiVariantPlaylist(request);
} }
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<FrameRate> targetFramerate) => private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<FrameRate> targetFramerate) =>
@ -139,12 +140,12 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
SessionMustBeInactive(request) SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request)); .BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request) private async Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{ {
var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null)) var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null))
.Where(success => success) .Where(success => success)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive()); .ToValidation<BaseError>(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request)));
if (result.IsFail && _ffmpegSegmenterService.TryGetWorker( if (result.IsFail && _ffmpegSegmenterService.TryGetWorker(
request.ChannelNumber, request.ChannelNumber,
@ -153,7 +154,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
worker?.Touch(Option<string>.None); worker?.Touch(Option<string>.None);
} }
return result.AsTask(); return result;
} }
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request) private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
@ -166,4 +167,52 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default); return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
} }
private async Task<string> GetMultiVariantPlaylist(StartFFmpegSession request)
{
var variantPlaylist =
$"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/hls.m3u8{request.AccessTokenQuery}";
Option<ChannelStreamingSpecsViewModel> 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<string> 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}";
}
} }

6
ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs

@ -1,8 +1,6 @@
namespace ErsatzTV.Core.Errors; 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;
{
}
} }

76
ErsatzTV/Controllers/IptvController.cs

@ -1,6 +1,4 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Text;
using CliWrap; using CliWrap;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Images; using ErsatzTV.Application.Images;
@ -93,6 +91,15 @@ public class IptvController : StreamingControllerBase
return NotFound(); 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 mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed") if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{ {
@ -224,11 +231,16 @@ public class IptvController : StreamingControllerBase
"Maybe starting ffmpeg session for channel {Channel}, mode {Mode}", "Maybe starting ffmpeg session for channel {Channel}, mode {Mode}",
channelNumber, channelNumber,
mode); mode);
var request = new StartFFmpegSession(channelNumber, mode, Request.Scheme, Request.Host.ToString()); var request = new StartFFmpegSession(
Either<BaseError, Unit> result = await _mediator.Send(request); channelNumber,
string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber); mode,
Request.Scheme,
Request.Host.ToString(),
Request.PathBase,
AccessTokenQuery());
Either<BaseError, string> result = await _mediator.Send(request);
return result.Match<IActionResult>( return result.Match<IActionResult>(
_ => multiVariantPlaylist =>
{ {
_logger.LogDebug( _logger.LogDebug(
"Session started; returning multi-variant playlist for channel {Channel}", "Session started; returning multi-variant playlist for channel {Channel}",
@ -241,12 +253,12 @@ public class IptvController : StreamingControllerBase
{ {
switch (error) switch (error)
{ {
case ChannelSessionAlreadyActive: case ChannelSessionAlreadyActive active:
_logger.LogDebug( _logger.LogDebug(
"Session is already active; returning multi-variant playlist for channel {Channel}", "Session is already active; returning multi-variant playlist for channel {Channel}",
channelNumber); channelNumber);
return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl"); return Content(active.MultiVariantPlaylist, "application/vnd.apple.mpegurl");
// return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8"); // return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8");
default: default:
_logger.LogWarning( _logger.LogWarning(
@ -289,54 +301,6 @@ public class IptvController : StreamingControllerBase
public async Task<IActionResult> GetStream(string channelNumber) => public async Task<IActionResult> GetStream(string channelNumber) =>
await GetHlsDirectStream(channelNumber); await GetHlsDirectStream(channelNumber);
private async Task<string> GetMultiVariantPlaylist(string channelNumber)
{
var variantPlaylist =
$"{Request.Scheme}://{Request.Host}{Request.PathBase}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}";
Option<ChannelStreamingSpecsViewModel> 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<string> 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<IActionResult> GetHlsDirectStream(string channelNumber) private async Task<IActionResult> GetHlsDirectStream(string channelNumber)
{ {
var request = new GetPlayoutItemProcessByChannelNumber( var request = new GetPlayoutItemProcessByChannelNumber(

Loading…
Cancel
Save