< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
Line coverage
5%
Covered lines: 13
Uncovered lines: 242
Coverable lines: 255
Total lines: 508
Line coverage: 5%
Branch coverage
0%
Covered branches: 0
Total branches: 92
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/15/2026 - 12:14:34 AM Line coverage: 100% (13/13) Total lines: 3614/19/2026 - 12:14:27 AM Line coverage: 7.5% (13/172) Branch coverage: 0% (0/56) Total lines: 3615/13/2026 - 12:15:27 AM Line coverage: 7.3% (13/177) Branch coverage: 0% (0/60) Total lines: 3726/4/2026 - 12:15:59 AM Line coverage: 5.1% (13/253) Branch coverage: 0% (0/90) Total lines: 5057/22/2026 - 12:16:22 AM Line coverage: 5% (13/255) Branch coverage: 0% (0/92) Total lines: 508 4/19/2026 - 12:14:27 AM Line coverage: 7.5% (13/172) Branch coverage: 0% (0/56) Total lines: 3615/13/2026 - 12:15:27 AM Line coverage: 7.3% (13/177) Branch coverage: 0% (0/60) Total lines: 3726/4/2026 - 12:15:59 AM Line coverage: 5.1% (13/253) Branch coverage: 0% (0/90) Total lines: 5057/22/2026 - 12:16:22 AM Line coverage: 5% (13/255) Branch coverage: 0% (0/92) Total lines: 508

Coverage delta

