diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs index 14cdef982..be4447f95 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs @@ -20,38 +20,50 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private RemoteEpisode parseResultMulti; private RemoteEpisode parseResultSingle; private Series series; + private List episodes; private QualityDefinition qualityType; [SetUp] public void Setup() { series = Builder.CreateNew() - .Build(); + .With(s => s.Seasons = Builder.CreateListOfSize(2).Build().ToList()) + .Build(); + + episodes = Builder.CreateListOfSize(10) + .All() + .With(s => s.SeasonNumber = 1) + .BuildList(); parseResultMultiSet = new RemoteEpisode { Series = series, Release = new ReleaseInfo(), ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = new List { new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), new Episode() } - }; + Episodes = Builder.CreateListOfSize(6).All().With(s => s.SeasonNumber = 1).BuildList() + }; parseResultMulti = new RemoteEpisode { Series = series, Release = new ReleaseInfo(), ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = new List { new Episode(), new Episode() } - }; + Episodes = Builder.CreateListOfSize(2).All().With(s => s.SeasonNumber = 1).BuildList() + }; parseResultSingle = new RemoteEpisode { Series = series, Release = new ReleaseInfo(), ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = new List { new Episode() { Id = 2 } } + Episodes = new List { + Builder.CreateNew() + .With(s => s.SeasonNumber = 1) + .With(s => s.EpisodeNumber = 1) + .Build() + } - }; + }; Mocker.GetMock() .Setup(v => v.Get(It.IsAny())) @@ -67,18 +79,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Mocker.GetMock().Setup( s => s.GetEpisodesBySeason(It.IsAny(), It.IsAny())) - .Returns(new List() { - new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), - new Episode(), new Episode(), new Episode(), new Episode() { Id = 2 }, new Episode() }); - } - - private void GivenLastEpisode() - { - Mocker.GetMock().Setup( - s => s.GetEpisodesBySeason(It.IsAny(), It.IsAny())) - .Returns(new List() { - new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), - new Episode(), new Episode(), new Episode(), new Episode(), new Episode() { Id = 2 } }); + .Returns(episodes); } [TestCase(30, 50, false)] @@ -92,6 +93,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests series.Runtime = runtime; parseResultSingle.Series = series; parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); + parseResultSingle.Episodes.First().Id = 5; Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(expectedResult); } @@ -100,13 +102,26 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase(30, 1000, false)] [TestCase(60, 1000, true)] [TestCase(60, 2000, false)] - public void single_episode_first_or_last(int runtime, int sizeInMegaBytes, bool expectedResult) + public void should_return_expected_result_for_first_episode_of_season(int runtime, int sizeInMegaBytes, bool expectedResult) { - GivenLastEpisode(); - series.Runtime = runtime; parseResultSingle.Series = series; parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); + parseResultSingle.Episodes.First().Id = episodes.First().Id; + + Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(expectedResult); + } + + [TestCase(30, 500, true)] + [TestCase(30, 1000, false)] + [TestCase(60, 1000, true)] + [TestCase(60, 2000, false)] + public void should_return_expected_result_for_last_episode_of_season(int runtime, int sizeInMegaBytes, bool expectedResult) + { + series.Runtime = runtime; + parseResultSingle.Series = series; + parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); + parseResultSingle.Episodes.First().Id = episodes.Last().Id; Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(expectedResult); } @@ -144,8 +159,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_if_size_is_zero() { - GivenLastEpisode(); - series.Runtime = 30; parseResultSingle.Series = series; parseResultSingle.Release.Size = 0; @@ -158,8 +171,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_if_unlimited_30_minute() { - GivenLastEpisode(); - series.Runtime = 30; parseResultSingle.Series = series; parseResultSingle.Release.Size = 18457280000; @@ -171,8 +182,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_if_unlimited_60_minute() { - GivenLastEpisode(); - series.Runtime = 60; parseResultSingle.Series = series; parseResultSingle.Release.Size = 36857280000; @@ -184,8 +193,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_treat_daily_series_as_single_episode() { - GivenLastEpisode(); - series.Runtime = 60; parseResultSingle.Series = series; parseResultSingle.Series.SeriesType = SeriesTypes.Daily; @@ -216,5 +223,94 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); } + + [Test] + public void should_return_false_if_series_runtime_is_zero_and_single_episode_is_not_from_first_season() + { + series.Runtime = 0; + parseResultSingle.Series = series; + parseResultSingle.Episodes.First().Id = 5; + parseResultSingle.Release.Size = 200.Megabytes(); + parseResultSingle.Episodes.First().SeasonNumber = 2; + + Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(false); + } + + [Test] + public void should_return_false_if_series_runtime_is_zero_and_single_episode_aired_more_than_24_hours_after_first_aired_episode() + { + series.Runtime = 0; + + parseResultSingle.Series = series; + parseResultSingle.Release.Size = 200.Megabytes(); + parseResultSingle.Episodes.First().Id = 5; + parseResultSingle.Episodes.First().SeasonNumber = 1; + parseResultSingle.Episodes.First().EpisodeNumber = 2; + parseResultSingle.Episodes.First().AirDateUtc = episodes.First().AirDateUtc.Value.AddDays(7); + + Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(false); + } + + [Test] + public void should_return_true_if_series_runtime_is_zero_and_single_episode_aired_less_than_24_hours_after_first_aired_episode() + { + series.Runtime = 0; + + parseResultSingle.Series = series; + parseResultSingle.Release.Size = 200.Megabytes(); + parseResultSingle.Episodes.First().Id = 5; + parseResultSingle.Episodes.First().SeasonNumber = 1; + parseResultSingle.Episodes.First().EpisodeNumber = 2; + parseResultSingle.Episodes.First().AirDateUtc = episodes.First().AirDateUtc.Value.AddHours(1); + + Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(true); + } + + [Test] + public void should_return_false_if_series_runtime_is_zero_and_multi_episode_is_not_from_first_season() + { + series.Runtime = 0; + parseResultMulti.Series = series; + parseResultMulti.Release.Size = 200.Megabytes(); + parseResultMulti.Episodes.ForEach(e => e.SeasonNumber = 2); + + Subject.IsSatisfiedBy(parseResultMulti, null).Accepted.Should().Be(false); + } + + [Test] + public void should_return_false_if_series_runtime_is_zero_and_multi_episode_aired_more_than_24_hours_after_first_aired_episode() + { + var airDateUtc = episodes.First().AirDateUtc.Value.AddDays(7); + + series.Runtime = 0; + + parseResultMulti.Series = series; + parseResultMulti.Release.Size = 200.Megabytes(); + parseResultMulti.Episodes.ForEach(e => + { + e.SeasonNumber = 1; + e.AirDateUtc = airDateUtc; + }); + + Subject.IsSatisfiedBy(parseResultMulti, null).Accepted.Should().Be(false); + } + + [Test] + public void should_return_true_if_series_runtime_is_zero_and_multi_episode_aired_less_than_24_hours_after_first_aired_episode() + { + var airDateUtc = episodes.First().AirDateUtc.Value.AddHours(1); + + series.Runtime = 0; + + parseResultMulti.Series = series; + parseResultMulti.Release.Size = 200.Megabytes(); + parseResultMulti.Episodes.ForEach(e => + { + e.SeasonNumber = 1; + e.AirDateUtc = airDateUtc; + }); + + Subject.IsSatisfiedBy(parseResultMulti, null).Accepted.Should().Be(true); + } } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs index fc2015ceb..b5403b58b 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using NLog; using NzbDrone.Common.Extensions; @@ -43,18 +44,47 @@ namespace NzbDrone.Core.DecisionEngine.Specifications return Decision.Accept(); } + var runtime = subject.Series.Runtime; + + if (runtime == 0) + { + var firstSeasonNumber = subject.Series.Seasons.Where(s => s.SeasonNumber > 0).Min(s => s.SeasonNumber); + var pilotEpisode = _episodeService.GetEpisodesBySeason(subject.Series.Id, firstSeasonNumber).First(); + + if (subject.Episodes.First().SeasonNumber == pilotEpisode.SeasonNumber) + { + // If the first episode has an air date use it, otherwise use the release's publish date because like runtime it may not have updated yet. + var gracePeriodEnd = (pilotEpisode.AirDateUtc ?? subject.Release.PublishDate).AddHours(24); + + // If episodes don't have an air date that is okay, otherwise make sure it's within 24 hours of the first episode airing. + if (subject.Episodes.All(e => !e.AirDateUtc.HasValue || e.AirDateUtc.Value.Before(gracePeriodEnd))) + { + _logger.Debug("Series runtime is 0, but all episodes in release aired within 24 hours of first episode in season, defaulting runtime to 45 minutes"); + runtime = 45; + } + } + + // Reject if the run time is still 0 + if (runtime == 0) + { + _logger.Debug("Series runtime is 0, unable to validate size until it is available, rejecting"); + return Decision.Reject("Series runtime is 0, unable to validate size until it is available"); + } + } + var qualityDefinition = _qualityDefinitionService.Get(quality); + if (qualityDefinition.MinSize.HasValue) { var minSize = qualityDefinition.MinSize.Value.Megabytes(); - //Multiply maxSize by Series.Runtime - minSize = minSize * subject.Series.Runtime * subject.Episodes.Count; + // Multiply maxSize by Series.Runtime + minSize = minSize * runtime * subject.Episodes.Count; - //If the parsed size is smaller than minSize we don't want it + // If the parsed size is smaller than minSize we don't want it if (subject.Release.Size < minSize) { - var runtimeMessage = subject.Episodes.Count == 1 ? $"{subject.Series.Runtime}min" : $"{subject.Episodes.Count}x {subject.Series.Runtime}min"; + var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count}x {runtime}min"; _logger.Debug("Item: {0}, Size: {1} is smaller than minimum allowed size ({2} bytes for {3}), rejecting.", subject, subject.Release.Size, minSize, runtimeMessage); return Decision.Reject("{0} is smaller than minimum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), minSize.SizeSuffix(), runtimeMessage); @@ -64,46 +94,31 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { _logger.Debug("Max size is unlimited, skipping size check"); } - else if (subject.Series.Runtime == 0) - { - _logger.Debug("Series runtime is 0, unable to validate size until it is available, rejecting"); - return Decision.Reject("Series runtime is 0, unable to validate size until it is available"); - } else { var maxSize = qualityDefinition.MaxSize.Value.Megabytes(); - //Multiply maxSize by Series.Runtime - maxSize = maxSize * subject.Series.Runtime * subject.Episodes.Count; + // Multiply maxSize by Series.Runtime + maxSize = maxSize * runtime * subject.Episodes.Count; if (subject.Episodes.Count == 1 && subject.Series.SeriesType == SeriesTypes.Standard) { - Episode episode = subject.Episodes.First(); - List seasonEpisodes; + var firstEpisode = subject.Episodes.First(); + var seasonEpisodes = GetSeasonEpisodes(subject, searchCriteria); - var seasonSearchCriteria = searchCriteria as SeasonSearchCriteria; - if (seasonSearchCriteria != null && !seasonSearchCriteria.Series.UseSceneNumbering && seasonSearchCriteria.Episodes.Any(v => v.Id == episode.Id)) - { - seasonEpisodes = seasonSearchCriteria.Episodes; - } - else - { - seasonEpisodes = _episodeService.GetEpisodesBySeason(episode.SeriesId, episode.SeasonNumber); - } - - //Ensure that this is either the first episode - //or is the last episode in a season that has 10 or more episodes - if (seasonEpisodes.First().Id == episode.Id || (seasonEpisodes.Count() >= 10 && seasonEpisodes.Last().Id == episode.Id)) + // Ensure that this is either the first episode + // or is the last episode in a season that has 10 or more episodes + if (seasonEpisodes.First().Id == firstEpisode.Id || (seasonEpisodes.Count() >= 10 && seasonEpisodes.Last().Id == firstEpisode.Id)) { _logger.Debug("Possible double episode, doubling allowed size."); maxSize = maxSize * 2; } } - //If the parsed size is greater than maxSize we don't want it + // If the parsed size is greater than maxSize we don't want it if (subject.Release.Size > maxSize) { - var runtimeMessage = subject.Episodes.Count == 1 ? $"{subject.Series.Runtime}min" : $"{subject.Episodes.Count}x {subject.Series.Runtime}min"; + var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count}x {runtime}min"; _logger.Debug("Item: {0}, Size: {1} is greater than maximum allowed size ({2} for {3}), rejecting", subject, subject.Release.Size, maxSize, runtimeMessage); return Decision.Reject("{0} is larger than maximum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), maxSize.SizeSuffix(), runtimeMessage); @@ -113,5 +128,17 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger.Debug("Item: {0}, meets size constraints", subject); return Decision.Accept(); } + + private List GetSeasonEpisodes(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + var firstEpisode = subject.Episodes.First(); + + if (searchCriteria is SeasonSearchCriteria seasonSearchCriteria && !seasonSearchCriteria.Series.UseSceneNumbering && seasonSearchCriteria.Episodes.Any(v => v.Id == firstEpisode.Id)) + { + return seasonSearchCriteria.Episodes; + } + + return _episodeService.GetEpisodesBySeason(firstEpisode.SeriesId, firstEpisode.SeasonNumber); + } } }