Pārlūkot izejas kodu

Refactor extras parsing

cvium 3 gadi atpakaļ
vecāks
revīzija
fde84a1e00
28 mainītis faili ar 681 papildinājumiem un 997 dzēšanām
  1. 5 5
      Emby.Naming/AudioBook/AudioBookListResolver.cs
  2. 9 3
      Emby.Naming/Common/NamingOptions.cs
  3. 75 28
      Emby.Naming/Video/ExtraResolver.cs
  4. 5 0
      Emby.Naming/Video/FileStack.cs
  5. 40 37
      Emby.Naming/Video/StackResolver.cs
  6. 6 7
      Emby.Naming/Video/VideoInfo.cs
  7. 30 139
      Emby.Naming/Video/VideoListResolver.cs
  8. 4 3
      Emby.Naming/Video/VideoResolver.cs
  9. 40 82
      Emby.Server.Implementations/Library/LibraryManager.cs
  10. 36 85
      Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs
  11. 62 41
      Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
  12. 23 21
      Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
  13. 1 1
      Jellyfin.Api/Controllers/UserLibraryController.cs
  14. 17 218
      MediaBrowser.Controller/Entities/BaseItem.cs
  15. 4 64
      MediaBrowser.Controller/Entities/IHasTrailers.cs
  16. 3 9
      MediaBrowser.Controller/Entities/Movies/BoxSet.cs
  17. 18 62
      MediaBrowser.Controller/Entities/Movies/Movie.cs
  18. 3 11
      MediaBrowser.Controller/Entities/TV/Episode.cs
  19. 3 7
      MediaBrowser.Controller/Entities/TV/Series.cs
  20. 4 5
      MediaBrowser.Controller/Entities/UserViewBuilder.cs
  21. 1 16
      MediaBrowser.Controller/Library/ILibraryManager.cs
  22. 12 11
      MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
  23. 2 9
      tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs
  24. 4 16
      tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
  25. 22 71
      tests/Jellyfin.Naming.Tests/Video/StackTests.cs
  26. 66 41
      tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
  27. 8 5
      tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
  28. 178 0
      tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs

+ 5 - 5
Emby.Naming/AudioBook/AudioBookListResolver.cs

@@ -14,6 +14,7 @@ namespace Emby.Naming.AudioBook
     public class AudioBookListResolver
     {
         private readonly NamingOptions _options;
+        private readonly AudioBookResolver _audioBookResolver;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="AudioBookListResolver"/> class.
@@ -22,6 +23,7 @@ namespace Emby.Naming.AudioBook
         public AudioBookListResolver(NamingOptions options)
         {
             _options = options;
+            _audioBookResolver = new AudioBookResolver(_options);
         }
 
         /// <summary>
@@ -31,21 +33,19 @@ namespace Emby.Naming.AudioBook
         /// <returns>Returns IEnumerable of <see cref="AudioBookInfo"/>.</returns>
         public IEnumerable<AudioBookInfo> Resolve(IEnumerable<FileSystemMetadata> files)
         {
-            var audioBookResolver = new AudioBookResolver(_options);
 
             // File with empty fullname will be sorted out here.
             var audiobookFileInfos = files
-                .Select(i => audioBookResolver.Resolve(i.FullName))
+                .Select(i => _audioBookResolver.Resolve(i.FullName))
                 .OfType<AudioBookFileInfo>()
                 .ToList();
 
-            var stackResult = new StackResolver(_options)
-                .ResolveAudioBooks(audiobookFileInfos);
+            var stackResult = StackResolver.ResolveAudioBooks(audiobookFileInfos);
 
             foreach (var stack in stackResult)
             {
                 var stackFiles = stack.Files
-                    .Select(i => audioBookResolver.Resolve(i))
+                    .Select(i => _audioBookResolver.Resolve(i))
                     .OfType<AudioBookFileInfo>()
                     .ToList();
 

+ 9 - 3
Emby.Naming/Common/NamingOptions.cs

@@ -126,9 +126,9 @@ namespace Emby.Naming.Common
 
             VideoFileStackingExpressions = new[]
             {
-                "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$",
-                "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$",
-                "(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$"
+                "^(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|part|pt|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$",
+                "^(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|part|pt|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$",
+                "^(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$"
             };
 
             CleanDateTimes = new[]
@@ -403,6 +403,12 @@ namespace Emby.Naming.Common
 
             VideoExtraRules = new[]
             {
+                new ExtraRule(
+                    ExtraType.Trailer,
+                    ExtraRuleType.DirectoryName,
+                    "trailers",
+                    MediaType.Video),
+
                 new ExtraRule(
                     ExtraType.Trailer,
                     ExtraRuleType.Filename,

+ 75 - 28
Emby.Naming/Video/ExtraResolver.cs

@@ -1,5 +1,7 @@
 using System;
+using System.Collections.Generic;
 using System.IO;
+using System.Linq;
 using System.Text.RegularExpressions;
 using Emby.Naming.Audio;
 using Emby.Naming.Common;
@@ -9,45 +11,27 @@ namespace Emby.Naming.Video
     /// <summary>
     /// Resolve if file is extra for video.
     /// </summary>
-    public class ExtraResolver
+    public static class ExtraResolver
     {
-        private static readonly char[] _digits = new[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ExtraResolver"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoExtraRules and passed to <see cref="AudioFileParser"/> and <see cref="VideoResolver"/>.</param>
-        public ExtraResolver(NamingOptions options)
-        {
-            _options = options;
-        }
+        private static readonly char[] _digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
 
         /// <summary>
         /// Attempts to resolve if file is extra.
         /// </summary>
         /// <param name="path">Path to file.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Returns <see cref="ExtraResult"/> object.</returns>
-        public ExtraResult GetExtraInfo(string path)
+        public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions)
         {
             var result = new ExtraResult();
 
-            for (var i = 0; i < _options.VideoExtraRules.Length; i++)
+            for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++)
             {
-                var rule = _options.VideoExtraRules[i];
-                if (rule.MediaType == MediaType.Audio)
+                var rule = namingOptions.VideoExtraRules[i];
+                if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions))
+                    || (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions)))
                 {
-                    if (!AudioFileParser.IsAudioFile(path, _options))
-                    {
-                        continue;
-                    }
-                }
-                else if (rule.MediaType == MediaType.Video)
-                {
-                    if (!VideoResolver.IsVideoFile(path, _options))
-                    {
-                        continue;
-                    }
+                    continue;
                 }
 
                 var pathSpan = path.AsSpan();
@@ -76,7 +60,7 @@ namespace Emby.Naming.Video
                 {
                     var filename = Path.GetFileName(path);
 
-                    var regex = new Regex(rule.Token, RegexOptions.IgnoreCase);
+                    var regex = new Regex(rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
 
                     if (regex.IsMatch(filename))
                     {
@@ -102,5 +86,68 @@ namespace Emby.Naming.Video
 
             return result;
         }
+
+        /// <summary>
+        /// Finds extras matching the video info.
+        /// </summary>
+        /// <param name="files">The list of file video infos.</param>
+        /// <param name="videoInfo">The video to compare against.</param>
+        /// <param name="videoFlagDelimiters">The video flag delimiters.</param>
+        /// <returns>A list of video extras for [videoInfo].</returns>
+        public static IReadOnlyList<VideoFileInfo> GetExtras(IReadOnlyList<VideoInfo> files, VideoFileInfo videoInfo, ReadOnlySpan<char> videoFlagDelimiters)
+        {
+            var parentDir = videoInfo.IsDirectory ? videoInfo.Path : Path.GetDirectoryName(videoInfo.Path.AsSpan());
+
+            var trimmedFileName = TrimFilenameDelimiters(videoInfo.Name, videoFlagDelimiters);
+            var trimmedFileNameWithoutExtension = TrimFilenameDelimiters(videoInfo.FileNameWithoutExtension, videoFlagDelimiters);
+            var trimmedVideoInfoName = TrimFilenameDelimiters(videoInfo.Name, videoFlagDelimiters);
+
+            var result = new List<VideoFileInfo>();
+            for (var pos = files.Count - 1; pos >= 0; pos--)
+            {
+                var current = files[pos];
+                // ignore non-extras and multi-file (can this happen?)
+                if (current.ExtraType == null || current.Files.Count > 1)
+                {
+                    continue;
+                }
+
+                var currentFile = files[pos].Files[0];
+                var trimmedCurrentFileName = TrimFilenameDelimiters(currentFile.Name, videoFlagDelimiters);
+
+                // first check filenames
+                bool isValid = StartsWith(trimmedCurrentFileName, trimmedFileNameWithoutExtension)
+                               || (StartsWith(trimmedCurrentFileName, trimmedFileName) && currentFile.Year == videoInfo.Year)
+                               || (StartsWith(trimmedCurrentFileName, trimmedVideoInfoName) && currentFile.Year == videoInfo.Year);
+
+                // then by directory
+                if (!isValid)
+                {
+                    // When the extra rule type is DirectoryName we must go one level higher to get the "real" dir name
+                    var currentParentDir = currentFile.ExtraRule?.RuleType == ExtraRuleType.DirectoryName
+                        ? Path.GetDirectoryName(Path.GetDirectoryName(currentFile.Path.AsSpan()))
+                        : Path.GetDirectoryName(currentFile.Path.AsSpan());
+
+                    isValid = !currentParentDir.IsEmpty && !parentDir.IsEmpty && currentParentDir.Equals(parentDir, StringComparison.OrdinalIgnoreCase);
+                }
+
+                if (isValid)
+                {
+                    result.Add(currentFile);
+                }
+            }
+
+            return result.OrderBy(r => r.Path).ToArray();
+        }
+
+        private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters)
+        {
+            return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd();
+        }
+
+        private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName)
+        {
+            return !baseName.IsEmpty && fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase);
+        }
     }
 }

+ 5 - 0
Emby.Naming/Video/FileStack.cs

@@ -40,6 +40,11 @@ namespace Emby.Naming.Video
         /// <returns>True if file is in the stack.</returns>
         public bool ContainsFile(string file, bool isDirectory)
         {
+            if (string.IsNullOrEmpty(file))
+            {
+                return false;
+            }
+
             if (IsDirectoryStack == isDirectory)
             {
                 return Files.Contains(file, StringComparer.OrdinalIgnoreCase);

+ 40 - 37
Emby.Naming/Video/StackResolver.cs

@@ -12,37 +12,28 @@ namespace Emby.Naming.Video
     /// <summary>
     /// Resolve <see cref="FileStack"/> from list of paths.
     /// </summary>
-    public class StackResolver
+    public static class StackResolver
     {
-        private readonly NamingOptions _options;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="StackResolver"/> class.
-        /// </summary>
-        /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileStackingRegexes and passes options to <see cref="VideoResolver"/>.</param>
-        public StackResolver(NamingOptions options)
-        {
-            _options = options;
-        }
-
         /// <summary>
         /// Resolves only directories from paths.
         /// </summary>
         /// <param name="files">List of paths.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
-        public IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files)
+        public static IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files, NamingOptions namingOptions)
         {
-            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }));
+            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }), namingOptions);
         }
 
         /// <summary>
         /// Resolves only files from paths.
         /// </summary>
         /// <param name="files">List of paths.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of files.</returns>
-        public IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files)
+        public static IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files, NamingOptions namingOptions)
         {
-            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }));
+            return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }), namingOptions);
         }
 
         /// <summary>
@@ -50,7 +41,7 @@ namespace Emby.Naming.Video
         /// </summary>
         /// <param name="files">List of paths.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
-        public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files)
+        public static IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files)
         {
             var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path));
 
