mirror of
https://github.com/Sonarr/Sonarr
synced 2026-05-07 12:30:56 +02:00
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
This commit is contained in:
parent
5bde924239
commit
18d7a4fe64
10 changed files with 199 additions and 41 deletions
|
|
@ -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<IDiskProvider>().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<IParsingService>().Setup(c => c.GetSeries("foldername")).Returns((Series)null);
|
||||
var imported = new List<ImportDecision>();
|
||||
|
||||
Mocker.GetMock<IMakeImportDecision>()
|
||||
.Verify(c => c.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<bool>(), true),
|
||||
Times.Never());
|
||||
.Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<bool>()))
|
||||
.Returns(imported);
|
||||
|
||||
VerifyNoImport();
|
||||
Mocker.GetMock<IImportApprovedEpisodes>()
|
||||
.Setup(s => s.Import(It.IsAny<List<ImportDecision>>(), true, It.IsAny<DownloadClientItem>(), ImportMode.Auto))
|
||||
.Returns(imported.Select(i => new ImportResult(i)).ToList());
|
||||
|
||||
Subject.ProcessPath(folderName, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem);
|
||||
|
||||
Mocker.GetMock<IMakeImportDecision>()
|
||||
.Verify(c => c.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<bool>()),
|
||||
Times.Once());
|
||||
}
|
||||
|
||||
private void VerifyNoImport()
|
||||
|
|
|
|||
|
|
@ -85,6 +85,13 @@ private void GivenFullSeason()
|
|||
_parsedEpisodeInfo.EpisodeNumbers = Array.Empty<int>();
|
||||
}
|
||||
|
||||
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<IEpisodeService>()
|
||||
.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<IEpisodeService>()
|
||||
.Setup(s => s.GetEpisodesBySeason(_series.Id, It.IsAny<int>()))
|
||||
.Returns(_episodes);
|
||||
|
||||
Subject.GetEpisodes(_parsedEpisodeInfo, _series, false, null);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 1), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 2), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 3), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySceneSeason(It.IsAny<int>(), It.IsAny<int>()), 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<IEpisodeService>()
|
||||
.Setup(s => s.GetEpisodesBySceneSeason(_series.Id, It.IsAny<int>()))
|
||||
.Returns(_episodes);
|
||||
|
||||
Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 1), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 2), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(It.IsAny<int>(), It.IsAny<int>()), 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<IEpisodeService>()
|
||||
.Setup(s => s.GetEpisodesBySceneSeason(_series.Id, It.IsAny<int>()))
|
||||
.Returns(new List<Episode>());
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Setup(s => s.GetEpisodesBySeason(_series.Id, It.IsAny<int>()))
|
||||
.Returns(_episodes);
|
||||
|
||||
Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 1), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySceneSeason(_series.Id, 2), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 1), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.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<ISceneMappingService>()
|
||||
.Setup(v => v.FindSceneMapping(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.Returns<string, string, int>((s, r, sn) => new SceneMapping { SceneSeasonNumber = 1, SeasonNumber = tvdbSeasonNumber });
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Setup(s => s.GetEpisodesBySeason(_series.Id, It.IsAny<int>()))
|
||||
.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<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 5), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 6), Times.Once);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.GetEpisodesBySeason(_series.Id, 7), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<TrackedDownloadStatusMessage>
|
||||
|
|
|
|||
|
|
@ -203,16 +203,6 @@ private List<ImportResult> 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<ImportResult>
|
||||
{
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<int>();
|
||||
AbsoluteEpisodeNumbers = Array.Empty<int>();
|
||||
SpecialAbsoluteEpisodeNumbers = Array.Empty<decimal>();
|
||||
SeasonNumbers = Array.Empty<int>();
|
||||
Languages = new List<Language>();
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ public enum ReleaseType
|
|||
MultiEpisode = 2,
|
||||
|
||||
[FieldOption(label: "Season Pack")]
|
||||
SeasonPack = 3
|
||||
SeasonPack = 3,
|
||||
|
||||
[FieldOption(label: "Multi-Season Pack")]
|
||||
MultiSeasonPack = 4
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -260,6 +260,38 @@ private List<Episode> GetEpisodes(ParsedEpisodeInfo parsedEpisodeInfo, Series se
|
|||
{
|
||||
if (parsedEpisodeInfo.FullSeason)
|
||||
{
|
||||
if (parsedEpisodeInfo.IsMultiSeason && parsedEpisodeInfo.SeasonNumbers.Length > 0)
|
||||
{
|
||||
var allEpisodes = new List<Episode>();
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue