Fixed: Bulk import grouped nested movie folders

This commit is contained in:
Guillem Saiz Pascual 2026-04-06 15:31:19 +02:00
parent 4b85fab05b
commit 3679f7facc
2 changed files with 305 additions and 20 deletions

View file

@ -51,6 +51,17 @@ private void WithNonExistingFolder()
.Returns(false);
}
private void GivenRootFolder(RootFolder rootFolder)
{
Mocker.GetMock<IRootFolderRepository>()
.Setup(s => s.Get(It.IsAny<int>()))
.Returns(rootFolder);
Mocker.GetMock<IMovieRepository>()
.Setup(s => s.AllMoviePaths())
.Returns(new Dictionary<int, string>());
}
[TestCase("D:\\TV Shows\\")]
[TestCase("//server//folder")]
public void should_be_able_to_add_root_dir(string path)
@ -278,20 +289,14 @@ public void should_get_unmapped_folders_inside_letter_subfolder()
var subFolders = new[]
{
"Movie1",
"Movie2",
"Movie3",
"Movie 1 (2001)",
"Movie 2 (2002)",
"Movie 3 (2003)",
};
var folders = subFolders.Select(f => Path.Combine(subFolderPath, f)).ToArray();
Mocker.GetMock<IRootFolderRepository>()
.Setup(s => s.Get(It.IsAny<int>()))
.Returns(rootFolder);
Mocker.GetMock<IMovieRepository>()
.Setup(s => s.AllMoviePaths())
.Returns(new Dictionary<int, string>());
GivenRootFolder(rootFolder);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(rootFolder.Path))
@ -305,5 +310,205 @@ public void should_get_unmapped_folders_inside_letter_subfolder()
unmappedFolders.Count.Should().Be(3);
}
[Test]
public void should_get_top_level_movie_folders()
{
var rootFolderPath = @"C:\Test\Movies".AsOsAgnostic();
var rootFolder = Builder<RootFolder>.CreateNew()
.With(r => r.Path = rootFolderPath)
.Build();
var movieFolders = new[]
{
"Movie 1 (2001)",
"Movie 2 (2002)",
"Movie 3 (2003)",
};
var folders = movieFolders.Select(f => Path.Combine(rootFolderPath, f)).ToArray();
GivenRootFolder(rootFolder);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(rootFolder.Path))
.Returns(folders);
foreach (var folder in folders)
{
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(folder))
.Returns(Array.Empty<string>());
}
var unmappedFolders = Subject.Get(rootFolder.Id, false).UnmappedFolders;
unmappedFolders.Select(f => f.Name).Should().BeEquivalentTo(movieFolders);
}
[Test]
public void should_get_nested_movie_folder_when_parent_is_only_a_grouping_folder()
{
var rootFolderPath = @"C:\Test\Movies".AsOsAgnostic();
var rootFolder = Builder<RootFolder>.CreateNew()
.With(r => r.Path = rootFolderPath)
.Build();
var groupingFolder = Path.Combine(rootFolderPath, "L");
var movieFolder = Path.Combine(groupingFolder, "Ladder 49 (2004)");
var movieFile = Path.Combine(movieFolder, "Ladder 49 (2004) [Bluray-1080p].mkv").AsOsAgnostic();
GivenRootFolder(rootFolder);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(rootFolder.Path))
.Returns(new[] { groupingFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(groupingFolder))
.Returns(new[] { movieFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(movieFolder))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(groupingFolder, false))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(movieFolder, false))
.Returns(new[] { movieFile });
var unmappedFolders = Subject.Get(rootFolder.Id, false).UnmappedFolders;
unmappedFolders.Should().HaveCount(1);
unmappedFolders[0].Name.Should().Be("Ladder 49 (2004)");
unmappedFolders[0].Path.Should().Be(movieFolder);
unmappedFolders[0].RelativePath.Should().Be(Path.Combine("L", "Ladder 49 (2004)").AsOsAgnostic());
}
[Test]
public void should_not_return_grouping_folder_when_only_child_folder_contains_video_file()
{
var rootFolderPath = @"C:\Test\Movies".AsOsAgnostic();
var rootFolder = Builder<RootFolder>.CreateNew()
.With(r => r.Path = rootFolderPath)
.Build();
var groupingFolder = Path.Combine(rootFolderPath, "L");
var movieFolder = Path.Combine(groupingFolder, "Ladder 49 (2004)");
var movieFile = Path.Combine(movieFolder, "Ladder 49 (2004).mkv").AsOsAgnostic();
GivenRootFolder(rootFolder);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(rootFolder.Path))
.Returns(new[] { groupingFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(groupingFolder))
.Returns(new[] { movieFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(movieFolder))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(groupingFolder, false))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(movieFolder, false))
.Returns(new[] { movieFile });
var unmappedFolders = Subject.Get(rootFolder.Id, false).UnmappedFolders;
unmappedFolders.Should().NotContain(u => u.Name == "L");
}
[Test]
public void should_not_return_already_mapped_nested_movie_folder()
{
var rootFolderPath = @"C:\Test\Movies".AsOsAgnostic();
var rootFolder = Builder<RootFolder>.CreateNew()
.With(r => r.Path = rootFolderPath)
.Build();
var groupingFolder = Path.Combine(rootFolderPath, "L");
var movieFolder = Path.Combine(groupingFolder, "Ladder 49 (2004)");
var movieFile = Path.Combine(movieFolder, "Ladder 49 (2004).mkv").AsOsAgnostic();
Mocker.GetMock<IRootFolderRepository>()
.Setup(s => s.Get(It.IsAny<int>()))
.Returns(rootFolder);
Mocker.GetMock<IMovieRepository>()
.Setup(s => s.AllMoviePaths())
.Returns(new Dictionary<int, string> { { 1, movieFolder } });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(rootFolder.Path))
.Returns(new[] { groupingFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(groupingFolder))
.Returns(new[] { movieFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(movieFolder))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(groupingFolder, false))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(movieFolder, false))
.Returns(new[] { movieFile });
var unmappedFolders = Subject.Get(rootFolder.Id, false).UnmappedFolders;
unmappedFolders.Should().BeEmpty();
}
[Test]
public void should_not_recurse_into_movie_folder_with_disc_subfolders()
{
var rootFolderPath = @"C:\Test\Movies".AsOsAgnostic();
var rootFolder = Builder<RootFolder>.CreateNew()
.With(r => r.Path = rootFolderPath)
.Build();
var movieFolder = Path.Combine(rootFolderPath, "Movie 1 (2001)");
var discFolder = Path.Combine(movieFolder, "BDMV");
GivenRootFolder(rootFolder);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(rootFolder.Path))
.Returns(new[] { movieFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(movieFolder))
.Returns(new[] { discFolder });
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetDirectories(discFolder))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(movieFolder, false))
.Returns(Array.Empty<string>());
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetFiles(discFolder, false))
.Returns(Array.Empty<string>());
var unmappedFolders = Subject.Get(rootFolder.Id, false).UnmappedFolders;
unmappedFolders.Should().HaveCount(1);
unmappedFolders[0].Name.Should().Be("Movie 1 (2001)");
}
}
}

