< 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: 350
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                var directory = Directory.CreateDirectory(outputFolder);
 137                var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
 138                var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains
 139                if (!missingFiles.Any())
 140                {
 141                    // Skip extraction if all files already exist
 142                    return;
 143                }
 144
 145                var processArgs = string.Format(
 146                    CultureInfo.InvariantCulture,
 147                    "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
 148                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 149                    inputPath);
 150
 151                int exitCode;
 152
 153                using (var process = new Process
 154                    {
 155                        StartInfo = new ProcessStartInfo
 156                        {
 157                            Arguments = processArgs,
 158                            FileName = _mediaEncoder.EncoderPath,
 159                            UseShellExecute = false,
 160                            CreateNoWindow = true,
 161                            WindowStyle = ProcessWindowStyle.Hidden,
 162                            WorkingDirectory = outputFolder,
 163                            ErrorDialog = false
 164                        },
 165                        EnableRaisingEvents = true
 166                    })
 167                {
 168                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 169
 170                    process.Start();
 171
 172                    try
 173                    {
 174                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 175                        exitCode = process.ExitCode;
 176                    }
 177                    catch (OperationCanceledException)
 178                    {
 179                        process.Kill(true);
 180                        exitCode = -1;
 181                    }
 182                }
 183
 184                var failed = false;
 185
 186                if (exitCode != 0)
 187                {
 188                    if (isExternal && exitCode == 1)
 189                    {
 190                        // ffmpeg returns exitCode 1 because there is no video or audio stream
 191                        // this can be ignored
 192                    }
 193                    else
 194                    {
 195                        failed = true;
 196
 197                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 198                        try
 199                        {
 200                            Directory.Delete(outputFolder);
 201                        }
 202                        catch (IOException ex)
 203                        {
 204                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 205                        }
 206                    }
 207                }
 208                else if (!Directory.Exists(outputFolder))
 209                {
 210                    failed = true;
 211                }
 212
 213                if (failed)
 214                {
 215                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 216
 217                    throw new InvalidOperationException(
 218                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 219                }
 220
 221                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 222            }
 223        }
 224
 225        private async Task<Stream> GetAttachmentStream(
 226            MediaSourceInfo mediaSource,
 227            MediaAttachment mediaAttachment,
 228            CancellationToken cancellationToken)
 229        {
 230            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 231                .ConfigureAwait(false);
 232            return AsyncFile.OpenRead(attachmentPath);
 233        }
 234
 235        private async Task<string> ExtractAttachment(
 236            string inputFile,
 237            MediaSourceInfo mediaSource,
 238            MediaAttachment mediaAttachment,
 239            CancellationToken cancellationToken)
 240        {
 241            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 242            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 243            {
 244                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAtt
 245                if (!File.Exists(attachmentPath))
 246                {
 247                    await ExtractAttachmentInternal(
 248                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 249                        mediaAttachment.Index,
 250                        attachmentPath,
 251                        cancellationToken).ConfigureAwait(false);
 252                }
 253
 254                return attachmentPath;
 255            }
 256        }
 257
 258        private async Task ExtractAttachmentInternal(
 259            string inputPath,
 260            int attachmentStreamIndex,
 261            string outputPath,
 262            CancellationToken cancellationToken)
 263        {
 264            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 265
 266            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 267
 268            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 269
 270            var processArgs = string.Format(
 271                CultureInfo.InvariantCulture,
 272                "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null",
 273                inputPath,
 274                attachmentStreamIndex,
 275                EncodingUtils.NormalizePath(outputPath));
 276
 277            int exitCode;
 278
 279            using (var process = new Process
 280                {
 281                    StartInfo = new ProcessStartInfo
 282                    {
 283                        Arguments = processArgs,
 284                        FileName = _mediaEncoder.EncoderPath,
 285                        UseShellExecute = false,
 286                        CreateNoWindow = true,
 287                        WindowStyle = ProcessWindowStyle.Hidden,
 288                        ErrorDialog = false
 289                    },
 290                    EnableRaisingEvents = true
 291                })
 292            {
 293                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 294
 295                process.Start();
 296
 297                try
 298                {
 299                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 300                    exitCode = process.ExitCode;
 301                }
 302                catch (OperationCanceledException)
 303                {
 304                    process.Kill(true);
 305                    exitCode = -1;
 306                }
 307            }
 308
 309            var failed = false;
 310
 311            if (exitCode != 0)
 312            {
 313                failed = true;
 314
 315                _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCo
 316                try
 317                {
 318                    if (File.Exists(outputPath))
 319                    {
 320                        _fileSystem.DeleteFile(outputPath);
 321                    }
 322                }
 323                catch (IOException ex)
 324                {
 325                    _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 326                }
 327            }
 328            else if (!File.Exists(outputPath))
 329            {
 330                failed = true;
 331            }
 332
 333            if (failed)
 334            {
 335                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 336
 337                throw new InvalidOperationException(
 338                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 339            }
 340
 341            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 342        }
 343
 344        /// <inheritdoc />
 345        public void Dispose()
 346        {
 2347            _semaphoreLocks.Dispose();
 2348        }
 349    }
 350}