fix hls direct (#2487)

This commit is contained in:
Jason Dove
2025-10-04 15:36:36 -05:00
committed by GitHub
parent 0363609923
commit c39858b2d8
5 changed files with 137 additions and 91 deletions
+1
View File
@@ -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();
} }
+6 -86
View File
@@ -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();
} }
} }
+25 -1
View File
@@ -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();
}
}