Extract all subtitle streams simultaneously

Extracting a subtitle stream is a disk I/O bottlenecked operation as
ffmpeg has to read through the whole file, but usually there is nothing
CPU intensive to do.

If a file has multiple subtitle streams, and we want to extract more
of them, extracting them one-by-one results in reading the whole file
again and again.

However ffmpeg can extract multiple streams at once.

We can optimize this by extracting the subtitle streams all at once
when only one of them gets queried, then we will have all of them
cached for later use.

It is useful for people switching subtitles during playback.

It is even more useful for people who extract all the subtitle streams
in advance, for example with the "Subtitle Extract" plugin.
In this case we reduce the extraction time significantly based on the
number of subtitle streams in the files, which can be 5-10 in many
cases.

Signed-off-by: Attila Szakacs <szakacs.attila96@gmail.com>
This commit is contained in:
Attila Szakacs 2024-01-18 16:38:47 +01:00
parent a884b1f786
commit 8fea819b51

View File

@ -2,6 +2,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@ -194,36 +195,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
string outputFormat;
string outputCodec;
await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
|| string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(subtitleStream.Codec, "srt", StringComparison.OrdinalIgnoreCase))
{
// Extract
outputCodec = "copy";
outputFormat = subtitleStream.Codec;
}
else if (string.Equals(subtitleStream.Codec, "subrip", StringComparison.OrdinalIgnoreCase))
{
// Extract
outputCodec = "copy";
outputFormat = "srt";
}
else
{
// Extract
outputCodec = "srt";
outputFormat = "srt";
}
// Extract
var outputFormat = GetTextSubtitleFormat(subtitleStream);
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat);
await ExtractTextSubtitle(mediaSource, subtitleStream, outputCodec, outputPath, cancellationToken)
.ConfigureAwait(false);
return new SubtitleInfo()
{
Path = outputPath,
@ -467,6 +443,197 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
private string GetTextSubtitleFormat(MediaStream subtitleStream)
{
if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
|| string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase))
{
return subtitleStream.Codec;
}
else
{
return "srt";
}
}
private bool IsCodecCopyable(string codec)
{
return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Extracts all text subtitles.
/// </summary>
/// <param name="mediaSource">The mediaSource.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
{
var semaphores = new List<SemaphoreSlim> { };
var extractableStreams = new List<MediaStream> { };
try
{
var subtitleStreams = mediaSource.MediaStreams
.Where(stream => stream.IsTextSubtitleStream && stream.SupportsExternalStream);
foreach (var subtitleStream in subtitleStreams)
{
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream));
var semaphore = GetLock(outputPath);
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath))
{
semaphore.Release();
continue;
}
semaphores.Add(semaphore);
extractableStreams.Add(subtitleStream);
}
if (extractableStreams.Count > 0)
{
await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
}
finally
{
foreach (var semaphore in semaphores)
{
semaphore.Release();
}
}
}
private async Task ExtractAllTextSubtitlesInternal(
MediaSourceInfo mediaSource,
List<MediaStream> subtitleStreams,
CancellationToken cancellationToken)
{
var inputPath = mediaSource.Path;
var outputPaths = new List<string> { };
var args = string.Format(
CultureInfo.InvariantCulture,
"-i {0} -copyts",
inputPath);
foreach (var subtitleStream in subtitleStreams)
{
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream));
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
outputPaths.Add(outputPath);
args += string.Format(
CultureInfo.InvariantCulture,
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
subtitleStream.Index,
outputCodec,
outputPath);
}
int exitCode;
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
{
await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode == -1)
{
failed = true;
foreach (var outputPath in outputPaths)
{
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);
}
}
}
else
{
foreach (var outputPath in outputPaths)
{
if (!File.Exists(outputPath))
{
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
failed = true;
}
else
{
if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
{
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
}
}
}
if (failed)
{
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
}
}
/// <summary>
/// Extracts the text subtitle.
/// </summary>