< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
Line coverage
68%
Covered lines: 13
Uncovered lines: 6
Coverable lines: 19
Total lines: 536
Line coverage: 68.4%
Branch coverage
0%
Covered branches: 0
Total branches: 2
Branch coverage: 0%
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%
GetAttachmentCachePath(...)0%620%
Dispose()100%11100%

File(s)

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

#LineLine coverage
 1#pragma warning disable CS1591
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Diagnostics;
 6using System.Globalization;
 7using System.IO;
 8using System.Linq;
 9using System.Threading;
 10using System.Threading.Tasks;
 11using AsyncKeyedLock;
 12using MediaBrowser.Common;
 13using MediaBrowser.Common.Configuration;
 14using MediaBrowser.Common.Extensions;
 15using MediaBrowser.Controller.Entities;
 16using MediaBrowser.Controller.Library;
 17using MediaBrowser.Controller.MediaEncoding;
 18using MediaBrowser.MediaEncoding.Encoder;
 19using MediaBrowser.Model.Dto;
 20using MediaBrowser.Model.Entities;
 21using MediaBrowser.Model.IO;
 22using MediaBrowser.Model.MediaInfo;
 23using Microsoft.Extensions.Logging;
 24
 25namespace MediaBrowser.MediaEncoding.Attachments
 26{
 27    public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
 28    {
 29        private readonly ILogger<AttachmentExtractor> _logger;
 30        private readonly IApplicationPaths _appPaths;
 31        private readonly IFileSystem _fileSystem;
 32        private readonly IMediaEncoder _mediaEncoder;
 33        private readonly IMediaSourceManager _mediaSourceManager;
 34
 235        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 236        {
 237            o.PoolSize = 20;
 238            o.PoolInitialFill = 1;
 239        });
 40
 41        public AttachmentExtractor(
 42            ILogger<AttachmentExtractor> logger,
 43            IApplicationPaths appPaths,
 44            IFileSystem fileSystem,
 45            IMediaEncoder mediaEncoder,
 46            IMediaSourceManager mediaSourceManager)
 47        {
 248            _logger = logger;
 249            _appPaths = appPaths;
 250            _fileSystem = fileSystem;
 251            _mediaEncoder = mediaEncoder;
 252            _mediaSourceManager = mediaSourceManager;
 253        }
 54
 55        /// <inheritdoc />
 56        public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId
 57        {
 58            ArgumentNullException.ThrowIfNull(item);
 59
 60            if (string.IsNullOrWhiteSpace(mediaSourceId))
 61            {
 62                throw new ArgumentNullException(nameof(mediaSourceId));
 63            }
 64
 65            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 66            var mediaSource = mediaSources
 67                .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 68            if (mediaSource is null)
 69            {
 70                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
 71            }
 72
 73            var mediaAttachment = mediaSource.MediaAttachments
 74                .FirstOrDefault(i => i.Index == attachmentStreamIndex);
 75            if (mediaAttachment is null)
 76            {
 77                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {a
 78            }
 79
 80            var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
 81                    .ConfigureAwait(false);
 82
 83            return (mediaAttachment, attachmentStream);
 84        }
 85
 86        public async Task ExtractAllAttachments(
 87            string inputFile,
 88            MediaSourceInfo mediaSource,
 89            string outputPath,
 90            CancellationToken cancellationToken)
 91        {
 92            var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
 93                                                                              && (a.FileName.Contains('/', StringCompari
 94            if (shouldExtractOneByOne)
 95            {
 96                var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
 97                foreach (var i in attachmentIndexes)
 98                {
 99                    var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
 100                    await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false)
 101                }
 102            }
 103            else
 104            {
 105                using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 106                {
 107                    if (!Directory.Exists(outputPath))
 108                    {
 109                        await ExtractAllAttachmentsInternal(
 110                            _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 111                            outputPath,
 112                            false,
 113                            cancellationToken).ConfigureAwait(false);
 114                    }
 115                }
 116            }
 117        }
 118
 119        public async Task ExtractAllAttachmentsExternal(
 120            string inputArgument,
 121            string id,
 122            string outputPath,
 123            CancellationToken cancellationToken)
 124        {
 125            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 126            {
 127                if (!File.Exists(Path.Join(outputPath, id)))
 128                {
 129                    await ExtractAllAttachmentsInternal(
 130                        inputArgument,
 131                        outputPath,
 132                        true,
 133                        cancellationToken).ConfigureAwait(false);
 134
 135                    if (Directory.Exists(outputPath))
 136                    {
 137                        File.Create(Path.Join(outputPath, id));
 138                    }
 139                }
 140            }
 141        }
 142
 143        private async Task ExtractAllAttachmentsInternal(
 144            string inputPath,
 145            string outputPath,
 146            bool isExternal,
 147            CancellationToken cancellationToken)
 148        {
 149            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 150            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 151
 152            Directory.CreateDirectory(outputPath);
 153
 154            var processArgs = string.Format(
 155                CultureInfo.InvariantCulture,
 156                "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null",
 157                inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty
 158                inputPath);
 159
 160            int exitCode;
 161
 162            using (var process = new Process
 163                {
 164                    StartInfo = new ProcessStartInfo
 165                    {
 166                        Arguments = processArgs,
 167                        FileName = _mediaEncoder.EncoderPath,
 168                        UseShellExecute = false,
 169                        CreateNoWindow = true,
 170                        WindowStyle = ProcessWindowStyle.Hidden,
 171                        WorkingDirectory = outputPath,
 172                        ErrorDialog = false
 173                    },
 174                    EnableRaisingEvents = true
 175                })
 176            {
 177                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 178
 179                process.Start();
 180
 181                try
 182                {
 183                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 184                    exitCode = process.ExitCode;
 185                }
 186                catch (OperationCanceledException)
 187                {
 188                    process.Kill(true);
 189                    exitCode = -1;
 190                }
 191            }
 192
 193            var failed = false;
 194
 195            if (exitCode != 0)
 196            {
 197                if (isExternal && exitCode == 1)
 198                {
 199                    // ffmpeg returns exitCode 1 because there is no video or audio stream
 200                    // this can be ignored
 201                }
 202                else
 203                {
 204                    failed = true;
 205
 206                    _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputPath, e
 207                    try
 208                    {
 209                        Directory.Delete(outputPath);
 210                    }
 211                    catch (IOException ex)
 212                    {
 213                        _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputPath);
 214                    }
 215                }
 216            }
 217            else if (!Directory.Exists(outputPath))
 218            {
 219                failed = true;
 220            }
 221
 222            if (failed)
 223            {
 224                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 225
 226                throw new InvalidOperationException(
 227                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 228            }
 229
 230            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 231        }
 232
 233        private async Task<Stream> GetAttachmentStream(
 234            MediaSourceInfo mediaSource,
 235            MediaAttachment mediaAttachment,
 236            CancellationToken cancellationToken)
 237        {
 238            var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment,
 239            return AsyncFile.OpenRead(attachmentPath);
 240        }
 241
 242        private async Task<string> GetReadableFile(
 243            string mediaPath,
 244            string inputFile,
 245            MediaSourceInfo mediaSource,
 246            MediaAttachment mediaAttachment,
 247            CancellationToken cancellationToken)
 248        {
 249            await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false);
 250
 251            var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
 252            await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
 253                .ConfigureAwait(false);
 254
 255            return outputPath;
 256        }
 257
 258        private async Task CacheAllAttachments(
 259            string mediaPath,
 260            string inputFile,
 261            MediaSourceInfo mediaSource,
 262            CancellationToken cancellationToken)
 263        {
 264            var outputFileLocks = new List<IDisposable>();
 265            var extractableAttachmentIds = new List<int>();
 266
 267            try
 268            {
 269                foreach (var attachment in mediaSource.MediaAttachments)
 270                {
 271                    var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
 272
 273                    var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
 274
 275                    if (File.Exists(outputPath))
 276                    {
 277                        releaser.Dispose();
 278                        continue;
 279                    }
 280
 281                    outputFileLocks.Add(releaser);
 282                    extractableAttachmentIds.Add(attachment.Index);
 283                }
 284
 285                if (extractableAttachmentIds.Count > 0)
 286                {
 287                    await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 288                }
 289            }
 290            catch (Exception ex)
 291            {
 292                _logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath);
 293            }
 294            finally
 295            {
 296                outputFileLocks.ForEach(x => x.Dispose());
 297            }
 298        }
 299
 300        private async Task CacheAllAttachmentsInternal(
 301            string mediaPath,
 302            string inputFile,
 303            MediaSourceInfo mediaSource,
 304            List<int> extractableAttachmentIds,
 305            CancellationToken cancellationToken)
 306        {
 307            var outputPaths = new List<string>();
 308            var processArgs = string.Empty;
 309
 310            foreach (var attachmentId in extractableAttachmentIds)
 311            {
 312                var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId);
 313
 314                Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calcula
 315
 316                outputPaths.Add(outputPath);
 317                processArgs += string.Format(
 318                    CultureInfo.InvariantCulture,
 319                    " -dump_attachment:{0} \"{1}\"",
 320                    attachmentId,
 321                    EncodingUtils.NormalizePath(outputPath));
 322            }
 323
 324            processArgs += string.Format(
 325                CultureInfo.InvariantCulture,
 326                " -i {0} -t 0 -f null null",
 327                inputFile);
 328
 329            int exitCode;
 330
 331            using (var process = new Process
 332                {
 333                    StartInfo = new ProcessStartInfo
 334                    {
 335                        Arguments = processArgs,
 336                        FileName = _mediaEncoder.EncoderPath,
 337                        UseShellExecute = false,
 338                        CreateNoWindow = true,
 339                        WindowStyle = ProcessWindowStyle.Hidden,
 340                        ErrorDialog = false
 341                    },
 342                    EnableRaisingEvents = true
 343                })
 344            {
 345                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 346
 347                process.Start();
 348
 349                try
 350                {
 351                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 352                    exitCode = process.ExitCode;
 353                }
 354                catch (OperationCanceledException)
 355                {
 356                    process.Kill(true);
 357                    exitCode = -1;
 358                }
 359            }
 360
 361            var failed = false;
 362
 363            if (exitCode == -1)
 364            {
 365                failed = true;
 366
 367                foreach (var outputPath in outputPaths)
 368                {
 369                    try
 370                    {
 371                        _logger.LogWarning("Deleting extracted media attachment due to failure: {Path}", outputPath);
 372                        _fileSystem.DeleteFile(outputPath);
 373                    }
 374                    catch (FileNotFoundException)
 375                    {
 376                        // ffmpeg failed, so it is normal that one or more expected output files do not exist.
 377                        // There is no need to log anything for the user here.
 378                    }
 379                    catch (IOException ex)
 380                    {
 381                        _logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath);
 382                    }
 383                }
 384            }
 385            else
 386            {
 387                foreach (var outputPath in outputPaths)
 388                {
 389                    if (!File.Exists(outputPath))
 390                    {
 391                        _logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", in
 392                        failed = true;
 393                        continue;
 394                    }
 395
 396                    _logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}
 397                }
 398            }
 399
 400            if (failed)
 401            {
 402                throw new FfmpegException(
 403                    string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inp
 404            }
 405        }
 406
 407        private async Task ExtractAttachment(
 408            string inputFile,
 409            MediaSourceInfo mediaSource,
 410            int attachmentStreamIndex,
 411            string outputPath,
 412            CancellationToken cancellationToken)
 413        {
 414            using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
 415            {
 416                if (!File.Exists(outputPath))
 417                {
 418                    await ExtractAttachmentInternal(
 419                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 420                        attachmentStreamIndex,
 421                        outputPath,
 422                        cancellationToken).ConfigureAwait(false);
 423                }
 424            }
 425        }
 426
 427        private async Task ExtractAttachmentInternal(
 428            string inputPath,
 429            int attachmentStreamIndex,
 430            string outputPath,
 431            CancellationToken cancellationToken)
 432        {
 433            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 434
 435            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 436
 437            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 438
 439            var processArgs = string.Format(
 440                CultureInfo.InvariantCulture,
 441                "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null",
 442                inputPath,
 443                attachmentStreamIndex,
 444                EncodingUtils.NormalizePath(outputPath));
 445
 446            int exitCode;
 447
 448            using (var process = new Process
 449                {
 450                    StartInfo = new ProcessStartInfo
 451                    {
 452                        Arguments = processArgs,
 453                        FileName = _mediaEncoder.EncoderPath,
 454                        UseShellExecute = false,
 455                        CreateNoWindow = true,
 456                        WindowStyle = ProcessWindowStyle.Hidden,
 457                        ErrorDialog = false
 458                    },
 459                    EnableRaisingEvents = true
 460                })
 461            {
 462                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 463
 464                process.Start();
 465
 466                try
 467                {
 468                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 469                    exitCode = process.ExitCode;
 470                }
 471                catch (OperationCanceledException)
 472                {
 473                    process.Kill(true);
 474                    exitCode = -1;
 475                }
 476            }
 477
 478            var failed = false;
 479
 480            if (exitCode != 0)
 481            {
 482                failed = true;
 483
 484                _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCo
 485                try
 486                {
 487                    if (File.Exists(outputPath))
 488                    {
 489                        _fileSystem.DeleteFile(outputPath);
 490                    }
 491                }
 492                catch (IOException ex)
 493                {
 494                    _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 495                }
 496            }
 497            else if (!File.Exists(outputPath))
 498            {
 499                failed = true;
 500            }
 501
 502            if (failed)
 503            {
 504                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 505
 506                throw new InvalidOperationException(
 507                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 508            }
 509
 510            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 511        }
 512
 513        private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex)
 514        {
 515            string filename;
 0516            if (mediaSource.Protocol == MediaProtocol.File)
 517            {
 0518                var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
 0519                filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.
 520            }
 521            else
 522            {
 0523                filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString(
 524            }
 525
 0526            var prefix = filename.AsSpan(0, 1);
 0527            return Path.Join(_appPaths.DataPath, "attachments", prefix, filename);
 528        }
 529
 530        /// <inheritdoc />
 531        public void Dispose()
 532        {
 2533            _semaphoreLocks.Dispose();
 2534        }
 535    }
 536}