Files
jellyfin/Emby.Naming/TV/SeasonPathParser.cs
2026-05-06 20:58:40 -04:00

194 lines
7.4 KiB
C#

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 partial class SeasonPathParser
{
private const string SeasonKeywordPattern =
@"시즌|シーズン|сезон" +
@"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" +
@"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi";
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
[GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost();
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
private static partial Regex SeasonPrefix();
[GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)]
private static partial Regex SeasonKeyword();
/// <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, 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, parentFolderName, supportSpecialAliases, supportNumericSeasonFolders);
result.SeasonNumber = seasonNumber;
if (result.SeasonNumber.HasValue)
{
result.Success = true;
result.IsSeasonFolder = isSeasonFolder;
}
return result;
}
/// <summary>
/// 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)
{
var fileName = Path.GetFileName(path);
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
if (seasonPrefixMatch.Success &&
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
return (val, true);
}
string filename = CleanNameRegex.Replace(fileName, string.Empty);
if (parentFolderName is not null)
{
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
}
if (supportSpecialAliases &&
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
{
return (0, true);
}
if (supportNumericSeasonFolders &&
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
{
return (val, true);
}
bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases;
var preMatch = ProcessPre().Match(filename);
if (preMatch.Success)
{
if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
{
return (null, false);
}
return CheckMatch(preMatch);
}
else
{
var postMatch = ProcessPost().Match(filename);
if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
{
return (null, false);
}
return CheckMatch(postMatch);
}
}
private static (int? SeasonNumber, bool IsSeasonFolder) CheckMatch(Match match)
{
var numberString = match.Groups["seasonnumber"];
if (numberString.Success)
{
if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
{
return (seasonNumber, true);
}
}
return (null, false);
}
/// <summary>
/// Extracts the season number from the second half of the Season folder name (everything after "Season", or "Staffel").
/// </summary>
/// <param name="path">The path.</param>
/// <returns>System.Nullable{System.Int32}.</returns>
private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPathSubstring(ReadOnlySpan<char> path)
{
var numericStart = -1;
var length = 0;
var hasOpenParenthesis = false;
var isSeasonFolder = true;
// Find out where the numbers start, and then keep going until they end
for (var i = 0; i < path.Length; i++)
{
if (char.IsNumber(path[i]))
{
if (!hasOpenParenthesis)
{
if (numericStart == -1)
{
numericStart = i;
}
length++;
}
}
else if (numericStart != -1)
{
// There's other stuff after the season number, e.g. episode number
isSeasonFolder = false;
break;
}
var currentChar = path[i];
if (currentChar == '(')
{
hasOpenParenthesis = true;
}
else if (currentChar == ')')
{
hasOpenParenthesis = false;
}
}
if (numericStart == -1)
{
return (null, isSeasonFolder);
}
return (int.Parse(path.Slice(numericStart, length), provider: CultureInfo.InvariantCulture), isSeasonFolder);
}
}
}