diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index 112c06948..d6ab72498 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -45,6 +45,7 @@ function EditIndexerModalContent(props) { supportsSearch, fields, priority, + seasonSearchMaximumSingleEpisodeAge, protocol, downloadClientId } = item; @@ -164,6 +165,23 @@ function EditIndexerModalContent(props) { /> + + Maximum Single Episode Age + + + + + { + private RemoteEpisode parseResultMulti; + private RemoteEpisode parseResultSingle; + private Series series; + private List episodes; + private SeasonSearchCriteria multiSearch; + + [SetUp] + public void Setup() + { + series = Builder.CreateNew() + .With(s => s.Seasons = Builder.CreateListOfSize(1).Build().ToList()) + .With(s => s.SeriesType = SeriesTypes.Standard) + .Build(); + + episodes = new List(); + episodes.Add(CreateEpisodeStub(1, 400)); + episodes.Add(CreateEpisodeStub(2, 370)); + episodes.Add(CreateEpisodeStub(3, 340)); + episodes.Add(CreateEpisodeStub(4, 310)); + + multiSearch = new SeasonSearchCriteria(); + multiSearch.Episodes = episodes.ToList(); + multiSearch.SeasonNumber = 1; + + parseResultMulti = new RemoteEpisode + { + Series = series, + Release = new ReleaseInfo(), + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)), FullSeason = true }, + Episodes = episodes.ToList() + }; + + parseResultSingle = new RemoteEpisode + { + Series = series, + Release = new ReleaseInfo(), + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, + Episodes = new List() + }; + } + + Episode CreateEpisodeStub(int number, int age) + { + return new Episode() { + SeasonNumber = 1, + EpisodeNumber = number, + AirDateUtc = DateTime.UtcNow.AddDays(-age) + }; + } + + [TestCase(1, 200, false)] + [TestCase(4, 200, false)] + [TestCase(1, 600, true)] + [TestCase(1, 365, true)] + [TestCase(4, 365, true)] + [TestCase(1, 0, true)] + public void single_episode_release(int episode, int SeasonSearchMaximumSingleEpisodeAge, bool expectedResult) + { + parseResultSingle.Release.SeasonSearchMaximumSingleEpisodeAge = SeasonSearchMaximumSingleEpisodeAge; + parseResultSingle.Episodes.Clear(); + parseResultSingle.Episodes.Add(episodes.Find(e => e.EpisodeNumber == episode)); + + Subject.IsSatisfiedBy(parseResultSingle, multiSearch).Accepted.Should().Be(expectedResult); + } + + // should always accept all season packs + [TestCase(200, true)] + [TestCase(600, true)] + [TestCase(365, true)] + [TestCase(0, true)] + public void multi_episode_release(int SeasonSearchMaximumSingleEpisodeAge, bool expectedResult) + { + parseResultMulti.Release.SeasonSearchMaximumSingleEpisodeAge = SeasonSearchMaximumSingleEpisodeAge; + + Subject.IsSatisfiedBy(parseResultMulti, multiSearch).Accepted.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/172_add_SeasonSearchMaximumSingleEpisodeAge_to_indexers.cs b/src/NzbDrone.Core/Datastore/Migration/172_add_SeasonSearchMaximumSingleEpisodeAge_to_indexers.cs new file mode 100644 index 000000000..a12f994c5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/172_add_SeasonSearchMaximumSingleEpisodeAge_to_indexers.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(172)] + public class add_SeasonSearchMaximumSingleEpisodeAge_to_indexers : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Indexers").AddColumn("SeasonSearchMaximumSingleEpisodeAge").AsInt32().NotNullable().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/SeasonPackOnlySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/SeasonPackOnlySpecification.cs new file mode 100644 index 000000000..ccc690dba --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/SeasonPackOnlySpecification.cs @@ -0,0 +1,51 @@ +using System; +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Common.Extensions; +using System.Linq; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class SeasonPackOnlySpecification : IDecisionEngineSpecification + { + private readonly IConfigService _configService; + private readonly Logger _logger; + + public SeasonPackOnlySpecification(IConfigService configService, Logger logger) + { + _configService = configService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + if (searchCriteria == null || searchCriteria.Episodes.Count == 1) + { + return Decision.Accept(); + } + + if (subject.Release.SeasonSearchMaximumSingleEpisodeAge > 0) + { + if (subject.Series.SeriesType == SeriesTypes.Standard && !subject.ParsedEpisodeInfo.FullSeason && subject.Episodes.Count >= 1) + { + // test against episodes of the same season in the current search, and make sure they have an air date + var subset = searchCriteria.Episodes.Where(e => e.AirDateUtc.HasValue && e.SeasonNumber == subject.Episodes.First().SeasonNumber).ToList(); + + if (subset.Count() > 0 && subset.Max(e => e.AirDateUtc).Value.Before(DateTime.UtcNow - TimeSpan.FromDays(subject.Release.SeasonSearchMaximumSingleEpisodeAge))) + { + _logger.Debug("Release {0}: last episode in this season aired more than {1} days ago, season pack required.", subject.Release.Title, subject.Release.SeasonSearchMaximumSingleEpisodeAge); + return Decision.Reject("Last episode in this season aired more than {0} days ago, season pack required.", subject.Release.SeasonSearchMaximumSingleEpisodeAge); + } + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index ce5946a87..1d566337b 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Indexers public abstract string Name { get; } public abstract DownloadProtocol Protocol { get; } public int Priority { get; set; } + public int SeasonSearchMaximumSingleEpisodeAge { get; set; } public abstract bool SupportsRss { get; } public abstract bool SupportsSearch { get; } @@ -82,6 +83,7 @@ namespace NzbDrone.Core.Indexers c.Indexer = Definition.Name; c.DownloadProtocol = Protocol; c.IndexerPriority = ((IndexerDefinition)Definition).Priority; + c.SeasonSearchMaximumSingleEpisodeAge = ((IndexerDefinition)Definition).SeasonSearchMaximumSingleEpisodeAge; }); return result; diff --git a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index a6ed69130..637d7c6c6 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Indexers public bool SupportsRss { get; set; } public bool SupportsSearch { get; set; } public int Priority { get; set; } = 25; + public int SeasonSearchMaximumSingleEpisodeAge { get; set; } = 0; public override bool Enable => EnableRss || EnableAutomaticSearch || EnableInteractiveSearch; diff --git a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs index a996ba0ee..abef90ac6 100644 --- a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.Parser.Model public string Artist { get; set; } public string Album { get; set; } public int IndexerPriority { get; set; } + public int SeasonSearchMaximumSingleEpisodeAge { get; set; } public DownloadProtocol DownloadProtocol { get; set; } public DateTime PublishDate { get; set; }