< 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: 240
Coverable lines: 253
Total lines: 505
Line coverage: 5.1%
Branch coverage
0%
Covered branches: 0
Total branches: 90
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 3/6/2026 - 12:14:09 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: 3726/4/2026 - 12:15:59 AM Line coverage: 5.1% (13/253) Branch coverage: 0% (0/90) Total lines: 505 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: 505

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%4260%
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 MediaBrowser.Common.Extensions;
 12using MediaBrowser.Controller.Entities;
 13using MediaBrowser.Controller.IO;
 14using MediaBrowser.Controller.Library;
 15using MediaBrowser.Controller.MediaEncoding;
 16using MediaBrowser.MediaEncoding.Encoder;
 17using MediaBrowser.Model.Dto;
 18using MediaBrowser.Model.Entities;
 19using MediaBrowser.Model.IO;
 20using Microsoft.Extensions.Logging;
 21
 22namespace MediaBrowser.MediaEncoding.Attachments
 23{
 24    /// <inheritdoc cref="IAttachmentExtractor"/>
 25    public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
 26    {
 27        private readonly ILogger<AttachmentExtractor> _logger;
 28        private readonly IFileSystem _fileSystem;
 29        private readonly IMediaEncoder _mediaEncoder;
 30        private readonly IMediaSourceManager _mediaSourceManager;
 31        private readonly IPathManager _pathManager;
 32
 233        private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
 234        {
 235            o.PoolSize = 20;
 236            o.PoolInitialFill = 1;
 237        });
 38
 39        /// <summary>
 40        /// Initializes a new instance of the <see cref="AttachmentExtractor"/> class.
 41        /// </summary>
 42        /// <param name="logger">The <see cref="ILogger{AttachmentExtractor}"/>.</param>
 43        /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 44        /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
 45        /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
 46        /// <param name="pathManager">The <see cref="IPathManager"/>.</param>
 47        public AttachmentExtractor(
 48            ILogger<AttachmentExtractor> logger,
 49            IFileSystem fileSystem,
 50            IMediaEncoder mediaEncoder,
 51            IMediaSourceManager mediaSourceManager,
 52            IPathManager pathManager)
 53        {
 254            _logger = logger;
 255            _fileSystem = fileSystem;
 256            _mediaEncoder = mediaEncoder;
 257            _mediaSourceManager = mediaSourceManager;
 258            _pathManager = pathManager;
 259        }
 60
 61        /// <inheritdoc />
 62        public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId
 63        {
 064            ArgumentNullException.ThrowIfNull(item);
 65
 066            if (string.IsNullOrWhiteSpace(mediaSourceId))
 67            {
 068                throw new ArgumentNullException(nameof(mediaSourceId));
 69            }
 70
 071            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 072            var mediaSource = mediaSources
 073                .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 074            if (mediaSource is null)
 75            {
 076                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
 77            }
 78
 079            var mediaAttachment = mediaSource.MediaAttachments
 080                .FirstOrDefault(i => i.Index == attachmentStreamIndex);
 081            if (mediaAttachment is null)
 82            {
 083                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {a
 84            }
 85
 086            if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 87            {
 088                throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extr
 89            }
 90
 091            var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
 092                    .ConfigureAwait(false);
 93
 094            return (mediaAttachment, attachmentStream);
 095        }
 96
 97        /// <inheritdoc />
 98        public async Task ExtractAllAttachments(
 99            string inputFile,
 100            MediaSourceInfo mediaSource,
 101            CancellationToken cancellationToken)
 102        {
 0103            var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
 0104                                                                              && (a.FileName.Contains('/', StringCompari
 0105            if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 106            {
 0107                await ExtractAllAttachmentsIndividuallyInternal(
 0108                    inputFile,
 0109                    mediaSource,
 0110                    cancellationToken).ConfigureAwait(false);
 111            }
 112            else
 113            {
 0114                await ExtractAllAttachmentsInternal(
 0115                    inputFile,
 0116                    mediaSource,
 0117                    cancellationToken).ConfigureAwait(false);
 118            }
 0119        }
 120
 121        private async Task ExtractAllAttachmentsIndividuallyInternal(
 122            string inputFile,
 123            MediaSourceInfo mediaSource,
 124            CancellationToken cancellationToken)
 125        {
 0126            var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
 127
 0128            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 129
 0130            var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0131            if (outputFolder is null)
 132            {
 0133                _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", 
 0134                return;
 135            }
 136
 0137            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 138            {
 0139                Directory.CreateDirectory(outputFolder);
 140
 0141                var dumpArgs = new StringBuilder();
 0142                var missingPaths = new List<string>();
 0143                foreach (var attachment in mediaSource.MediaAttachments)
 144                {
 0145                    if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 146                    {
 147                        continue;
 148                    }
 149
 0150                    var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture);
 0151                    var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName
 0152                                         ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
 0153                    if (File.Exists(attachmentPath))
 154                    {
 155                        continue;
 156                    }
 157
 0158                    dumpArgs.AppendFormat(
 0159                        CultureInfo.InvariantCulture,
 0160                        "-dump_attachment:{0} \"{1}\" ",
 0161                        attachment.Index,
 0162                        EncodingUtils.NormalizePath(attachmentPath));
 0163                    missingPaths.Add(attachmentPath);
 164                }
 165
 0166                if (missingPaths.Count == 0)
 167                {
 168                    // Skip extraction if all files already exist
 0169                    return;
 170                }
 171
 0172                var hasVideoOrAudioStream = mediaSource.MediaStreams
 0173                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0174                var processArgs = string.Format(
 0175                    CultureInfo.InvariantCulture,
 0176                    "{0}{1} -i {2} {3}",
 0177                    dumpArgs,
 0178                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 0179                    inputPath,
 0180                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 181
 182                int exitCode;
 183
 0184                using (var process = new Process
 0185                {
 0186                    StartInfo = new ProcessStartInfo
 0187                    {
 0188                        Arguments = processArgs,
 0189                        FileName = _mediaEncoder.EncoderPath,
 0190                        UseShellExecute = false,
 0191                        CreateNoWindow = true,
 0192                        WindowStyle = ProcessWindowStyle.Hidden,
 0193                        ErrorDialog = false
 0194                    },
 0195                    EnableRaisingEvents = true
 0196                })
 197                {
 0198                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 199
 0200                    process.Start();
 201
 202                    try
 203                    {
 0204                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0205                        exitCode = process.ExitCode;
 0206                    }
 0207                    catch (OperationCanceledException)
 208                    {
 0209                        process.Kill(true);
 0210                        exitCode = -1;
 0211                    }
 0212                }
 213
 0214                var failed = false;
 215
 0216                if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1))
 217                {
 0218                    failed = true;
 219
 0220                    foreach (var path in missingPaths)
 221                    {
 0222                        if (!File.Exists(path))
 223                        {
 224                            continue;
 225                        }
 226
 227                        try
 228                        {
 0229                            _fileSystem.DeleteFile(path);
 0230                        }
 0231                        catch (IOException ex)
 232                        {
 0233                            _logger.LogError(ex, "Error deleting extracted attachment {Path}", path);
 0234                        }
 235                    }
 236                }
 237
 0238                if (!failed && missingPaths.Exists(p => !File.Exists(p)))
 239                {
 0240                    failed = true;
 241                }
 242
 0243                if (failed)
 244                {
 0245                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 246
 0247                    throw new InvalidOperationException(
 0248                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 249                }
 250
 0251                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 0252            }
 0253        }
 254
 255        private async Task ExtractAllAttachmentsInternal(
 256            string inputFile,
 257            MediaSourceInfo mediaSource,
 258            CancellationToken cancellationToken)
 259        {
 0260            var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
 261
 0262            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 263
 0264            var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0265            if (outputFolder is null)
 266            {
 0267                _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", 
 0268                return;
 269            }
 270
 0271            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 272            {
 0273                var directory = Directory.CreateDirectory(outputFolder);
 0274                var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
 0275                var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains
 0276                if (!missingFiles.Any())
 277                {
 278                    // Skip extraction if all files already exist
 0279                    return;
 280                }
 281
 282                // Files without video/audio streams (e.g. MKS subtitle files) don't need a dummy
 283                // output since there are no streams to process. Omit "-t 0 -f null null" so ffmpeg
 284                // doesn't fail trying to open an output with no streams. It will exit with code 1
 285                // ("at least one output file must be specified") which is expected and harmless
 286                // since we only need the -dump_attachment side effect.
 0287                var hasVideoOrAudioStream = mediaSource.MediaStreams
 0288                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0289                var processArgs = string.Format(
 0290                    CultureInfo.InvariantCulture,
 0291                    "-dump_attachment:t \"\" -y {0} -i {1} {2}",
 0292                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 0293                    inputPath,
 0294                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 295
 296                int exitCode;
 297
 0298                using (var process = new Process
 0299                {
 0300                    StartInfo = new ProcessStartInfo
 0301                    {
 0302                        Arguments = processArgs,
 0303                        FileName = _mediaEncoder.EncoderPath,
 0304                        UseShellExecute = false,
 0305                        CreateNoWindow = true,
 0306                        WindowStyle = ProcessWindowStyle.Hidden,
 0307                        WorkingDirectory = outputFolder,
 0308                        ErrorDialog = false
 0309                    },
 0310                    EnableRaisingEvents = true
 0311                })
 312                {
 0313                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 314
 0315                    process.Start();
 316
 317                    try
 318                    {
 0319                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0320                        exitCode = process.ExitCode;
 0321                    }
 0322                    catch (OperationCanceledException)
 323                    {
 0324                        process.Kill(true);
 0325                        exitCode = -1;
 0326                    }
 0327                }
 328
 0329                var failed = false;
 330
 0331                if (exitCode != 0)
 332                {
 0333                    if (hasVideoOrAudioStream || exitCode != 1)
 334                    {
 0335                        failed = true;
 336
 0337                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 338                        try
 339                        {
 0340                            Directory.Delete(outputFolder);
 0341                        }
 0342                        catch (IOException ex)
 343                        {
 0344                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 0345                        }
 346                    }
 347                }
 348
 0349                if (!failed && !Directory.Exists(outputFolder))
 350                {
 0351                    failed = true;
 352                }
 353
 0354                if (failed)
 355                {
 0356                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 357
 0358                    throw new InvalidOperationException(
 0359                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 360                }
 361
 0362                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 0363            }
 0364        }
 365
 366        private async Task<Stream> GetAttachmentStream(
 367            MediaSourceInfo mediaSource,
 368            MediaAttachment mediaAttachment,
 369            CancellationToken cancellationToken)
 370        {
 0371            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 0372                .ConfigureAwait(false);
 0373            return AsyncFile.OpenRead(attachmentPath);
 0374        }
 375
 376        private async Task<string> ExtractAttachment(
 377            string inputFile,
 378            MediaSourceInfo mediaSource,
 379            MediaAttachment mediaAttachment,
 380            CancellationToken cancellationToken)
 381        {
 0382            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 0383            if (attachmentFolderPath is null)
 384            {
 0385                throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id,
 386            }
 387
 0388            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 389            {
 0390                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAtt
 0391                if (!File.Exists(attachmentPath))
 392                {
 0393                    await ExtractAttachmentInternal(
 0394                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 0395                        mediaSource,
 0396                        mediaAttachment.Index,
 0397                        attachmentPath,
 0398                        cancellationToken).ConfigureAwait(false);
 399                }
 400
 0401                return attachmentPath;
 402            }
 0403        }
 404
 405        private async Task ExtractAttachmentInternal(
 406            string inputPath,
 407            MediaSourceInfo mediaSource,
 408            int attachmentStreamIndex,
 409            string outputPath,
 410            CancellationToken cancellationToken)
 411        {
 0412            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 413
 0414            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 415
 0416            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 417
 0418            var hasVideoOrAudioStream = mediaSource.MediaStreams
 0419                .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 0420            var processArgs = string.Format(
 0421                CultureInfo.InvariantCulture,
 0422                "-dump_attachment:{1} \"{2}\" -i {0} {3}",
 0423                inputPath,
 0424                attachmentStreamIndex,
 0425                EncodingUtils.NormalizePath(outputPath),
 0426                hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 427
 428            int exitCode;
 429
 0430            using (var process = new Process
 0431            {
 0432                StartInfo = new ProcessStartInfo
 0433                {
 0434                    Arguments = processArgs,
 0435                    FileName = _mediaEncoder.EncoderPath,
 0436                    UseShellExecute = false,
 0437                    CreateNoWindow = true,
 0438                    WindowStyle = ProcessWindowStyle.Hidden,
 0439                    ErrorDialog = false
 0440                },
 0441                EnableRaisingEvents = true
 0442            })
 443            {
 0444                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 445
 0446                process.Start();
 447
 448                try
 449                {
 0450                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 0451                    exitCode = process.ExitCode;
 0452                }
 0453                catch (OperationCanceledException)
 454                {
 0455                    process.Kill(true);
 0456                    exitCode = -1;
 0457                }
 0458            }
 459
 0460            var failed = false;
 461
 0462            if (exitCode != 0)
 463            {
 0464                if (hasVideoOrAudioStream || exitCode != 1)
 465                {
 0466                    failed = true;
 467
 0468                    _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, ex
 469                    try
 470                    {
 0471                        if (File.Exists(outputPath))
 472                        {
 0473                            _fileSystem.DeleteFile(outputPath);
 474                        }
 0475                    }
 0476                    catch (IOException ex)
 477                    {
 0478                        _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 0479                    }
 480                }
 481            }
 482
 0483            if (!failed && !File.Exists(outputPath))
 484            {
 0485                failed = true;
 486            }
 487
 0488            if (failed)
 489            {
 0490                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 491
 0492                throw new InvalidOperationException(
 0493                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 494            }
 495
 0496            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 0497        }
 498
 499        /// <inheritdoc />
 500        public void Dispose()
 501        {
 2502            _semaphoreLocks.Dispose();
 2503        }
 504    }
 505}