diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs
index a4bfb8d4a1..99a73c224d 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,21 @@ namespace Emby.Naming.Video
///
/// Resolves alternative versions and extras from list of video files.
///
- public static partial class VideoListResolver
+ public partial class VideoListResolver
{
+ private readonly NamingOptions _namingOptions;
+ private readonly EpisodePathParser _episodePathParser;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The naming options.
+ 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 +39,12 @@ namespace Emby.Naming.Video
/// Resolves alternative versions and extras from list of video files.
///
/// List of related video files.
- /// The naming options.
/// Indication we should consider multi-versions of content.
/// Whether to parse the name or use the filename.
/// Top-level folder for the containing library.
+ /// The type of the containing collection, if known.
/// Returns enumerable of which groups files together when related.
- public static IReadOnlyList Resolve(IReadOnlyList videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "")
+ public IReadOnlyList Resolve(IReadOnlyList 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 +52,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();
var standaloneMedia = new List();
@@ -67,7 +81,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()
.ToList()
};
@@ -86,7 +100,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 +116,7 @@ namespace Emby.Naming.Video
return list;
}
- private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions)
+ private List GetVideosGroupedByVersion(List videos)
{
if (videos.Count == 0)
{
@@ -124,7 +140,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 +151,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();
+ var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString());
- videos.Clear();
-
- 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
- {
- 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 videos)
@@ -195,7 +175,7 @@ namespace Emby.Naming.Video
return true;
}
- private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename, NamingOptions namingOptions)
+ private bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename)
{
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
{
@@ -209,7 +189,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 +201,117 @@ namespace Emby.Naming.Video
|| testFilename[0] == '.'
|| CheckMultiVersionRegex().IsMatch(testFilename);
}
+
+ private List GetEpisodesGroupedByVersion(List videos)
+ {
+ if (videos.Count < 2)
+ {
+ return videos;
+ }
+
+ var result = new List();
+ var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 0; i < videos.Count; i++)
+ {
+ var video = videos[i];
+ if (video.ExtraType is not null)
+ {
+ result.Add(video);
+ continue;
+ }
+
+ 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 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 = [];
+
+ 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));
+ }
+ }
+ }
+
+ var primary = primaryOverride ?? videos[0];
+ videos.Remove(primary);
+
+ primary.AlternateVersions = [.. videos.Select(x => x.Files[0])];
+
+ if (nameOverride is not null)
+ {
+ primary.Name = nameOverride;
+ }
+
+ return primary;
+ }
}
}
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 3e98a5276c..00b029f18c 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -14,6 +14,7 @@ using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Emby.Naming.Common;
+using Emby.Naming.Video;
using Emby.Photos;
using Emby.Server.Implementations.Chapters;
using Emby.Server.Implementations.Collections;
@@ -530,6 +531,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService));
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index 98e8f5350b..8750e15ca6 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
public partial class MovieResolver : BaseVideoResolver