mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-07-19 12:44:19 +01:00
Prevent ffmpeg from hanging extracting subtitles (#17297)
* Prevent ffmpeg from hanging extracting subtitles Add `RunSubtitleExtractionProcess` to unify the external _ffmpeg_ process handling and error management. Add a `-nostdin` flag that prevents _ffmpeg_ from reading from _stdin_ and blocking on an inherited stdin handle (e.g. when Jellyfin runs as a service under NSSM), which otherwise hangs subtitle extraction forever when _ffmpeg_ blocks on any keyboard-interaction read until the timeout (30 minutes). Close the redirected _stdin_ to ensure immediage EOF. Drain the _stderr_ to a string and log it, to ensure we don't block the _ffmpeg_ process on errors that exceed the pipe length. Pass `-y` to _ffmpeg_ to ensure it overwrites any existing output file without prompting for confirmation. * Address review comments Make sure we always drain stderr. Make sure the timeout also honors the cancellationToken. Make sure when we get cancelled we don't log it as a ffmpeg error.
This commit is contained in:
@@ -445,98 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
encodingParam = " -sub_charenc " + encodingParam;
|
encodingParam = " -sub_charenc " + encodingParam;
|
||||||
}
|
}
|
||||||
|
|
||||||
int exitCode;
|
var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
|
||||||
|
|
||||||
using (var process = new Process
|
await ExtractSubtitlesForFile(
|
||||||
{
|
inputPath,
|
||||||
StartInfo = new ProcessStartInfo
|
args,
|
||||||
{
|
[outputPath],
|
||||||
CreateNoWindow = true,
|
cancellationToken).ConfigureAwait(false);
|
||||||
UseShellExecute = false,
|
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
|
||||||
Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden,
|
|
||||||
ErrorDialog = false
|
|
||||||
},
|
|
||||||
EnableRaisingEvents = true
|
|
||||||
})
|
|
||||||
{
|
|
||||||
_logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error starting ffmpeg");
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
|
||||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
|
|
||||||
exitCode = process.ExitCode;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
process.Kill(true);
|
|
||||||
exitCode = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var failed = false;
|
|
||||||
|
|
||||||
if (exitCode == -1)
|
|
||||||
{
|
|
||||||
failed = true;
|
|
||||||
|
|
||||||
if (File.Exists(outputPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
|
|
||||||
_fileSystem.DeleteFile(outputPath);
|
|
||||||
}
|
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
|
||||||
{
|
|
||||||
failed = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
|
|
||||||
_fileSystem.DeleteFile(outputPath);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed)
|
|
||||||
{
|
|
||||||
_logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
|
|
||||||
|
|
||||||
throw new FfmpegException(
|
|
||||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
WriteCacheMeta(outputPath, inputPath);
|
WriteCacheMeta(outputPath, inputPath);
|
||||||
|
|
||||||
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
|
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
|
||||||
@@ -727,7 +644,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
var outputPaths = new List<string>();
|
var outputPaths = new List<string>();
|
||||||
var args = string.Format(
|
var args = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"-i {0}",
|
"-y -i {0}",
|
||||||
inputPath);
|
inputPath);
|
||||||
|
|
||||||
foreach (var subtitleStream in subtitleStreams)
|
foreach (var subtitleStream in subtitleStreams)
|
||||||
@@ -781,50 +698,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
private async Task ExtractSubtitlesForFile(
|
private async Task ExtractSubtitlesForFile(
|
||||||
string inputPath,
|
string inputPath,
|
||||||
string args,
|
string args,
|
||||||
List<string> outputPaths,
|
IReadOnlyList<string> outputPaths,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
int exitCode;
|
var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
using (var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
|
||||||
Arguments = args,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden,
|
|
||||||
ErrorDialog = false
|
|
||||||
},
|
|
||||||
EnableRaisingEvents = true
|
|
||||||
})
|
|
||||||
{
|
|
||||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error starting ffmpeg");
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
|
||||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
|
|
||||||
exitCode = process.ExitCode;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
process.Kill(true);
|
|
||||||
exitCode = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var failed = false;
|
var failed = false;
|
||||||
|
|
||||||
@@ -884,6 +761,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
|
|
||||||
if (failed)
|
if (failed)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ffmpegError))
|
||||||
|
{
|
||||||
|
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
|
||||||
|
}
|
||||||
|
|
||||||
throw new FfmpegException(
|
throw new FfmpegException(
|
||||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
|
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
|
||||||
}
|
}
|
||||||
@@ -941,16 +825,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(outputPath);
|
ArgumentException.ThrowIfNullOrEmpty(outputPath);
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
|
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
|
||||||
|
|
||||||
var processArgs = string.Format(
|
var processArgs = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
|
"-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
|
||||||
inputPath,
|
inputPath,
|
||||||
subtitleStreamIndex,
|
subtitleStreamIndex,
|
||||||
outputCodec,
|
outputCodec,
|
||||||
outputPath);
|
outputPath);
|
||||||
|
|
||||||
|
await ExtractSubtitlesForFile(
|
||||||
|
inputPath,
|
||||||
|
processArgs,
|
||||||
|
[outputPath],
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never
|
||||||
|
/// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
|
||||||
|
/// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
|
||||||
|
/// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
|
||||||
|
/// unused for subtitle extraction.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="arguments">The ffmpeg command line arguments.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns>
|
||||||
|
private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
int exitCode;
|
int exitCode;
|
||||||
|
var standardError = string.Empty;
|
||||||
|
|
||||||
using (var process = new Process
|
using (var process = new Process
|
||||||
{
|
{
|
||||||
@@ -958,8 +864,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
{
|
{
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
FileName = _mediaEncoder.EncoderPath,
|
FileName = _mediaEncoder.EncoderPath,
|
||||||
Arguments = processArgs,
|
Arguments = "-nostdin " + arguments,
|
||||||
WindowStyle = ProcessWindowStyle.Hidden,
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
ErrorDialog = false
|
ErrorDialog = false
|
||||||
},
|
},
|
||||||
@@ -975,14 +883,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error starting ffmpeg");
|
_logger.LogError(ex, "Error starting ffmpeg");
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
|
||||||
|
process.StandardInput.Close();
|
||||||
|
|
||||||
|
// Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
|
||||||
|
var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
|
||||||
|
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
||||||
|
using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
|
||||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
|
|
||||||
exitCode = process.ExitCode;
|
exitCode = process.ExitCode;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -990,59 +905,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||||||
process.Kill(true);
|
process.Kill(true);
|
||||||
exitCode = -1;
|
exitCode = -1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var failed = false;
|
|
||||||
|
|
||||||
if (exitCode == -1)
|
|
||||||
{
|
|
||||||
failed = true;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
standardError = await standardErrorTask.ConfigureAwait(false);
|
||||||
_fileSystem.DeleteFile(outputPath);
|
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
}
|
// Reading ffmpeg output was cancelled; nothing more to capture.
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
|
||||||
{
|
|
||||||
failed = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
|
||||||
_fileSystem.DeleteFile(outputPath);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (IOException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failed)
|
return (exitCode, standardError);
|
||||||
{
|
|
||||||
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
|
||||||
|
|
||||||
throw new FfmpegException(
|
|
||||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
|
||||||
|
|
||||||
if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user