@@ -82,15 +73,20 @@ namespace Emby.Naming.Video
         /// Resolves videos from paths.
         /// </summary>
         /// <param name="files">List of paths.</param>
+        /// <param name="namingOptions">The naming options.</param>
         /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns>
-        public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files)
+        public static IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions)
         {
             var list = files
-                .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, _options) || VideoResolver.IsStubFile(i.FullName, _options))
+                .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, namingOptions) || VideoResolver.IsStubFile(i.FullName, namingOptions))
                 .OrderBy(i => i.FullName)
+                .Select(f => (f.IsDirectory, FileName: GetFileNameWithExtension(f), f.FullName))
                 .ToList();
 
-            var expressions = _options.VideoFileStackingRegexes;
+            // TODO is there a "nicer" way?
+            var cache = new Dictionary<(string, Regex, int), Match>();
+
+            var expressions = namingOptions.VideoFileStackingRegexes;
 
             for (var i = 0; i < list.Count; i++)
             {
@@ -102,17 +98,17 @@ namespace Emby.Naming.Video
                 while (expressionIndex < expressions.Length)
                 {
                     var exp = expressions[expressionIndex];
-                    var stack = new FileStack();
+                    FileStack? stack = null;
 
                     // (Title)(Volume)(Ignore)(Extension)
-                    var match1 = FindMatch(file1, exp, offset);
+                    var match1 = FindMatch(file1.FileName, exp, offset, cache);
 
                     if (match1.Success)
                     {
-                        var title1 = match1.Groups["title"].Value;
-                        var volume1 = match1.Groups["volume"].Value;
-                        var ignore1 = match1.Groups["ignore"].Value;
-                        var extension1 = match1.Groups["extension"].Value;
+                        var title1 = match1.Groups[1].Value;
+                        var volume1 = match1.Groups[2].Value;
+                        var ignore1 = match1.Groups[3].Value;
+                        var extension1 = match1.Groups[4].Value;
 
                         var j = i + 1;
                         while (j < list.Count)
@@ -126,7 +122,7 @@ namespace Emby.Naming.Video
                             }
 
                             // (Title)(Volume)(Ignore)(Extension)
-                            var match2 = FindMatch(file2, exp, offset);
+                            var match2 = FindMatch(file2.FileName, exp, offset, cache);
 
                             if (match2.Success)
                             {
@@ -142,6 +138,7 @@ namespace Emby.Naming.Video
                                         if (string.Equals(ignore1, ignore2, StringComparison.OrdinalIgnoreCase)
                                             && string.Equals(extension1, extension2, StringComparison.OrdinalIgnoreCase))
                                         {
+                                            stack ??= new FileStack();
                                             if (stack.Files.Count == 0)
                                             {
                                                 stack.Name = title1 + ignore1;
@@ -204,7 +201,7 @@ namespace Emby.Naming.Video
                         expressionIndex++;
                     }
 
-                    if (stack.Files.Count > 1)
+                    if (stack?.Files.Count > 1)
                     {
                         yield return stack;
                         i += stack.Files.Count - 1;
@@ -214,26 +211,32 @@ namespace Emby.Naming.Video
             }
         }
 
-        private static string GetRegexInput(FileSystemMetadata file)
+        private static string GetFileNameWithExtension(FileSystemMetadata file)
         {
             // For directories, dummy up an extension otherwise the expressions will fail
-            var input = !file.IsDirectory
-                ? file.FullName
-                : file.FullName + ".mkv";
+            var input = file.FullName;
+            if (file.IsDirectory)
+            {
+                input = Path.ChangeExtension(input, "mkv");
+            }
 
             return Path.GetFileName(input);
         }
 
-        private static Match FindMatch(FileSystemMetadata input, Regex regex, int offset)
+        private static Match FindMatch(string input, Regex regex, int offset, Dictionary<(string, Regex, int), Match> cache)
         {
-            var regexInput = GetRegexInput(input);
-
-            if (offset < 0 || offset >= regexInput.Length)
+            if (offset < 0 || offset >= input.Length)
             {
                 return Match.Empty;
             }
 
-            return regex.Match(regexInput, offset);
+            if (!cache.TryGetValue((input, regex, offset), out var result))
+            {
+                result = regex.Match(input, offset, input.Length - offset);
+                cache.Add((input, regex, offset), result);
+            }
+
+            return result;
         }
     }
 }

+ 6 - 7
Emby.Naming/Video/VideoInfo.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
 
 namespace Emby.Naming.Video
 {
@@ -17,7 +18,6 @@ namespace Emby.Naming.Video
             Name = name;
 
             Files = Array.Empty<VideoFileInfo>();
-            Extras = Array.Empty<VideoFileInfo>();
             AlternateVersions = Array.Empty<VideoFileInfo>();
         }
 
@@ -39,16 +39,15 @@ namespace Emby.Naming.Video
         /// <value>The files.</value>
         public IReadOnlyList<VideoFileInfo> Files { get; set; }
 
-        /// <summary>
-        /// Gets or sets the extras.
-        /// </summary>
-        /// <value>The extras.</value>
-        public IReadOnlyList<VideoFileInfo> Extras { get; set; }
-
         /// <summary>
         /// Gets or sets the alternate versions.
         /// </summary>
         /// <value>The alternate versions.</value>
         public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
+
+        /// <summary>
+        /// Gets or sets the extra type.
+        /// </summary>
+        public ExtraType? ExtraType { get; set; }
     }
 }

+ 30 - 139
Emby.Naming/Video/VideoListResolver.cs

@@ -4,7 +4,6 @@ using System.IO;
 using System.Linq;
 using System.Text.RegularExpressions;
 using Emby.Naming.Common;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 
 namespace Emby.Naming.Video
@@ -20,11 +19,12 @@ namespace Emby.Naming.Video
         /// <param name="files">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>
         /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
-        public static IEnumerable<VideoInfo> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true)
+        public static IReadOnlyList<VideoInfo> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true)
         {
             var videoInfos = files
-                .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions))
+                .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions, parseName))
                 .OfType<VideoFileInfo>()
                 .ToList();
 
@@ -34,12 +34,25 @@ namespace Emby.Naming.Video
                 .Where(i => i.ExtraType == null)
                 .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
 
-            var stackResult = new StackResolver(namingOptions)
-                .Resolve(nonExtras).ToList();
+            var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList();
 
-            var remainingFiles = videoInfos
-                .Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory)))
-                .ToList();
+            var remainingFiles = new List<VideoFileInfo>();
+            var standaloneMedia = new List<VideoFileInfo>();
+
+            for (var i = 0; i < videoInfos.Count; i++)
+            {
+                var current = videoInfos[i];
+                if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory)))
+                {
+                    continue;
+                }
+
+                remainingFiles.Add(current);
+                if (current.ExtraType == null)
+                {
+                    standaloneMedia.Add(current);
+                }
+            }
 
             var list = new List<VideoInfo>();
 
@@ -47,27 +60,15 @@ namespace Emby.Naming.Video
             {
                 var info = new VideoInfo(stack.Name)
                 {
-                    Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions))
+                    Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName))
                         .OfType<VideoFileInfo>()
                         .ToList()
                 };
 
                 info.Year = info.Files[0].Year;
-
-                var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters);
-
-                if (extras.Count > 0)
-                {
-                    info.Extras = extras;
-                }
-
                 list.Add(info);
             }
 
-            var standaloneMedia = remainingFiles
-                .Where(i => i.ExtraType == null)
-                .ToList();
-
             foreach (var media in standaloneMedia)
             {
                 var info = new VideoInfo(media.Name) { Files = new[] { media } };
@@ -75,10 +76,6 @@ namespace Emby.Naming.Video
                 info.Year = info.Files[0].Year;
 
                 remainingFiles.Remove(media);
-                var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters);
-
-                info.Extras = extras;
-
                 list.Add(info);
             }
 
@@ -87,58 +84,12 @@ namespace Emby.Naming.Video
                 list = GetVideosGroupedByVersion(list, namingOptions);
             }
 
-            // If there's only one resolved video, use the folder name as well to find extras
-            if (list.Count == 1)
-            {
-                var info = list[0];
-                var videoPath = list[0].Files[0].Path;
-                var parentPath = Path.GetDirectoryName(videoPath.AsSpan());
-
-                if (!parentPath.IsEmpty)
-                {
-                    var folderName = Path.GetFileName(parentPath);
-                    if (!folderName.IsEmpty)
-                    {
-                        var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters);
-                        extras.AddRange(info.Extras);
-                        info.Extras = extras;
-                    }
-                }
-
-                // Add the extras that are just based on file name as well
-                var extrasByFileName = remainingFiles
-                    .Where(i => i.ExtraRule != null && i.ExtraRule.RuleType == ExtraRuleType.Filename)
-                    .ToList();
-
-                remainingFiles = remainingFiles
-                    .Except(extrasByFileName)
-                    .ToList();
-
-                extrasByFileName.AddRange(info.Extras);
-                info.Extras = extrasByFileName;
-            }
-
-            // If there's only one video, accept all trailers
-            // Be lenient because people use all kinds of mishmash conventions with trailers.
-            if (list.Count == 1)
-            {
-                var trailers = remainingFiles
-                    .Where(i => i.ExtraType == ExtraType.Trailer)
-                    .ToList();
-
-                trailers.AddRange(list[0].Extras);
-                list[0].Extras = trailers;
-
-                remainingFiles = remainingFiles
-                    .Except(trailers)
-                    .ToList();
-            }
-
             // Whatever files are left, just add them
             list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
             {
                 Files = new[] { i },
-                Year = i.Year
+                Year = i.Year,
+                ExtraType = i.ExtraType
             }));
 
             return list;
@@ -162,6 +113,11 @@ namespace Emby.Naming.Video
             for (var i = 0; i < videos.Count; i++)
             {
                 var video = videos[i];
+                if (video.ExtraType != null)
+                {
+                    continue;
+                }
+
                 if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions))
                 {
                     return videos;
@@ -178,17 +134,14 @@ namespace Emby.Naming.Video
 
             var alternateVersionsLen = videos.Count - 1;
             var alternateVersions = new VideoFileInfo[alternateVersionsLen];
-            var extras = new List<VideoFileInfo>(list[0].Extras);
             for (int i = 0; i < alternateVersionsLen; i++)
             {
                 var video = videos[i + 1];
                 alternateVersions[i] = video.Files[0];
-                extras.AddRange(video.Extras);
             }
 
             list[0].AlternateVersions = alternateVersions;
             list[0].Name = folderName.ToString();
-            list[0].Extras = extras;
 
             return list;
         }
@@ -230,7 +183,7 @@ namespace Emby.Naming.Video
             var tmpTestFilename = testFilename.ToString();
             if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName))
             {
-                tmpTestFilename = cleanName.Trim().ToString();
+                tmpTestFilename = cleanName.Trim();
             }
 
             // The CleanStringParser should have removed common keywords etc.
@@ -238,67 +191,5 @@ namespace Emby.Naming.Video
                    || testFilename[0] == '-'
                    || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled);
         }
