< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.MediaInfo.MediaInfoResolver
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
Line coverage
71%
Covered lines: 99
Uncovered lines: 39
Coverable lines: 138
Total lines: 436
Line coverage: 71.7%
Branch coverage
74%
Covered branches: 76
Total branches: 102
Branch coverage: 74.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 65.4% (74/113) Branch coverage: 70.2% (59/84) Total lines: 34710/29/2026 - 12:17:11 AM Line coverage: 71.7% (99/138) Branch coverage: 74.5% (76/102) Total lines: 436 5/16/2026 - 12:15:55 AM Line coverage: 65.4% (74/113) Branch coverage: 70.2% (59/84) Total lines: 34710/29/2026 - 12:17:11 AM Line coverage: 71.7% (99/138) Branch coverage: 74.5% (76/102) Total lines: 436

Coverage delta

Coverage delta 7 -7

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetExternalStreamsAsync()96.87%333290%
GetExternalStreams(...)0%4260%
GetExternalFiles(...)100%2424100%
GetExternalFiles(...)0%342180%
IsSuppressedVobSubFile(...)100%66100%
GetPairedVobSubKeys(...)100%66100%
GetVobSubPairingKey(...)50%22100%
GetMediaInfo(...)100%11100%
MergeMetadata(...)100%88100%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.IO;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Emby.Naming.Common;
 8using Emby.Naming.ExternalFiles;
 9using MediaBrowser.Controller.Entities;
 10using MediaBrowser.Controller.Entities.Audio;
 11using MediaBrowser.Controller.MediaEncoding;
 12using MediaBrowser.Controller.Providers;
 13using MediaBrowser.Model.Dlna;
 14using MediaBrowser.Model.Dto;
 15using MediaBrowser.Model.Entities;
 16using MediaBrowser.Model.Globalization;
 17using MediaBrowser.Model.IO;
 18using MediaBrowser.Model.MediaInfo;
 19using Microsoft.Extensions.Logging;
 20
 21namespace MediaBrowser.Providers.MediaInfo
 22{
 23    /// <summary>
 24    /// Resolves external files for <see cref="Video"/>.
 25    /// </summary>
 26    public abstract class MediaInfoResolver
 27    {
 28        /// <summary>
 29        /// The <see cref="ExternalPathParser"/> instance.
 30        /// </summary>
 31        private readonly ExternalPathParser _externalPathParser;
 32
 33        /// <summary>
 34        /// The <see cref="IMediaEncoder"/> instance.
 35        /// </summary>
 36        private readonly IMediaEncoder _mediaEncoder;
 37
 38        private readonly ILogger _logger;
 39        private readonly IFileSystem _fileSystem;
 40
 41        /// <summary>
 42        /// The <see cref="NamingOptions"/> instance.
 43        /// </summary>
 44        private readonly NamingOptions _namingOptions;
 45
 46        /// <summary>
 47        /// The <see cref="DlnaProfileType"/> of the files this resolver should resolve.
 48        /// </summary>
 49        private readonly DlnaProfileType _type;
 50
 51        /// <summary>
 52        /// Initializes a new instance of the <see cref="MediaInfoResolver"/> class.
 53        /// </summary>
 54        /// <param name="logger">The logger.</param>
 55        /// <param name="localizationManager">The localization manager.</param>
 56        /// <param name="mediaEncoder">The media encoder.</param>
 57        /// <param name="fileSystem">The file system.</param>
 58        /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFl
 59        /// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param>
 60        protected MediaInfoResolver(
 61            ILogger logger,
 62            ILocalizationManager localizationManager,
 63            IMediaEncoder mediaEncoder,
 64            IFileSystem fileSystem,
 65            NamingOptions namingOptions,
 66            DlnaProfileType type)
 67        {
 19568            _logger = logger;
 19569            _mediaEncoder = mediaEncoder;
 19570            _fileSystem = fileSystem;
 19571            _namingOptions = namingOptions;
 19572            _type = type;
 19573            _externalPathParser = new ExternalPathParser(namingOptions, localizationManager, _type);
 19574        }
 75
 76        /// <summary>
 77        /// Retrieves the external streams for the provided video.
 78        /// </summary>
 79        /// <param name="video">The <see cref="Video"/> object to search external streams for.</param>
 80        /// <param name="startIndex">The stream index to start adding external streams at.</param>
 81        /// <param name="directoryService">The directory service to search for files.</param>
 82        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
 83        /// <param name="cancellationToken">The cancellation token.</param>
 84        /// <returns>The external streams located.</returns>
 85        public async Task<IReadOnlyList<MediaStream>> GetExternalStreamsAsync(
 86            Video video,
 87            int startIndex,
 88            IDirectoryService directoryService,
 89            bool clearCache,
 90            CancellationToken cancellationToken)
 91        {
 1992            if (!video.IsFileProtocol)
 93            {
 194                return Array.Empty<MediaStream>();
 95            }
 96
 1897            var pathInfos = GetExternalFiles(video, directoryService, clearCache);
 98
 1899            if (!pathInfos.Any())
 100            {
 5101                return Array.Empty<MediaStream>();
 102            }
 103
 13104            var mediaStreams = new List<MediaStream>();
 105
 56106            foreach (var pathInfo in pathInfos)
 107            {
 15108                if (!pathInfo.Path.AsSpan().EndsWith(".strm", StringComparison.OrdinalIgnoreCase))
 109                {
 110                    try
 111                    {
 15112                        var mediaInfo = await GetMediaInfo(pathInfo.Path, _type, cancellationToken).ConfigureAwait(false
 113
 15114                        if (mediaInfo.MediaStreams.Count == 1)
 115                        {
 11116                            MediaStream mediaStream = mediaInfo.MediaStreams[0];
 117
 11118                            if ((mediaStream.Type == MediaStreamType.Audio && _type == DlnaProfileType.Audio)
 11119                                || (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle))
 120                            {
 11121                                mediaStream.Index = startIndex++;
 11122                                mediaStream.IsDefault = pathInfo.IsDefault;
 11123                                mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced;
 11124                                mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpai
 125
 11126                                mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
 127                            }
 128                        }
 129                        else
 130                        {
 24131                            foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
 132                            {
 8133                                if ((mediaStream.Type == MediaStreamType.Audio && _type == DlnaProfileType.Audio)
 8134                                    || (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitl
 135                                {
 8136                                    mediaStream.Index = startIndex++;
 137
 8138                                    mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
 139                                }
 140                            }
 141                        }
 15142                    }
 0143                    catch (Exception ex)
 144                    {
 0145                        _logger.LogError(ex, "Error getting external streams from {Path}", pathInfo.Path);
 146
 0147                        continue;
 148                    }
 149                }
 15150            }
 151
 13152            return mediaStreams;
 19153        }
 154
 155        /// <summary>
 156        /// Retrieves the external streams for the provided audio.
 157        /// </summary>
 158        /// <param name="audio">The <see cref="Audio"/> object to search external streams for.</param>
 159        /// <param name="startIndex">The stream index to start adding external streams at.</param>
 160        /// <param name="directoryService">The directory service to search for files.</param>
 161        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
 162        /// <returns>The external streams located.</returns>
 163        public IReadOnlyList<MediaStream> GetExternalStreams(
 164            Audio audio,
 165            int startIndex,
 166            IDirectoryService directoryService,
 167            bool clearCache)
 168        {
 0169            if (!audio.IsFileProtocol)
 170            {
 0171                return Array.Empty<MediaStream>();
 172            }
 173
 0174            var pathInfos = GetExternalFiles(audio, directoryService, clearCache);
 175
 0176            if (pathInfos.Count == 0)
 177            {
 0178                return Array.Empty<MediaStream>();
 179            }
 180
 0181            var mediaStreams = new MediaStream[pathInfos.Count];
 182
 0183            for (var i = 0; i < pathInfos.Count; i++)
 184            {
 0185                mediaStreams[i] = new MediaStream
 0186                {
 0187                    Type = MediaStreamType.Lyric,
 0188                    Path = pathInfos[i].Path,
 0189                    Language = pathInfos[i].Language,
 0190                    Index = startIndex++
 0191                };
 192            }
 193
 0194            return mediaStreams;
 195        }
 196
 197        /// <summary>
 198        /// Returns the external file infos for the given video.
 199        /// </summary>
 200        /// <param name="video">The <see cref="Video"/> object to search external files for.</param>
 201        /// <param name="directoryService">The directory service to search for files.</param>
 202        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
 203        /// <returns>The external file paths located.</returns>
 204        public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
 205            Video video,
 206            IDirectoryService directoryService,
 207            bool clearCache)
 208        {
 48209            if (!video.IsFileProtocol)
 210            {
 1211                return Array.Empty<ExternalPathParserResult>();
 212            }
 213
 214            // Check if video folder exists
 47215            string folder = video.ContainingFolderPath;
 47216            if (!_fileSystem.DirectoryExists(folder))
 217            {
 12218                return Array.Empty<ExternalPathParserResult>();
 219            }
 220
 35221            var files = directoryService.GetFilePaths(folder, clearCache).ToList();
 35222            files.Remove(video.Path);
 35223            var internalMetadataPath = video.GetInternalMetadataPath();
 35224            if (_fileSystem.DirectoryExists(internalMetadataPath))
 225            {
 34226                files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
 227            }
 228
 35229            if (files.Count == 0)
 230            {
 1231                return Array.Empty<ExternalPathParserResult>();
 232            }
 233
 234            // VobSub .sub payloads only carry per-track language metadata when read via
 235            // their paired .idx file, so probe the .idx instead and skip the .sub. Pairing
 236            // requires the same directory (ffprobe can't resolve a split pair) and an
 237            // ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive
 238            // filesystems, so a looser match could suppress a .sub with no working .idx).
 239            // An .idx file with no paired .sub cannot be probed at all, so it is left out
 240            // entirely rather than surfaced (which would otherwise fail every probe and,
 241            // since the .idx would keep "existing" from Jellyfin's point of view, prevent
 242            // stale subtitle stream metadata from ever being cleared once the .sub is gone).
 34243            HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle
 34244                ? GetPairedVobSubKeys(files)
 34245                : null;
 246
 34247            var externalPathInfos = new List<ExternalPathParserResult>();
 34248            ReadOnlySpan<char> prefix = video.FileNameWithoutExtension;
 148249            foreach (var file in files)
 250            {
 40251                if (IsSuppressedVobSubFile(file, pairedVobSubKeys))
 252                {
 253                    continue;
 254                }
 255
 35256                var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
 35257                if (fileNameWithoutExtension.Length >= prefix.Length
 35258                    && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
 35259                    && (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(
 260                {
 32261                    var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..]
 262
 32263                    if (externalPathInfo is not null)
 264                    {
 25265                        externalPathInfos.Add(externalPathInfo);
 266                    }
 267                }
 268            }
 269
 34270            return externalPathInfos;
 271        }
 272
 273        /// <summary>
 274        /// Returns the external file infos for the given audio.
 275        /// </summary>
 276        /// <param name="audio">The <see cref="Audio"/> object to search external files for.</param>
 277        /// <param name="directoryService">The directory service to search for files.</param>
 278        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
 279        /// <returns>The external file paths located.</returns>
 280        public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
 281            Audio audio,
 282            IDirectoryService directoryService,
 283            bool clearCache)
 284        {
 0285            if (!audio.IsFileProtocol)
 286            {
 0287                return Array.Empty<ExternalPathParserResult>();
 288            }
 289
 0290            string folder = audio.ContainingFolderPath;
 0291            var files = directoryService.GetFilePaths(folder, clearCache).ToList();
 0292            files.Remove(audio.Path);
 0293            var internalMetadataPath = audio.GetInternalMetadataPath();
 0294            if (_fileSystem.DirectoryExists(internalMetadataPath))
 295            {
 0296                files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
 297            }
 298
 0299            if (files.Count == 0)
 300            {
 0301                return Array.Empty<ExternalPathParserResult>();
 302            }
 303
 0304            var externalPathInfos = new List<ExternalPathParserResult>();
 0305            ReadOnlySpan<char> prefix = audio.FileNameWithoutExtension;
 0306            foreach (var file in files)
 307            {
 0308                var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
 0309                if (fileNameWithoutExtension.Length >= prefix.Length
 0310                    && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
 0311                    && (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(
 312                {
 0313                    var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..]
 314
 0315                    if (externalPathInfo is not null)
 316                    {
 0317                        externalPathInfos.Add(externalPathInfo);
 318                    }
 319                }
 320            }
 321
 0322            return externalPathInfos;
 323        }
 324
 325        /// <summary>
 326        /// Determines whether a candidate file is part of a VobSub .idx/.sub pair that
 327        /// should be resolved to only its .idx file, or an .idx file with no paired .sub
 328        /// that cannot be probed at all.
 329        /// </summary>
 330        /// <param name="file">The full path to the candidate file.</param>
 331        /// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not ap
 332        /// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns>
 333        private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys)
 334        {
 40335            if (pairedVobSubKeys is null)
 336            {
 4337                return false;
 338            }
 339
 36340            var extension = Path.GetExtension(file.AsSpan());
 36341            if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
 342            {
 343                // A paired .idx exists; probe it instead of the .sub payload.
 5344                return pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
 345            }
 346
 31347            if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
 348            {
 349                // Without its .sub payload, the .idx cannot be probed for any data.
 5350                return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
 351            }
 352
 26353            return false;
 354        }
 355
 356        /// <summary>
 357        /// Builds the set of directory+basename keys that have both an .idx and a .sub
 358        /// file present, in a single pass over the candidate files.
 359        /// </summary>
 360        /// <param name="files">The candidate files to search.</param>
 361        /// <returns>The set of pairing keys with both an .idx and .sub present.</returns>
 362        private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files)
 363        {
 30364            var idxKeys = new HashSet<string>(StringComparer.Ordinal);
 30365            var subKeys = new HashSet<string>(StringComparer.Ordinal);
 132366            foreach (var file in files)
 367            {
 36368                var extension = Path.GetExtension(file.AsSpan());
 36369                if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
 370                {
 5371                    idxKeys.Add(GetVobSubPairingKey(file));
 372                }
 31373                else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
 374                {
 5375                    subKeys.Add(GetVobSubPairingKey(file));
 376                }
 377            }
 378
 30379            idxKeys.IntersectWith(subKeys);
 30380            return idxKeys;
 381        }
 382
 383        /// <summary>
 384        /// Builds a directory+basename key used to pair a VobSub .idx file with its .sub
 385        /// payload only when both live in the same directory.
 386        /// </summary>
 387        /// <param name="file">The full path to the file.</param>
 388        /// <returns>A key combining the containing directory and file name without extension.</returns>
 389        private static string GetVobSubPairingKey(string file)
 390        {
 20391            var directory = Path.GetDirectoryName(file) ?? string.Empty;
 20392            var baseName = Path.GetFileNameWithoutExtension(file);
 20393            return Path.Combine(directory, baseName);
 394        }
 395
 396        /// <summary>
 397        /// Returns the media info of the given file.
 398        /// </summary>
 399        /// <param name="path">The path to the file.</param>
 400        /// <param name="type">The <see cref="DlnaProfileType"/>.</param>
 401        /// <param name="cancellationToken">The cancellation token to cancel operation.</param>
 402        /// <returns>The media info for the given file.</returns>
 403        private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, DlnaProfileType type, CancellationToken cancel
 404        {
 15405            cancellationToken.ThrowIfCancellationRequested();
 406
 15407            return _mediaEncoder.GetMediaInfo(
 15408                new MediaInfoRequest
 15409                {
 15410                    MediaType = type,
 15411                    MediaSource = new MediaSourceInfo
 15412                    {
 15413                        Path = path,
 15414                        Protocol = MediaProtocol.File
 15415                    }
 15416                },
 15417                cancellationToken);
 418        }
 419
 420        /// <summary>
 421        /// Merges path metadata into stream metadata.
 422        /// </summary>
 423        /// <param name="mediaStream">The <see cref="MediaStream"/> object.</param>
 424        /// <param name="pathInfo">The <see cref="ExternalPathParserResult"/> object.</param>
 425        /// <returns>The modified mediaStream.</returns>
 426        private MediaStream MergeMetadata(MediaStream mediaStream, ExternalPathParserResult pathInfo)
 427        {
 19428            mediaStream.Path = pathInfo.Path;
 19429            mediaStream.IsExternal = true;
 19430            mediaStream.Title = string.IsNullOrEmpty(mediaStream.Title) ? (string.IsNullOrEmpty(pathInfo.Title) ? null :
 19431            mediaStream.Language = string.IsNullOrEmpty(mediaStream.Language) ? (string.IsNullOrEmpty(pathInfo.Language)
 432
 19433            return mediaStream;
 434        }
 435    }
 436}