From 18d7a4fe640de2ef8ae03b73b9b3cbc53a902e63 Mon Sep 17 00:00:00 2001 From: Karolis Mazukna Date: Sat, 4 Apr 2026 11:39:38 +0300 Subject: [PATCH] feat: add multi-season pack auto-import support Sonarr already parsed multi-season releases (e.g. S01-S09) but discarded all season numbers except the first and hard-rejected them during import. This change preserves all parsed season numbers through the pipeline and resolves episodes for all seasons, enabling auto-import of manually downloaded multi-season packs. - Add SeasonNumbers array to ParsedEpisodeInfo and populate with expanded range in the parser (discrete seasons kept as-is) - Add MultiSeasonPack (4) to the ReleaseType enum - Extend ParsingService.GetEpisodes() to fetch episodes for all seasons with per-season scene mapping lookup - Remove multi-season import rejection in DownloadedEpisodesImportService - Remove multi-season import-blocked handling in CompletedDownloadService - Derive V5 Queue SeasonNumbers from actual episode data --- .../DownloadedEpisodesImportServiceFixture.cs | 24 ++-- .../ParsingServiceTests/GetEpisodesFixture.cs | 110 ++++++++++++++++++ .../ParserTests/SeasonParserFixture.cs | 21 ++-- .../Download/CompletedDownloadService.cs | 7 -- .../DownloadedEpisodesImportService.cs | 10 -- .../Parser/Model/ParsedEpisodeInfo.cs | 8 +- src/NzbDrone.Core/Parser/Model/ReleaseType.cs | 5 +- src/NzbDrone.Core/Parser/Parser.cs | 19 ++- src/NzbDrone.Core/Parser/ParsingService.cs | 32 +++++ src/Sonarr.Api.V5/Queue/QueueResource.cs | 4 +- 10 files changed, 199 insertions(+), 41 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs index 82e07a190..49f6a70a9 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs @@ -500,7 +500,7 @@ public void should_return_rejection_if_nothing_imported_and_contains_executable_ } [Test] - public void should_reject_if_download_is_multi_season() + public void should_process_multi_season_download() { GivenValidSeries(); @@ -511,19 +511,21 @@ public void should_reject_if_download_is_multi_season() 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); + var imported = new List(); Mocker.GetMock() - .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), true), - Times.Never()); + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(imported); - VerifyNoImport(); + Mocker.GetMock() + .Setup(s => s.Import(It.IsAny>(), true, It.IsAny(), ImportMode.Auto)) + .Returns(imported.Select(i => new ImportResult(i)).ToList()); + + Subject.ProcessPath(folderName, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem); + + Mocker.GetMock() + .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once()); } private void VerifyNoImport() diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs index 9a5610838..754f8c0e8 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs @@ -85,6 +85,13 @@ private void GivenFullSeason() _parsedEpisodeInfo.EpisodeNumbers = Array.Empty(); } + private void GivenMultiSeason(int[] seasonNumbers) + { + GivenFullSeason(); + _parsedEpisodeInfo.IsMultiSeason = true; + _parsedEpisodeInfo.SeasonNumbers = seasonNumbers; + } + [Test] public void should_get_daily_episode_episode_when_search_criteria_is_null() { @@ -560,5 +567,108 @@ public void should_use_original_parse_result_when_special_episode_lookup_by_titl Mocker.GetMock() .Verify(v => v.FindEpisode(_series.TvdbId, _parsedEpisodeInfo.SeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Once()); } + + [Test] + public void should_lookup_multi_season_by_season_numbers() + { + GivenMultiSeason(new[] { 1, 2, 3 }); + + Mocker.GetMock() + .Setup(s => s.GetEpisodesBySeason(_series.Id, It.IsAny())) + .Returns(_episodes); + + Subject.GetEpisodes(_parsedEpisodeInfo, _series, false, null); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 1), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 2), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 3), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySceneSeason(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void should_lookup_multi_season_by_scene_season_numbers_when_series_uses_scene_numbering() + { + GivenSceneNumberingSeries(); + GivenMultiSeason(new[] { 1, 2 }); + + Mocker.GetMock() + .Setup(s => s.GetEpisodesBySceneSeason(_series.Id, It.IsAny())) + .Returns(_episodes); + + Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 1), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 2), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void should_fallback_to_season_lookup_for_multi_season_when_scene_season_returns_no_results() + { + GivenSceneNumberingSeries(); + GivenMultiSeason(new[] { 1, 2 }); + + Mocker.GetMock() + .Setup(s => s.GetEpisodesBySceneSeason(_series.Id, It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.GetEpisodesBySeason(_series.Id, It.IsAny())) + .Returns(_episodes); + + Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 1), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 2), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 1), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 2), Times.Once); + } + + [Test] + public void should_apply_scene_mapping_offset_to_multi_season_numbers() + { + const int tvdbSeasonNumber = 5; + + GivenMultiSeason(new[] { 1, 2, 3 }); + + Mocker.GetMock() + .Setup(v => v.FindSceneMapping(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((s, r, sn) => new SceneMapping { SceneSeasonNumber = 1, SeasonNumber = tvdbSeasonNumber }); + + Mocker.GetMock() + .Setup(s => s.GetEpisodesBySeason(_series.Id, It.IsAny())) + .Returns(_episodes); + + Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); + + // Scene offset is +4 (tvdb 5 - scene 1), so seasons 1,2,3 become 5,6,7 + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 5), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 6), Times.Once); + + Mocker.GetMock() + .Verify(v => v.GetEpisodesBySeason(_series.Id, 7), Times.Once); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs index 17a8a9280..e14cef20f 100644 --- a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs @@ -1,3 +1,4 @@ +using System.Linq; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Test.Framework; @@ -51,6 +52,7 @@ public void should_parse_full_season_release(string postTitle, string title, int result.EpisodeNumbers.Should().BeEmpty(); result.AbsoluteEpisodeNumbers.Should().BeEmpty(); result.FullSeason.Should().BeTrue(); + result.SeasonNumbers.Should().BeEquivalentTo(new[] { season }); } [TestCase("Acropolis Series S05 EXTRAS DVDRip XviD RUNNER", "Acropolis Series", 5)] @@ -98,15 +100,15 @@ public void should_parse_partial_season_release(string postTitle, string title, result.SeasonPart.Should().Be(seasonPart); } - [TestCase("The Series S01-05 WS BDRip X264-REWARD-No Rars", "The Series", 1)] - [TestCase("Series.Title.S01-S09.1080p.AMZN.WEB-DL.DDP2.0.H.264-NTb", "Series Title", 1)] - [TestCase("Series Title S01 - S07 BluRay 1080p x264 REPACK -SacReD", "Series Title", 1)] - [TestCase("Series Title Season 01-07 BluRay 1080p x264 REPACK -SacReD", "Series Title", 1)] - [TestCase("Series Title Season 01 - Season 07 BluRay 1080p x264 REPACK -SacReD", "Series Title", 1)] - [TestCase("Series Title Complete Series S01 S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)", "Series Title", 1)] - [TestCase("Series Title S01 S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)", "Series Title", 1)] - [TestCase("Series Title S01 04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)", "Series Title", 1)] - public void should_parse_multi_season_release(string postTitle, string title, int firstSeason) + [TestCase("The Series S01-05 WS BDRip X264-REWARD-No Rars", "The Series", 1, 5)] + [TestCase("Series.Title.S01-S09.1080p.AMZN.WEB-DL.DDP2.0.H.264-NTb", "Series Title", 1, 9)] + [TestCase("Series Title S01 - S07 BluRay 1080p x264 REPACK -SacReD", "Series Title", 1, 7)] + [TestCase("Series Title Season 01-07 BluRay 1080p x264 REPACK -SacReD", "Series Title", 1, 7)] + [TestCase("Series Title Season 01 - Season 07 BluRay 1080p x264 REPACK -SacReD", "Series Title", 1, 7)] + [TestCase("Series Title Complete Series S01 S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)", "Series Title", 1, 4)] + [TestCase("Series Title S01 S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)", "Series Title", 1, 4)] + [TestCase("Series Title S01 04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)", "Series Title", 1, 4)] + public void should_parse_multi_season_release(string postTitle, string title, int firstSeason, int lastSeason) { var result = Parser.Parser.ParseTitle(postTitle); result.SeasonNumber.Should().Be(firstSeason); @@ -116,6 +118,7 @@ public void should_parse_multi_season_release(string postTitle, string title, in result.FullSeason.Should().BeTrue(); result.IsPartialSeason.Should().BeFalse(); result.IsMultiSeason.Should().BeTrue(); + result.SeasonNumbers.Should().BeEquivalentTo(Enumerable.Range(firstSeason, lastSeason - firstSeason + 1)); } [Test] diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 292009e39..ffb7b60be 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -172,13 +172,6 @@ public void Import(TrackedDownload trackedDownload) { 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 be003b5e4..c173857fb 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -203,16 +203,6 @@ private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode } } - 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); diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index f1a7bdcb4..56296b449 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -22,6 +22,7 @@ public class ParsedEpisodeInfo public bool FullSeason { get; set; } public bool IsPartialSeason { get; set; } public bool IsMultiSeason { get; set; } + public int[] SeasonNumbers { get; set; } public bool IsSeasonExtra { get; set; } public bool IsSplitEpisode { get; set; } public bool IsMiniSeries { get; set; } @@ -37,6 +38,7 @@ public ParsedEpisodeInfo() EpisodeNumbers = Array.Empty(); AbsoluteEpisodeNumbers = Array.Empty(); SpecialAbsoluteEpisodeNumbers = Array.Empty(); + SeasonNumbers = Array.Empty(); Languages = new List(); } @@ -107,7 +109,7 @@ public ReleaseType ReleaseType if (FullSeason) { - return Model.ReleaseType.SeasonPack; + return IsMultiSeason ? Model.ReleaseType.MultiSeasonPack : Model.ReleaseType.SeasonPack; } return Model.ReleaseType.Unknown; @@ -122,6 +124,10 @@ public override string ToString() { episodeString = string.Format("{0}", AirDate); } + else if (FullSeason && IsMultiSeason && SeasonNumbers.Length > 1) + { + episodeString = string.Format("Season {0:00}-{1:00}", SeasonNumbers[0], SeasonNumbers[SeasonNumbers.Length - 1]); + } else if (FullSeason) { episodeString = string.Format("Season {0:00}", SeasonNumber); diff --git a/src/NzbDrone.Core/Parser/Model/ReleaseType.cs b/src/NzbDrone.Core/Parser/Model/ReleaseType.cs index 75d44c424..14f521c49 100644 --- a/src/NzbDrone.Core/Parser/Model/ReleaseType.cs +++ b/src/NzbDrone.Core/Parser/Model/ReleaseType.cs @@ -13,6 +13,9 @@ public enum ReleaseType MultiEpisode = 2, [FieldOption(label: "Season Pack")] - SeasonPack = 3 + SeasonPack = 3, + + [FieldOption(label: "Multi-Season Pack")] + MultiSeasonPack = 4 } } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 02d0aa82e..47104c35a 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -1054,10 +1054,27 @@ private static ParsedEpisodeInfo ParseMatchCollection(MatchCollection matchColle } } - // If more than 1 season was parsed set IsMultiSeason to true so it can be rejected later + // If more than 1 season was parsed set IsMultiSeason to true if (seasons.Distinct().Count() > 1) { result.IsMultiSeason = true; + + var distinctSeasons = seasons.Distinct().OrderBy(s => s).ToArray(); + + if (distinctSeasons.Length == 2) + { + // Range format (e.g., S01-S09) where regex captures only endpoints, expand to full range + result.SeasonNumbers = Enumerable.Range(distinctSeasons[0], distinctSeasons[1] - distinctSeasons[0] + 1).ToArray(); + } + else + { + // Discrete seasons explicitly named (e.g., S01 S03 S05), keep as-is + result.SeasonNumbers = distinctSeasons; + } + } + else if (seasons.Any()) + { + result.SeasonNumbers = seasons.Distinct().ToArray(); } if (seasons.Any()) diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index ac4b3add6..ccb8a5956 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -260,6 +260,38 @@ private List GetEpisodes(ParsedEpisodeInfo parsedEpisodeInfo, Series se { if (parsedEpisodeInfo.FullSeason) { + if (parsedEpisodeInfo.IsMultiSeason && parsedEpisodeInfo.SeasonNumbers.Length > 0) + { + var allEpisodes = new List(); + + foreach (var seasonNum in parsedEpisodeInfo.SeasonNumbers) + { + // Look up scene mapping per season to handle shows with different offsets per season + var seasonMapping = _sceneMappingService.FindSceneMapping(parsedEpisodeInfo.SeriesTitle, parsedEpisodeInfo.ReleaseTitle, seasonNum); + var mappedSeason = seasonNum; + + if (seasonMapping?.SeasonNumber is >= 0 && seasonMapping.SceneSeasonNumber <= seasonNum) + { + mappedSeason += seasonMapping.SeasonNumber.Value - seasonMapping.SceneSeasonNumber.Value; + } + + if (series.UseSceneNumbering && sceneSource) + { + var sceneEpisodes = _episodeService.GetEpisodesBySceneSeason(series.Id, mappedSeason); + + if (sceneEpisodes.Any()) + { + allEpisodes.AddRange(sceneEpisodes); + continue; + } + } + + allEpisodes.AddRange(_episodeService.GetEpisodesBySeason(series.Id, mappedSeason)); + } + + return allEpisodes; + } + if (series.UseSceneNumbering && sceneSource) { var episodes = _episodeService.GetEpisodesBySceneSeason(series.Id, mappedSeasonNumber); diff --git a/src/Sonarr.Api.V5/Queue/QueueResource.cs b/src/Sonarr.Api.V5/Queue/QueueResource.cs index b4dcefd89..8f7a4dfb2 100644 --- a/src/Sonarr.Api.V5/Queue/QueueResource.cs +++ b/src/Sonarr.Api.V5/Queue/QueueResource.cs @@ -54,7 +54,9 @@ public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, boo Id = model.Id, SeriesId = model.Series?.Id, EpisodeIds = model.Episodes?.Select(e => e.Id).ToList() ?? [], - SeasonNumbers = model.SeasonNumber.HasValue ? [model.SeasonNumber.Value] : [], + SeasonNumbers = model.Episodes?.Select(e => e.SeasonNumber).Distinct().OrderBy(s => s).ToList() is { Count: > 0 } seasonNumbers + ? seasonNumbers + : (model.SeasonNumber.HasValue ? [model.SeasonNumber.Value] : []), Series = includeSeries && model.Series != null ? model.Series.ToResource() : null, Episodes = includeEpisodes ? model.Episodes?.ToResource() : null, Languages = model.Languages,