-
-        private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters)
-        {
-            return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd();
-        }
-
-        private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName, ReadOnlySpan<char> trimmedBaseName)
-        {
-            if (baseName.IsEmpty)
-            {
-                return false;
-            }
-
-            return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
-                   || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase));
-        }
-
-        /// <summary>
-        /// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles].
-        /// </summary>
-        /// <param name="remainingFiles">The list of remaining filenames.</param>
-        /// <param name="baseName">The base name to use for the comparison.</param>
-        /// <param name="videoFlagDelimiters">The video flag delimiters.</param>
-        /// <returns>A list of video extras for [baseName].</returns>
-        private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> baseName, ReadOnlySpan<char> videoFlagDelimiters)
-        {
-            return ExtractExtras(remainingFiles, baseName, ReadOnlySpan<char>.Empty, videoFlagDelimiters);
-        }
-
-        /// <summary>
-        /// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles].
-        /// </summary>
-        /// <param name="remainingFiles">The list of remaining filenames.</param>
-        /// <param name="firstBaseName">The first base name to use for the comparison.</param>
-        /// <param name="secondBaseName">The second base name to use for the comparison.</param>
-        /// <param name="videoFlagDelimiters">The video flag delimiters.</param>
-        /// <returns>A list of video extras for [firstBaseName] and [secondBaseName].</returns>
-        private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> firstBaseName, ReadOnlySpan<char> secondBaseName, ReadOnlySpan<char> videoFlagDelimiters)
-        {
-            var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters);
-            var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters);
-
-            var result = new List<VideoFileInfo>();
-            for (var pos = remainingFiles.Count - 1; pos >= 0; pos--)
-            {
-                var file = remainingFiles[pos];
-                if (file.ExtraType == null)
-                {
-                    continue;
-                }
-
-                var filename = file.FileNameWithoutExtension;
-                if (StartsWith(filename, firstBaseName, trimmedFirstBaseName)
-                    || StartsWith(filename, secondBaseName, trimmedSecondBaseName))
-                {
-                    result.Add(file);
-                    remainingFiles.RemoveAt(pos);
-                }
-            }
-
-            return result;
-        }
     }
 }

+ 4 - 3
Emby.Naming/Video/VideoResolver.cs

@@ -16,10 +16,11 @@ namespace Emby.Naming.Video
         /// </summary>
         /// <param name="path">The path.</param>
         /// <param name="namingOptions">The naming options.</param>
+        /// <param name="parseName">Whether to parse the name or use the filename.</param>
         /// <returns>VideoFileInfo.</returns>
-        public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions)
+        public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true)
         {
-            return Resolve(path, true, namingOptions);
+            return Resolve(path, true, namingOptions, parseName);
         }
 
         /// <summary>
@@ -74,7 +75,7 @@ namespace Emby.Naming.Video
 
             var format3DResult = Format3DParser.Parse(path, namingOptions);
 
-            var extraResult = new ExtraResolver(namingOptions).GetExtraInfo(path);
+            var extraResult = ExtraResolver.GetExtraInfo(path, namingOptions);
 
             var name = Path.GetFileNameWithoutExtension(path);
 

+ 40 - 82
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -11,11 +11,9 @@ using System.Net;
 using System.Net.Http;
 using System.Threading;
 using System.Threading.Tasks;
-using Emby.Naming.Audio;
 using Emby.Naming.Common;
 using Emby.Naming.TV;
 using Emby.Naming.Video;
-using Emby.Server.Implementations.Library.Resolvers;
 using Emby.Server.Implementations.Library.Validators;
 using Emby.Server.Implementations.Playlists;
 using Emby.Server.Implementations.ScheduledTasks;
@@ -677,7 +675,7 @@ namespace Emby.Server.Implementations.Library
                 {
                     var result = resolver.ResolveMultiple(parent, fileList, collectionType, directoryService);
 
-                    if (result != null && result.Items.Count > 0)
+                    if (result?.Items.Count > 0)
                     {
                         var items = new List<BaseItem>();
                         items.AddRange(result.Items);
@@ -2685,89 +2683,58 @@ namespace Emby.Server.Implementations.Library
             };
         }
 
-        public IEnumerable<Video> FindTrailers(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
+        public IEnumerable<Video> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren)
         {
-            var namingOptions = _namingOptions;
-
-            var files = owner.IsInMixedFolder ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory)
-                .Where(i => string.Equals(i.Name, BaseItem.TrailersFolderName, StringComparison.OrdinalIgnoreCase))
-                .SelectMany(i => _fileSystem.GetFiles(i.FullName, namingOptions.VideoFileExtensions, false, false))
-                .ToList();
-
-            var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions);
-
-            var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase));
-
-            if (currentVideo != null)
+            var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions);
+            if (ownerVideoInfo == null)
             {
-                files.AddRange(currentVideo.Extras.Where(i => i.ExtraType == ExtraType.Trailer).Select(i => _fileSystem.GetFileInfo(i.Path)));
+                yield break;
             }
 
-            var resolvers = new IItemResolver[]
+            var count = fileSystemChildren.Count;
+            var files = new List<FileSystemMetadata>();
+            for (var i = 0; i < count; i++)
             {
-                new GenericVideoResolver<Trailer>(_namingOptions)
-            };
-
-            return ResolvePaths(files, directoryService, null, new LibraryOptions(), null, resolvers)
-                .OfType<Trailer>()
-                .Select(video =>
+                var current = fileSystemChildren[i];
+                if (current.IsDirectory && BaseItem.AllExtrasTypesFolderNames.ContainsKey(current.Name))
                 {
-                    // Try to retrieve it from the db. If we don't find it, use the resolved version
-                    if (GetItemById(video.Id) is Trailer dbItem)
-                    {
-                        video = dbItem;
-                    }
-
-                    video.ParentId = Guid.Empty;
-                    video.OwnerId = owner.Id;
-                    video.ExtraType = ExtraType.Trailer;
-                    video.TrailerTypes = new[] { TrailerType.LocalTrailer };
-
-                    return video;
-
-                    // Sort them so that the list can be easily compared for changes
-                }).OrderBy(i => i.Path);
-        }
-
-        public IEnumerable<Video> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
-        {
-            var namingOptions = _namingOptions;
-
-            var files = owner.IsInMixedFolder ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory)
-                .Where(i => BaseItem.AllExtrasTypesFolderNames.ContainsKey(i.Name ?? string.Empty))
-                .SelectMany(i => _fileSystem.GetFiles(i.FullName, namingOptions.VideoFileExtensions, false, false))
-                .ToList();
-
-            var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions);
-
-            var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase));
+                    files.AddRange(_fileSystem.GetFiles(current.FullName, _namingOptions.VideoFileExtensions, false, false));
+                }
+                else if (!current.IsDirectory)
+                {
+                    files.Add(current);
+                }
+            }
 
-            if (currentVideo != null)
+            if (files.Count == 0)
             {
-                files.AddRange(currentVideo.Extras.Where(i => i.ExtraType != ExtraType.Trailer).Select(i => _fileSystem.GetFileInfo(i.Path)));
+                yield break;
             }
 
-            return ResolvePaths(files, directoryService, null, new LibraryOptions(), null)
-                .OfType<Video>()
-                .Select(video =>
-                {
-                    // Try to retrieve it from the db. If we don't find it, use the resolved version
-                    var dbItem = GetItemById(video.Id) as Video;
+            var videos = VideoListResolver.Resolve(files, _namingOptions);
+            // owner video info cannot be null as that implies it has no path
+            var extras = ExtraResolver.GetExtras(videos, ownerVideoInfo, _namingOptions.VideoFlagDelimiters);
 
-                    if (dbItem != null)
-                    {
-                        video = dbItem;
-                    }
-
-                    video.ParentId = Guid.Empty;
-                    video.OwnerId = owner.Id;
-
-                    SetExtraTypeFromFilename(video);
+            for (var i = 0; i < extras.Count; i++)
+            {
+                var currentExtra = extras[i];
+                var resolved = ResolvePath(_fileSystem.GetFileInfo(currentExtra.Path));
+                if (resolved is not Video video)
+                {
+                    continue;
+                }
 
-                    return video;
+                // Try to retrieve it from the db. If we don't find it, use the resolved version
+                if (GetItemById(resolved.Id) is Video dbItem)
+                {
+                    video = dbItem;
+                }
 
-                    // Sort them so that the list can be easily compared for changes
-                }).OrderBy(i => i.Path);
+                video.ExtraType = currentExtra.ExtraType;
+                video.ParentId = Guid.Empty;
+                video.OwnerId = owner.Id;
+                yield return video;
+            }
         }
 
         public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem)
@@ -2817,15 +2784,6 @@ namespace Emby.Server.Implementations.Library
             return path;
         }
 
-        private void SetExtraTypeFromFilename(Video item)
-        {
-            var resolver = new ExtraResolver(_namingOptions);
-
-            var result = resolver.GetExtraInfo(item.Path);
-
-            item.ExtraType = result.ExtraType;
-        }
-
         public List<PersonInfo> GetPeople(InternalPeopleQuery query)
         {
             return _itemRepository.GetPeople(query);

+ 36 - 85
Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs

@@ -49,120 +49,71 @@ namespace Emby.Server.Implementations.Library.Resolvers
         protected virtual TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName)
               where TVideoType : Video, new()
         {
-            var namingOptions = NamingOptions;
+            VideoFileInfo videoInfo = null;
+            VideoType? videoType = null;
 
             // If the path is a file check for a matching extensions
             if (args.IsDirectory)
             {
-                TVideoType video = null;
-                VideoFileInfo videoInfo = null;
-
                 // Loop through each child file/folder and see if we find a video
                 foreach (var child in args.FileSystemChildren)
                 {
                     var filename = child.Name;
-
                     if (child.IsDirectory)
                     {
                         if (IsDvdDirectory(child.FullName, filename, args.DirectoryService))
                         {
-                            videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions);
-
-                            if (videoInfo == null)
-                            {
-                                return null;
-                            }
-
-                            video = new TVideoType
-                            {
-                                Path = args.Path,
-                                VideoType = VideoType.Dvd,
-                                ProductionYear = videoInfo.Year
-                            };
-                            break;
+                            videoType = VideoType.Dvd;
                         }
-
-                        if (IsBluRayDirectory(filename))
+                        else if (IsBluRayDirectory(filename))
                         {
-                            videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions);
-
-                            if (videoInfo == null)
-                            {
-                                return null;
-                            }
-
-                            video = new TVideoType
-                            {
-                                Path = args.Path,
-                                VideoType = VideoType.BluRay,
-                                ProductionYear = videoInfo.Year
-                            };
-                            break;
+                            videoType = VideoType.BluRay;
                         }
                     }
                     else if (IsDvdFile(filename))
                     {
-                        videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions);
-
-                        if (videoInfo == null)
-                        {
-                            return null;
-                        }
-
-                        video = new TVideoType
-                        {
-                            Path = args.Path,
-                            VideoType = VideoType.Dvd,
-                            ProductionYear = videoInfo.Year
-                        };
-                        break;
+                        videoType = VideoType.Dvd;
                     }
-                }
 
-                if (video != null)
-                {
-                    video.Name = parseName ?
-                        videoInfo.Name :
-                        Path.GetFileName(args.Path);
+                    if (videoType == null)
+                    {
+                        continue;
+                    }
 
-                    Set3DFormat(video, videoInfo);
+                    videoInfo = VideoResolver.ResolveDirectory(args.Path, NamingOptions, parseName);
+                    break;
                 }
