fix seeking with text subtitles (#2214)

This commit is contained in:
Jason Dove
2025-07-28 16:19:20 +00:00
committed by GitHub
parent e9d494c24e
commit 6c6ccfa94b
17 changed files with 228 additions and 29 deletions
+1
View File
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed ### Fixed
- Fix app startup with MySql/MariaDB - Fix app startup with MySql/MariaDB
- YAML playout: fix `pad_to_next` always running over time - YAML playout: fix `pad_to_next` always running over time
- Fix playback with text subtitles when seeking into content, i.e. when first joining a channel
### Changed ### Changed
- Always tell ffmpeg to stop encoding with a specific duration - Always tell ffmpeg to stop encoding with a specific duration
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Streaming;
public record GetSeekTextSubtitleProcess(string SubtitlePath, TimeSpan Seek)
: IRequest<Either<BaseError, SeekTextSubtitleProcess>>;
@@ -0,0 +1,47 @@
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetSeekTextSubtitleProcessHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: IRequestHandler<GetSeekTextSubtitleProcess,
Either<BaseError, SeekTextSubtitleProcess>>
{
public async Task<Either<BaseError, SeekTextSubtitleProcess>> Handle(
GetSeekTextSubtitleProcess request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext);
return await validation.Match(
ffmpegPath => GetProcess(request, ffmpegPath),
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
}
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
GetSeekTextSubtitleProcess request,
string ffmpegPath)
{
Command process = await ffmpegProcessService.SeekTextSubtitle(
ffmpegPath,
request.SubtitlePath,
request.Seek);
return new SeekTextSubtitleProcess(process);
}
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext) =>
await FFmpegPathMustExist(dbContext);
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
}
@@ -0,0 +1,5 @@
using CliWrap;
namespace ErsatzTV.Application.Streaming;
public record SeekTextSubtitleProcess(Command Process);
@@ -141,6 +141,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
// proxy to avoid dealing with escaping // proxy to avoid dealing with escaping
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}"; subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}";
foreach (TimeSpan seek in playbackSettings.StreamSeek)
{
subtitle.Path += $"?seekToMs={(int)seek.TotalMilliseconds}";
}
} }
} }
@@ -920,6 +925,45 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkWidthPercent, watermarkWidthPercent,
cancellationToken); cancellationToken);
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, TimeSpan seek)
{
var videoInputFile = new VideoInputFile(
inputFile,
new List<VideoStream>
{
new(
0,
string.Empty,
string.Empty,
None,
ColorParams.Default,
FrameSize.Unknown,
string.Empty,
string.Empty,
None,
true,
ScanKind.Progressive)
});
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
videoInputFile,
None,
None,
None,
Option<ConcatInputFile>.None,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, seek);
return GetCommand(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
}
private static Option<WatermarkInputFile> GetWatermarkInputFile( private static Option<WatermarkInputFile> GetWatermarkInputFile(
Option<WatermarkOptions> watermarkOptions, Option<WatermarkOptions> watermarkOptions,
Option<List<FadePoint>> maybeFadePoints) Option<List<FadePoint>> maybeFadePoints)
@@ -90,4 +90,6 @@ public interface IFFmpegProcessService
int verticalMarginPercent, int verticalMarginPercent,
int watermarkWidthPercent, int watermarkWidthPercent,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, TimeSpan seek);
} }
@@ -7,7 +7,7 @@ public class CopyTimestampInputOption : IInputOption
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>(); public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>(); public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => new[] { "-copyts" }; public string[] InputOptions(InputFile inputFile) => []; //new[] { "-copyts" };
public string[] FilterOptions => Array.Empty<string>(); public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => Array.Empty<string>(); public string[] OutputOptions => Array.Empty<string>();
@@ -0,0 +1,13 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.OutputFormat;
public class OutputFormatAss : IPipelineStep
{
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public string[] OutputOptions => ["-f", "ass"];
public FrameState NextState(FrameState currentState) => currentState;
}
@@ -0,0 +1,13 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.OutputFormat;
public class OutputFormatSrt : IPipelineStep
{
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public string[] OutputOptions => ["-f", "srt"];
public FrameState NextState(FrameState currentState) => currentState;
}
@@ -0,0 +1,13 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.OutputFormat;
public class OutputFormatWebVtt : IPipelineStep
{
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public string[] OutputOptions => ["-f", "webvtt"];
public FrameState NextState(FrameState currentState) => currentState;
}
@@ -3,6 +3,7 @@ namespace ErsatzTV.FFmpeg.Pipeline;
public interface IPipelineBuilder public interface IPipelineBuilder
{ {
FFmpegPipeline Resize(string outputFile, FrameSize scaledSize); FFmpegPipeline Resize(string outputFile, FrameSize scaledSize);
FFmpegPipeline Seek(string inputFile, TimeSpan seek);
FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState); FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState); FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState); FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState);
@@ -542,8 +542,6 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
{ {
if (context.HasSubtitleText) if (context.HasSubtitleText)
{ {
videoInputFile.AddOption(new CopyTimestampInputOption());
if (videoInputFile.FilterSteps.Count == 0 && videoInputFile.InputOptions.OfType<CuvidDecoder>().Any()) if (videoInputFile.FilterSteps.Count == 0 && videoInputFile.InputOptions.OfType<CuvidDecoder>().Any())
{ {
// change the hw accel output to software so the explicit download isn't needed // change the hw accel output to software so the explicit download isn't needed
@@ -65,13 +65,37 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
IPipelineFilterStep scaleStep = new ScaleImageFilter(scaledSize); IPipelineFilterStep scaleStep = new ScaleImageFilter(scaledSize);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleStep)); _videoInputFile.Iter(f => f.FilterSteps.Add(scaleStep));
pipelineSteps.Add(new VideoFilter(new[] { scaleStep })); pipelineSteps.Add(new VideoFilter([scaleStep]));
pipelineSteps.Add(scaleStep); pipelineSteps.Add(scaleStep);
pipelineSteps.Add(new FileNameOutputOption(outputFile)); pipelineSteps.Add(new FileNameOutputOption(outputFile));
return new FFmpegPipeline(pipelineSteps, false); return new FFmpegPipeline(pipelineSteps, false);
} }
public FFmpegPipeline Seek(string inputFile, TimeSpan seek)
{
IPipelineStep outputFormat = Path.GetExtension(inputFile).ToLowerInvariant() switch
{
"ass" or "ssa" => new OutputFormatAss(),
"vtt" => new OutputFormatWebVtt(),
_ => new OutputFormatSrt()
};
var pipelineSteps = new List<IPipelineStep>
{
new NoStandardInputOption(),
new HideBannerOption(),
new NoStatsOption(),
new LoglevelErrorOption(),
new StreamSeekFilterOption(seek),
new EncoderCopySubtitle(),
outputFormat,
new PipeProtocol(),
};
return new FFmpegPipeline(pipelineSteps, false);
}
public FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState) public FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
{ {
var pipelineSteps = new List<IPipelineStep> var pipelineSteps = new List<IPipelineStep>
@@ -823,10 +847,10 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
videoInputFile.AddOption(option); videoInputFile.AddOption(option);
// need to seek text subtitle files // need to seek text subtitle files
if (context.HasSubtitleText) // if (context.HasSubtitleText)
{ // {
pipelineSteps.Add(new StreamSeekFilterOption(desiredStart)); // pipelineSteps.Add(new StreamSeekFilterOption(desiredStart));
} // }
} }
} }
@@ -508,8 +508,6 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
{ {
if (context.HasSubtitleText) if (context.HasSubtitleText)
{ {
videoInputFile.AddOption(new CopyTimestampInputOption());
var downloadFilter = new HardwareDownloadFilter(currentState); var downloadFilter = new HardwareDownloadFilter(currentState);
currentState = downloadFilter.NextState(currentState); currentState = downloadFilter.NextState(currentState);
videoInputFile.FilterSteps.Add(downloadFilter); videoInputFile.FilterSteps.Add(downloadFilter);
@@ -270,8 +270,6 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
{ {
if (context.HasSubtitleText) if (context.HasSubtitleText)
{ {
videoInputFile.AddOption(new CopyTimestampInputOption());
var subtitlesFilter = new SubtitlesFilter(fontsFolder, subtitle); var subtitlesFilter = new SubtitlesFilter(fontsFolder, subtitle);
videoInputFile.FilterSteps.Add(subtitlesFilter); videoInputFile.FilterSteps.Add(subtitlesFilter);
} }
@@ -449,8 +449,6 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
{ {
if (context.HasSubtitleText) if (context.HasSubtitleText)
{ {
videoInputFile.AddOption(new CopyTimestampInputOption());
// if (videoInputFile.FilterSteps.Count == 0 && videoInputFile.InputOptions.OfType<CuvidDecoder>().Any()) // if (videoInputFile.FilterSteps.Count == 0 && videoInputFile.InputOptions.OfType<CuvidDecoder>().Any())
// { // {
// // change the hw accel output to software so the explicit download isn't needed // // change the hw accel output to software so the explicit download isn't needed
+53 -15
View File
@@ -190,27 +190,65 @@ public class InternalController : ControllerBase
} }
[HttpGet("/media/subtitle/{id:int}")] [HttpGet("/media/subtitle/{id:int}")]
public async Task<IActionResult> GetSubtitle(int id) public async Task<IActionResult> GetSubtitle(int id, [FromQuery] long? seekToMs)
{ {
Either<BaseError, string> path = await _mediator.Send(new GetSubtitlePathById(id)); Either<BaseError, string> maybePath = await _mediator.Send(new GetSubtitlePathById(id));
return path.Match<IActionResult>(
Left: _ => new NotFoundResult(), foreach (string path in maybePath.RightToSeq())
Right: r => {
string mimeType = Path.GetExtension(path).ToLowerInvariant() switch
{ {
if (r.StartsWith("http", StringComparison.OrdinalIgnoreCase)) "ass" or "ssa" => "text/x-ssa",
"vtt" => "text/vtt",
_ => "application/x-subrip"
};
if (seekToMs is > 0)
{
Either<BaseError, SeekTextSubtitleProcess> maybeProcess = await _mediator.Send(
new GetSeekTextSubtitleProcess(path, TimeSpan.FromMilliseconds(seekToMs.Value)));
foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq())
{ {
return new RedirectResult(r); Command command = processModel.Process;
_logger.LogDebug("ffmpeg text subtitle arguments {FFmpegArguments}", command.Arguments);
var process = new FFmpegProcess
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
HttpContext.Response.RegisterForDispose(process);
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, mimeType);
} }
string mimeType = Path.GetExtension(r).ToLowerInvariant() switch return new NotFoundResult();
{ }
"ass" or "ssa" => "text/x-ssa",
"vtt" => "text/vtt",
_ => "application/x-subrip"
};
return new PhysicalFileResult(r, mimeType); if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
}); {
return new RedirectResult(path);
}
return new PhysicalFileResult(path, mimeType);
}
return new NotFoundResult();
} }
private async Task<IActionResult> GetSegmenterV2Stream(string channelNumber) private async Task<IActionResult> GetSegmenterV2Stream(string channelNumber)