aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Eisele <Ghost_of_Stone@web.de>2025-03-23 17:05:40 +0100
committerGitHub <noreply@github.com>2025-03-23 10:05:40 -0600
commitdfb485d1f205c8eda95bc3b79d341f3c3aef7ec4 (patch)
tree584a53baf9fba5321cd655ae3298bbd7d622f0a9
parent8db6a39e92acfd76689e77c71b00ac96e60c515b (diff)
Rework season folder parsing (#11748)
-rw-r--r--Emby.Naming/TV/SeasonPathParser.cs90
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs7
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs2
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs7
-rw-r--r--MediaBrowser.Controller/Entities/TV/Season.cs2
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs3
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs68
7 files changed, 96 insertions, 83 deletions
diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs
index 45b91971b..98ee1e4b8 100644
--- a/Emby.Naming/TV/SeasonPathParser.cs
+++ b/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>
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 3432aa322..b810ad4de 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/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)
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index abf2d0115..6cb63a28a 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/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
{
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
index fb48d7bf1..c81a0adb8 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
+++ b/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;
}
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index e3fbe8e4d..9dbac1e92 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/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)
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 03a28fd8c..e4490bca3 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/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.
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
index 3a042df68..4c8ba58d0 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
+++ b/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);
}
}