From f91ebd4c072d8027c474ae5208a3480e34d7713e Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 16 Feb 2026 08:20:51 -0800 Subject: [PATCH] New: Do not automatically import multi-season releases Closes #8133 --- .../DownloadedEpisodesImportServiceFixture.cs | 47 +++++++++++++++---- .../ImportDecisionMakerFixture.cs | 2 +- .../Download/CompletedDownloadService.cs | 7 +++ .../DownloadedEpisodesImportService.cs | 16 ++++++- .../EpisodeImport/ImportDecisionMaker.cs | 19 +++----- .../EpisodeImport/ImportRejectionReason.cs | 3 +- .../Manual/ManualImportService.cs | 6 ++- 7 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs index 0dce58278..82e07a190 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -124,7 +124,7 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); Mocker.GetMock() - .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), true), + .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), true), Times.Never()); VerifyNoImport(); @@ -175,7 +175,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -201,7 +201,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -271,7 +271,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -322,7 +322,7 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.ProcessPath(fileName); Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.Is(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); + .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.Is(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); } [Test] @@ -346,7 +346,7 @@ namespace NzbDrone.Core.Test.MediaFiles var result = Subject.ProcessPath(fileName); Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true), Times.Once()); + .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true), Times.Once()); } [Test] @@ -379,7 +379,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -456,7 +456,7 @@ namespace NzbDrone.Core.Test.MediaFiles var imported = new List(); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -482,7 +482,7 @@ namespace NzbDrone.Core.Test.MediaFiles var imported = new List(); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, true, true)) .Returns(imported); Mocker.GetMock() @@ -499,6 +499,33 @@ namespace NzbDrone.Core.Test.MediaFiles result.First().Result.Should().Be(ImportResultType.Rejected); } + [Test] + public void should_reject_if_download_is_multi_season() + { + GivenValidSeries(); + + _trackedDownload.DownloadItem.Title = "Series Title S01-S11"; + + var folderName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]".AsOsAgnostic(); + + Mocker.GetMock().Setup(c => c.FolderExists(folderName)) + .Returns(true); + + var result = Subject.ProcessPath(folderName, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem); + + result.Count.Should().Be(1); + result.First().Result.Should().Be(ImportResultType.Rejected); + result.First().ImportDecision.Rejections.First().Reason.Should().Be(ImportRejectionReason.MultiSeason); + + Mocker.GetMock().Setup(c => c.GetSeries("foldername")).Returns((Series)null); + + Mocker.GetMock() + .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), true), + Times.Never()); + + VerifyNoImport(); + } + private void VerifyNoImport() { Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true, null, ImportMode.Auto), diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs index 51d181abe..783afd001 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs @@ -103,7 +103,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - Subject.GetImportDecisions(_videoFiles, _series, downloadClientItem, null, false, true); + Subject.GetImportDecisions(_videoFiles, _series, downloadClientItem, null, null, false, true); _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), downloadClientItem), Times.Once()); diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index ffb7b60be..292009e39 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -172,6 +172,13 @@ namespace NzbDrone.Core.Download { return; } + + if (firstResult.ImportDecision.Rejections.FirstOrDefault()?.Reason == ImportRejectionReason.MultiSeason) + { + trackedDownload.Warn(new TrackedDownloadStatusMessage(trackedDownload.DownloadItem.Title, firstResult.Errors)); + SetStateToImportBlocked(trackedDownload); + return; + } } var statusMessages = new List diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index 3584851ab..be003b5e4 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -187,6 +187,7 @@ namespace NzbDrone.Core.MediaFiles var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); var videoFiles = _diskScanService.FilterPaths(directoryInfo.FullName, _diskScanService.GetVideoFiles(directoryInfo.FullName)); + var downloadClientItemInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title); if (downloadClientItem == null) { @@ -202,7 +203,17 @@ namespace NzbDrone.Core.MediaFiles } } - var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, downloadClientItem, folderInfo, true); + if (downloadClientItemInfo is { IsMultiSeason: true }) + { + _logger.Debug("Download client item is marked as multi-season, not processing automatically to avoid importing incorrect files"); + + return new List + { + RejectionResult(ImportRejectionReason.MultiSeason, "Multi-season download, unable to import automatically") + }; + } + + var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, downloadClientItem, downloadClientItemInfo, folderInfo, true); var importResults = _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); if (importMode == ImportMode.Auto) @@ -328,7 +339,8 @@ namespace NzbDrone.Core.MediaFiles } } - var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, series, downloadClientItem, null, true); + var downloadClientItemInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title); + var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, series, downloadClientItem, downloadClientItemInfo, null, true); return _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 64ee67750..18288cd96 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -16,8 +16,8 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport { List GetImportDecisions(List videoFiles, Series series); List GetImportDecisions(List videoFiles, Series series, bool filterExistingFiles); - List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource); - List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles); + List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource); + List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles); ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem); } @@ -58,27 +58,20 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public List GetImportDecisions(List videoFiles, Series series, bool filterExistingFiles) { - return GetImportDecisions(videoFiles, series, null, null, false, filterExistingFiles); + return GetImportDecisions(videoFiles, series, null, null, null, false, filterExistingFiles); } - public List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource) + public List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource) { - return GetImportDecisions(videoFiles, series, downloadClientItem, folderInfo, sceneSource, true); + return GetImportDecisions(videoFiles, series, downloadClientItem, downloadClientItemInfo, folderInfo, sceneSource, true); } - public List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles) + public List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles) { var newFiles = filterExistingFiles ? _mediaFileService.FilterExistingFiles(videoFiles.ToList(), series) : videoFiles.ToList(); _logger.Debug("Analyzing {0}/{1} files.", newFiles.Count, videoFiles.Count); - ParsedEpisodeInfo downloadClientItemInfo = null; - - if (downloadClientItem != null) - { - downloadClientItemInfo = Parser.Parser.ParseTitle(downloadClientItem.Title); - } - // If not importing from a scene source (series folder for example), then assume all files are not samples // to avoid using media info on every file needlessly (especially if Analyse Media Files is disabled). var nonSampleVideoFileCount = sceneSource ? GetNonSampleVideoFileCount(newFiles, series, downloadClientItemInfo, folderInfo) : videoFiles.Count; diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs index 20ca27b02..3a8f49d4c 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs @@ -37,5 +37,6 @@ public enum ImportRejectionReason NotQualityUpgrade, NotRevisionUpgrade, NotCustomFormatUpgrade, - NotCustomFormatUpgradeAfterRename + NotCustomFormatUpgradeAfterRename, + MultiSeason } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index 11c9d56b6..9b80a82ff 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -290,9 +290,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return processedFiles.Concat(processedFolders).Where(i => i != null).ToList(); } + var downloadClientItemInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title); var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); var seriesFiles = _diskScanService.FilterPaths(rootFolder, _diskScanService.GetVideoFiles(baseFolder).ToList()); - var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, downloadClientItem, folderInfo, SceneSource(series, baseFolder), filterExistingFiles); + var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, downloadClientItem, downloadClientItemInfo, folderInfo, SceneSource(series, baseFolder), filterExistingFiles); return decisions.Select(decision => MapItem(decision, rootFolder, downloadId, directoryInfo.Name)).ToList(); } @@ -345,9 +346,12 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual null); } + var downloadClientItemInfo = trackedDownload?.DownloadItem == null ? null : Parser.Parser.ParseTitle(trackedDownload.DownloadItem.Title); + var importDecisions = _importDecisionMaker.GetImportDecisions(new List { file }, series, trackedDownload?.DownloadItem, + downloadClientItemInfo, null, SceneSource(series, baseFolder));