using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; using Jellyfin.Extensions; using MediaBrowser.Model.IO; namespace Emby.Naming.Video { /// /// Resolves alternative versions and extras from list of video files. /// public static class VideoListResolver { private static readonly Regex _resolutionRegex = new Regex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase | RegexOptions.Compiled); /// /// Resolves alternative versions and extras from list of video files. /// /// List of related video files. /// The naming options. /// Indication we should consider multi-versions of content. /// Whether to parse the name or use the filename. /// Returns enumerable of which groups files together when related. public static IReadOnlyList Resolve(IReadOnlyList videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true) { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer var nonExtras = videoInfos .Where(i => i.ExtraType is null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList(); var remainingFiles = new List(); var standaloneMedia = new List(); for (var i = 0; i < videoInfos.Count; i++) { var current = videoInfos[i]; if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory))) { continue; } if (current.ExtraType is null) { standaloneMedia.Add(current); } else { remainingFiles.Add(current); } } var list = new List(); foreach (var stack in stackResult) { var info = new VideoInfo(stack.Name) { Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName)) .OfType() .ToList() }; info.Year = info.Files[0].Year; list.Add(info); } foreach (var media in standaloneMedia) { var info = new VideoInfo(media.Name) { Files = new[] { media } }; info.Year = info.Files[0].Year; list.Add(info); } if (supportMultiVersion) { list = GetVideosGroupedByVersion(list, namingOptions); } // Whatever files are left, just add them list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name) { Files = new[] { i }, Year = i.Year, ExtraType = i.ExtraType })); return list; } private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions) { if (videos.Count == 0) { return videos; } var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan())); if (folderName.Length <= 1 || !HaveSameYear(videos)) { return videos; } // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if] VideoInfo? primary = null; for (var i = 0; i < videos.Count; i++) { var video = videos[i]; if (video.ExtraType is not null) { continue; } if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) { return videos; } if (folderName.Equals(video.Files[0].FileNameWithoutExtension, StringComparison.Ordinal)) { primary = video; } } if (videos.Count > 1) { var groups = videos.GroupBy(x => _resolutionRegex.IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); videos.Clear(); foreach (var group in groups) { if (group.Key) { videos.InsertRange(0, group.OrderByDescending(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); } else { videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); } } } primary ??= videos[0]; videos.Remove(primary); var list = new List { primary }; list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray(); list[0].Name = folderName.ToString(); return list; } private static bool HaveSameYear(IReadOnlyList videos) { if (videos.Count == 1) { return true; } var firstYear = videos[0].Year ?? -1; for (var i = 1; i < videos.Count; i++) { if ((videos[i].Year ?? -1) != firstYear) { return false; } } return true; } private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename, NamingOptions namingOptions) { if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { return false; } // Remove the folder name before cleaning as we don't care about cleaning that part if (folderName.Length <= testFilename.Length) { testFilename = testFilename[folderName.Length..].Trim(); } // There are no span overloads for regex unfortunately if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) { testFilename = cleanName.AsSpan().Trim(); } // The CleanStringParser should have removed common keywords etc. return testFilename.IsEmpty || testFilename[0] == '-' || Regex.IsMatch(testFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); } } }