Coverage delta 93 -93

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetAttachment()0%7280%
ExtractAllAttachments()0%2040%
ExtractAllAttachmentsIndividuallyInternal()0%1190340%
ExtractAllAttachmentsInternal()0%420200%
GetAttachmentStream()100%210%
ExtractAttachment()0%7280%
ExtractAttachmentInternal()0%342180%
Dispose()100%11100%

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Diagnostics;
 4using System.Globalization;
 5using System.IO;
 6using System.Linq;
 7using System.Text;
 8using System.Threading;
 9using System.Threading.Tasks;
 10using AsyncKeyedLock;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Common.Extensions;
 13using MediaBrowser.Controller.Entities;
 14using MediaBrowser.Controller.IO;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Controller.MediaEncoding;
 17using MediaBrowser.MediaEncoding.Encoder;
 18using MediaBrowser.Model.Dto;
 19using MediaBrowser.Model.Entities;
 20using MediaBrowser.Model.IO;
 21using Microsoft.Extensions.Logging;
 22
 23namespace MediaBrowser.MediaEncoding.Attachments
 24{
 25    /// <inheritdoc cref="IAttachmentExtractor"/>
 26    public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
 27    {
 28        private readonly ILogger<AttachmentExtractor> _logger;
 29        private readonly IFileSystem _fileSystem;
 30        private readonly IMediaEncoder _mediaEncoder;
 31        private readonly IMediaSourceManager _mediaSourceManager;
 32        private readonly IPathManager _pathManager;
 33
 234        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 235        {
 236            o.PoolSize = 20;
 237            o.PoolInitialFill = 1;
 238        });
 39
 40        /// <summary>
 41        /// Initializes a new instance of the <see cref="AttachmentExtractor"/> class.
 42        /// </summary>
 43        /// <param name="logger">The <see cref="ILogger{AttachmentExtractor}"/>.</param>
 44        /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 45        /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
 46        /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
 47        /// <param name="pathManager">The <see cref="IPathManager"/>.</param>
 48        public AttachmentExtractor(
 49            ILogger<AttachmentExtractor> logger,
 50            IFileSystem fileSystem,
 51            IMediaEncoder mediaEncoder,
 52            IMediaSourceManager mediaSourceManager,
 53            IPathManager pathManager)
 54        {
 255            _logger = logger;
 256            _fileSystem = fileSystem;
 257            _mediaEncoder = mediaEncoder;
 258            _mediaSourceManager = mediaSourceManager;
 259            _pathManager = pathManager;
 260        }
 61
 62        /// <inheritdoc />
 63        public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId
 64        {
 065            ArgumentNullException.ThrowIfNull(item);
 66
 067            if (string.IsNullOrWhiteSpace(mediaSourceId))
 68            {
 069                throw new ArgumentNullException(nameof(mediaSourceId));
 70            }
 71
 072            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 073            var mediaSource = mediaSources
 074                .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 075            if (mediaSource is null)
 76            {
 077                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
 78            }
 79
 080            var mediaAttachment = mediaSource.MediaAttachments
 081                .FirstOrDefault(i => i.Index == attachmentStreamIndex);
 082            if (mediaAttachment is null)
 83            {
 084                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {a
 85            }
 86
 087            if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 88            {
 089                throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extr
 90            }
 91
 092            var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
 093                    .ConfigureAwait(false);
 94
 095            return (mediaAttachment, attachmentStream);
 096        }
 97
 98        /// <inheritdoc />
 99        public async Task ExtractAllAttachments(
 100            string inputFile,
 101            MediaSourceInfo mediaSource,
 102            CancellationToken cancellationToken)
 103        {
 0104            var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
 0105                                                                              && !string.Equals(PathHelper.GetSafeLeafFi
 0106            if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 107            {
 0108                await ExtractAllAttachmentsIndividuallyInternal(
 0109                    inputFile,
 0110                    mediaSource,
 0111                    cancellationToken).ConfigureAwait(false);
 112            }
 113            else
 114            {
 0115                await ExtractAllAttachmentsInternal(
 0116                    inputFile,
 0117                    mediaSource,
 0118                    cancellationToken).ConfigureAwait(false);
 119            }
 0120        }
 121
 122        private async Task ExtractAllAttachmentsIndividuallyInternal(
 123            string inputFile,
 124            MediaSourceInfo mediaSource,
 125            CancellationToken cancellationToken)
 126        {
 0127            var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
 128
 0129            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 130
 0131            var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0132            if (outputFolder is null)
 133            {
 0134                _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", 
 0135                return;
 136            }
 137
 0138            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 139            {
 0140                Directory.CreateDirectory(outputFolder);
 141
 0142                var dumpArgs = new StringBuilder();
 0143                var missingPaths = new List<string>();
 0144                foreach (var attachment in mediaSource.MediaAttachments)
 145                {
 0146                    if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 147                    {
 148                        continue;
 149                    }
 150
 0151                    var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture);
 0152                    var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName
 0153                                         ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
 0154                    if (File.Exists(attachmentPath))
 155                    {
 156                        continue;
 157                    }
 158
 0159                    dumpArgs.AppendFormat(
 0160                        CultureInfo.InvariantCulture,
 0161                        "-dump_attachment:{0} \"{1}\" ",
 0162                        attachment.Index,
 0163                        EncodingUtils.NormalizePath(attachmentPath));
 0164                    missingPaths.Add(attachmentPath);
 165                }
 166
 0167                if (missingPaths.Count == 0)
 168                {
 169                    // Skip extraction if all files already exist
 0170                    return;
 171                }
 172
 0173                var hasVideoOrAudioStream = mediaSource.MediaStreams
 0174                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0175                var processArgs = string.Format(
 0176                    CultureInfo.InvariantCulture,
 0177                    "{0}{1} -i {2} {3}",
 0178                    dumpArgs,
 0179                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 0180                    inputPath,
 0181                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 182
 183                int exitCode;
 184
 0185                using (var process = new Process
 0186                {
 0187                    StartInfo = new ProcessStartInfo
 0188                    {
 0189                        Arguments = processArgs,
 0190                        FileName = _mediaEncoder.EncoderPath,
 0191                        UseShellExecute = false,
 0192                        CreateNoWindow = true,
 0193                        WindowStyle = ProcessWindowStyle.Hidden,
 0194                        ErrorDialog = false
 0195                    },
 0196                    EnableRaisingEvents = true
 0197                })
 198                {
 0199                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 200
 0201                    process.Start();
 202
 203                    try
 204                    {
 0205                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0206                        exitCode = process.ExitCode;
 0207                    }
 0208                    catch (OperationCanceledException)
 209                    {
 0210                        process.Kill(true);
 0211                        exitCode = -1;
 0212                    }
 0213                }
 214
 0215                var failed = false;
 216
 0217                if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1))
 218                {
 0219                    failed = true;
 220
 0221                    foreach (var path in missingPaths)
 222                    {
 0223                        if (!File.Exists(path))
 224                        {
 225                            continue;
 226                        }
 227
 228                        try
 229                        {
 0230                            _fileSystem.DeleteFile(path);
 0231                        }
 0232                        catch (IOException ex)
 233                        {
 0234                            _logger.LogError(ex, "Error deleting extracted attachment {Path}", path);
 0235                        }
 236                    }
 237                }
 238
 0239                if (!failed && missingPaths.Exists(p => !File.Exists(p)))
 240                {
 0241                    failed = true;
 242                }
 243
 0244                if (failed)
 245                {
 0246                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 247
 0248                    throw new InvalidOperationException(
 0249                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 250                }
 251
 0252                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 0253            }
 0254        }
 255
 256        private async Task ExtractAllAttachmentsInternal(
 257            string inputFile,
 258            MediaSourceInfo mediaSource,
 259            CancellationToken cancellationToken)
 260        {
 0261            var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
 262
 0263            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 264
 0265            var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0266            if (outputFolder is null)
 267            {
 0268                _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", 
 0269                return;
 270            }
 271
 0272            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 273            {
 0274                var directory = Directory.CreateDirectory(outputFolder);
 0275                var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
 0276                var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains
 0277                if (!missingFiles.Any())
 278                {
 279                    // Skip extraction if all files already exist
 0280                    return;
 281                }
 282
 283                // Files without video/audio streams (e.g. MKS subtitle files) don't need a dummy
 284                // output since there are no streams to process. Omit "-t 0 -f null null" so ffmpeg
 285                // doesn't fail trying to open an output with no streams. It will exit with code 1
 286                // ("at least one output file must be specified") which is expected and harmless
 287                // since we only need the -dump_attachment side effect.
 0288                var hasVideoOrAudioStream = mediaSource.MediaStreams
 0289                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0290                var processArgs = string.Format(
 0291                    CultureInfo.InvariantCulture,
 0292                    "-dump_attachment:t \"\" -y {0} -i {1} {2}",
 0293                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 0294                    inputPath,
 0295                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 296
 297                int exitCode;
 298
 0299                using (var process = new Process
 0300                {
 0301                    StartInfo = new ProcessStartInfo
 0302                    {
 0303                        Arguments = processArgs,
 0304                        FileName = _mediaEncoder.EncoderPath,
 0305                        UseShellExecute = false,
 0306                        CreateNoWindow = true,
 0307                        WindowStyle = ProcessWindowStyle.Hidden,
 0308                        WorkingDirectory = outputFolder,
 0309                        ErrorDialog = false
 0310                    },
 0311                    EnableRaisingEvents = true
 0312                })
 313                {
 0314                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 315
 0316                    process.Start();
 317
 318                    try
 319                    {
 0320                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0321                        exitCode = process.ExitCode;
 0322                    }
 0323                    catch (OperationCanceledException)
 324                    {
 0325                        process.Kill(true);
 0326                        exitCode = -1;
 0327                    }
 0328                }
 329
 0330                var failed = false;
 331
 0332                if (exitCode != 0)
 333                {
 0334                    if (hasVideoOrAudioStream || exitCode != 1)
 335                    {
 0336                        failed = true;
 337
 0338                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 339                        try
 340                        {
 0341                            Directory.Delete(outputFolder);
 0342                        }
 0343                        catch (IOException ex)
 344                        {
 0345                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 0346                        }
 347                    }
 348                }
 349
 0350                if (!failed && !Directory.Exists(outputFolder))
 351                {
 0352                    failed = true;
 353                }
 354
 0355                if (failed)
 356                {
 0357                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 358
 0359                    throw new InvalidOperationException(
 0360                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 361                }
 362
 0363                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 0364            }
 0365        }
 366
 367        private async Task<Stream> GetAttachmentStream(
 368            MediaSourceInfo mediaSource,
 369            MediaAttachment mediaAttachment,
 370            CancellationToken cancellationToken)
 371        {
 0372            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 0373                .ConfigureAwait(false);
 0374            return AsyncFile.OpenRead(attachmentPath);
 0375        }
 376
 377        private async Task<string> ExtractAttachment(
 378            string inputFile,
 379            MediaSourceInfo mediaSource,
 380            MediaAttachment mediaAttachment,
 381            CancellationToken cancellationToken)
 382        {
 0383            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0384            if (attachmentFolderPath is null)
 385            {
 0386                throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id,
 387            }
 388
 0389            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 390            {
 0391                var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
 0392                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexNam
 0393                                     ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
 0394                if (!File.Exists(attachmentPath))
 395                {
 0396                    await ExtractAttachmentInternal(
 0397                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 0398                        mediaSource,
 0399                        mediaAttachment.Index,
 0400                        attachmentPath,
 0401                        cancellationToken).ConfigureAwait(false);
 402                }
 403
 0404                return attachmentPath;
 405            }
 0406        }
 407
 408        private async Task ExtractAttachmentInternal(
 409            string inputPath,
 410            MediaSourceInfo mediaSource,
 411            int attachmentStreamIndex,
 412            string outputPath,
 413            CancellationToken cancellationToken)
 414        {
 0415            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 416
 0417            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 418
 0419            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 420
 0421            var hasVideoOrAudioStream = mediaSource.MediaStreams
 0422                .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0423            var processArgs = string.Format(
 0424                CultureInfo.InvariantCulture,
 0425                "-dump_attachment:{1} \"{2}\" -i {0} {3}",
 0426                inputPath,
 0427                attachmentStreamIndex,
 0428                EncodingUtils.NormalizePath(outputPath),
 0429                hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 430
 431            int exitCode;
 432
 0433            using (var process = new Process
 0434            {
 0435                StartInfo = new ProcessStartInfo
 0436                {
 0437                    Arguments = processArgs,
 0438                    FileName = _mediaEncoder.EncoderPath,
 0439                    UseShellExecute = false,
 0440                    CreateNoWindow = true,
 0441                    WindowStyle = ProcessWindowStyle.Hidden,
 0442                    ErrorDialog = false
 0443                },
 0444                EnableRaisingEvents = true
 0445            })
 446            {
 0447                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 448
 0449                process.Start();
 450
 451                try
 452                {
 0453                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0454                    exitCode = process.ExitCode;
 0455                }
 0456                catch (OperationCanceledException)
 457                {
 0458                    process.Kill(true);
 0459                    exitCode = -1;
 0460                }
 0461            }
 462
 0463            var failed = false;
 464
 0465            if (exitCode != 0)
 466            {
 0467                if (hasVideoOrAudioStream || exitCode != 1)
 468                {
 0469                    failed = true;
 470
 0471                    _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, ex
 472                    try
 473                    {
 0474                        if (File.Exists(outputPath))
 475                        {
 0476                            _fileSystem.DeleteFile(outputPath);
 477                        }
 0478                    }
 0479                    catch (IOException ex)
 480                    {
 0481                        _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 0482                    }
 483                }
 484            }
 485
 0486            if (!failed && !File.Exists(outputPath))
 487            {
 0488                failed = true;
 489            }
 490
 0491            if (failed)
 492            {
 0493                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 494
 0495                throw new InvalidOperationException(
 0496                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 497            }
 498
 0499            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 0500        }
 501
 502        /// <inheritdoc />
 503        public void Dispose()
 504        {
 2505            _semaphoreLocks.Dispose();
 2506        }
 507    }
 508}