-
-                return video;
             }
             else
             {
-                var videoInfo = VideoResolver.Resolve(args.Path, false, namingOptions, false);
-
-                if (videoInfo == null)
-                {
-                    return null;
-                }
-
-                if (VideoResolver.IsVideoFile(args.Path, NamingOptions) || videoInfo.IsStub)
-                {
-                    var path = args.Path;
-
-                    var video = new TVideoType
-                    {
-                        Path = path,
-                        IsInMixedFolder = true,
-                        ProductionYear = videoInfo.Year
-                    };
-
-                    SetVideoType(video, videoInfo);
+                videoInfo = VideoResolver.Resolve(args.Path, false, NamingOptions, parseName);
+            }
 
-                    video.Name = parseName ?
-                        videoInfo.Name :
-                        Path.GetFileNameWithoutExtension(args.Path);
+            if (videoInfo == null || (!videoInfo.IsStub && !VideoResolver.IsVideoFile(args.Path, NamingOptions)))
+            {
+                return null;
+            }
 
-                    Set3DFormat(video, videoInfo);
+            var video = new TVideoType
+            {
+                Name = videoInfo.Name,
+                Path = args.Path,
+                ProductionYear = videoInfo.Year,
+                ExtraType = videoInfo.ExtraType
+            };
 
-                    return video;
-                }
+            if (videoType.HasValue)
+            {
+                video.VideoType = videoType.Value;
             }
+            else
+            {
+                SetVideoType(video, videoInfo);
+            }
+
+            Set3DFormat(video, videoInfo);
 
-            return null;
+            return video;
         }
 
         protected void SetVideoType(Video video, VideoFileInfo videoInfo)
@@ -207,8 +158,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
                 {
                     // use disc-utils, both DVDs and BDs use UDF filesystem
                     using (var videoFileStream = File.Open(video.Path, FileMode.Open, FileAccess.Read))
+                    using (UdfReader udfReader = new UdfReader(videoFileStream))
                     {
-                        UdfReader udfReader = new UdfReader(videoFileStream);
                         if (udfReader.DirectoryExists("VIDEO_TS"))
                         {
                             video.IsoType = IsoType.Dvd;

+ 62 - 41
Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs

@@ -26,7 +26,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
     public class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver
     {
         private readonly IImageProcessor _imageProcessor;
-        private readonly StackResolver _stackResolver;
 
         private string[] _validCollectionTypes = new[]
         {
@@ -46,7 +45,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
             : base(namingOptions)
         {
             _imageProcessor = imageProcessor;
-            _stackResolver = new StackResolver(NamingOptions);
         }
 
         /// <summary>
@@ -62,7 +60,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
             string collectionType,
             IDirectoryService directoryService)
         {
-            var result = ResolveMultipleInternal(parent, files, collectionType, directoryService);
+            var result = ResolveMultipleInternal(parent, files, collectionType);
 
             if (result != null)
             {
@@ -92,16 +90,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                     return null;
                 }
 
+                Video movie = null;
                 var files = args.GetActualFileSystemChildren().ToList();
 
                 if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase))
                 {
-                    return FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
+                    movie = FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
                 }
 
                 if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase))
                 {
-                    return FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
+                    movie = FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
                 }
 
                 if (string.IsNullOrEmpty(collectionType))
@@ -118,17 +117,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                         return null;
                     }
 
-                    {
-                        return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
-                    }
+                    movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
                 }
 
                 if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))
                 {
-                    return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
+                    movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
                 }
 
-                return null;
+                // ignore extras
+                return movie?.ExtraType == null ? movie : null;
             }
 
             // Handle owned items
@@ -169,6 +167,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                 item = ResolveVideo<Video>(args, false);
             }
 
+            // Ignore extras
+            if (item?.ExtraType != null)
+            {
+                return null;
+            }
+
             if (item != null)
             {
                 item.IsInMixedFolder = true;
@@ -180,8 +184,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
         private MultiItemResolverResult ResolveMultipleInternal(
             Folder parent,
             List<FileSystemMetadata> files,
-            string collectionType,
-            IDirectoryService directoryService)
+            string collectionType)
         {
             if (IsInvalid(parent, collectionType))
             {
@@ -190,13 +193,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
 
             if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase))
             {
-                return ResolveVideos<MusicVideo>(parent, files, directoryService, true, collectionType, false);
+                return ResolveVideos<MusicVideo>(parent, files, true, collectionType, false);
             }
 
             if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) ||
                             string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase))
             {
-                return ResolveVideos<Video>(parent, files, directoryService, false, collectionType, false);
+                return ResolveVideos<Video>(parent, files, false, collectionType, false);
             }
 
             if (string.IsNullOrEmpty(collectionType))
@@ -204,7 +207,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                 // Owned items should just use the plain video type
                 if (parent == null)
                 {
-                    return ResolveVideos<Video>(parent, files, directoryService, false, collectionType, false);
+                    return ResolveVideos<Video>(parent, files, false, collectionType, false);
                 }
 
                 if (parent is Series || parent.GetParents().OfType<Series>().Any())
@@ -212,12 +215,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                     return null;
                 }
 
-                return ResolveVideos<Movie>(parent, files, directoryService, false, collectionType, true);
+                return ResolveVideos<Movie>(parent, files, false, collectionType, true);
             }
 
             if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))
             {
-                return ResolveVideos<Movie>(parent, files, directoryService, true, collectionType, true);
+                return ResolveVideos<Movie>(parent, files, true, collectionType, true);
             }
 
             return null;
@@ -226,21 +229,20 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
         private MultiItemResolverResult ResolveVideos<T>(
             Folder parent,
             IEnumerable<FileSystemMetadata> fileSystemEntries,
-            IDirectoryService directoryService,
-            bool suppportMultiEditions,
+            bool supportMultiEditions,
             string collectionType,
             bool parseName)
             where T : Video, new()
         {
             var files = new List<FileSystemMetadata>();
-            var videos = new List<BaseItem>();
             var leftOver = new List<FileSystemMetadata>();
+            var hasCollectionType = !string.IsNullOrEmpty(collectionType);
 
             // Loop through each child file/folder and see if we find a video
             foreach (var child in fileSystemEntries)
             {
                 // This is a hack but currently no better way to resolve a sometimes ambiguous situation
-                if (string.IsNullOrEmpty(collectionType))
+                if (!hasCollectionType)
                 {
                     if (string.Equals(child.Name, "tvshow.nfo", StringComparison.OrdinalIgnoreCase)
                         || string.Equals(child.Name, "season.nfo", StringComparison.OrdinalIgnoreCase))
@@ -259,29 +261,35 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                 }
             }
 
-            var resolverResult = VideoListResolver.Resolve(files, NamingOptions, suppportMultiEditions).ToList();
+            var resolverResult = VideoListResolver.Resolve(files, NamingOptions, supportMultiEditions, parseName);
 
             var result = new MultiItemResolverResult
             {
-                ExtraFiles = leftOver,
-                Items = videos
+                ExtraFiles = leftOver
             };
 
-            var isInMixedFolder = resolverResult.Count > 1 || (parent != null && parent.IsTopParent);
+            var isInMixedFolder = resolverResult.Count > 1 || parent?.IsTopParent == true;
 
             foreach (var video in resolverResult)
             {
                 var firstVideo = video.Files[0];
+                var path = firstVideo.Path;
+                if (video.ExtraType != null)
+                {
+                    // TODO
+                    result.ExtraFiles.Add(files.First(f => string.Equals(f.FullName, path, StringComparison.OrdinalIgnoreCase)));
+                    continue;
+                }
+
+                var additionalParts = video.Files.Count > 1 ? video.Files.Skip(1).Select(i => i.Path).ToArray() : Array.Empty<string>();
 
                 var videoItem = new T
                 {
-                    Path = video.Files[0].Path,
+                    Path = path,
                     IsInMixedFolder = isInMixedFolder,
                     ProductionYear = video.Year,
-                    Name = parseName ?
-                        video.Name :
-                        Path.GetFileNameWithoutExtension(video.Files[0].Path),
-                    AdditionalParts = video.Files.Skip(1).Select(i => i.Path).ToArray(),
+                    Name = parseName ? video.Name : firstVideo.Name,
+                    AdditionalParts = additionalParts,
                     LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray()
                 };
 
@@ -299,21 +307,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
         private static bool IsIgnored(string filename)
         {
             // Ignore samples
-            Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase);
+            Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 
             return m.Success;
         }
 
-        private bool ContainsFile(List<VideoInfo> result, FileSystemMetadata file)
+        private static bool ContainsFile(IReadOnlyList<VideoInfo> result, FileSystemMetadata file)
         {
-            return result.Any(i => ContainsFile(i, file));
-        }
+            for (var i = 0; i < result.Count; i++)
+            {
+                var current = result[i];
+                for (var j = 0; j < current.Files.Count; j++)
+                {
+                    if (ContainsFile(current.Files[j], file))
+                    {
+                        return true;
+                    }
+                }
 
-        private bool ContainsFile(VideoInfo result, FileSystemMetadata file)
-        {
-            return result.Files.Any(i => ContainsFile(i, file)) ||
-                result.AlternateVersions.Any(i => ContainsFile(i, file)) ||
-                result.Extras.Any(i => ContainsFile(i, file));
+                for (var j = 0; j < current.AlternateVersions.Count; j++)
+                {
+                    if (ContainsFile(current.AlternateVersions[j], file))
+                    {
+                        return true;
+                    }
+                }
+            }
+
+            return false;
         }
 
         private static bool ContainsFile(VideoFileInfo result, FileSystemMetadata file)
@@ -431,7 +452,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
             // TODO: Allow GetMultiDiscMovie in here
             const bool SupportsMultiVersion = true;
 
-            var result = ResolveVideos<T>(parent, fileSystemEntries, directoryService, SupportsMultiVersion, collectionType, parseName) ??
+            var result = ResolveVideos<T>(parent, fileSystemEntries, SupportsMultiVersion, collectionType, parseName) ??
                 new MultiItemResolverResult();
 
             if (result.Items.Count == 1)
@@ -510,7 +531,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                 return null;
             }
 
-            var result = _stackResolver.ResolveDirectories(folderPaths).ToList();
+            var result = StackResolver.ResolveDirectories(folderPaths, NamingOptions).ToList();
 
             if (result.Count != 1)
             {

+ 23 - 21
Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs

@@ -45,34 +45,36 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
 
             // If the parent is a Season or Series and the parent is not an extras folder, then this is an Episode if the VideoResolver returns something
             // Also handle flat tv folders
-            if ((season != null ||
-                 string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) ||
-                 args.HasParent<Series>())
-                && (parent is Series || !BaseItem.AllExtrasTypesFolderNames.ContainsKey(parent.Name)))
+            if (season != null ||
+                string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) ||
+                args.HasParent<Series>())
             {
                 var episode = ResolveVideo<Episode>(args, false);
 
-                if (episode != null)
+                // Ignore extras
+                if (episode == null || episode.ExtraType != null)
                 {
-                    var series = parent as Series ?? parent.GetParents().OfType<Series>().FirstOrDefault();
+                    return null;
+                }
 
-                    if (series != null)
-                    {
-                        episode.SeriesId = series.Id;
-                        episode.SeriesName = series.Name;
-                    }
+                var series = parent as Series ?? parent.GetParents().OfType<Series>().FirstOrDefault();
 
-                    if (season != null)
-                    {
-                        episode.SeasonId = season.Id;
-                        episode.SeasonName = season.Name;
-                    }
+                if (series != null)
+                {
+                    episode.SeriesId = series.Id;
+                    episode.SeriesName = series.Name;
+                }
 
-                    // Assume season 1 if there's no season folder and a season number could not be determined
-                    if (season == null && !episode.ParentIndexNumber.HasValue && (episode.IndexNumber.HasValue || episode.PremiereDate.HasValue))
-                    {
-                        episode.ParentIndexNumber = 1;
-                    }
+                if (season != null)
+                {
+                    episode.SeasonId = season.Id;
+                    episode.SeasonName = season.Name;
+                }
+
+                // Assume season 1 if there's no season folder and a season number could not be determined
+                if (season == null && !episode.ParentIndexNumber.HasValue && (episode.IndexNumber.HasValue || episode.PremiereDate.HasValue))
+                {
+                    episode.ParentIndexNumber = 1;
                 }
 
                 return episode;

+ 1 - 1
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -213,7 +213,7 @@ namespace Jellyfin.Api.Controllers
 
             if (item is IHasTrailers hasTrailers)
             {
-                var trailers = hasTrailers.GetTrailers();
+                var trailers = hasTrailers.LocalTrailers;
                 var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item);
                 var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count];
                 dtosExtras.CopyTo(allTrailers, 0);

