fix hls direct (#2487)
This commit is contained in:
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Do not allow deleting ffmpeg profiles that are used by channels
|
- Do not allow deleting ffmpeg profiles that are used by channels
|
||||||
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
|
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
|
||||||
|
- Fix HLS Direct playback, and make it accessible on separate streaming port
|
||||||
|
|
||||||
## [25.7.0] - 2025-09-14
|
## [25.7.0] - 2025-09-14
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -37,9 +37,18 @@ public class GetHlsPlaylistByChannelNumberHandler :
|
|||||||
Parameters parameters,
|
Parameters parameters,
|
||||||
DateTimeOffset now)
|
DateTimeOffset now)
|
||||||
{
|
{
|
||||||
string mode = string.IsNullOrWhiteSpace(request.Mode)
|
string mode = request.Mode switch
|
||||||
? string.Empty
|
{
|
||||||
: $"&mode={request.Mode}";
|
"segmenter" or "segmenter-fmp4" or "segmenter-v2" or "ts-legacy" or "ts" => $"&mode={request.Mode}",
|
||||||
|
// "hls-direct" => string.Empty,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
string endpoint = request.Mode switch
|
||||||
|
{
|
||||||
|
"hls-direct" => "iptv/hls-direct",
|
||||||
|
_ => "ffmpeg/stream"
|
||||||
|
};
|
||||||
|
|
||||||
long index = GetIndexForChannel(parameters.Channel, parameters.PlayoutItem);
|
long index = GetIndexForChannel(parameters.Channel, parameters.PlayoutItem);
|
||||||
double timeRemaining = Math.Abs((parameters.PlayoutItem.FinishOffset - now).TotalSeconds);
|
double timeRemaining = Math.Abs((parameters.PlayoutItem.FinishOffset - now).TotalSeconds);
|
||||||
@@ -49,7 +58,7 @@ public class GetHlsPlaylistByChannelNumberHandler :
|
|||||||
#EXT-X-MEDIA-SEQUENCE:{index}
|
#EXT-X-MEDIA-SEQUENCE:{index}
|
||||||
#EXT-X-DISCONTINUITY
|
#EXT-X-DISCONTINUITY
|
||||||
#EXTINF:{timeRemaining:F2},
|
#EXTINF:{timeRemaining:F2},
|
||||||
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}{mode}
|
{request.Scheme}://{request.Host}/{endpoint}/{request.ChannelNumber}?index={index}{mode}
|
||||||
".AsTask();
|
".AsTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO.Pipelines;
|
|
||||||
using System.Text;
|
|
||||||
using CliWrap;
|
using CliWrap;
|
||||||
using ErsatzTV.Application.Emby;
|
using ErsatzTV.Application.Emby;
|
||||||
using ErsatzTV.Application.Jellyfin;
|
using ErsatzTV.Application.Jellyfin;
|
||||||
@@ -23,10 +21,9 @@ namespace ErsatzTV.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public class InternalController : ControllerBase
|
public class InternalController : StreamingControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||||
private readonly IGraphicsEngine _graphicsEngine;
|
|
||||||
private readonly ILogger<InternalController> _logger;
|
private readonly ILogger<InternalController> _logger;
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
@@ -35,9 +32,9 @@ public class InternalController : ControllerBase
|
|||||||
IGraphicsEngine graphicsEngine,
|
IGraphicsEngine graphicsEngine,
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
ILogger<InternalController> logger)
|
ILogger<InternalController> logger)
|
||||||
|
: base(graphicsEngine, logger)
|
||||||
{
|
{
|
||||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||||
_graphicsEngine = graphicsEngine;
|
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -59,7 +56,7 @@ public class InternalController : ControllerBase
|
|||||||
case "segmenter-v2":
|
case "segmenter-v2":
|
||||||
return await GetSegmenterV2Stream(channelNumber);
|
return await GetSegmenterV2Stream(channelNumber);
|
||||||
default:
|
default:
|
||||||
return await GetTsLegacyStream(channelNumber, mode);
|
return await GetTsLegacyStream(channelNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,14 +268,14 @@ public class InternalController : ControllerBase
|
|||||||
worker is HlsSessionWorkerV2 v2)
|
worker is HlsSessionWorkerV2 v2)
|
||||||
{
|
{
|
||||||
Either<BaseError, PlayoutItemProcessModel> result = await v2.GetNextPlayoutItemProcess();
|
Either<BaseError, PlayoutItemProcessModel> result = await v2.GetNextPlayoutItemProcess();
|
||||||
return GetProcessResponse(result, channelNumber, "segmenter-v2");
|
return GetProcessResponse(result, channelNumber, StreamingMode.HttpLiveStreamingSegmenterV2);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("Unable to locate session worker for channel {Channel}", channelNumber);
|
_logger.LogWarning("Unable to locate session worker for channel {Channel}", channelNumber);
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> GetTsLegacyStream(string channelNumber, string mode)
|
private async Task<IActionResult> GetTsLegacyStream(string channelNumber)
|
||||||
{
|
{
|
||||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||||
channelNumber,
|
channelNumber,
|
||||||
@@ -292,83 +289,6 @@ public class InternalController : ControllerBase
|
|||||||
|
|
||||||
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
||||||
|
|
||||||
return GetProcessResponse(result, channelNumber, mode);
|
return GetProcessResponse(result, channelNumber, StreamingMode.TransportStream);
|
||||||
}
|
|
||||||
|
|
||||||
private IActionResult GetProcessResponse(
|
|
||||||
Either<BaseError, PlayoutItemProcessModel> result,
|
|
||||||
string channelNumber,
|
|
||||||
string mode)
|
|
||||||
{
|
|
||||||
foreach (BaseError error in result.LeftToSeq())
|
|
||||||
{
|
|
||||||
_logger.LogError(
|
|
||||||
"Failed to create stream for channel {ChannelNumber}: {Error}",
|
|
||||||
channelNumber,
|
|
||||||
error.Value);
|
|
||||||
|
|
||||||
return BadRequest(error.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
|
|
||||||
{
|
|
||||||
// for process counter
|
|
||||||
var ffmpegProcess = new FFmpegProcess();
|
|
||||||
|
|
||||||
Command process = processModel.Process;
|
|
||||||
|
|
||||||
_logger.LogDebug("ffmpeg arguments {FFmpegArguments}", process.Arguments);
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
HttpContext.Response.OnCompleted(async () =>
|
|
||||||
{
|
|
||||||
ffmpegProcess.Dispose();
|
|
||||||
await cts.CancelAsync();
|
|
||||||
cts.Dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
|
||||||
cts.Token,
|
|
||||||
HttpContext.RequestAborted);
|
|
||||||
|
|
||||||
var pipe = new Pipe();
|
|
||||||
var stdErrBuffer = new StringBuilder();
|
|
||||||
|
|
||||||
Command processWithPipe = process;
|
|
||||||
foreach (GraphicsEngineContext graphicsEngineContext in processModel.GraphicsEngineContext)
|
|
||||||
{
|
|
||||||
var gePipe = new Pipe();
|
|
||||||
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(gePipe.Reader.AsStream()));
|
|
||||||
|
|
||||||
// fire and forget graphics engine task
|
|
||||||
_ = _graphicsEngine.Run(
|
|
||||||
graphicsEngineContext,
|
|
||||||
gePipe.Writer,
|
|
||||||
linkedCts.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandTask<CommandResult> task = processWithPipe
|
|
||||||
.WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream()))
|
|
||||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
|
||||||
.WithValidation(CommandResultValidation.None)
|
|
||||||
.ExecuteAsync(linkedCts.Token);
|
|
||||||
|
|
||||||
// ensure pipe writer is completed when ffmpeg exits
|
|
||||||
_ = task.Task.ContinueWith(
|
|
||||||
(_, state) => ((PipeWriter)state!).Complete(),
|
|
||||||
pipe.Writer,
|
|
||||||
TaskScheduler.Default);
|
|
||||||
|
|
||||||
string contentType = mode switch
|
|
||||||
{
|
|
||||||
"segmenter-v2" => "video/x-matroska",
|
|
||||||
_ => "video/mp2t"
|
|
||||||
};
|
|
||||||
|
|
||||||
return new FileStreamResult(pipe.Reader.AsStream(), contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// this will never happen
|
|
||||||
return new NotFoundResult();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Domain;
|
|||||||
using ErsatzTV.Core.Errors;
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.FFmpeg;
|
using ErsatzTV.Core.FFmpeg;
|
||||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Streaming;
|
||||||
using ErsatzTV.Core.Iptv;
|
using ErsatzTV.Core.Iptv;
|
||||||
using ErsatzTV.Extensions;
|
using ErsatzTV.Extensions;
|
||||||
using ErsatzTV.Filters;
|
using ErsatzTV.Filters;
|
||||||
@@ -21,7 +22,7 @@ namespace ErsatzTV.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
|
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
|
||||||
public class IptvController : ControllerBase
|
public class IptvController : StreamingControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||||
private readonly ILogger<IptvController> _logger;
|
private readonly ILogger<IptvController> _logger;
|
||||||
@@ -29,8 +30,10 @@ public class IptvController : ControllerBase
|
|||||||
|
|
||||||
public IptvController(
|
public IptvController(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
IGraphicsEngine graphicsEngine,
|
||||||
ILogger<IptvController> logger,
|
ILogger<IptvController> logger,
|
||||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||||
|
: base(graphicsEngine, logger)
|
||||||
{
|
{
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -284,6 +287,10 @@ public class IptvController : ControllerBase
|
|||||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("iptv/hls-direct/{channelNumber}")]
|
||||||
|
public async Task<IActionResult> GetStream(string channelNumber) =>
|
||||||
|
await GetHlsDirectStream(channelNumber);
|
||||||
|
|
||||||
private async Task<string> GetMultiVariantPlaylist(string channelNumber, string mode)
|
private async Task<string> GetMultiVariantPlaylist(string channelNumber, string mode)
|
||||||
{
|
{
|
||||||
string file = mode switch
|
string file = mode switch
|
||||||
@@ -359,6 +366,23 @@ public class IptvController : ControllerBase
|
|||||||
{variantPlaylist}";
|
{variantPlaylist}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> GetHlsDirectStream(string channelNumber)
|
||||||
|
{
|
||||||
|
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||||
|
channelNumber,
|
||||||
|
StreamingMode.HttpLiveStreamingDirect,
|
||||||
|
DateTimeOffset.Now,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
DateTimeOffset.Now,
|
||||||
|
0,
|
||||||
|
Option<int>.None);
|
||||||
|
|
||||||
|
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
||||||
|
|
||||||
|
return GetProcessResponse(result, channelNumber, StreamingMode.HttpLiveStreamingDirect);
|
||||||
|
}
|
||||||
|
|
||||||
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
|
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
|
||||||
? string.Empty
|
? string.Empty
|
||||||
: $"?access_token={Request.Query["access_token"]}";
|
: $"?access_token={Request.Query["access_token"]}";
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.IO.Pipelines;
|
||||||
|
using System.Text;
|
||||||
|
using CliWrap;
|
||||||
|
using ErsatzTV.Application.Streaming;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Streaming;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Controllers;
|
||||||
|
|
||||||
|
public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, ILogger logger)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
protected IActionResult GetProcessResponse(
|
||||||
|
Either<BaseError, PlayoutItemProcessModel> result,
|
||||||
|
string channelNumber,
|
||||||
|
StreamingMode mode)
|
||||||
|
{
|
||||||
|
foreach (BaseError error in result.LeftToSeq())
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
"Failed to create stream for channel {ChannelNumber}: {Error}",
|
||||||
|
channelNumber,
|
||||||
|
error.Value);
|
||||||
|
|
||||||
|
return BadRequest(error.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
|
||||||
|
{
|
||||||
|
// for process counter
|
||||||
|
var ffmpegProcess = new FFmpegProcess();
|
||||||
|
|
||||||
|
Command process = processModel.Process;
|
||||||
|
|
||||||
|
logger.LogDebug("ffmpeg arguments {FFmpegArguments}", process.Arguments);
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
HttpContext.Response.OnCompleted(async () =>
|
||||||
|
{
|
||||||
|
ffmpegProcess.Dispose();
|
||||||
|
await cts.CancelAsync();
|
||||||
|
cts.Dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cts.Token,
|
||||||
|
HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
var pipe = new Pipe();
|
||||||
|
var stdErrBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
Command processWithPipe = process;
|
||||||
|
foreach (GraphicsEngineContext graphicsEngineContext in processModel.GraphicsEngineContext)
|
||||||
|
{
|
||||||
|
var gePipe = new Pipe();
|
||||||
|
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(gePipe.Reader.AsStream()));
|
||||||
|
|
||||||
|
// fire and forget graphics engine task
|
||||||
|
_ = graphicsEngine.Run(
|
||||||
|
graphicsEngineContext,
|
||||||
|
gePipe.Writer,
|
||||||
|
linkedCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandTask<CommandResult> task = processWithPipe
|
||||||
|
.WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream()))
|
||||||
|
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
||||||
|
.WithValidation(CommandResultValidation.None)
|
||||||
|
.ExecuteAsync(linkedCts.Token);
|
||||||
|
|
||||||
|
// ensure pipe writer is completed when ffmpeg exits
|
||||||
|
_ = task.Task.ContinueWith(
|
||||||
|
(_, state) => ((PipeWriter)state!).Complete(),
|
||||||
|
pipe.Writer,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
|
||||||
|
string contentType = mode switch
|
||||||
|
{
|
||||||
|
StreamingMode.HttpLiveStreamingSegmenterV2 => "video/x-matroska",
|
||||||
|
_ => "video/mp2t"
|
||||||
|
};
|
||||||
|
|
||||||
|
return new FileStreamResult(pipe.Reader.AsStream(), contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// this will never happen
|
||||||
|
return new NotFoundResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user