From 8d40d431e8e5b067a535e564362b902480a13259 Mon Sep 17 00:00:00 2001 From: Attila Szakacs Date: Sun, 3 Mar 2024 21:33:54 +0100 Subject: [PATCH] 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 --- .../Attachments/AttachmentExtractor.cs | 158 +++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index ff91a60a79..a97cca6b84 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -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>(); + var extractableAttachmentIds = new List(); + + 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 extractableAttachmentIds, + CancellationToken cancellationToken) + { + var outputPaths = new List(); + 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,