From e627c723e29804e8f6f682bb61032908961f8699 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 23 May 2026 22:41:44 +0200 Subject: [PATCH] Extract attachments in one ffmpeg command when dumping --- .../Attachments/AttachmentExtractor.cs | 147 +++++++++++++++++- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index d9cb7a450f..9dd3dcecba 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - foreach (var attachment in mediaSource.MediaAttachments) - { - if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) - { - await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false); - } - } + await ExtractAllAttachmentsIndividuallyInternal( + inputFile, + mediaSource, + cancellationToken).ConfigureAwait(false); } else { @@ -119,6 +118,140 @@ namespace MediaBrowser.MediaEncoding.Attachments } } + private async Task ExtractAllAttachmentsIndividuallyInternal( + string inputFile, + MediaSourceInfo mediaSource, + CancellationToken cancellationToken) + { + var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource); + + ArgumentException.ThrowIfNullOrEmpty(inputPath); + + var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + if (outputFolder is null) + { + _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile); + return; + } + + using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false)) + { + Directory.CreateDirectory(outputFolder); + + var dumpArgs = new StringBuilder(); + var missingPaths = new List(); + foreach (var attachment in mediaSource.MediaAttachments) + { + if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; + if (File.Exists(attachmentPath)) + { + continue; + } + + dumpArgs.AppendFormat( + CultureInfo.InvariantCulture, + "-dump_attachment:{0} \"{1}\" ", + attachment.Index, + EncodingUtils.NormalizePath(attachmentPath)); + missingPaths.Add(attachmentPath); + } + + if (missingPaths.Count == 0) + { + // Skip extraction if all files already exist + return; + } + + var hasVideoOrAudioStream = mediaSource.MediaStreams + .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio); + var processArgs = string.Format( + CultureInfo.InvariantCulture, + "{0}{1} -i {2} {3}", + dumpArgs, + inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty, + inputPath, + hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); + + 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 != 0 && (hasVideoOrAudioStream || exitCode != 1)) + { + failed = true; + + foreach (var path in missingPaths) + { + if (!File.Exists(path)) + { + continue; + } + + try + { + _fileSystem.DeleteFile(path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted attachment {Path}", path); + } + } + } + + if (!failed && missingPaths.Exists(p => !File.Exists(p))) + { + failed = true; + } + + if (failed) + { + _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder); + + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder)); + } + + _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder); + } + } + private async Task ExtractAllAttachmentsInternal( string inputFile, MediaSourceInfo mediaSource,