This commit is contained in:
Karolis Mažukna 2026-04-25 02:02:40 +00:00 committed by GitHub
commit 4326b5ed5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 199 additions and 41 deletions

View file

@ -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()

View file

@ -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);
}
}
}

View file

@ -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]

View file

@ -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>

View file

@ -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);

View file

@ -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);

View file

@ -13,6 +13,9 @@ public enum ReleaseType
MultiEpisode = 2,
[FieldOption(label: "Season Pack")]
SeasonPack = 3
SeasonPack = 3,
[FieldOption(label: "Multi-Season Pack")]
MultiSeasonPack = 4
}
}

View file

@ -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())

View file

@ -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);

View file

@ -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,