Browse Source

Rework season folder parsing (#11748)

Tim Eisele 2 months ago
parent
commit
dfb485d1f2

+ 38 - 52
Emby.Naming/TV/SeasonPathParser.cs

@@ -1,43 +1,35 @@
 using System;
 using System.Globalization;
 using System.IO;
+using System.Text.RegularExpressions;
 
 namespace Emby.Naming.TV
 {
     /// <summary>
     /// Class to parse season paths.
     /// </summary>
-    public static class SeasonPathParser
+    public static partial class SeasonPathParser
     {
-        /// <summary>
-        /// A season folder must contain one of these somewhere in the name.
-        /// </summary>
-        private static readonly string[] _seasonFolderNames =
-        {
-            "season",
-            "sæson",
-            "temporada",
-            "saison",
-            "staffel",
-            "series",
-            "сезон",
-            "stagione"
-        };
-
-        private static readonly char[] _splitChars = ['.', '_', ' ', '-'];
+        [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
+        private static partial Regex ProcessPre();
+
+        [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
+        private static partial Regex ProcessPost();
 
         /// <summary>
         /// Attempts to parse season number from path.
         /// </summary>
         /// <param name="path">Path to season.</param>
+        /// <param name="parentPath">Folder name of the parent.</param>
         /// <param name="supportSpecialAliases">Support special aliases when parsing.</param>
         /// <param name="supportNumericSeasonFolders">Support numeric season folders when parsing.</param>
         /// <returns>Returns <see cref="SeasonPathParserResult"/> object.</returns>
-        public static SeasonPathParserResult Parse(string path, bool supportSpecialAliases, bool supportNumericSeasonFolders)
+        public static SeasonPathParserResult Parse(string path, string? parentPath, bool supportSpecialAliases, bool supportNumericSeasonFolders)
         {
             var result = new SeasonPathParserResult();
+            var parentFolderName = parentPath is null ? null : new DirectoryInfo(parentPath).Name;
 
-            var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, supportSpecialAliases, supportNumericSeasonFolders);
+            var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, parentFolderName, supportSpecialAliases, supportNumericSeasonFolders);
 
             result.SeasonNumber = seasonNumber;
 
@@ -54,15 +46,24 @@ namespace Emby.Naming.TV
         /// Gets the season number from path.
         /// </summary>
         /// <param name="path">The path.</param>
+        /// <param name="parentFolderName">The parent folder name.</param>
         /// <param name="supportSpecialAliases">if set to <c>true</c> [support special aliases].</param>
         /// <param name="supportNumericSeasonFolders">if set to <c>true</c> [support numeric season folders].</param>
         /// <returns>System.Nullable{System.Int32}.</returns>
         private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPath(
             string path,
+            string? parentFolderName,
             bool supportSpecialAliases,
             bool supportNumericSeasonFolders)
         {
             string filename = Path.GetFileName(path);
+            filename = Regex.Replace(filename, "[ ._-]", string.Empty);
+
+            if (parentFolderName is not null)
+            {
+                parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
+                filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
+            }
 
             if (supportSpecialAliases)
             {
@@ -85,53 +86,38 @@ namespace Emby.Naming.TV
                 }
             }
 
-            if (TryGetSeasonNumberFromPart(filename, out int seasonNumber))
+            if (filename.StartsWith('s'))
             {
-                return (seasonNumber, true);
-            }
+                var testFilename = filename.AsSpan()[1..];
 
-            // Look for one of the season folder names
-            foreach (var name in _seasonFolderNames)
-            {
-                if (filename.Contains(name, StringComparison.OrdinalIgnoreCase))
+                if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
                 {
-                    var result = GetSeasonNumberFromPathSubstring(filename.Replace(name, " ", StringComparison.OrdinalIgnoreCase));
-                    if (result.SeasonNumber.HasValue)
-                    {
-                        return result;
-                    }
-
-                    break;
+                    return (val, true);
                 }
             }
 
-            var parts = filename.Split(_splitChars, StringSplitOptions.RemoveEmptyEntries);
-            foreach (var part in parts)
+            var preMatch = ProcessPre().Match(filename);
+            if (preMatch.Success)
             {
-                if (TryGetSeasonNumberFromPart(part, out seasonNumber))
-                {
-                    return (seasonNumber, true);
-                }
+                return CheckMatch(preMatch);
             }
-
-            return (null, true);
-        }
-
-        private static bool TryGetSeasonNumberFromPart(ReadOnlySpan<char> part, out int seasonNumber)
-        {
-            seasonNumber = 0;
-            if (part.Length < 2 || !part.StartsWith("s", StringComparison.OrdinalIgnoreCase))
+            else
             {
-                return false;
+                var postMatch = ProcessPost().Match(filename);
+                return CheckMatch(postMatch);
             }
+        }
 
-            if (int.TryParse(part.Slice(1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+        private static (int? SeasonNumber, bool IsSeasonFolder) CheckMatch(Match match)
+        {
+            var numberString = match.Groups["seasonnumber"];
+            if (numberString.Success)
             {
-                seasonNumber = value;
-                return true;
+                var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
+                return (seasonNumber, true);
             }
 
-            return false;
+            return (null, false);
         }
 
         /// <summary>

+ 5 - 2
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -2512,8 +2512,11 @@ namespace Emby.Server.Implementations.Library
         }
 
         /// <inheritdoc />
-        public int? GetSeasonNumberFromPath(string path)
-            => SeasonPathParser.Parse(path, true, true).SeasonNumber;
+        public int? GetSeasonNumberFromPath(string path, Guid? parentId)
+        {
+            var parentPath = parentId.HasValue ? GetItemById(parentId.Value)?.ContainingFolderPath : null;
+            return SeasonPathParser.Parse(path, parentPath, true, true).SeasonNumber;
+        }
 
         /// <inheritdoc />
         public bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh)

+ 1 - 1
Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs

@@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
 
                 var path = args.Path;
 
-                var seasonParserResult = SeasonPathParser.Parse(path, true, true);
+                var seasonParserResult = SeasonPathParser.Parse(path, series.ContainingFolderPath, true, true);
 
                 var season = new Season
                 {

+ 4 - 3
Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs

@@ -118,7 +118,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
             {
                 if (child.IsDirectory)
                 {
-                    if (IsSeasonFolder(child.FullName, isTvContentType))
+                    if (IsSeasonFolder(child.FullName, path, isTvContentType))
                     {
                         _logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName);
                         return true;
@@ -155,11 +155,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
         /// Determines whether [is season folder] [the specified path].
         /// </summary>
         /// <param name="path">The path.</param>
+        /// <param name="parentPath">The parentpath.</param>
         /// <param name="isTvContentType">if set to <c>true</c> [is tv content type].</param>
         /// <returns><c>true</c> if [is season folder] [the specified path]; otherwise, <c>false</c>.</returns>
-        private static bool IsSeasonFolder(string path, bool isTvContentType)
+        private static bool IsSeasonFolder(string path, string parentPath, bool isTvContentType)
         {
-            var seasonNumber = SeasonPathParser.Parse(path, isTvContentType, isTvContentType).SeasonNumber;
+            var seasonNumber = SeasonPathParser.Parse(path, parentPath, isTvContentType, isTvContentType).SeasonNumber;
 
             return seasonNumber.HasValue;
         }

+ 1 - 1
MediaBrowser.Controller/Entities/TV/Season.cs

@@ -257,7 +257,7 @@ namespace MediaBrowser.Controller.Entities.TV
 
             if (!IndexNumber.HasValue && !string.IsNullOrEmpty(Path))
             {
-                IndexNumber ??= LibraryManager.GetSeasonNumberFromPath(Path);
+                IndexNumber ??= LibraryManager.GetSeasonNumberFromPath(Path, ParentId);
 
                 // If a change was made record it
                 if (IndexNumber.HasValue)

+ 2 - 1
MediaBrowser.Controller/Library/ILibraryManager.cs

@@ -426,8 +426,9 @@ namespace MediaBrowser.Controller.Library
         /// Gets the season number from path.
         /// </summary>
         /// <param name="path">The path.</param>
+        /// <param name="parentId">The parent id.</param>
         /// <returns>System.Nullable&lt;System.Int32&gt;.</returns>
-        int? GetSeasonNumberFromPath(string path);
+        int? GetSeasonNumberFromPath(string path, Guid? parentId);
 
         /// <summary>
         /// Fills the missing episode numbers from path.

+ 45 - 23
tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs

@@ -6,32 +6,54 @@ namespace Jellyfin.Naming.Tests.TV;
 public class SeasonPathParserTests
 {
     [Theory]
-    [InlineData("/Drive/Season 1", 1, true)]
-    [InlineData("/Drive/s1", 1, true)]
-    [InlineData("/Drive/S1", 1, true)]
-    [InlineData("/Drive/Season 2", 2, true)]
-    [InlineData("/Drive/Season 02", 2, true)]
-    [InlineData("/Drive/Seinfeld/S02", 2, true)]
-    [InlineData("/Drive/Seinfeld/2", 2, true)]
-    [InlineData("/Drive/Seinfeld - S02", 2, true)]
-    [InlineData("/Drive/Season 2009", 2009, true)]
-    [InlineData("/Drive/Season1", 1, true)]
-    [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)]
-    [InlineData("/Drive/Season 7 (2016)", 7, false)]
-    [InlineData("/Drive/Staffel 7 (2016)", 7, false)]
-    [InlineData("/Drive/Stagione 7 (2016)", 7, false)]
-    [InlineData("/Drive/Season (8)", null, false)]
-    [InlineData("/Drive/3.Staffel", 3, false)]
-    [InlineData("/Drive/s06e05", null, false)]
-    [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)]
-    [InlineData("/Drive/extras", 0, true)]
-    [InlineData("/Drive/specials", 0, true)]
-    public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory)
+    [InlineData("/Drive/Season 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Staffel 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Stagione 1", "/Drive", 1, true)]
+    [InlineData("/Drive/sæson 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Temporada 1", "/Drive", 1, true)]
+    [InlineData("/Drive/series 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Kausi 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Säsong 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Seizoen 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Seasong 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Sezon 1", "/Drive", 1, true)]
+    [InlineData("/Drive/sezona 1", "/Drive", 1, true)]
+    [InlineData("/Drive/sezóna 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Sezonul 1", "/Drive", 1, true)]
+    [InlineData("/Drive/시즌 1", "/Drive", 1, true)]
+    [InlineData("/Drive/シーズン 1", "/Drive", 1, true)]
+    [InlineData("/Drive/сезон 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Сезон 1", "/Drive", 1, true)]
+    [InlineData("/Drive/Season 10", "/Drive", 10, true)]
+    [InlineData("/Drive/Season 100", "/Drive", 100, true)]
+    [InlineData("/Drive/s1", "/Drive", 1, true)]
+    [InlineData("/Drive/S1", "/Drive", 1, true)]
+    [InlineData("/Drive/Season 2", "/Drive", 2, true)]
+    [InlineData("/Drive/Season 02", "/Drive", 2, true)]
+    [InlineData("/Drive/Seinfeld/S02", "/Seinfeld", 2, true)]
+    [InlineData("/Drive/Seinfeld/2", "/Seinfeld", 2, true)]
+    [InlineData("/Drive/Seinfeld Season 2", "/Drive", null, false)]
+    [InlineData("/Drive/Season 2009", "/Drive", 2009, true)]
+    [InlineData("/Drive/Season1", "/Drive", 1, true)]
+    [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", "/The Wonder Years", 4, true)]
+    [InlineData("/Drive/Season 7 (2016)", "/Drive", 7, true)]
+    [InlineData("/Drive/Staffel 7 (2016)", "/Drive", 7, true)]
+    [InlineData("/Drive/Stagione 7 (2016)", "/Drive", 7, true)]
+    [InlineData("/Drive/Stargate SG-1/Season 1", "/Drive/Stargate SG-1", 1, true)]
+    [InlineData("/Drive/Stargate SG-1/Stargate SG-1 Season 1", "/Drive/Stargate SG-1", 1, true)]
+    [InlineData("/Drive/Season (8)", "/Drive", null, false)]
+    [InlineData("/Drive/3.Staffel", "/Drive", 3, true)]
+    [InlineData("/Drive/s06e05", "/Drive", null, false)]
+    [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)]
+    [InlineData("/Drive/extras", "/Drive", 0, true)]
+    [InlineData("/Drive/specials", "/Drive", 0, true)]
+    [InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)]
+    public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
     {
-        var result = SeasonPathParser.Parse(path, true, true);
+        var result = SeasonPathParser.Parse(path, parentPath, true, true);
 
         Assert.Equal(result.SeasonNumber is not null, result.Success);
-        Assert.Equal(result.SeasonNumber, seasonNumber);
+        Assert.Equal(seasonNumber, result.SeasonNumber);
         Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
     }
 }