+ 17 - 218
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -40,9 +40,7 @@ namespace MediaBrowser.Controller.Entities
     /// </summary>
     public abstract class BaseItem : IHasProviderIds, IHasLookupInfo<ItemLookupInfo>, IEquatable<BaseItem>
     {
-        /// <summary>
-        /// The trailer folder name.
-        /// </summary>
+        public const string TrailerFileName = "trailer";
         public const string TrailersFolderName = "trailers";
         public const string ThemeSongsFolderName = "theme-music";
         public const string ThemeSongFileName = "theme";
@@ -99,8 +97,6 @@ namespace MediaBrowser.Controller.Entities
         };
 
         private string _sortName;
-        private Guid[] _themeSongIds;
-        private Guid[] _themeVideoIds;
 
         private string _forcedSortName;
 
@@ -121,40 +117,6 @@ namespace MediaBrowser.Controller.Entities
             ExtraIds = Array.Empty<Guid>();
         }
 
-        [JsonIgnore]
-        public Guid[] ThemeSongIds
-        {
-            get
-            {
-                return _themeSongIds ??= GetExtras()
-                    .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeSong)
-                    .Select(song => song.Id)
-                    .ToArray();
-            }
-
-            private set
-            {
-                _themeSongIds = value;
-            }
-        }
-
-        [JsonIgnore]
-        public Guid[] ThemeVideoIds
-        {
-            get
-            {
-                return _themeVideoIds ??= GetExtras()
-                    .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeVideo)
-                    .Select(song => song.Id)
-                    .ToArray();
-            }
-
-            private set
-            {
-                _themeVideoIds = value;
-            }
-        }
-
         [JsonIgnore]
         public string PreferredMetadataCountryCode { get; set; }
 
@@ -1379,28 +1341,6 @@ namespace MediaBrowser.Controller.Entities
                 }).OrderBy(i => i.Path).ToArray();
         }
 
