< Summary - Jellyfin

Information
Class: MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor
Assembly: MediaBrowser.MediaEncoding
File(s): /srv/git/jellyfin/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
Line coverage
100%
Covered lines: 13
Uncovered lines: 0
Coverable lines: 13
Total lines: 361
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/27/2025 - 12:11:51 AM Line coverage: 100% (13/13) Total lines: 3503/30/2026 - 12:14:34 AM Line coverage: 100% (13/13) Total lines: 361

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
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        {
 62            ArgumentNullException.ThrowIfNull(item);
 63
 64            if (string.IsNullOrWhiteSpace(mediaSourceId))
 65            {
 66                throw new ArgumentNullException(nameof(mediaSourceId));
 67            }
 68
 69            var mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, null, true, false, cancellationTo
 70            var mediaSource = mediaSources
 71                .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase));
 72            if (mediaSource is null)
 73            {
 74                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} not found");
 75            }
 76
 77            var mediaAttachment = mediaSource.MediaAttachments
 78                .FirstOrDefault(i => i.Index == attachmentStreamIndex);
 79            if (mediaAttachment is null)
 80            {
 81                throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {a
 82            }
 83
 84            if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 85            {
 86                throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extr
 87            }
 88
 89            var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken)
 90                    .ConfigureAwait(false);
 91
 92            return (mediaAttachment, attachmentStream);
 93        }
 94
 95        /// <inheritdoc />
 96        public async Task ExtractAllAttachments(
 97            string inputFile,
 98            MediaSourceInfo mediaSource,
 99            CancellationToken cancellationToken)
 100        {
 101            var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
 102                                                                              && (a.FileName.Contains('/', StringCompari
 103            if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
 104            {
 105                foreach (var attachment in mediaSource.MediaAttachments)
 106                {
 107                    if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
 108                    {
 109                        await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(fa
 110                    }
 111                }
 112            }
 113            else
 114            {
 115                await ExtractAllAttachmentsInternal(
 116                    inputFile,
 117                    mediaSource,
 118                    cancellationToken).ConfigureAwait(false);
 119            }
 120        }
 121
 122        private async Task ExtractAllAttachmentsInternal(
 123            string inputFile,
 124            MediaSourceInfo mediaSource,
 125            CancellationToken cancellationToken)
 126        {
 127            var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
 128
 129            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 130
 131            var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 132            using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
 133            {
 134                var directory = Directory.CreateDirectory(outputFolder);
 135                var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
 136                var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains
 137                if (!missingFiles.Any())
 138                {
 139                    // Skip extraction if all files already exist
 140                    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.
 148                var hasVideoOrAudioStream = mediaSource.MediaStreams
 149                    .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 150                var processArgs = string.Format(
 151                    CultureInfo.InvariantCulture,
 152                    "-dump_attachment:t \"\" -y {0} -i {1} {2}",
 153                    inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.E
 154                    inputPath,
 155                    hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 156
 157                int exitCode;
 158
 159                using (var process = new Process
 160                    {
 161                        StartInfo = new ProcessStartInfo
 162                        {
 163                            Arguments = processArgs,
 164                            FileName = _mediaEncoder.EncoderPath,
 165                            UseShellExecute = false,
 166                            CreateNoWindow = true,
 167                            WindowStyle = ProcessWindowStyle.Hidden,
 168                            WorkingDirectory = outputFolder,
 169                            ErrorDialog = false
 170                        },
 171                        EnableRaisingEvents = true
 172                    })
 173                {
 174                    _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments
 175
 176                    process.Start();
 177
 178                    try
 179                    {
 180                        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 181                        exitCode = process.ExitCode;
 182                    }
 183                    catch (OperationCanceledException)
 184                    {
 185                        process.Kill(true);
 186                        exitCode = -1;
 187                    }
 188                }
 189
 190                var failed = false;
 191
 192                if (exitCode != 0)
 193                {
 194                    if (hasVideoOrAudioStream || exitCode != 1)
 195                    {
 196                        failed = true;
 197
 198                        _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFol
 199                        try
 200                        {
 201                            Directory.Delete(outputFolder);
 202                        }
 203                        catch (IOException ex)
 204                        {
 205                            _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder);
 206                        }
 207                    }
 208                }
 209
 210                if (!failed && !Directory.Exists(outputFolder))
 211                {
 212                    failed = true;
 213                }
 214
 215                if (failed)
 216                {
 217                    _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, o
 218
 219                    throw new InvalidOperationException(
 220                        string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}"
 221                }
 222
 223                _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPa
 224            }
 225        }
 226
 227        private async Task<Stream> GetAttachmentStream(
 228            MediaSourceInfo mediaSource,
 229            MediaAttachment mediaAttachment,
 230            CancellationToken cancellationToken)
 231        {
 232            var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationTok
 233                .ConfigureAwait(false);
 234            return AsyncFile.OpenRead(attachmentPath);
 235        }
 236
 237        private async Task<string> ExtractAttachment(
 238            string inputFile,
 239            MediaSourceInfo mediaSource,
 240            MediaAttachment mediaAttachment,
 241            CancellationToken cancellationToken)
 242        {
 243            var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
 244            using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
 245            {
 246                var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAtt
 247                if (!File.Exists(attachmentPath))
 248                {
 249                    await ExtractAttachmentInternal(
 250                        _mediaEncoder.GetInputArgument(inputFile, mediaSource),
 251                        mediaSource,
 252                        mediaAttachment.Index,
 253                        attachmentPath,
 254                        cancellationToken).ConfigureAwait(false);
 255                }
 256
 257                return attachmentPath;
 258            }
 259        }
 260
 261        private async Task ExtractAttachmentInternal(
 262            string inputPath,
 263            MediaSourceInfo mediaSource,
 264            int attachmentStreamIndex,
 265            string outputPath,
 266            CancellationToken cancellationToken)
 267        {
 268            ArgumentException.ThrowIfNullOrEmpty(inputPath);
 269
 270            ArgumentException.ThrowIfNullOrEmpty(outputPath);
 271
 272            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a 
 273
 274            var hasVideoOrAudioStream = mediaSource.MediaStreams
 275                .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
 276            var processArgs = string.Format(
 277                CultureInfo.InvariantCulture,
 278                "-dump_attachment:{1} \"{2}\" -i {0} {3}",
 279                inputPath,
 280                attachmentStreamIndex,
 281                EncodingUtils.NormalizePath(outputPath),
 282                hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
 283
 284            int exitCode;
 285
 286            using (var process = new Process
 287                {
 288                    StartInfo = new ProcessStartInfo
 289                    {
 290                        Arguments = processArgs,
 291                        FileName = _mediaEncoder.EncoderPath,
 292                        UseShellExecute = false,
 293                        CreateNoWindow = true,
 294                        WindowStyle = ProcessWindowStyle.Hidden,
 295                        ErrorDialog = false
 296                    },
 297                    EnableRaisingEvents = true
 298                })
 299            {
 300                _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
 301
 302                process.Start();
 303
 304                try
 305                {
 306                    await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 307                    exitCode = process.ExitCode;
 308                }
 309                catch (OperationCanceledException)
 310                {
 311                    process.Kill(true);
 312                    exitCode = -1;
 313                }
 314            }
 315
 316            var failed = false;
 317
 318            if (exitCode != 0)
 319            {
 320                if (hasVideoOrAudioStream || exitCode != 1)
 321                {
 322                    failed = true;
 323
 324                    _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, ex
 325                    try
 326                    {
 327                        if (File.Exists(outputPath))
 328                        {
 329                            _fileSystem.DeleteFile(outputPath);
 330                        }
 331                    }
 332                    catch (IOException ex)
 333                    {
 334                        _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath);
 335                    }
 336                }
 337            }
 338
 339            if (!failed && !File.Exists(outputPath))
 340            {
 341                failed = true;
 342            }
 343
 344            if (failed)
 345            {
 346                _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outpu
 347
 348                throw new InvalidOperationException(
 349                    string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", in
 350            }
 351
 352            _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, 
 353        }
 354
 355        /// <inheritdoc />
 356        public void Dispose()
 357        {
 2358            _semaphoreLocks.Dispose();
 2359        }
 360    }
 361}