View file

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common;
@ -9,6 +10,7 @@
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Organizer;
@ -48,6 +50,8 @@ public class RootFolderService : IRootFolderService
".grab"
};
private static readonly Regex MovieFolderYearRegex = new Regex(@"\(\d{4}\)", RegexOptions.Compiled);
public RootFolderService(IRootFolderRepository rootFolderRepository,
IDiskProvider diskProvider,
IMovieRepository movieRepository,
@ -157,16 +161,7 @@ private List<UnmappedFolder> GetUnmappedFolders(string path, Dictionary<int, str
return results;
}
var subFolderDepth = _namingConfigService.GetConfig().MovieFolderFormat.Count(f => f == Path.DirectorySeparatorChar);
var possibleMovieFolders = _diskProvider.GetDirectories(path).ToList();
if (subFolderDepth > 0)
{
for (var i = 0; i < subFolderDepth; i++)
{
possibleMovieFolders = possibleMovieFolders.SelectMany(_diskProvider.GetDirectories).ToList();
}
}
var possibleMovieFolders = GetPossibleMovieFolders(path);
var unmappedFolders = possibleMovieFolders.Except(moviePaths.Select(s => s.Value), PathEqualityComparer.Instance).ToList();
@ -197,6 +192,91 @@ private List<UnmappedFolder> GetUnmappedFolders(string path, Dictionary<int, str
return results.OrderBy(u => u.Name, StringComparer.InvariantCultureIgnoreCase).ToList();
}
private List<string> GetPossibleMovieFolders(string path)
{
var subFolderDepth = _namingConfigService.GetConfig().MovieFolderFormat.Count(f => f == Path.DirectorySeparatorChar);
var possibleMovieFolders = new List<string>();
FindPossibleMovieFolders(path, 0, subFolderDepth, possibleMovieFolders);
return possibleMovieFolders;
}
private void FindPossibleMovieFolders(string path, int currentDepth, int expectedMovieFolderDepth, List<string> possibleMovieFolders)
{
var directories = (_diskProvider.GetDirectories(path) ?? Enumerable.Empty<string>()).ToList();
foreach (var directory in directories)
{
var childDirectories = (_diskProvider.GetDirectories(directory) ?? Enumerable.Empty<string>()).ToList();
if (currentDepth < expectedMovieFolderDepth)
{
FindPossibleMovieFolders(directory, currentDepth + 1, expectedMovieFolderDepth, possibleMovieFolders);
continue;
}
if (ShouldRecurseIntoGroupingFolder(directory, childDirectories))
{
FindPossibleMovieFolders(directory, currentDepth + 1, expectedMovieFolderDepth, possibleMovieFolders);
continue;
}
if (IsPossibleMovieFolder(directory, childDirectories))
{
possibleMovieFolders.Add(directory);
}
}
}
private bool IsPossibleMovieFolder(string path, List<string> childDirectories)
{
if (HasVideoFiles(path))
{
return true;
}
if (!childDirectories.Any())
{
return true;
}
return MovieFolderYearRegex.IsMatch(new DirectoryInfo(path).Name);
}
private bool ShouldRecurseIntoGroupingFolder(string path, List<string> childDirectories)
{
if (HasVideoFiles(path) || !childDirectories.Any())
{
return false;
}
var directoryName = new DirectoryInfo(path).Name;
if (MovieFolderYearRegex.IsMatch(directoryName))
{
return false;
}
return childDirectories.All(ContainsMovieFolder);
}
private bool ContainsMovieFolder(string path)
{
if (HasVideoFiles(path))
{
return true;
}
return MovieFolderYearRegex.IsMatch(new DirectoryInfo(path).Name);
}
private bool HasVideoFiles(string path)
{
return (_diskProvider.GetFiles(path, false) ?? Enumerable.Empty<string>())
.Any(file => MediaFileExtensions.Extensions.Contains(Path.GetExtension(file)));
}
public RootFolder Get(int id, bool timeout)
{
var rootFolder = _rootFolderRepository.Get(id);