< Summary - Jellyfin

Information
Class: Emby.Naming.Video.VideoListResolver
Assembly: Emby.Naming
File(s): /srv/git/jellyfin/Emby.Naming/Video/VideoListResolver.cs
Line coverage
96%
Covered lines: 122
Uncovered lines: 5
Coverable lines: 127
Total lines: 321
Line coverage: 96%
Branch coverage
85%
Covered branches: 75
Total branches: 88
Branch coverage: 85.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/12/2026 - 12:15:57 AM Line coverage: 100% (90/90) Branch coverage: 100% (54/54) Total lines: 2255/16/2026 - 12:15:55 AM Line coverage: 96% (122/127) Branch coverage: 93% (80/86) Total lines: 3155/20/2026 - 12:15:44 AM Line coverage: 96% (122/127) Branch coverage: 86% (74/86) Total lines: 31510/29/2026 - 12:17:11 AM Line coverage: 96% (122/127) Branch coverage: 85.2% (75/88) Total lines: 321 5/12/2026 - 12:15:57 AM Line coverage: 100% (90/90) Branch coverage: 100% (54/54) Total lines: 2255/16/2026 - 12:15:55 AM Line coverage: 96% (122/127) Branch coverage: 93% (80/86) Total lines: 3155/20/2026 - 12:15:44 AM Line coverage: 96% (122/127) Branch coverage: 86% (74/86) Total lines: 31510/29/2026 - 12:17:11 AM Line coverage: 96% (122/127) Branch coverage: 85.2% (75/88) Total lines: 321

Coverage delta

Coverage delta 7 -7

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
Resolve(...)87.5%1616100%
GetVideosGroupedByVersion(...)92.85%1414100%
HaveSameYear(...)100%66100%
IsEligibleForMultiVersion(...)92.85%1414100%
GetEpisodesGroupedByVersion(...)91.66%121295%
GetEpisodeVersionKey(...)42.85%231463.63%
OrganizeAlternateVersions(...)100%1212100%

File(s)