-        protected virtual BaseItem[] LoadExtras(List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
-        {
-            return fileSystemChildren
-                .Where(child => child.IsDirectory && AllExtrasTypesFolderNames.ContainsKey(child.Name))
-                .SelectMany(folder => LibraryManager
-                    .ResolvePaths(FileSystem.GetFiles(folder.FullName), directoryService, null, new LibraryOptions())
-                    .OfType<Video>()
-                    .Select(video =>
-                    {
-                        // Try to retrieve it from the db. If we don't find it, use the resolved version
-                        if (LibraryManager.GetItemById(video.Id) is Video dbItem)
-                        {
-                            video = dbItem;
-                        }
-
-                        video.ExtraType = AllExtrasTypesFolderNames[folder.Name];
-                        return video;
-                    })
-                    .OrderBy(video => video.Path)) // Sort them so that the list can be easily compared for changes
-                .ToArray();
-        }
-
         public Task RefreshMetadata(CancellationToken cancellationToken)
         {
             return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
@@ -1434,13 +1374,8 @@ namespace MediaBrowser.Controller.Entities
                         GetFileSystemChildren(options.DirectoryService).ToList() :
                         new List<FileSystemMetadata>();
 
-                    var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
+                    requiresSave = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
                     await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh
-
-                    if (ownedItemsChanged)
-                    {
-                        requiresSave = true;
-                    }
                 }
                 catch (Exception ex)
                 {
@@ -1516,35 +1451,12 @@ namespace MediaBrowser.Controller.Entities
         /// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns>
         protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
         {
-            var themeSongsChanged = false;
-
-            var themeVideosChanged = false;
-
-            var extrasChanged = false;
-
-            var localTrailersChanged = false;
-
-            if (IsFileProtocol && SupportsOwnedItems)
+            if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder)
             {
-                if (SupportsThemeMedia)
-                {
-                    if (!IsInMixedFolder)
-                    {
-                        themeSongsChanged = await RefreshThemeSongs(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
-
-                        themeVideosChanged = await RefreshThemeVideos(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
-
-                        extrasChanged = await RefreshExtras(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
-                    }
-                }
-
-                if (this is IHasTrailers hasTrailers)
-                {
-                    localTrailersChanged = await RefreshLocalTrailers(hasTrailers, options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
-                }
+                return false;
             }
 
-            return themeSongsChanged || themeVideosChanged || extrasChanged || localTrailersChanged;
+            return await RefreshExtras(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
         }
 
         protected virtual FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService)
@@ -1554,98 +1466,24 @@ namespace MediaBrowser.Controller.Entities
             return directoryService.GetFileSystemEntries(path);
         }
 
-        private async Task<bool> RefreshLocalTrailers(IHasTrailers item, MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
-        {
-            var newItems = LibraryManager.FindTrailers(this, fileSystemChildren, options.DirectoryService);
-
-            var newItemIds = newItems.Select(i => i.Id);
-
-            var itemsChanged = !item.LocalTrailerIds.SequenceEqual(newItemIds);
-            var ownerId = item.Id;
-
-            var tasks = newItems.Select(i =>
-            {
-                var subOptions = new MetadataRefreshOptions(options);
-
-                if (i.ExtraType != Model.Entities.ExtraType.Trailer ||
-                    i.OwnerId != ownerId ||
-                    !i.ParentId.Equals(Guid.Empty))
-                {
-                    i.ExtraType = Model.Entities.ExtraType.Trailer;
-                    i.OwnerId = ownerId;
-                    i.ParentId = Guid.Empty;
-                    subOptions.ForceSave = true;
-                }
-
-                return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
-            });
-
-            await Task.WhenAll(tasks).ConfigureAwait(false);
-
-            item.LocalTrailerIds = newItemIds.ToArray();
-
-            return itemsChanged;
-        }
-
         private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
         {
-            var extras = LoadExtras(fileSystemChildren, options.DirectoryService);
-            var themeVideos = LoadThemeVideos(fileSystemChildren, options.DirectoryService);
-            var themeSongs = LoadThemeSongs(fileSystemChildren, options.DirectoryService);
-            var newExtras = new BaseItem[extras.Length + themeVideos.Length + themeSongs.Length];
-            extras.CopyTo(newExtras, 0);
-            themeVideos.CopyTo(newExtras, extras.Length);
-            themeSongs.CopyTo(newExtras, extras.Length + themeVideos.Length);
-
-            var newExtraIds = newExtras.Select(i => i.Id).ToArray();
-
+            var extras = LibraryManager.FindExtras(item, fileSystemChildren).ToArray();
+            var newExtraIds = extras.Select(i => i.Id).ToArray();
             var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds);
 
-            if (extrasChanged)
+            if (!extrasChanged)
             {
-                var ownerId = item.Id;
-
-                var tasks = newExtras.Select(i =>
-                {
-                    var subOptions = new MetadataRefreshOptions(options);
-                    if (i.OwnerId != ownerId || i.ParentId != Guid.Empty)
-                    {
-                        i.OwnerId = ownerId;
-                        i.ParentId = Guid.Empty;
-                        subOptions.ForceSave = true;
-                    }
-
-                    return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
-                });
-
-                await Task.WhenAll(tasks).ConfigureAwait(false);
-
-                item.ExtraIds = newExtraIds;
+                return false;
             }
 
-            return extrasChanged;
-        }
-
-        private async Task<bool> RefreshThemeVideos(BaseItem item, MetadataRefreshOptions options, IEnumerable<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
-        {
-            var newThemeVideos = LoadThemeVideos(fileSystemChildren, options.DirectoryService);
-
-            var newThemeVideoIds = newThemeVideos.Select(i => i.Id).ToArray();
-
-            var themeVideosChanged = !item.ThemeVideoIds.SequenceEqual(newThemeVideoIds);
-
             var ownerId = item.Id;
 
-            var tasks = newThemeVideos.Select(i =>
+            var tasks = extras.Select(i =>
             {
                 var subOptions = new MetadataRefreshOptions(options);
-
-                if (!i.ExtraType.HasValue ||
-                    i.ExtraType.Value != Model.Entities.ExtraType.ThemeVideo ||
-                    i.OwnerId != ownerId ||
-                    !i.ParentId.Equals(Guid.Empty))
+                if (i.OwnerId != ownerId || i.ParentId != Guid.Empty)
                 {
-                    i.ExtraType = Model.Entities.ExtraType.ThemeVideo;
                     i.OwnerId = ownerId;
                     i.ParentId = Guid.Empty;
                     subOptions.ForceSave = true;
@@ -1656,48 +1494,9 @@ namespace MediaBrowser.Controller.Entities
 
             await Task.WhenAll(tasks).ConfigureAwait(false);
 
-            // They are expected to be sorted by SortName
-            item.ThemeVideoIds = newThemeVideos.OrderBy(i => i.SortName).Select(i => i.Id).ToArray();
-
-            return themeVideosChanged;
-        }
-
-        /// <summary>
-        /// Refreshes the theme songs.
-        /// </summary>
-        private async Task<bool> RefreshThemeSongs(BaseItem item, MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
-        {
-            var newThemeSongs = LoadThemeSongs(fileSystemChildren, options.DirectoryService);
-            var newThemeSongIds = newThemeSongs.Select(i => i.Id).ToArray();
-
-            var themeSongsChanged = !item.ThemeSongIds.SequenceEqual(newThemeSongIds);
-
-            var ownerId = item.Id;
-
-            var tasks = newThemeSongs.Select(i =>
-            {
-                var subOptions = new MetadataRefreshOptions(options);
+            item.ExtraIds = newExtraIds;
 
-                if (!i.ExtraType.HasValue ||
-                    i.ExtraType.Value != Model.Entities.ExtraType.ThemeSong ||
-                    i.OwnerId != ownerId ||
-                    !i.ParentId.Equals(Guid.Empty))
-                {
-                    i.ExtraType = Model.Entities.ExtraType.ThemeSong;
-                    i.OwnerId = ownerId;
-                    i.ParentId = Guid.Empty;
-                    subOptions.ForceSave = true;
-                }
-
-                return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
-            });
-
-            await Task.WhenAll(tasks).ConfigureAwait(false);
-
-            // They are expected to be sorted by SortName
-            item.ThemeSongIds = newThemeSongs.OrderBy(i => i.SortName).Select(i => i.Id).ToArray();
-
-            return themeSongsChanged;
+            return true;
         }
 
         public string GetPresentationUniqueKey()
@@ -2891,14 +2690,14 @@ namespace MediaBrowser.Controller.Entities
                 StringComparison.OrdinalIgnoreCase);
         }
 
-        public IEnumerable<BaseItem> GetThemeSongs()
+        public IReadOnlyList<BaseItem> GetThemeSongs()
         {
-            return ThemeSongIds.Select(LibraryManager.GetItemById);
+            return GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong).ToArray();
         }
 
-        public IEnumerable<BaseItem> GetThemeVideos()
+        public IReadOnlyList<BaseItem> GetThemeVideos()
         {
-            return ThemeVideoIds.Select(LibraryManager.GetItemById);
+            return GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo).ToArray();
         }
 
         /// <summary>

+ 4 - 64
MediaBrowser.Controller/Entities/IHasTrailers.cs

@@ -2,7 +2,6 @@
 
 #pragma warning disable CS1591
 
-using System;
 using System.Collections.Generic;
 using MediaBrowser.Model.Entities;
 
@@ -17,18 +16,10 @@ namespace MediaBrowser.Controller.Entities
         IReadOnlyList<MediaUrl> RemoteTrailers { get; set; }
 
         /// <summary>
-        /// Gets or sets the local trailer ids.
+        /// Gets the local trailers.
         /// </summary>
-        /// <value>The local trailer ids.</value>
-        IReadOnlyList<Guid> LocalTrailerIds { get; set; }
-
-        /// <summary>
-        /// Gets or sets the remote trailer ids.
-        /// </summary>
-        /// <value>The remote trailer ids.</value>
-        IReadOnlyList<Guid> RemoteTrailerIds { get; set; }
-
-        Guid Id { get; set; }
+        /// <value>The local trailers.</value>
+        IReadOnlyList<BaseItem> LocalTrailers { get; }
     }
 
     /// <summary>
@@ -42,57 +33,6 @@ namespace MediaBrowser.Controller.Entities
         /// <param name="item">Media item.</param>
         /// <returns><see cref="IReadOnlyList{Guid}" />.</returns>
         public static int GetTrailerCount(this IHasTrailers item)
-            => item.LocalTrailerIds.Count + item.RemoteTrailerIds.Count;
-
-        /// <summary>
-        /// Gets the trailer ids.
-        /// </summary>
-        /// <param name="item">Media item.</param>
-        /// <returns><see cref="IReadOnlyList{Guid}" />.</returns>
-        public static IReadOnlyList<Guid> GetTrailerIds(this IHasTrailers item)
-        {
-            var localIds = item.LocalTrailerIds;
-            var remoteIds = item.RemoteTrailerIds;
-
-            var all = new Guid[localIds.Count + remoteIds.Count];
-            var index = 0;
-            foreach (var id in localIds)
-            {
-                all[index++] = id;
-            }
-
-            foreach (var id in remoteIds)
-            {
-                all[index++] = id;
-            }
-
-            return all;
-        }
-
-        /// <summary>
-        /// Gets the trailers.
-        /// </summary>
-        /// <param name="item">Media item.</param>
-        /// <returns><see cref="IReadOnlyList{BaseItem}" />.</returns>
-        public static IReadOnlyList<BaseItem> GetTrailers(this IHasTrailers item)
-        {
-            var localIds = item.LocalTrailerIds;
-            var remoteIds = item.RemoteTrailerIds;
-            var libraryManager = BaseItem.LibraryManager;
-
-            var all = new BaseItem[localIds.Count + remoteIds.Count];
-            var index = 0;
-            foreach (var id in localIds)
-            {
-                all[index++] = libraryManager.GetItemById(id);
-            }
-
-            foreach (var id in remoteIds)
-            {
-                all[index++] = libraryManager.GetItemById(id);
-            }
-
-            return all;
-        }
+            => item.LocalTrailers.Count + item.RemoteTrailers.Count;
     }
 }

+ 3 - 9
MediaBrowser.Controller/Entities/Movies/BoxSet.cs

@@ -9,7 +9,6 @@ using System.Text.Json.Serialization;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 
 namespace MediaBrowser.Controller.Entities.Movies
@@ -21,10 +20,6 @@ namespace MediaBrowser.Controller.Entities.Movies
     {
         public BoxSet()
         {
-            RemoteTrailers = Array.Empty<MediaUrl>();
-            LocalTrailerIds = Array.Empty<Guid>();
-            RemoteTrailerIds = Array.Empty<Guid>();
-
             DisplayOrder = ItemSortBy.PremiereDate;
         }
 
@@ -38,10 +33,9 @@ namespace MediaBrowser.Controller.Entities.Movies
         public override bool SupportsPeople => true;
 
         /// <inheritdoc />
-        public IReadOnlyList<Guid> LocalTrailerIds { get; set; }
-
-        /// <inheritdoc />
-        public IReadOnlyList<Guid> RemoteTrailerIds { get; set; }
+        public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
+            .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
+            .ToArray();
 
         /// <summary>
         /// Gets or sets the display order.

+ 18 - 62
MediaBrowser.Controller/Entities/Movies/Movie.cs

@@ -7,12 +7,9 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using System.Text.Json.Serialization;
-using System.Threading;
-using System.Threading.Tasks;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Providers;
 
 namespace MediaBrowser.Controller.Entities.Movies
@@ -22,22 +19,29 @@ namespace MediaBrowser.Controller.Entities.Movies
     /// </summary>
     public class Movie : Video, IHasSpecialFeatures, IHasTrailers, IHasLookupInfo<MovieInfo>, ISupportsBoxSetGrouping
     {
-        public Movie()
-        {
-            SpecialFeatureIds = Array.Empty<Guid>();
-            RemoteTrailers = Array.Empty<MediaUrl>();
-            LocalTrailerIds = Array.Empty<Guid>();
-            RemoteTrailerIds = Array.Empty<Guid>();
-        }
+        private IReadOnlyList<Guid> _specialFeatureIds;
 
         /// <inheritdoc />
-        public IReadOnlyList<Guid> SpecialFeatureIds { get; set; }
+        public IReadOnlyList<Guid> SpecialFeatureIds
+        {
+            get
+            {
+                return _specialFeatureIds ??= GetExtras()
+                    .Where(extra => extra.ExtraType != Model.Entities.ExtraType.Trailer)
+                    .Select(song => song.Id)
+                    .ToArray();
+            }
 
-        /// <inheritdoc />
-        public IReadOnlyList<Guid> LocalTrailerIds { get; set; }
+            set
+            {
+                _specialFeatureIds = value;
+            }
+        }
 
         /// <inheritdoc />
-        public IReadOnlyList<Guid> RemoteTrailerIds { get; set; }
+        public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
+            .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
+            .ToArray();
 
         /// <summary>
         /// Gets or sets the name of the TMDB collection.
@@ -66,54 +70,6 @@ namespace MediaBrowser.Controller.Entities.Movies
             return 2.0 / 3;
         }
 
-        protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
-        {
-            var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
-
-            // Must have a parent to have special features
-            // In other words, it must be part of the Parent/Child tree
-            if (IsFileProtocol && SupportsOwnedItems && !IsInMixedFolder)
-            {
-                var specialFeaturesChanged = await RefreshSpecialFeatures(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
-
-                if (specialFeaturesChanged)
-                {
-                    hasChanges = true;
-                }
-            }
-
-            return hasChanges;
-        }
-
-        private async Task<bool> RefreshSpecialFeatures(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
-        {
-            var newItems = LibraryManager.FindExtras(this, fileSystemChildren, options.DirectoryService).ToList();
-            var newItemIds = newItems.Select(i => i.Id).ToArray();
-
-            var itemsChanged = !SpecialFeatureIds.SequenceEqual(newItemIds);
-
-            var ownerId = Id;
-
-            var tasks = newItems.Select(i =>
-            {
-                var subOptions = new MetadataRefreshOptions(options);
-
-                if (i.OwnerId != ownerId)
-                {
-                    i.OwnerId = ownerId;
-                    subOptions.ForceSave = true;
-                }
-
-                return RefreshMetadataForOwnedItem(i, false, subOptions, cancellationToken);
-            });
-
-            await Task.WhenAll(tasks).ConfigureAwait(false);
-
-            SpecialFeatureIds = newItemIds;
-
-            return itemsChanged;
-        }
-
         /// <inheritdoc />
         public override UnratedItem GetBlockUnratedType()
         {

+ 3 - 11
MediaBrowser.Controller/Entities/TV/Episode.cs

@@ -20,18 +20,10 @@ namespace MediaBrowser.Controller.Entities.TV
     /// </summary>
     public class Episode : Video, IHasTrailers, IHasLookupInfo<EpisodeInfo>, IHasSeries
     {
-        public Episode()
-        {
-            RemoteTrailers = Array.Empty<MediaUrl>();
-            LocalTrailerIds = Array.Empty<Guid>();
-            RemoteTrailerIds = Array.Empty<Guid>();
-        }
-
-        /// <inheritdoc />
-        public IReadOnlyList<Guid> LocalTrailerIds { get; set; }
-
         /// <inheritdoc />
-        public IReadOnlyList<Guid> RemoteTrailerIds { get; set; }
+        public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
+            .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
+            .ToArray();
 
         /// <summary>
         /// Gets or sets the season in which it aired.

+ 3 - 7
MediaBrowser.Controller/Entities/TV/Series.cs

@@ -27,9 +27,6 @@ namespace MediaBrowser.Controller.Entities.TV
     {
         public Series()
         {
-            RemoteTrailers = Array.Empty<MediaUrl>();
-            LocalTrailerIds = Array.Empty<Guid>();
-            RemoteTrailerIds = Array.Empty<Guid>();
             AirDays = Array.Empty<DayOfWeek>();
         }
 
@@ -53,10 +50,9 @@ namespace MediaBrowser.Controller.Entities.TV
         public override bool SupportsPeople => true;
 
         /// <inheritdoc />
-        public IReadOnlyList<Guid> LocalTrailerIds { get; set; }
-
-        /// <inheritdoc />
-        public IReadOnlyList<Guid> RemoteTrailerIds { get; set; }
+        public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
+            .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer)
+            .ToArray();
 
         /// <summary>
         /// Gets or sets the display order.

+ 4 - 5
MediaBrowser.Controller/Entities/UserViewBuilder.cs

@@ -745,10 +745,9 @@ namespace MediaBrowser.Controller.Entities
                 var val = query.HasTrailer.Value;
                 var trailerCount = 0;
 
-                var hasTrailers = item as IHasTrailers;
-                if (hasTrailers != null)
+                if (item is IHasTrailers hasTrailers)
                 {
-                    trailerCount = hasTrailers.GetTrailerIds().Count;
+                    trailerCount = hasTrailers.GetTrailerCount();
                 }
 
                 var ok = val ? trailerCount > 0 : trailerCount == 0;
@@ -763,7 +762,7 @@ namespace MediaBrowser.Controller.Entities
             {
                 var filterValue = query.HasThemeSong.Value;
 
-                var themeCount = item.ThemeSongIds.Length;
+                var themeCount = item.GetThemeSongs().Count;
                 var ok = filterValue ? themeCount > 0 : themeCount == 0;
 
                 if (!ok)
@@ -776,7 +775,7 @@ namespace MediaBrowser.Controller.Entities
             {
                 var filterValue = query.HasThemeVideo.Value;
 
-                var themeCount = item.ThemeVideoIds.Length;
+                var themeCount = item.GetThemeVideos().Count;
                 var ok = filterValue ? themeCount > 0 : themeCount == 0;
 
                 if (!ok)

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

@@ -6,7 +6,6 @@ using System;
 using System.Collections.Generic;
 using System.Threading;
 using System.Threading.Tasks;
-using Emby.Naming.Common;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
@@ -426,29 +425,15 @@ namespace MediaBrowser.Controller.Library
         /// <returns>Guid.</returns>
         Guid GetNewItemId(string key, Type type);
 
-        /// <summary>
-        /// Finds the trailers.
-        /// </summary>
-        /// <param name="owner">The owner.</param>
-        /// <param name="fileSystemChildren">The file system children.</param>
-        /// <param name="directoryService">The directory service.</param>
-        /// <returns>IEnumerable&lt;Trailer&gt;.</returns>
-        IEnumerable<Video> FindTrailers(
-            BaseItem owner,
-            List<FileSystemMetadata> fileSystemChildren,
-            IDirectoryService directoryService);
-
         /// <summary>
         /// Finds the extras.
         /// </summary>
         /// <param name="owner">The owner.</param>
         /// <param name="fileSystemChildren">The file system children.</param>
-        /// <param name="directoryService">The directory service.</param>
         /// <returns>IEnumerable&lt;Video&gt;.</returns>
         IEnumerable<Video> FindExtras(
             BaseItem owner,
-            List<FileSystemMetadata> fileSystemChildren,
-            IDirectoryService directoryService);
+            List<FileSystemMetadata> fileSystemChildren);
 
         /// <summary>
         /// Gets the collection folders.

+ 12 - 11
MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs

@@ -106,7 +106,7 @@ namespace MediaBrowser.LocalMetadata.Images
         {
             if (!item.IsFileProtocol)
             {
-                return Enumerable.Empty<FileSystemMetadata>();
+                yield break;
             }
 
             var path = item.ContainingFolderPath;
@@ -114,20 +114,21 @@ namespace MediaBrowser.LocalMetadata.Images
             // Exit if the cache dir does not exist, alternative solution is to create it, but that's a lot of empty dirs...
             if (!Directory.Exists(path))
             {
-                return Enumerable.Empty<FileSystemMetadata>();
+                yield break;
             }
 
-            if (includeDirectories)
+            var files = directoryService.GetFileSystemEntries(path).OrderBy(i => Array.IndexOf(BaseItem.SupportedImageExtensions, i.Extension ?? string.Empty));
+            var count = BaseItem.SupportedImageExtensions.Length;
+            foreach (var file in files)
             {
-                return directoryService.GetFileSystemEntries(path)
-                .Where(i => BaseItem.SupportedImageExtensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase) || i.IsDirectory)
-
-                .OrderBy(i => Array.IndexOf(BaseItem.SupportedImageExtensions, i.Extension ?? string.Empty));
+                for (var i = 0; i < count; i++)
+                {
+                    if ((includeDirectories && file.IsDirectory) || string.Equals(BaseItem.SupportedImageExtensions[i], file.Extension, StringComparison.OrdinalIgnoreCase))
+                    {
+                        yield return file;
+                    }
+                }
             }
-
-            return directoryService.GetFiles(path)
-                .Where(i => BaseItem.SupportedImageExtensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase))
-                .OrderBy(i => Array.IndexOf(BaseItem.SupportedImageExtensions, i.Extension ?? string.Empty));
         }
 
         /// <inheritdoc />

+ 2 - 9
tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs

@@ -81,9 +81,7 @@ namespace Jellyfin.Naming.Tests.Video
 
         private void Test(string input, ExtraType? expectedType)
         {
-            var parser = GetExtraTypeParser(_videoOptions);
-
-            var extraType = parser.GetExtraInfo(input).ExtraType;
+            var extraType = ExtraResolver.GetExtraInfo(input, _videoOptions).ExtraType;
 
             Assert.Equal(expectedType, extraType);
         }
@@ -93,14 +91,9 @@ namespace Jellyfin.Naming.Tests.Video
         {
             var rule = new ExtraRule(ExtraType.Unknown, ExtraRuleType.Regex, @"([eE]x(tra)?\.\w+)", MediaType.Video);
             var options = new NamingOptions { VideoExtraRules = new[] { rule } };
-            var res = GetExtraTypeParser(options).GetExtraInfo("extra.mp4");
+            var res = ExtraResolver.GetExtraInfo("extra.mp4", options);
 
             Assert.Equal(rule, res.Rule);
         }
-
-        private ExtraResolver GetExtraTypeParser(NamingOptions videoOptions)
-        {
-            return new ExtraResolver(videoOptions);
-        }
     }
 }

+ 4 - 16
tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs

@@ -30,8 +30,8 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
-            Assert.Single(result[0].Extras);
+            Assert.Single(result.Where(v => v.ExtraType == null));
+            Assert.Single(result.Where(v => v.ExtraType != null));
         }
 
         [Fact]
@@ -53,8 +53,8 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
-            Assert.Single(result[0].Extras);
+            Assert.Single(result.Where(v => v.ExtraType == null));
+            Assert.Single(result.Where(v => v.ExtraType != null));
             Assert.Equal(2, result[0].AlternateVersions.Count);
         }
 
@@ -102,7 +102,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Equal(7, result.Count);
-            Assert.Empty(result[0].Extras);
             Assert.Empty(result[0].AlternateVersions);
         }
 
@@ -130,7 +129,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Single(result);
-            Assert.Empty(result[0].Extras);
             Assert.Equal(7, result[0].AlternateVersions.Count);
         }
 
@@ -159,7 +157,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Equal(9, result.Count);
-            Assert.Empty(result[0].Extras);
             Assert.Empty(result[0].AlternateVersions);
         }
 
