< 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: 159
Coverable lines: 172
Total lines: 361
Line coverage: 7.5%
Branch coverage
0%
Covered branches: 0
Total branches: 56
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 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: 361 4/19/2026 - 12:14:27 AM Line coverage: 7.5% (13/172) Branch coverage: 0% (0/56) Total lines: 361

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%342180%
GetAttachmentStream()100%210%
ExtractAttachment()0%2040%
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            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 133            {
 0134                var directory = Directory.CreateDirectory(outputFolder);
 0135                var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
 0136                var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains
 0137                if (!missingFiles.Any())
 138                {
 139                    // Skip extraction if all files already exist
 0140                    return;
 141                }
 142
 143                // Files without video/audio streams (e.g. MKS subtitle files) don't need a dummy
 144                // output since there are no streams to process. Omit "-t 0 -f null null" so ffmpeg
 145                // doesn't fail trying to open an output with no streams. It will exit with code 1
 146                // ("at least one output file must be specified") which is expected and harmless
 147                // since we only need the -dump_attachment side effect.
 0148                var hasVideoOrAudioStream = mediaSource.MediaStreams
 0149                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0150                var processArgs = string.Format(
 0151                    CultureInfo.InvariantCulture,
 0152                    "-dump_attachment:t \"\" -y {0} -i {1} {2}",
 0153                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 0154                    inputPath,
 0155                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 156
 157                int exitCode;
 158
 0159                using (var process = new Process
 0160                    {
 0161                        StartInfo = new ProcessStartInfo
 0162                        {
 0163                            Arguments = processArgs,
 0164                            FileName = _mediaEncoder.EncoderPath,
 0165                            UseShellExecute = false,
 0166                            CreateNoWindow = true,
 0167                            WindowStyle = ProcessWindowStyle.Hidden,
 0168                            WorkingDirectory = outputFolder,
 0169                            ErrorDialog = false
 0170                        },
 0171                        EnableRaisingEvents = true
 0172                    })
 173                {
 0174                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 175
 0176                    process.Start();
 177
 178                    try
 179                    {
 0180                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0181                        exitCode = process.ExitCode;
 0182                    }
 0183                    catch (OperationCanceledException)
 184                    {
 0185                        process.Kill(true);
 0186                        exitCode = -1;
 0187                    }
 0188                }
 189
 0190                var failed = false;
 191
 0192                if (exitCode != 0)
 193                {
 0194                    if (hasVideoOrAudioStream || exitCode != 1)
 195                    {
 0196                        failed = true;
 197
 0198                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 199                        try
 200                        {
 0201                            Directory.Delete(outputFolder);
 0202                        }
 0203                        catch (IOException ex)
 204                        {
 0205                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 0206                        }
 207                    }
 208                }
 209
 0210                if (!failed && !Directory.Exists(outputFolder))
 211                {
 0212                    failed = true;
 213                }
 214
 0215                if (failed)
 216                {
 0217                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 218
 0219                    throw new InvalidOperationException(
 0220                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 221                }
 222
 0223                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 0224            }
 0225        }
 226
 227        private async Task<Stream> GetAttachmentStream(
 228            MediaSourceInfo mediaSource,
 229            MediaAttachment mediaAttachment,
 230            CancellationToken cancellationToken)
 231        {
 0232            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 0233                .ConfigureAwait(false);
 0234            return AsyncFile.OpenRead(attachmentPath);
 0235        }
 236
 237        private async Task<string> ExtractAttachment(
 238            string inputFile,
 239            MediaSourceInfo mediaSource,
 240            MediaAttachment mediaAttachment,
 241            CancellationToken cancellationToken)
 242        {
 0243            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0244            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 245            {
 0246                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAtt
 0247                if (!File.Exists(attachmentPath))
 248                {
 0249                    await ExtractAttachmentInternal(
 0250                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 0251                        mediaSource,
 0252                        mediaAttachment.Index,
 0253                        attachmentPath,
 0254                        cancellationToken).ConfigureAwait(false);
 255                }
 256
 0257                return attachmentPath;
 258            }
 0259        }
 260
 261        private async Task ExtractAttachmentInternal(
 262            string inputPath,
 263            MediaSourceInfo mediaSource,
 264            int attachmentStreamIndex,
 265            string outputPath,
 266            CancellationToken cancellationToken)
 267        {
 0268            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 269
 0270            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 271
 0272            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 273
 0274            var hasVideoOrAudioStream = mediaSource.MediaStreams
 0275                .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0276            var processArgs = string.Format(
 0277                CultureInfo.InvariantCulture,
 0278                "-dump_attachment:{1} \"{2}\" -i {0} {3}",
 0279                inputPath,
 0280                attachmentStreamIndex,
 0281                EncodingUtils.NormalizePath(outputPath),
 0282                hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 283
 284            int exitCode;
 285
 0286            using (var process = new Process
 0287                {
 0288                    StartInfo = new ProcessStartInfo
 0289                    {
 0290                        Arguments = processArgs,
 0291                        FileName = _mediaEncoder.EncoderPath,
 0292                        UseShellExecute = false,
 0293                        CreateNoWindow = true,
 0294                        WindowStyle = ProcessWindowStyle.Hidden,
 0295                        ErrorDialog = false
 0296                    },
 0297                    EnableRaisingEvents = true
 0298                })
 299            {
 0300                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 301
 0302                process.Start();
 303
 304                try
 305                {
 0306                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0307                    exitCode = process.ExitCode;
 0308                }
 0309                catch (OperationCanceledException)
 310                {
 0311                    process.Kill(true);
 0312                    exitCode = -1;
 0313                }
 0314            }
 315
 0316            var failed = false;
 317
 0318            if (exitCode != 0)
 319            {
 0320                if (hasVideoOrAudioStream || exitCode != 1)
 321                {
 0322                    failed = true;
 323
 0324                    _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, ex
 325                    try
 326                    {
 0327                        if (File.Exists(outputPath))
 328                        {
 0329                            _fileSystem.DeleteFile(outputPath);
 330                        }
 0331                    }
 0332                    catch (IOException ex)
 333                    {
 0334                        _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 0335                    }
 336                }
 337            }
 338
 0339            if (!failed && !File.Exists(outputPath))
 340            {
 0341                failed = true;
 342            }
 343
 0344            if (failed)
 345            {
 0346                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 347
 0348                throw new InvalidOperationException(
 0349                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 350            }
 351
 0352            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 0353        }
 354
 355        /// <inheritdoc />
 356        public void Dispose()
 357        {
 2358            _semaphoreLocks.Dispose();
 2359        }
 360    }
 361}