diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index 8847ee9bc9..028b639122 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -17,8 +17,8 @@ namespace Emby.Naming.Video { Name = name; - Files = Array.Empty(); - AlternateVersions = Array.Empty(); + Files = []; + AlternateVersions = []; } /// @@ -40,10 +40,10 @@ namespace Emby.Naming.Video public IReadOnlyList Files { get; set; } /// - /// Gets or sets the alternate versions. + /// Gets or sets the alternate versions. Each alternate may itself span multiple files. /// /// The alternate versions. - public IReadOnlyList AlternateVersions { get; set; } + public IReadOnlyList AlternateVersions { get; set; } /// /// Gets or sets the extra type. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index a4bfb8d4a1..29330b132d 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -5,7 +5,8 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; -using Jellyfin.Extensions; +using Emby.Naming.TV; +using Jellyfin.Data.Enums; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -13,8 +14,23 @@ namespace Emby.Naming.Video /// /// Resolves alternative versions and extras from list of video files. /// - public static partial class VideoListResolver + public partial class VideoListResolver { + private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + + private readonly NamingOptions _namingOptions; + private readonly EpisodePathParser _episodePathParser; + + /// + /// Initializes a new instance of the class. + /// + /// The naming options. + public VideoListResolver(NamingOptions namingOptions) + { + _namingOptions = namingOptions; + _episodePathParser = new EpisodePathParser(namingOptions); + } + [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)] private static partial Regex ResolutionRegex(); @@ -25,12 +41,12 @@ namespace Emby.Naming.Video /// 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. /// Top-level folder for the containing library. + /// The type of the containing collection, if known. /// Returns enumerable of which groups files together when related. - public static IReadOnlyList Resolve(IReadOnlyList videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "") + public IReadOnlyList Resolve(IReadOnlyList videoInfos, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "", CollectionType? collectionType = null) { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer @@ -38,7 +54,7 @@ namespace Emby.Naming.Video .Where(i => i.ExtraType is null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList(); + var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList(); var remainingFiles = new List(); var standaloneMedia = new List(); @@ -67,7 +83,7 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, parseName, libraryRoot)) .OfType() .ToList() }; @@ -86,7 +102,9 @@ namespace Emby.Naming.Video if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list, namingOptions); + list = collectionType is CollectionType.tvshows + ? GetEpisodesGroupedByVersion(list) + : GetVideosGroupedByVersion(list); } // Whatever files are left, just add them @@ -100,7 +118,7 @@ namespace Emby.Naming.Video return list; } - private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions) + private List GetVideosGroupedByVersion(List videos) { if (videos.Count == 0) { @@ -124,7 +142,7 @@ namespace Emby.Naming.Video continue; } - if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) + if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension)) { return videos; } @@ -135,45 +153,9 @@ namespace Emby.Naming.Video } } - if (videos.Count > 1) - { - var groups = videos - .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) - .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) - .GroupBy(x => x.resolutionMatch.Success) - .ToList(); + var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString()); - videos.Clear(); - - StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); - foreach (var group in groups) - { - if (group.Key) - { - videos.InsertRange(0, group - .OrderByDescending(x => x.resolutionMatch.Value, comparer) - .ThenBy(x => x.filename, comparer) - .Select(x => x.value)); - } - else - { - videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value)); - } - } - } - - 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; + return [organized]; } private static bool HaveSameYear(IReadOnlyList videos) @@ -195,7 +177,7 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename, NamingOptions namingOptions) + private bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename) { if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { @@ -209,7 +191,7 @@ namespace Emby.Naming.Video } // There are no span overloads for regex unfortunately - if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) + if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName)) { testFilename = cleanName.AsSpan().Trim(); } @@ -221,5 +203,113 @@ namespace Emby.Naming.Video || testFilename[0] == '.' || CheckMultiVersionRegex().IsMatch(testFilename); } + + private List GetEpisodesGroupedByVersion(List videos) + { + if (videos.Count < 2) + { + return videos; + } + + var result = new List(); + var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false); + string? key = null; + if (episodeResult.Success) + { + if (episodeResult.IsByDate + && episodeResult.Year.HasValue + && episodeResult.Month.HasValue + && episodeResult.Day.HasValue) + { + key = FormattableString.Invariant( + $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}"); + } + else if (episodeResult.EpisodeNumber.HasValue) + { + key = FormattableString.Invariant( + $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}"); + } + } + + if (key is null) + { + result.Add(video); + continue; + } + + if (!groups.TryGetValue(key, out var group)) + { + group = []; + groups[key] = group; + } + + group.Add(video); + } + + foreach (var group in groups.Values) + { + if (group.Count == 1) + { + result.Add(group[0]); + continue; + } + + result.Add(OrganizeAlternateVersions(group)); + } + + return result; + } + + private static VideoInfo OrganizeAlternateVersions( + List videos, + VideoInfo? primaryOverride = null, + string? nameOverride = null) + { + if (videos.Count > 1) + { + var groups = videos + .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) + .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) + .GroupBy(x => x.resolutionMatch.Success) + .ToList(); + + videos = []; + + foreach (var group in groups) + { + if (group.Key) + { + videos.InsertRange(0, group + .OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer) + .ThenBy(x => x.filename, _numericOrdinalComparer) + .Select(x => x.value)); + } + else + { + videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value)); + } + } + } + + // Prefer a stacked entry (more than one part) as primary + var primary = primaryOverride + ?? videos.FirstOrDefault(v => v.Files.Count > 1) + ?? videos[0]; + videos.Remove(primary); + + primary.AlternateVersions = videos; + + if (nameOverride is not null) + { + primary.Name = nameOverride; + } + + return primary; + } } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index b624a6d4f9..c81829688f 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -14,6 +14,7 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Photos; using Emby.Server.Implementations.Chapters; using Emby.Server.Implementations.Collections; @@ -540,6 +541,7 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 11f1496086..f2480679d9 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using BitFaster.Caching.Lru; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; @@ -787,6 +788,42 @@ namespace Emby.Server.Implementations.Library CollectionType? collectionType = null) => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); + private void SetAdditionalPartsFromStack(Video altVideo, string path) + { + if (altVideo.AdditionalParts is { Length: > 0 }) + { + return; + } + + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + IEnumerable siblings; + try + { + siblings = _fileSystem.GetFiles(directory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate siblings to detect stack for {Path}", path); + return; + } + + var stacks = StackResolver.Resolve(siblings, _namingOptions); + foreach (var stack in stacks) + { + if (stack.Files.Count > 1 + && string.Equals(stack.Files[0], path, StringComparison.OrdinalIgnoreCase)) + { + altVideo.AdditionalParts = stack.Files.Skip(1).ToArray(); + return; + } + } + } + /// public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType) { @@ -2307,6 +2344,10 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); allItems.Add(altVideo); } } @@ -2510,6 +2551,10 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); allItems.Add(altVideo); } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 98e8f5350b..68b66ab7f5 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies public partial class MovieResolver : BaseVideoResolver