@@ -184,7 +181,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Equal(5, result.Count);
-            Assert.Empty(result[0].Extras);
             Assert.Empty(result[0].AlternateVersions);
         }
 
@@ -211,7 +207,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Equal(5, result.Count);
-            Assert.Empty(result[0].Extras);
             Assert.Empty(result[0].AlternateVersions);
         }
 
@@ -239,7 +234,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Single(result);
-            Assert.Empty(result[0].Extras);
             Assert.Equal(7, result[0].AlternateVersions.Count);
             Assert.False(result[0].AlternateVersions[2].Is3D);
             Assert.True(result[0].AlternateVersions[3].Is3D);
@@ -270,7 +264,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Single(result);
-            Assert.Empty(result[0].Extras);
             Assert.Equal(7, result[0].AlternateVersions.Count);
             Assert.False(result[0].AlternateVersions[3].Is3D);
             Assert.True(result[0].AlternateVersions[4].Is3D);
@@ -320,7 +313,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Equal(7, result.Count);
-            Assert.Empty(result[0].Extras);
             Assert.Empty(result[0].AlternateVersions);
         }
 
@@ -347,7 +339,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Equal(5, result.Count);
-            Assert.Empty(result[0].Extras);
             Assert.Empty(result[0].AlternateVersions);
         }
 
@@ -369,7 +360,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Single(result);
-            Assert.Empty(result[0].Extras);
             Assert.Single(result[0].AlternateVersions);
         }
 
@@ -391,7 +381,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Single(result);
-            Assert.Empty(result[0].Extras);
             Assert.Single(result[0].AlternateVersions);
         }
 
@@ -413,7 +402,6 @@ namespace Jellyfin.Naming.Tests.Video
                 _namingOptions).ToList();
 
             Assert.Single(result);
-            Assert.Empty(result[0].Extras);
             Assert.Single(result[0].AlternateVersions);
         }
 

+ 22 - 71
tests/Jellyfin.Naming.Tests/Video/StackTests.cs

@@ -22,9 +22,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Bad Boys (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
             TestStackInfo(result[0], "Bad Boys (2006)", 4);
@@ -39,9 +37,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Bad Boys (2007).mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -55,9 +51,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Bad Boys 2007.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -71,9 +65,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300 (2007).mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -87,9 +79,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300 2007.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -103,9 +93,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Star Trek 2- The wrath of khan.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
             Assert.Empty(result);
         }
 
@@ -119,9 +107,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Red Riding in the Year of Our Lord 1974 (2009).mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -135,9 +121,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "d:/movies/300 2006 part2.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
             TestStackInfo(result[0], "300 2006", 2);
@@ -155,9 +139,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Bad Boys (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
             TestStackInfo(result[0], "Bad Boys (2006).stv.unrated.multi.1080p.bluray.x264-rough", 4);
@@ -175,9 +157,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Bad Boys (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -194,9 +174,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300 (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
             TestStackInfo(result[0], "300 (2006)", 4);
@@ -214,9 +192,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Bad Boys (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
             TestStackInfo(result[0], "Bad Boys (2006)", 3);
@@ -238,9 +214,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300 (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Equal(2, result.Count);
             TestStackInfo(result[1], "Bad Boys (2006)", 4);
@@ -256,9 +230,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "blah blah - cd 2"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveDirectories(files).ToList();
+            var result = StackResolver.ResolveDirectories(files, _namingOptions).ToList();
 
             Assert.Single(result);
             TestStackInfo(result[0], "blah blah", 2);
@@ -275,9 +247,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
 
@@ -297,9 +267,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Avengers part3.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Equal(2, result.Count);
 
@@ -328,9 +296,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Equal(3, result.Count);
 
@@ -354,9 +320,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "300 (2006)-trailer.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
 
@@ -375,9 +339,7 @@ namespace Jellyfin.Naming.Tests.Video
                 new FileSystemMetadata { FullName = "300 (2006) part1", IsDirectory = true }
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.Resolve(files).ToList();
+            var result = StackResolver.Resolve(files, _namingOptions).ToList();
 
             Assert.Equal(2, result.Count);
             TestStackInfo(result[0], "300 (2006)", 3);
@@ -397,9 +359,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Harry Potter and the Deathly Hallows 4.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Empty(result);
         }
@@ -414,9 +374,7 @@ namespace Jellyfin.Naming.Tests.Video
                 "Neverland (2011)[720p][PG][Voted 6.5][Family-Fantasy]part2.mkv"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveFiles(files).ToList();
+            var result = StackResolver.ResolveFiles(files, _namingOptions).ToList();
 
             Assert.Single(result);
             Assert.Equal(2, result[0].Files.Count);
@@ -432,9 +390,7 @@ namespace Jellyfin.Naming.Tests.Video
                 @"M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 02)"
             };
 
-            var resolver = GetResolver();
-
-            var result = resolver.ResolveDirectories(files).ToList();
+            var result = StackResolver.ResolveDirectories(files, _namingOptions).ToList();
 
             Assert.Single(result);
             Assert.Equal(2, result[0].Files.Count);
@@ -445,10 +401,5 @@ namespace Jellyfin.Naming.Tests.Video
             Assert.Equal(fileCount, stack.Files.Count);
             Assert.Equal(name, stack.Name);
         }
-
-        private StackResolver GetResolver()
-        {
-            return new StackResolver(_namingOptions);
-        }
     }
 }

+ 66 - 41
tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs

@@ -2,6 +2,7 @@ using System;
 using System.Linq;
 using Emby.Naming.Common;
 using Emby.Naming.Video;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using Xunit;
 
@@ -48,16 +49,25 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Equal(5, result.Count);
+            Assert.Equal(11, result.Count);
             var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal));
             Assert.NotNull(batman);
             Assert.Equal(3, batman!.Files.Count);
-            Assert.Equal(3, batman!.Extras.Count);
 
             var harry = result.FirstOrDefault(x => string.Equals(x.Name, "Harry Potter and the Deathly Hallows", StringComparison.Ordinal));
             Assert.NotNull(harry);
             Assert.Equal(4, harry!.Files.Count);
-            Assert.Equal(2, harry!.Extras.Count);
+
+            Assert.False(result[2].ExtraType.HasValue);
+
+            Assert.Equal(ExtraType.Trailer, result[3].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[4].ExtraType);
+            Assert.Equal(ExtraType.DeletedScene, result[5].ExtraType);
+            Assert.Equal(ExtraType.Sample, result[6].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[7].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[8].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[9].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[10].ExtraType);
         }
 
         [Fact]
@@ -97,7 +107,8 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
         }
 
         [Fact]
@@ -117,7 +128,8 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
         }
 
         [Fact]
@@ -138,15 +150,18 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[2].ExtraType);
         }
 
         [Fact]
