diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 333d237a24..53cee3858d 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -146,8 +146,8 @@ namespace Emby.Naming.Common CleanDateTimes = new[] { - @"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*", - @"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*" + @"(.+?[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*", + @"(.+?[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*" }; CleanStrings = new[] @@ -739,6 +739,12 @@ namespace Emby.Naming.Common @"^\s*(?[^ ].*?)\s*$" }; + VideoVersionExpressions = new[] + { + // Get filename before final space-dash-space + @"^(?.*?)(?:\s-\s(?!.*\s-\s)(.*))?$" + }; + MultipleEpisodeExpressions = new[] { @".*(\\|\/)[sS]?(?[0-9]{1,4})[xX](?[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?[0-9]{1,3}))+[^\\\/]*$", @@ -864,6 +870,11 @@ namespace Emby.Naming.Common /// public string[] CleanStrings { get; set; } + /// + /// Gets or sets list of raw clean strings regular expressions strings. + /// + public string[] VideoVersionExpressions { get; set; } + /// /// Gets or sets list of multi-episode regular expressions. /// @@ -884,6 +895,11 @@ namespace Emby.Naming.Common /// public Regex[] CleanStringRegexes { get; private set; } = Array.Empty(); + /// + /// Gets list of video version regular expressions. + /// + public Regex[] VideoVersionRegexes { get; private set; } = Array.Empty(); + /// /// Compiles raw regex strings into regexes. /// @@ -891,6 +907,7 @@ namespace Emby.Naming.Common { CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray(); CleanStringRegexes = CleanStrings.Select(Compile).ToArray(); + VideoVersionRegexes = VideoVersionExpressions.Select(Compile).ToArray(); } private Regex Compile(string exp) diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 51f29cf088..e95fd25742 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -4,7 +4,9 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; +using Emby.Naming.TV; using Jellyfin.Extensions; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -25,10 +27,16 @@ namespace Emby.Naming.Video /// /// List of related video files. /// The naming options. + /// Collection type of videos being resolved. /// 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) + public static IReadOnlyList Resolve( + IReadOnlyList videoInfos, + NamingOptions namingOptions, + string collectionType, + bool supportMultiVersion = true, + bool parseName = true) { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer @@ -79,12 +87,19 @@ namespace Emby.Naming.Video var info = new VideoInfo(media.Name) { Files = new[] { media } }; info.Year = info.Files[0].Year; + if (info.Year is null) + { + // Parse name for year info. Episodes don't get parsed up to this point for year info. + var info2 = VideoResolver.Resolve(media.Path, media.IsDirectory, namingOptions, parseName); + info.Year = info2?.Year; + } + list.Add(info); } if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list, namingOptions); + list = GetVideosGroupedByVersion(list, namingOptions, collectionType); } // Whatever files are left, just add them @@ -98,7 +113,7 @@ namespace Emby.Naming.Video return list; } - private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions) + private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions, string collectionType) { if (videos.Count == 0) { @@ -112,6 +127,8 @@ namespace Emby.Naming.Video return videos; } + var mergeable = new List(); + var notMergeable = new List(); // 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++) @@ -122,9 +139,14 @@ namespace Emby.Naming.Video continue; } - if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) + // Don't merge stacked episodes + if (video.Files.Count == 1 && IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions, collectionType)) + { + mergeable.Add(video); + } + else { - return videos; + notMergeable.Add(video); } if (folderName.Equals(video.Files[0].FileNameWithoutExtension, StringComparison.Ordinal)) @@ -133,35 +155,59 @@ namespace Emby.Naming.Video } } - if (videos.Count > 1) + var list = new List(); + if (collectionType.Equals(CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + { + var groupedList = mergeable.GroupBy(x => EpisodeGrouper(x.Files[0].Path, namingOptions, collectionType)); + foreach (var grouping in groupedList) + { + list.Add(OrganizeAlternateVersions(grouping.ToList(), grouping.Key.AsSpan(), primary)); + } + } + else if (mergeable.Count > 0) { - var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); - videos.Clear(); + list.Add(OrganizeAlternateVersions(mergeable, folderName, primary)); + } + + // Add non mergeables back in + list.AddRange(notMergeable); + list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal)); + + return list; + } + + private static VideoInfo OrganizeAlternateVersions(List grouping, ReadOnlySpan name, VideoInfo? primary) + { + VideoInfo? groupPrimary = null; + if (primary is not null && grouping.Contains(primary)) + { + groupPrimary = primary; + } + + var alternateVersions = new List(); + if (grouping.Count > 1) + { + // groups resolution based into one, and all other names + var groups = grouping.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)); foreach (var group in groups) { if (group.Key) { - videos.InsertRange(0, group.OrderByDescending(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); + alternateVersions.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())); + alternateVersions.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(); + groupPrimary ??= alternateVersions.FirstOrDefault() ?? grouping.First(); + alternateVersions.Remove(groupPrimary); + groupPrimary.AlternateVersions = alternateVersions.Select(x => x.Files[0]).ToArray(); - return list; + groupPrimary.Name = name.ToString(); + return groupPrimary; } private static bool HaveSameYear(IReadOnlyList videos) @@ -183,8 +229,16 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename, NamingOptions namingOptions) + private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilePath, NamingOptions namingOptions, ReadOnlySpan collectionType) { + var testFilename = Path.GetFileNameWithoutExtension(testFilePath); + + if (collectionType.Equals(CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + { + // Episodes are always eligible to be grouped + return true; + } + if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { return false; @@ -207,5 +261,41 @@ namespace Emby.Naming.Video || testFilename[0] == '-' || CheckMultiVersionRegex().IsMatch(testFilename); } + + private static string EpisodeGrouper(string testFilePath, NamingOptions namingOptions, ReadOnlySpan collectionType) + { + // Grouper for tv shows/episodes should be everything before space-dash-space + var resolver = new EpisodeResolver(namingOptions); + EpisodeInfo? episodeInfo = resolver.Resolve(testFilePath, false); + ReadOnlySpan seriesName = episodeInfo!.SeriesName; + + var filename = Path.GetFileNameWithoutExtension(testFilePath); + // Start with grouping by filename + string g = filename; + for (var i = 0; i < namingOptions.VideoVersionRegexes.Length; i++) + { + var rule = namingOptions.VideoVersionRegexes[i]; + var match = rule.Match(filename); + if (!match.Success) + { + continue; + } + + g = match.Groups["filename"].Value; + // Clean the filename + if (VideoResolver.TryCleanString(g, namingOptions, out string newName)) + { + g = newName; + } + + // Never group episodes under series name + if (MemoryExtensions.Equals(g.AsSpan(), seriesName, StringComparison.OrdinalIgnoreCase)) + { + g = filename; + } + } + + return g; + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 1a210e3cc8..1bb434d17b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -228,7 +228,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (collectionType == CollectionType.tvshows) { - return ResolveVideos(parent, files, false, collectionType, true); + return ResolveVideos(parent, files, true, collectionType, false); } return null; @@ -274,7 +274,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies .Where(f => f is not null) .ToList(); - var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName); + var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, collectionType, supportMultiEditions, parseName); var result = new MultiItemResolverResult { diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 22793206e9..3ac79017ec 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1192,10 +1192,18 @@ namespace MediaBrowser.Controller.Entities { if (HasLocalAlternateVersions) { - var displayName = System.IO.Path.GetFileNameWithoutExtension(path) + var fileName = System.IO.Path.GetFileNameWithoutExtension(path); + var displayName = fileName .Replace(System.IO.Path.GetFileName(ContainingFolderPath), string.Empty, StringComparison.OrdinalIgnoreCase) .TrimStart(new char[] { ' ', '-' }); + if (fileName == displayName) + { + // File does not start with parent folder name. This must be an episode in a mixed directory + // Get string after last dash - this is the version name + displayName = fileName.Substring(fileName.LastIndexOf('-') + 1).TrimStart(new char[] { ' ', '-' }); + } + if (!string.IsNullOrEmpty(displayName)) { terms.Add(displayName); diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 183ec89848..a810a829cc 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; +using MediaBrowser.Model.Entities; using Xunit; namespace Jellyfin.Naming.Tests.Video @@ -23,7 +25,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result.Where(v => v.ExtraType is null)); Assert.Single(result.Where(v => v.ExtraType is not null)); @@ -42,7 +45,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result.Where(v => v.ExtraType is null)); Assert.Single(result.Where(v => v.ExtraType is not null)); @@ -60,7 +64,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -82,7 +87,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(7, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -105,7 +111,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); Assert.Equal(7, result[0].AlternateVersions.Count); @@ -129,7 +136,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(9, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -149,7 +157,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -171,7 +180,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -193,7 +203,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path); @@ -222,7 +233,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path); @@ -246,7 +258,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); } @@ -267,7 +280,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(7, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -289,7 +303,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -306,7 +321,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -323,7 +339,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -344,7 +361,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies); Assert.Single(result); Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path); @@ -367,7 +385,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -384,7 +403,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies); Assert.Equal(2, result.Count); } @@ -392,9 +412,186 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestEmptyList() { - var result = VideoListResolver.Resolve(new List(), _namingOptions).ToList(); + var result = VideoListResolver.Resolve(new List(), _namingOptions, string.Empty).ToList(); Assert.Empty(result); } + + [Fact] + public void TestMultiVersionEpisodeDontCollapse() + { + // Test for false positive + + var files = new[] + { + @"/TV/Dexter/Dexter - S01E01 - One.mkv", + @"/TV/Dexter/Dexter - S01E02 - Two.mkv", + @"/TV/Dexter/Dexter - S01E03 - Three.mkv", + @"/TV/Dexter/Dexter - S01E04 - Four.mkv", + @"/TV/Dexter/Dexter - S01E05 - Five.mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Equal(5, result.Count); + Assert.Empty(result[0].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeDontCollapse2() + { + // Test for false positive + + var files = new[] + { + @"/TV/Dexter/Dexter - S01E01 One.mkv", + @"/TV/Dexter/Dexter - S01E02 Two.mkv", + @"/TV/Dexter/Dexter - S01E03 Three.mkv", + @"/TV/Dexter/Dexter - S01E04 Four.mkv", + @"/TV/Dexter/Dexter - S01E05 Five.mkv", + @"/TV/Star Trek- Picard/Season 3/Star Trek - Picard 3x01 [WEBDL-720p Proper x264][EAC3 5.1] - Part One - The Next Generation.mkv", + @"/TV/Star Trek- Picard/Season 3/Star Trek - Picard 3x02 [WEBDL-720p Proper x264][EAC3 5.1] - Part Two - Disengage.mkv", + @"/TV/Star Trek- Picard/Season 3/Star Trek - Picard 3x03 [WEBDL-720p x264][EAC3 5.1] - Part Three - Seventeen Seconds.mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Equal(8, result.Count); + Assert.Empty(result[0].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisode() + { + var files = new[] + { + @"/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - One.mkv", + @"/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - Two.mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeMixedSeriesFolder() + { + var files = new[] + { + @"/TV/Dexter/Dexter - S01E01.mkv", + @"/TV/Dexter/Dexter - S01E01 - Unaired.mkv", + @"/TV/Dexter/Dexter - S01E02 - Two.mkv", + @"/TV/Dexter/Dexter - S01E03 - Three.mkv", + @"/TV/Dexter/Dexter S02E01 - Ia.mkv", + @"/TV/Dexter/Dexter S02E01 - I.mkv", + @"/TV/Dexter/Dexter - S02E02.mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Equal(5, result.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Empty(result[1].AlternateVersions); + Assert.Empty(result[2].AlternateVersions); + Assert.Empty(result[3].AlternateVersions); + + var s02e01 = result.FirstOrDefault(x => string.Equals(x.Name, "Dexter S02E01", StringComparison.Ordinal)); + Assert.NotNull(s02e01); + Assert.Single(s02e01!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeMixedSeasonFolder() + { + var files = new[] + { + @"/TV/Dexter/Season 2/Dexter - S02E01 - Ia.mkv", + @"/TV/Dexter/Season 2/Dexter - S02E01 - I.mkv", + @"/TV/Dexter/Season 2/Dexter - S02E02.mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Equal(2, result.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Empty(result[1].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeMixedSeasonFolderWithYear() + { + var files = new[] + { + @"/TV/Name (2020)/Season 1/Name (2020) - S01E01 - [ORIGINAL].mkv", + @"/TV/Name (2020)/Season 1/Name (2020) - S01E01 - [VERSION].mkv", + @"/TV/Name (2020)/Season 1/Name (2020) - S01E02 - [ORIGINAL].mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Equal(2, result.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Empty(result[1].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeMixedSeasonFolderWithYearAndDirtyNames() + { + var files = new[] + { + @"/TV/Name (2020)/Season 1/Name (2020) - S01E01 [BluRay-480p x264][AC3 2.0] - [ORIGINAL].mkv", + @"/TV/Name (2020)/Season 1/Name (2020) - S01E01 [BluRay-1080p x264][AC3 5.1] - [Remaster].mkv", + @"/TV/Name (2020)/Season 1/Name (2020) - S01E02 - [ORIGINAL].mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Equal(2, result.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Empty(result[1].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeABCD() + { + var files = new[] + { + @"/TV/SeriesName/SeriesName- S01E01 - EpisodeTitle - [VersionA].mkv", + @"/TV/SeriesName/SeriesName- S01E01 - EpisodeTitle - [VersionB].mkv", + @"/TV/SeriesName/SeriesName- S01E01 - EpisodeTitle - VersionC.mkv", + @"/TV/SeriesName/SeriesName- S01E01 - EpisodeTitle - VersionD.mkv" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows); + + Assert.Single(result); + Assert.Equal(3, result[0].AlternateVersions.Count); + } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 377f82eac7..759bca63e0 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -42,7 +42,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(11, result.Count); var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal)); @@ -65,6 +66,87 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(ExtraType.Trailer, result[10].ExtraType); } + [Fact] + public void TestTVStackAndVersions() + { + var files = new[] + { + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) - s01e01 CD1.avi", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) - s01e01 CD2.avi", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) - s01e02 - The First Cut is the Deepest.avi", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) - s01e03.mp4", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) - s01e04 - Aired Version.mp4", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) - s01e04 - Uncensored Version.mp4" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows).ToList(); + + Assert.Equal(4, result.Count); + + var s01e01 = result.FirstOrDefault(x => string.Equals(x.Name, "Grey's Anatomy (2005) - s01e01", StringComparison.Ordinal)); + Assert.NotNull(s01e01); + Assert.Equal(2, s01e01!.Files.Count); + + var s01e04 = result.FirstOrDefault(x => string.Equals(x.Name, "Grey's Anatomy (2005) - s01e04", StringComparison.Ordinal)); + Assert.NotNull(s01e04); + Assert.Single(s01e04!.AlternateVersions); + } + + [Fact] + public void TestTVStackAndVersionsNoFirstDash() + { + var files = new[] + { + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) s01e01 - pt1.avi", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) s01e01 - pt2.avi", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) s01e02 - The First Cut is the Deepest.avi", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) s01e03.mp4", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) s01e04 - Aired Version.mp4", + @"/TV/Grey's Anatomy (2005)/Grey's Anatomy (2005) s01e04 - Uncensored Version.mp4" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows).ToList(); + + Assert.Equal(4, result.Count); + + var s01e01 = result.FirstOrDefault(x => string.Equals(x.Name, "Grey's Anatomy (2005) s01e01", StringComparison.Ordinal)); + Assert.NotNull(s01e01); + Assert.Equal(2, s01e01!.Files.Count); + + var s01e04 = result.FirstOrDefault(x => string.Equals(x.Name, "Grey's Anatomy (2005) s01e04", StringComparison.Ordinal)); + Assert.NotNull(s01e04); + Assert.Single(s01e04!.AlternateVersions); + } + + [Fact] + public void TestTVStack() + { + var files = new[] + { + @"/TV/Doctor Who/Season 21/Doctor Who 21x11 - Resurrection of the Daleks - Part 1.mkv", + @"/TV/Doctor Who/Season 21/Doctor Who 21x11 - Resurrection of the Daleks - Part 2.mkv", + @"/TV/Doctor Who/Season 21/Doctor Who 21x12 - Resurrection of the Daleks - Part 3.mkv", + @"/TV/Doctor Who/Season 21/Doctor Who 21x12 - Resurrection of the Daleks - Part 4.mkv" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions, false)).OfType().ToList(), + _namingOptions, + CollectionType.TvShows).ToList(); + + Assert.Equal(2, result.Count); + + var s21e12 = result.FirstOrDefault(x => string.Equals(x.Name, "Doctor Who 21x12 - Resurrection of the Daleks", StringComparison.Ordinal)); + Assert.NotNull(s21e12); + Assert.Equal(2, s21e12!.Files.Count); + } + [Fact] public void TestWithMetadata() { @@ -76,7 +158,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); } @@ -92,7 +175,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -110,7 +194,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -129,7 +214,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -149,7 +235,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -168,7 +255,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -190,7 +278,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + string.Empty).ToList(); Assert.Equal(5, result.Count); } @@ -206,7 +295,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); } @@ -223,7 +313,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); } @@ -241,7 +332,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -262,7 +354,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(4, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -284,7 +377,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + string.Empty).ToList(); Assert.Equal(2, result.Count); } @@ -299,7 +393,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); } @@ -314,7 +409,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Single(result); } @@ -330,7 +426,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); // The result should contain two individual movies // Version grouping should not work here, because the files are not in a directory with the name 'Four Sisters and a Wedding' @@ -348,7 +445,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); } @@ -364,7 +462,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -382,7 +481,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -400,7 +500,8 @@ namespace Jellyfin.Naming.Tests.Video var result = VideoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), - _namingOptions).ToList(); + _namingOptions, + CollectionType.Movies).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue);