Browse Source

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>
Attila Szakacs 1 year ago
parent
commit
8d40d431e8
1 changed files with 157 additions and 1 deletions
  1. 157 1
      MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

+ 157 - 1
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<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,