aboutsummaryrefslogtreecommitdiff
path: root/Emby.Naming
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Naming')
-rw-r--r--Emby.Naming/Book/BookFileNameParser.cs29
-rw-r--r--Emby.Naming/Book/BookFileNameParserResult.cs8
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs2
-rw-r--r--Emby.Naming/TV/EpisodePathParser.cs2
-rw-r--r--Emby.Naming/TV/SeasonPathParser.cs23
-rw-r--r--Emby.Naming/TV/SeriesInfo.cs6
-rw-r--r--Emby.Naming/TV/SeriesResolver.cs5
-rw-r--r--Emby.Naming/Video/VideoInfo.cs8
-rw-r--r--Emby.Naming/Video/VideoListResolver.cs188
9 files changed, 205 insertions, 66 deletions
diff --git a/Emby.Naming/Book/BookFileNameParser.cs b/Emby.Naming/Book/BookFileNameParser.cs
index 28625f16de..080e25969b 100644
--- a/Emby.Naming/Book/BookFileNameParser.cs
+++ b/Emby.Naming/Book/BookFileNameParser.cs
@@ -1,3 +1,4 @@
+using System;
using System.Text.RegularExpressions;
namespace Emby.Naming.Book
@@ -5,7 +6,7 @@ namespace Emby.Naming.Book
/// <summary>
/// Helper class to retrieve basic metadata from a book filename.
/// </summary>
- public static class BookFileNameParser
+ public static partial class BookFileNameParser
{
private const string NameMatchGroup = "name";
private const string IndexMatchGroup = "index";
@@ -15,14 +16,17 @@ namespace Emby.Naming.Book
private static readonly Regex[] _nameMatches =
[
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
- new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
- new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
- new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
+ new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
+ new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"),
+ new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
];
+ [GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
+ private static partial Regex ComicRegex();
+
/// <summary>
/// Parse a filename name to retrieve the book name, series name, index, and year.
/// </summary>
@@ -48,7 +52,22 @@ namespace Emby.Naming.Book
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
{
- result.Name = nameGroup.Value.Trim();
+ var comicMatch = ComicRegex().Match(nameGroup.Value.Trim());
+
+ if (comicMatch.Success)
+ {
+ if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume))
+ {
+ result.ParentIndex = volume;
+ }
+
+ if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter))
+ {
+ result.Index = chapter;
+ }
+ }
+
+ result.Name = nameGroup.ValueSpan.Trim().ToString();
}
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
diff --git a/Emby.Naming/Book/BookFileNameParserResult.cs b/Emby.Naming/Book/BookFileNameParserResult.cs
index f29716b9e3..f313b202c5 100644
--- a/Emby.Naming/Book/BookFileNameParserResult.cs
+++ b/Emby.Naming/Book/BookFileNameParserResult.cs
@@ -1,5 +1,3 @@
-using System;
-
namespace Emby.Naming.Book
{
/// <summary>
@@ -14,6 +12,7 @@ namespace Emby.Naming.Book
{
Name = null;
Index = null;
+ ParentIndex = null;
Year = null;
SeriesName = null;
}
@@ -29,6 +28,11 @@ namespace Emby.Naming.Book
public int? Index { get; set; }
/// <summary>
+ /// Gets or sets the parent index number.
+ /// </summary>
+ public int? ParentIndex { get; set; }
+
+ /// <summary>
/// Gets or sets the publication year.
/// </summary>
public int? Year { get; set; }
diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
index 3461b3c0d6..8e7da5db42 100644
--- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs
+++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
@@ -70,7 +70,7 @@ namespace Emby.Naming.ExternalFiles
if (lastSeparator == -1)
{
- break;
+ break;
}
string currentSlice = languageString[lastSeparator..];
diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs
index 8cd5a126e0..0c737964b4 100644
--- a/Emby.Naming/TV/EpisodePathParser.cs
+++ b/Emby.Naming/TV/EpisodePathParser.cs
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
result.Success = true;
}
}
- else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
+ else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date))
{
result.Year = date.Year;
result.Month = date.Month;
diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs
index ea4875e00a..9caebaf7ac 100644
--- a/Emby.Naming/TV/SeasonPathParser.cs
+++ b/Emby.Naming/TV/SeasonPathParser.cs
@@ -10,17 +10,25 @@ namespace Emby.Naming.TV
/// </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*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
+ [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
- [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
+ [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>
@@ -91,14 +99,25 @@ namespace Emby.Naming.TV
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);
}
}
diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs
index 5d6cb4bd37..e145ff8003 100644
--- a/Emby.Naming/TV/SeriesInfo.cs
+++ b/Emby.Naming/TV/SeriesInfo.cs
@@ -25,5 +25,11 @@ namespace Emby.Naming.TV
/// </summary>
/// <value>The name of the series.</value>
public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets the year of the series.
+ /// </summary>
+ /// <value>The year of the series.</value>
+ public int? Year { get; set; }
}
}
diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs
index 0b7309bae0..733e2418c2 100644
--- a/Emby.Naming/TV/SeriesResolver.cs
+++ b/Emby.Naming/TV/SeriesResolver.cs
@@ -21,7 +21,7 @@ namespace Emby.Naming.TV
/// Regex that matches titles with year in parentheses. Captures the title (which may be
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
/// </summary>
- [GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
+ [GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
private static partial Regex TitleWithYearRegex();
/// <summary>
@@ -43,7 +43,8 @@ namespace Emby.Naming.TV
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
return new SeriesInfo(path)
{
- Name = seriesName
+ Name = seriesName,
+ Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null
};
}
}
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<VideoFileInfo>();
- AlternateVersions = Array.Empty<VideoFileInfo>();
+ Files = [];
+ AlternateVersions = [];
}
/// <summary>
@@ -40,10 +40,10 @@ namespace Emby.Naming.Video
public IReadOnlyList<VideoFileInfo> Files { get; set; }
/// <summary>
- /// Gets or sets the alternate versions.
+ /// Gets or sets the alternate versions. Each alternate may itself span multiple files.
/// </summary>
/// <value>The alternate versions.</value>
- public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
+ public IReadOnlyList<VideoInfo> AlternateVersions { get; set; }
/// <summary>
/// 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
/// <summary>
/// Resolves alternative versions and extras from list of video files.
/// </summary>
- 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;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="VideoListResolver"/> class.
+ /// </summary>
+ /// <param name="namingOptions">The naming options.</param>
+ 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.
/// </summary>
/// <param name="videoInfos">List of related video files.</param>
- /// <param name="namingOptions">The naming options.</param>
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
/// <param name="parseName">Whether to parse the name or use the filename.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
+ /// <param name="collectionType">The type of the containing collection, if known.</param>
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
- public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "")
+ public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> 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<VideoFileInfo>();
var standaloneMedia = new List<VideoFileInfo>();
@@ -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<VideoFileInfo>()
.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<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
+ private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> 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();
-
- videos.Clear();
+ var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString());
- 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<VideoInfo>
- {
- 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<VideoInfo> videos)
@@ -195,7 +177,7 @@ namespace Emby.Naming.Video
return true;
}
- private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions)
+ private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> 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<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos)
+ {
+ if (videos.Count < 2)
+ {
+ return videos;
+ }
+
+ var result = new List<VideoInfo>();
+ var groups = new Dictionary<string, List<VideoInfo>>(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<VideoInfo> 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;
+ }
}
}