/srv/git/jellyfin/Emby.Naming/Video/VideoListResolver.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Text.RegularExpressions;
 7using Emby.Naming.Common;
 8using Emby.Naming.TV;
 9using Jellyfin.Data.Enums;
 10using MediaBrowser.Model.IO;
 11
 12namespace Emby.Naming.Video
 13{
 14    /// <summary>
 15    /// Resolves alternative versions and extras from list of video files.
 16    /// </summary>
 17    public partial class VideoListResolver
 18    {
 219        private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCult
 20
 21        private readonly NamingOptions _namingOptions;
 22        private readonly EpisodePathParser _episodePathParser;
 23
 24        /// <summary>
 25        /// Initializes a new instance of the <see cref="VideoListResolver"/> class.
 26        /// </summary>
 27        /// <param name="namingOptions">The naming options.</param>
 28        public VideoListResolver(NamingOptions namingOptions)
 29        {
 10330            _namingOptions = namingOptions;
 10331            _episodePathParser = new EpisodePathParser(namingOptions);
 10332        }
 33
 34        [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
 35        private static partial Regex ResolutionRegex();
 36
 37        [GeneratedRegex(@"^\[([^]]*)\]")]
 38        private static partial Regex CheckMultiVersionRegex();
 39
 40        /// <summary>
 41        /// Resolves alternative versions and extras from list of video files.
 42        /// </summary>
 43        /// <param name="videoInfos">List of related video files.</param>
 44        /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
 45        /// <param name="parseName">Whether to parse the name or use the filename.</param>
 46        /// <param name="libraryRoot">Top-level folder for the containing library.</param>
 47        /// <param name="collectionType">The type of the containing collection, if known.</param>
 48        /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
 49        public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, bool supportMultiVersion = true
 50        {
 51            // Filter out all extras, otherwise they could cause stacks to not be resolved
 52            // See the unit test TestStackedWithTrailer
 8253            var nonExtras = videoInfos
 8254                .Where(i => i.ExtraType is null)
 8255                .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
 56
 8257            var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList();
 58
 8259            var remainingFiles = new List<VideoFileInfo>();
 8260            var standaloneMedia = new List<VideoFileInfo>();
 61
 72062            for (var i = 0; i < videoInfos.Count; i++)
 63            {
 27864                var current = videoInfos[i];
 27865                if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory)))
 66                {
 67                    continue;
 68                }
 69
 22670                if (current.ExtraType is null)
 71                {
 19872                    standaloneMedia.Add(current);
 73                }
 74                else
 75                {
 2876                    remainingFiles.Add(current);
 77                }
 78            }
 79
 8280            var list = new List<VideoInfo>();
 81
 21282            foreach (var stack in stackResult)
 83            {
 2484                var info = new VideoInfo(stack.Name)
 2485                {
 2486                    Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, par
 2487                        .OfType<VideoFileInfo>()
 2488                        .ToList()
 2489                };
 90
 2491                info.Year = info.Files[0].Year;
 2492                list.Add(info);
 93            }
 94
 56095            foreach (var media in standaloneMedia)
 96            {
 19897                var info = new VideoInfo(media.Name) { Files = new[] { media } };
 98
 19899                info.Year = info.Files[0].Year;
 198100                list.Add(info);
 101            }
 102
 82103            if (supportMultiVersion)
 104            {
 82105                list = collectionType is CollectionType.tvshows
 82106                    ? GetEpisodesGroupedByVersion(list)
 82107                    : GetVideosGroupedByVersion(list);
 108            }
 109
 110            // Whatever files are left, just add them
 82111            list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
 82112            {
 82113                Files = new[] { i },
 82114                Year = i.Year,
 82115                ExtraType = i.ExtraType
 82116            }));
 117
 82118            return list;
 119        }
 120
 121        private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos)
 122        {
 56123            if (videos.Count == 0)
 124            {
 1125                return videos;
 126            }
 127
 55128            var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan()));
 129
 55130            if (folderName.Length <= 1 || !HaveSameYear(videos))
 131            {
 17132                return videos;
 133            }
 134
 135            // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the abov
 38136            VideoInfo? primary = null;
 240137            for (var i = 0; i < videos.Count; i++)
 138            {
 93139                var video = videos[i];
 93140                if (video.ExtraType is not null)
 141                {
 142                    continue;
 143                }
 144
 93145                if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension))
 146                {
 11147                    return videos;
 148                }
 149
 82150                if (folderName.Equals(video.Files[0].FileNameWithoutExtension, StringComparison.Ordinal))
 151                {
 14152                    primary = video;
 153                }
 154            }
 155
 27156            var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString());
 157
 27158            return [organized];
 159        }
 160
 161        private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
 162        {
 42163            if (videos.Count == 1)
 164            {
 17165                return true;
 166            }
 167
 25168            var firstYear = videos[0].Year ?? -1;
 186169            for (var i = 1; i < videos.Count; i++)
 170            {
 72171                if ((videos[i].Year ?? -1) != firstYear)
 172                {
 4173                    return false;
 174                }
 175            }
 176
 21177            return true;
 178        }
 179
 180        private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename)
 181        {
 93182            if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
 183            {
 5184                return false;
 185            }
 186
 187            // Remove the folder name before cleaning as we don't care about cleaning that part
 88188            if (folderName.Length <= testFilename.Length)
 189            {
 88190                testFilename = testFilename[folderName.Length..].Trim();
 191            }
 192
 193            // There are no span overloads for regex unfortunately
 88194            if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName
 195            {
 28196                testFilename = cleanName.AsSpan().Trim();
 197            }
 198
 199            // The CleanStringParser should have removed common keywords etc.
 88200            return testFilename.IsEmpty
 88201                   || testFilename[0] == '-'
 88202                   || testFilename[0] == '_'
 88203                   || testFilename[0] == '.'
 88204                   || CheckMultiVersionRegex().IsMatch(testFilename);
 205        }
 206
 207        private List<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos)
 208        {
 26209            if (videos.Count < 2)
 210            {
 0211                return videos;
 212            }
 213
 26214            var result = new List<VideoInfo>();
 26215            var groups = new Dictionary<string, List<VideoInfo>>(StringComparer.OrdinalIgnoreCase);
 216
 200217            for (var i = 0; i < videos.Count; i++)
 218            {
 74219                var video = videos[i];
 74220                var key = GetEpisodeVersionKey(video.Files[0].Path);
 221
 74222                if (key is null)
 223                {
 7224                    result.Add(video);
 7225                    continue;
 226                }
 227
 67228                if (!groups.TryGetValue(key, out var group))
 229                {
 37230                    group = [];
 37231                    groups[key] = group;
 232                }
 233
 67234                group.Add(video);
 235            }
 236
 126237            foreach (var group in groups.Values)
 238            {
 37239                if (group.Count == 1)
 240                {
 11241                    result.Add(group[0]);
 11242                    continue;
 243                }
 244
 26245                result.Add(OrganizeAlternateVersions(group));
 246            }
 247
 26248            return result;
 249        }
 250
 251        private string? GetEpisodeVersionKey(string path)
 252        {
 253            // Optimistic expressions are guesses, so they are not consulted here: merging is destructive,
 254            // a file collapsed into the alternate versions of another one is no longer an episode of its own.
 74255            var episodeResult = _episodePathParser.Parse(path, false, isOptimistic: false, fillExtendedInfo: false);
 74256            if (!episodeResult.Success)
 257            {
 3258                return null;
 259            }
 260
 71261            if (episodeResult.IsByDate)
 262            {
 0263                return episodeResult.Year.HasValue && episodeResult.Month.HasValue && episodeResult.Day.HasValue
 0264                    ? FormattableString.Invariant(
 0265                        $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}")
 0266                    : null;
 267            }
 268
 71269            return episodeResult.SeasonNumber.HasValue && episodeResult.EpisodeNumber.HasValue
 71270                ? FormattableString.Invariant($"S{episodeResult.SeasonNumber.Value}E{episodeResult.EpisodeNumber.Value}"
 71271                : null;
 272        }
 273
 274        private static VideoInfo OrganizeAlternateVersions(
 275            List<VideoInfo> videos,
 276            VideoInfo? primaryOverride = null,
 277            string? nameOverride = null)
 278        {
 53279            if (videos.Count > 1)
 280            {
 43281                var groups = videos
 43282                    .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x))
 43283                    .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value))
 43284                    .GroupBy(x => x.resolutionMatch.Success)
 43285                    .ToList();
 286
 43287                videos = [];
 288
 190289                foreach (var group in groups)
 290                {
 52291                    if (group.Key)
 292                    {
 32293                        videos.InsertRange(0, group
 32294                            .OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer)
 32295                            .ThenBy(x => x.filename, _numericOrdinalComparer)
 32296                            .Select(x => x.value));
 297                    }
 298                    else
 299                    {
 20300                        videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value));
 301                    }
 302                }
 303            }
 304
 305            // Prefer a stacked entry (more than one part) as primary
 53306            var primary = primaryOverride
 53307                ?? videos.FirstOrDefault(v => v.Files.Count > 1)
 53308                ?? videos[0];
 53309            videos.Remove(primary);
 310
 53311            primary.AlternateVersions = videos;
 312
 53313            if (nameOverride is not null)
 314            {
 27315                primary.Name = nameOverride;
 316            }
 317
 53318            return primary;
 319        }
 320    }
 321}