< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
Line coverage
7%
Covered lines: 13
Uncovered lines: 164
Coverable lines: 177
Total lines: 372
Line coverage: 7.3%
Branch coverage
0%
Covered branches: 0
Total branches: 60
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 2/13/2026 - 12:11:21 AM Line coverage: 100% (13/13) Total lines: 3503/30/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: 372 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: 372

Coverage delta

Coverage delta 93 -93

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetAttachment()0%7280%
ExtractAllAttachments()0%7280%
ExtractAllAttachmentsInternal()0%420200%
GetAttachmentStream()100%210%
ExtractAttachment()0%4260%
ExtractAttachmentInternal()0%342180%
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        {
 062            ArgumentNullException.ThrowIfNull(item);
 63
 064            if (string.IsNullOrWhiteSpace(mediaSourceId))
 65            {
 066                throw new ArgumentNullException(nameof(mediaSourceId));
 67            }
 68
 069            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 070            var mediaSource = mediaSources
 071                .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 072            if (mediaSource is null)
 73            {
 074                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
 75            }
 76
 077            var mediaAttachment = mediaSource.MediaAttachments
 078                .FirstOrDefault(i => i.Index == attachmentStreamIndex);
 079            if (mediaAttachment is null)
 80            {
 081                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {a
 82            }
 83
 084            if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 85            {
 086                throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extr
 87            }
 88
 089            var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
 090                    .ConfigureAwait(false);
 91
 092            return (mediaAttachment, attachmentStream);
 093        }
 94
 95        /// <inheritdoc />
 96        public async Task ExtractAllAttachments(
 97            string inputFile,
 98            MediaSourceInfo mediaSource,
 99            CancellationToken cancellationToken)
 100        {
 0101            var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
 0102                                                                              && (a.FileName.Contains('/', StringCompari
 0103            if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 104            {
 0105                foreach (var attachment in mediaSource.MediaAttachments)
 106                {
 0107                    if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 108                    {
 0109                        await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(fa
 110                    }
 111                }
 112            }
 113            else
 114            {
 0115                await ExtractAllAttachmentsInternal(
 0116                    inputFile,
 0117                    mediaSource,
 0118                    cancellationToken).ConfigureAwait(false);
 119            }
 0120        }
 121
 122        private async Task ExtractAllAttachmentsInternal(
 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                var directory = Directory.CreateDirectory(outputFolder);
 0141                var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
 0142                var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains
 0143                if (!missingFiles.Any())
 144                {
 145                    // Skip extraction if all files already exist
 0146                    return;
 147                }
 148
 149                // Files without video/audio streams (e.g. MKS subtitle files) don't need a dummy
 150                // output since there are no streams to process. Omit "-t 0 -f null null" so ffmpeg
 151                // doesn't fail trying to open an output with no streams. It will exit with code 1
 152                // ("at least one output file must be specified") which is expected and harmless
 153                // since we only need the -dump_attachment side effect.
 0154                var hasVideoOrAudioStream = mediaSource.MediaStreams
 0155                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0156                var processArgs = string.Format(
 0157                    CultureInfo.InvariantCulture,
 0158                    "-dump_attachment:t \"\" -y {0} -i {1} {2}",
 0159                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 0160                    inputPath,
 0161                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 162
 163                int exitCode;
 164
 0165                using (var process = new Process
 0166                {
 0167                    StartInfo = new ProcessStartInfo
 0168                    {
 0169                        Arguments = processArgs,
 0170                        FileName = _mediaEncoder.EncoderPath,
 0171                        UseShellExecute = false,
 0172                        CreateNoWindow = true,
 0173                        WindowStyle = ProcessWindowStyle.Hidden,
 0174                        WorkingDirectory = outputFolder,
 0175                        ErrorDialog = false
 0176                    },
 0177                    EnableRaisingEvents = true
 0178                })
 179                {
 0180                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 181
 0182                    process.Start();
 183
 184                    try
 185                    {
 0186                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0187                        exitCode = process.ExitCode;
 0188                    }
 0189                    catch (OperationCanceledException)
 190                    {
 0191                        process.Kill(true);
 0192                        exitCode = -1;
 0193                    }
 0194                }
 195
 0196                var failed = false;
 197
 0198                if (exitCode != 0)
 199                {
 0200                    if (hasVideoOrAudioStream || exitCode != 1)
 201                    {
 0202                        failed = true;
 203
 0204                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 205                        try
 206                        {
 0207                            Directory.Delete(outputFolder);
 0208                        }
 0209                        catch (IOException ex)
 210                        {
 0211                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 0212                        }
 213                    }
 214                }
 215
 0216                if (!failed && !Directory.Exists(outputFolder))
 217                {
 0218                    failed = true;
 219                }
 220
 0221                if (failed)
 222                {
 0223                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 224
 0225                    throw new InvalidOperationException(
 0226                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 227                }
 228
 0229                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 0230            }
 0231        }
 232
 233        private async Task<Stream> GetAttachmentStream(
 234            MediaSourceInfo mediaSource,
 235            MediaAttachment mediaAttachment,
 236            CancellationToken cancellationToken)
 237        {
 0238            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 0239                .ConfigureAwait(false);
 0240            return AsyncFile.OpenRead(attachmentPath);
 0241        }
 242
 243        private async Task<string> ExtractAttachment(
 244            string inputFile,
 245            MediaSourceInfo mediaSource,
 246            MediaAttachment mediaAttachment,
 247            CancellationToken cancellationToken)
 248        {
 0249            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0250            if (attachmentFolderPath is null)
 251            {
 0252                throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id,
 253            }
 254
 0255            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 256            {
 0257                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAtt
 0258                if (!File.Exists(attachmentPath))
 259                {
 0260                    await ExtractAttachmentInternal(
 0261                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 0262                        mediaSource,
 0263                        mediaAttachment.Index,
 0264                        attachmentPath,
 0265                        cancellationToken).ConfigureAwait(false);
 266                }
 267
 0268                return attachmentPath;
 269            }
 0270        }
 271
 272        private async Task ExtractAttachmentInternal(
 273            string inputPath,
 274            MediaSourceInfo mediaSource,
 275            int attachmentStreamIndex,
 276            string outputPath,
 277            CancellationToken cancellationToken)
 278        {
 0279            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 280
 0281            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 282
 0283            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 284
 0285            var hasVideoOrAudioStream = mediaSource.MediaStreams
 0286                .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0287            var processArgs = string.Format(
 0288                CultureInfo.InvariantCulture,
 0289                "-dump_attachment:{1} \"{2}\" -i {0} {3}",
 0290                inputPath,
 0291                attachmentStreamIndex,
 0292                EncodingUtils.NormalizePath(outputPath),
 0293                hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 294
 295            int exitCode;
 296
 0297            using (var process = new Process
 0298            {
 0299                StartInfo = new ProcessStartInfo
 0300                {
 0301                    Arguments = processArgs,
 0302                    FileName = _mediaEncoder.EncoderPath,
 0303                    UseShellExecute = false,
 0304                    CreateNoWindow = true,
 0305                    WindowStyle = ProcessWindowStyle.Hidden,
 0306                    ErrorDialog = false
 0307                },
 0308                EnableRaisingEvents = true
 0309            })
 310            {
 0311                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 312
 0313                process.Start();
 314
 315                try
 316                {
 0317                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0318                    exitCode = process.ExitCode;
 0319                }
 0320                catch (OperationCanceledException)
 321                {
 0322                    process.Kill(true);
 0323                    exitCode = -1;
 0324                }
 0325            }
 326
 0327            var failed = false;
 328
 0329            if (exitCode != 0)
 330            {
 0331                if (hasVideoOrAudioStream || exitCode != 1)
 332                {
 0333                    failed = true;
 334
 0335                    _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, ex
 336                    try
 337                    {
 0338                        if (File.Exists(outputPath))
 339                        {
 0340                            _fileSystem.DeleteFile(outputPath);
 341                        }
 0342                    }
 0343                    catch (IOException ex)
 344                    {
 0345                        _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 0346                    }
 347                }
 348            }
 349
 0350            if (!failed && !File.Exists(outputPath))
 351            {
 0352                failed = true;
 353            }
 354
 0355            if (failed)
 356            {
 0357                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 358
 0359                throw new InvalidOperationException(
 0360                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 361            }
 362
 0363            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 0364        }
 365
 366        /// <inheritdoc />
 367        public void Dispose()
 368        {
 2369            _semaphoreLocks.Dispose();
 2370        }
 371    }
 372}