Extract and cache all media attachments in bulk (#11029)

Similar to https://github.com/jellyfin/jellyfin/pull/10884

---

Jellyfin clients need fonts for subtitles, and each font is a separate
attachment, which causes a lot of re-reads of the file. Certain contents,
like anime in a lot of cases, contain 50-80 different attachments.

Spawning 80 ffmpeg processes at the same time on the same file might
cause swapping on slower HDDs and can bring disk subsystem to a crawl.

(For more info, see https://github.com/jellyfin/jellyfin/3215)

This change helps a lot in this scenario.

Signed-off-by: Attila Szakacs <szakacs.attila96@gmail.com>
This commit is contained in:
Attila Szakacs 2024-03-03 21:33:54 +01:00 committed by GitHub
parent f7f3ad9eb7
commit 8d40d431e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,7 +1,7 @@
#pragma warning disable CS1591
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@ -9,6 +9,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
@ -230,6 +231,8 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false);
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
.ConfigureAwait(false);
@ -237,6 +240,159 @@ namespace MediaBrowser.MediaEncoding.Attachments
return outputPath;
}
private async Task CacheAllAttachments(
string mediaPath,
string inputFile,
MediaSourceInfo mediaSource,
CancellationToken cancellationToken)
{
var outputFileLocks = new List<AsyncKeyedLockReleaser<string>>();
var extractableAttachmentIds = new List<int>();
try
{
foreach (var attachment in mediaSource.MediaAttachments)
{
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
var @outputFileLock = _semaphoreLocks.GetOrAdd(outputPath);
await @outputFileLock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath))
{
@outputFileLock.Dispose();
continue;
}
outputFileLocks.Add(@outputFileLock);
extractableAttachmentIds.Add(attachment.Index);
}
if (extractableAttachmentIds.Count > 0)
{
await CacheAllAttachmentsInternal(mediaPath, inputFile, mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath);
}
finally
{
foreach (var @outputFileLock in outputFileLocks)
{
@outputFileLock.Dispose();
}
}
}
private async Task CacheAllAttachmentsInternal(
string mediaPath,
string inputFile,
MediaSourceInfo mediaSource,
List<int> extractableAttachmentIds,
CancellationToken cancellationToken)
{
var outputPaths = new List<string>();
var processArgs = string.Empty;
foreach (var attachmentId in extractableAttachmentIds)
{
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
outputPaths.Add(outputPath);
processArgs += string.Format(
CultureInfo.InvariantCulture,
" -dump_attachment:{0} \"{1}\"",
attachmentId,
EncodingUtils.NormalizePath(outputPath));
}
processArgs += string.Format(
CultureInfo.InvariantCulture,
" -i \"{0}\" -t 0 -f null null",
inputFile);
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
Arguments = processArgs,
FileName = _mediaEncoder.EncoderPath,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken).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 media attachment due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
// ffmpeg failed, so it is normal that one or more expected output files do not exist.
// There is no need to log anything for the user here.
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath);
}
}
}
else
{
foreach (var outputPath in outputPaths)
{
if (!File.Exists(outputPath))
{
_logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", inputFile, outputPath);
failed = true;
continue;
}
_logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}", inputFile, outputPath);
}
}
if (failed)
{
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inputFile));
}
}
private async Task ExtractAttachment(
string inputFile,
MediaSourceInfo mediaSource,