-        public void TestDifferentNames()
+        public void Resolve_SameNameAndYear_ReturnsSingleItem()
         {
             var files = new[]
             {
                 "Looper (2012)-trailer.mkv",
+                "Looper 2012-trailer.mkv",
                 "Looper.2012.bluray.720p.x264.mkv"
             };
 
@@ -158,7 +173,30 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[2].ExtraType);
+        }
+
+        [Fact]
+        public void Resolve_TrailerMatchesFolderName_ReturnsSingleItem()
+        {
+            var files = new[]
+            {
+                "/movies/Looper (2012)/Looper (2012)-trailer.mkv",
+                "/movies/Looper (2012)/Looper.bluray.720p.x264.mkv"
+            };
+
+            var result = VideoListResolver.Resolve(
+                files.Select(i => new FileSystemMetadata
+                {
+                    IsDirectory = false,
+                    FullName = i
+                }).ToList(),
+                _namingOptions).ToList();
+
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
         }
 
         [Fact]
@@ -233,27 +271,7 @@ namespace Jellyfin.Naming.Tests.Video
             {
                 @"No (2012) part1.mp4",
                 @"No (2012) part2.mp4",
-                @"No (2012) part1-trailer.mp4"
-            };
-
-            var result = VideoListResolver.Resolve(
-                files.Select(i => new FileSystemMetadata
-                {
-                    IsDirectory = false,
-                    FullName = i
-                }).ToList(),
-                _namingOptions).ToList();
-
-            Assert.Single(result);
-        }
-
-        [Fact]
-        public void TestStackedWithTrailer2()
-        {
-            var files = new[]
-            {
-                @"No (2012) part1.mp4",
-                @"No (2012) part2.mp4",
+                @"No (2012) part1-trailer.mp4",
                 @"No (2012)-trailer.mp4"
             };
 
@@ -265,7 +283,10 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.Equal(3, result.Count);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[2].ExtraType);
         }
 
         [Fact]
@@ -276,7 +297,7 @@ namespace Jellyfin.Naming.Tests.Video
                 @"/Movies/Top Gun (1984)/movie.mp4",
                 @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer.mp4",
                 @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer2.mp4",
-                @"trailer.mp4"
+                @"/Movies/trailer.mp4"
             };
 
             var result = VideoListResolver.Resolve(
@@ -287,7 +308,10 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[2].ExtraType);
+            Assert.Equal(ExtraType.Trailer, result[3].ExtraType);
         }
 
         [Fact]
@@ -396,7 +420,7 @@ namespace Jellyfin.Naming.Tests.Video
             var files = new[]
             {
                 @"/Server/Despicable Me/Despicable Me (2010).mkv",
-                @"/Server/Despicable Me/movie-trailer.mkv"
+                @"/Server/Despicable Me/trailer.mkv"
             };
 
             var result = VideoListResolver.Resolve(
@@ -407,18 +431,17 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
         }
 
         [Fact]
-        public void TestTrailerFalsePositives()
+        public void Resolve_TrailerInTrailersFolder_ReturnsCorrectExtraType()
         {
             var files = new[]
             {
-                @"/Server/Despicable Me/Skyscraper (2018) - Big Game Spot.mkv",
-                @"/Server/Despicable Me/Skyscraper (2018) - Trailer.mkv",
-                @"/Server/Despicable Me/Baywatch (2017) - Big Game Spot.mkv",
-                @"/Server/Despicable Me/Baywatch (2017) - Trailer.mkv"
+                @"/Server/Despicable Me/Despicable Me (2010).mkv",
+                @"/Server/Despicable Me/trailers/some title.mkv"
             };
 
             var result = VideoListResolver.Resolve(
@@ -429,7 +452,8 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Equal(4, result.Count);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
         }
 
         [Fact]
@@ -449,7 +473,8 @@ namespace Jellyfin.Naming.Tests.Video
                 }).ToList(),
                 _namingOptions).ToList();
 
-            Assert.Single(result);
+            Assert.False(result[0].ExtraType.HasValue);
+            Assert.Equal(ExtraType.Trailer, result[1].ExtraType);
         }
 
         [Fact]

+ 8 - 5
tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs

@@ -1,4 +1,5 @@
-using Emby.Server.Implementations.Library.Resolvers.TV;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers.TV;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.TV;
@@ -13,12 +14,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library
 {
     public class EpisodeResolverTest
     {
+        private static readonly NamingOptions _namingOptions = new ();
+
         [Fact]
         public void Resolve_GivenVideoInExtrasFolder_DoesNotResolveToEpisode()
         {
             var parent = new Folder { Name = "extras" };
 
-            var episodeResolver = new EpisodeResolver(null);
+            var episodeResolver = new EpisodeResolver(_namingOptions);
             var itemResolveArgs = new ItemResolveArgs(
                 Mock.Of<IServerApplicationPaths>(),
                 Mock.Of<IDirectoryService>())
@@ -41,14 +44,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library
 
             // Have to create a mock because of moq proxies not being castable to a concrete implementation
             // https://github.com/jellyfin/jellyfin/blob/ab0cff8556403e123642dc9717ba778329554634/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs#L48
-            var episodeResolver = new EpisodeResolverMock();
+            var episodeResolver = new EpisodeResolverMock(_namingOptions);
             var itemResolveArgs = new ItemResolveArgs(
                 Mock.Of<IServerApplicationPaths>(),
                 Mock.Of<IDirectoryService>())
             {
                 Parent = series,
                 CollectionType = CollectionType.TvShows,
-                FileInfo = new FileSystemMetadata()
+                FileInfo = new FileSystemMetadata
                 {
                     FullName = "Extras/Extras S01E01.mkv"
                 }
@@ -58,7 +61,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
 
         private class EpisodeResolverMock : EpisodeResolver
         {
-            public EpisodeResolverMock() : base(null)
+            public EpisodeResolverMock(NamingOptions namingOptions) : base(namingOptions)
             {
             }
 

+ 178 - 0
tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs

@@ -0,0 +1,178 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager;
+
+public class FindExtrasTests
+{
+    private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager;
+
+    public FindExtrasTests()
+    {
+        var fixture = new Fixture().Customize(new AutoMoqCustomization());
+        fixture.Register(() => new NamingOptions());
+        var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
+        configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+        var fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
+        fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
+        _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts(
+                fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
+                new List<IItemResolver> { new GenericVideoResolver<Video>(fixture.Create<NamingOptions>()) },
+                fixture.Create<IEnumerable<IIntroProvider>>(),
+                fixture.Create<IEnumerable<IBaseItemComparer>>(),
+                fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
+            .Create();
+
+        // This is pretty terrible but unavoidable
+        BaseItem.FileSystem ??= fixture.Create<IFileSystem>();
+        BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>();
+    }
+
+    [Fact]
+    public void FindExtras_SeparateMovieFolder_FindsCorrectExtras()
+    {
+        var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+        var paths = new List<string>
+        {
+            "/movies/Up/Up.mkv",
+            "/movies/Up/Up - trailer.mkv",
+            "/movies/Up/Up - sample.mkv",
+            "/movies/Up/Up something else.mkv"
+        };
+
+        var files = paths.Select(p => new FileSystemMetadata
+        {
+            FullName = p,
+            IsDirectory = false
+        }).ToList();
+
+        var extras = _libraryManager.FindExtras(owner, files).OrderBy(e => e.ExtraType).ToList();
+
+        Assert.Equal(2, extras.Count);
+        Assert.Equal(ExtraType.Trailer, extras[0].ExtraType);
+        Assert.Equal(ExtraType.Sample, extras[1].ExtraType);
+    }
+
+    [Fact]
+    public void FindExtras_SeparateMovieFolderWithMixedExtras_FindsCorrectExtras()
+    {
+        var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+        var paths = new List<string>
+        {
+            "/movies/Up/Up.mkv",
+            "/movies/Up/Up - trailer.mkv",
+            "/movies/Up/trailers/some trailer.mkv",
+            "/movies/Up/behind the scenes/the making of Up.mkv",
+            "/movies/Up/behind the scenes.mkv",
+            "/movies/Up/Up - sample.mkv",
+            "/movies/Up/Up something else.mkv"
+        };
+
+        var files = paths.Select(p => new FileSystemMetadata
+        {
+            FullName = p,
+            IsDirectory = false
+        }).ToList();
+
+        var extras = _libraryManager.FindExtras(owner, files).OrderBy(e => e.ExtraType).ToList();
+
+        Assert.Equal(4, extras.Count);
+        Assert.Equal(ExtraType.Trailer, extras[0].ExtraType);
+        Assert.Equal(ExtraType.Trailer, extras[1].ExtraType);
+        Assert.Equal(ExtraType.BehindTheScenes, extras[2].ExtraType);
+        Assert.Equal(ExtraType.Sample, extras[3].ExtraType);
+    }
+
+    [Fact]
+    public void FindExtras_SeparateMovieFolderWithMixedExtras_FindsOnlyExtrasInMovieFolder()
+    {
+        var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+        var paths = new List<string>
+        {
+            "/movies/Up/Up.mkv",
+            "/movies/Up/trailer.mkv",
+            "/movies/Another Movie/trailer.mkv"
+        };
+
+        var files = paths.Select(p => new FileSystemMetadata
+        {
+            FullName = p,
+            IsDirectory = false
+        }).ToList();
+
+        var extras = _libraryManager.FindExtras(owner, files).OrderBy(e => e.ExtraType).ToList();
+
+        Assert.Single(extras);
+        Assert.Equal(ExtraType.Trailer, extras[0].ExtraType);
+        Assert.Equal("trailer", extras[0].FileNameWithoutExtension);
+        Assert.Equal("/movies/Up/trailer.mkv", extras[0].Path);
+    }
+
+    [Fact]
+    public void FindExtras_SeparateMovieFolderWithParts_FindsCorrectExtras()
+    {
+        var owner = new Movie { Name = "Up", Path = "/movies/Up/Up - part1.mkv" };
+        var paths = new List<string>
+        {
+            "/movies/Up/Up - part1.mkv",
+            "/movies/Up/Up - part2.mkv",
+            "/movies/Up/trailer.mkv",
+            "/movies/Another Movie/trailer.mkv"
+        };
+
+        var files = paths.Select(p => new FileSystemMetadata
+        {
+            FullName = p,
+            IsDirectory = false
+        }).ToList();
+
+        var extras = _libraryManager.FindExtras(owner, files).OrderBy(e => e.ExtraType).ToList();
+
+        Assert.Single(extras);
+        Assert.Equal(ExtraType.Trailer, extras[0].ExtraType);
+        Assert.Equal("trailer", extras[0].FileNameWithoutExtension);
+        Assert.Equal("/movies/Up/trailer.mkv", extras[0].Path);
+    }
+
+    [Fact]
+    public void FindExtras_SeriesWithTrailers_FindsCorrectExtras()
+    {
+        var owner = new Series { Name = "Dexter", Path = "/series/Dexter" };
+        var paths = new List<string>
+        {
+            "/series/Dexter/Season 1/S01E01.mkv",
+            "/series/Dexter/trailer.mkv",
+            "/series/Dexter/trailers/trailer2.mkv",
+        };
+
+        var files = paths.Select(p => new FileSystemMetadata
+        {
+            FullName = p,
+            IsDirectory = string.IsNullOrEmpty(Path.GetExtension(p))
+        }).ToList();
+
+        var extras = _libraryManager.FindExtras(owner, files).OrderBy(e => e.ExtraType).ToList();
+
+        Assert.Equal(2, extras.Count);
+        Assert.Equal(ExtraType.Trailer, extras[0].ExtraType);
+        Assert.Equal("trailer", extras[0].FileNameWithoutExtension);
+        Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path);
+        Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path);
+    }
+}