< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
Line coverage
100%
Covered lines: 13
Uncovered lines: 0
Coverable lines: 13
Total lines: 356
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Dispose()100%11100%

File(s)

/srv/git/jellyfin/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

#LineLine coverage
 1using System;
 2using System.Diagnostics;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using AsyncKeyedLock;
 9using MediaBrowser.Common.Extensions;
 10using MediaBrowser.Controller.Entities;
 11using MediaBrowser.Controller.IO;
 12using MediaBrowser.Controller.Library;
 13using MediaBrowser.Controller.MediaEncoding;
 14using MediaBrowser.MediaEncoding.Encoder;
 15using MediaBrowser.Model.Dto;
 16using MediaBrowser.Model.Entities;
 17using MediaBrowser.Model.IO;
 18using Microsoft.Extensions.Logging;
 19
 20namespace MediaBrowser.MediaEncoding.Attachments
 21{
 22    /// <inheritdoc cref="IAttachmentExtractor"/>
 23    public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
 24    {
 25        private readonly ILogger<AttachmentExtractor> _logger;
 26        private readonly IFileSystem _fileSystem;
 27        private readonly IMediaEncoder _mediaEncoder;
 28        private readonly IMediaSourceManager _mediaSourceManager;
 29        private readonly IPathManager _pathManager;
 30
 231        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 232        {
 233            o.PoolSize = 20;
 234            o.PoolInitialFill = 1;
 235        });
 36
 37        /// <summary>
 38        /// Initializes a new instance of the <see cref="AttachmentExtractor"/> class.
 39        /// </summary>
 40        /// <param name="logger">The <see cref="ILogger{AttachmentExtractor}"/>.</param>
 41        /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 42        /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
 43        /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
 44        /// <param name="pathManager">The <see cref="IPathManager"/>.</param>
 45        public AttachmentExtractor(
 46            ILogger<AttachmentExtractor> logger,
 47            IFileSystem fileSystem,
 48            IMediaEncoder mediaEncoder,
 49            IMediaSourceManager mediaSourceManager,
 50            IPathManager pathManager)
 51        {
 252            _logger = logger;
 253            _fileSystem = fileSystem;
 254            _mediaEncoder = mediaEncoder;
 255            _mediaSourceManager = mediaSourceManager;
 256            _pathManager = pathManager;
 257        }
 58
 59        /// <inheritdoc />
 60        public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId
 61        {
 62            ArgumentNullException.ThrowIfNull(item);
 63
 64            if (string.IsNullOrWhiteSpace(mediaSourceId))
 65            {
 66                throw new ArgumentNullException(nameof(mediaSourceId));
 67            }
 68
 69            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 70            var mediaSource = mediaSources
 71                .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 72            if (mediaSource is null)
 73            {
 74                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
 75            }
 76
 77            var mediaAttachment = mediaSource.MediaAttachments
 78                .FirstOrDefault(i => i.Index == attachmentStreamIndex);
 79            if (mediaAttachment is null)
 80            {
 81                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {a
 82            }
 83
 84            if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 85            {
 86                throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extr
 87            }
 88
 89            var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
 90                    .ConfigureAwait(false);
 91
 92            return (mediaAttachment, attachmentStream);
 93        }
 94
 95        /// <inheritdoc />
 96        public async Task ExtractAllAttachments(
 97            string inputFile,
 98            MediaSourceInfo mediaSource,
 99            CancellationToken cancellationToken)
 100        {
 101            var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
 102                                                                              && (a.FileName.Contains('/', StringCompari
 103            if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 104            {
 105                foreach (var attachment in mediaSource.MediaAttachments)
 106                {
 107                    if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 108                    {
 109                        await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(fa
 110                    }
 111                }
 112            }
 113            else
 114            {
 115                await ExtractAllAttachmentsInternal(
 116                    inputFile,
 117                    mediaSource,
 118                    false,
 119                    cancellationToken).ConfigureAwait(false);
 120            }
 121        }
 122
 123        private async Task ExtractAllAttachmentsInternal(
 124            string inputFile,
 125            MediaSourceInfo mediaSource,
 126            bool isExternal,
 127            CancellationToken cancellationToken)
 128        {
 129            var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
 130
 131            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 132
 133            var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 134            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 135            {
 136                if (!Directory.Exists(outputFolder))
 137                {
 138                    Directory.CreateDirectory(outputFolder);
 139                }
 140                else
 141                {
 142                    var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Pat
 143                    var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !strin
 144                    if (!missingFiles.Any())
 145                    {
 146                        // Skip extraction if all files already exist
 147                        return;
 148                    }
 149                }
 150
 151                var processArgs = string.Format(
 152                    CultureInfo.InvariantCulture,
 153                    "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
 154                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 155                    inputPath);
 156
 157                int exitCode;
 158
 159                using (var process = new Process
 160                    {
 161                        StartInfo = new ProcessStartInfo
 162                        {
 163                            Arguments = processArgs,
 164                            FileName = _mediaEncoder.EncoderPath,
 165                            UseShellExecute = false,
 166                            CreateNoWindow = true,
 167                            WindowStyle = ProcessWindowStyle.Hidden,
 168                            WorkingDirectory = outputFolder,
 169                            ErrorDialog = false
 170                        },
 171                        EnableRaisingEvents = true
 172                    })
 173                {
 174                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 175
 176                    process.Start();
 177
 178                    try
 179                    {
 180                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 181                        exitCode = process.ExitCode;
 182                    }
 183                    catch (OperationCanceledException)
 184                    {
 185                        process.Kill(true);
 186                        exitCode = -1;
 187                    }
 188                }
 189
 190                var failed = false;
 191
 192                if (exitCode != 0)
 193                {
 194                    if (isExternal && exitCode == 1)
 195                    {
 196                        // ffmpeg returns exitCode 1 because there is no video or audio stream
 197                        // this can be ignored
 198                    }
 199                    else
 200                    {
 201                        failed = true;
 202
 203                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 204                        try
 205                        {
 206                            Directory.Delete(outputFolder);
 207                        }
 208                        catch (IOException ex)
 209                        {
 210                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 211                        }
 212                    }
 213                }
 214                else if (!Directory.Exists(outputFolder))
 215                {
 216                    failed = true;
 217                }
 218
 219                if (failed)
 220                {
 221                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 222
 223                    throw new InvalidOperationException(
 224                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 225                }
 226
 227                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 228            }
 229        }
 230
 231        private async Task<Stream> GetAttachmentStream(
 232            MediaSourceInfo mediaSource,
 233            MediaAttachment mediaAttachment,
 234            CancellationToken cancellationToken)
 235        {
 236            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 237                .ConfigureAwait(false);
 238            return AsyncFile.OpenRead(attachmentPath);
 239        }
 240
 241        private async Task<string> ExtractAttachment(
 242            string inputFile,
 243            MediaSourceInfo mediaSource,
 244            MediaAttachment mediaAttachment,
 245            CancellationToken cancellationToken)
 246        {
 247            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 248            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 249            {
 250                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAtt
 251                if (!File.Exists(attachmentPath))
 252                {
 253                    await ExtractAttachmentInternal(
 254                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 255                        mediaAttachment.Index,
 256                        attachmentPath,
 257                        cancellationToken).ConfigureAwait(false);
 258                }
 259
 260                return attachmentPath;
 261            }
 262        }
 263
 264        private async Task ExtractAttachmentInternal(
 265            string inputPath,
 266            int attachmentStreamIndex,
 267            string outputPath,
 268            CancellationToken cancellationToken)
 269        {
 270            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 271
 272            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 273
 274            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 275
 276            var processArgs = string.Format(
 277                CultureInfo.InvariantCulture,
 278                "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null",
 279                inputPath,
 280                attachmentStreamIndex,
 281                EncodingUtils.NormalizePath(outputPath));
 282
 283            int exitCode;
 284
 285            using (var process = new Process
 286                {
 287                    StartInfo = new ProcessStartInfo
 288                    {
 289                        Arguments = processArgs,
 290                        FileName = _mediaEncoder.EncoderPath,
 291                        UseShellExecute = false,
 292                        CreateNoWindow = true,
 293                        WindowStyle = ProcessWindowStyle.Hidden,
 294                        ErrorDialog = false
 295                    },
 296                    EnableRaisingEvents = true
 297                })
 298            {
 299                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 300
 301                process.Start();
 302
 303                try
 304                {
 305                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 306                    exitCode = process.ExitCode;
 307                }
 308                catch (OperationCanceledException)
 309                {
 310                    process.Kill(true);
 311                    exitCode = -1;
 312                }
 313            }
 314
 315            var failed = false;
 316
 317            if (exitCode != 0)
 318            {
 319                failed = true;
 320
 321                _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCo
 322                try
 323                {
 324                    if (File.Exists(outputPath))
 325                    {
 326                        _fileSystem.DeleteFile(outputPath);
 327                    }
 328                }
 329                catch (IOException ex)
 330                {
 331                    _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 332                }
 333            }
 334            else if (!File.Exists(outputPath))
 335            {
 336                failed = true;
 337            }
 338
 339            if (failed)
 340            {
 341                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 342
 343                throw new InvalidOperationException(
 344                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 345            }
 346
 347            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 348        }
 349
 350        /// <inheritdoc />
 351        public void Dispose()
 352        {
 2353            _semaphoreLocks.Dispose();
 2354        }
 355    }
 356}