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