diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx
index e1e35ef58..0c8fec237 100644
--- a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx
+++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx
@@ -39,8 +39,17 @@ function EditReleaseProfileModalContent({
saveProvider,
} = useManageReleaseProfile(id ?? 0);
- const { name, enabled, required, ignored, indexerIds, tags, excludedTags } =
- item;
+ const {
+ name,
+ enabled,
+ required,
+ ignored,
+ airDateRestriction,
+ airDateGracePeriod,
+ indexerIds,
+ tags,
+ excludedTags,
+ } = item;
const wasSaving = usePrevious(isSaving);
@@ -131,6 +140,33 @@ function EditReleaseProfileModalContent({
/>
+
+ {translate('AirDateRestriction')}
+
+
+
+
+ {airDateRestriction.value ? (
+
+ {translate('AirDateGracePeriod')}
+
+
+
+ ) : null}
+
{translate('Indexer')}
diff --git a/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts b/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts
index 445aa9c88..2f5038858 100644
--- a/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts
+++ b/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts
@@ -10,6 +10,8 @@ export interface ReleaseProfileModel extends ModelBase {
enabled: boolean;
required: string[];
ignored: string[];
+ airDateRestriction: boolean;
+ airDateGracePeriod: number;
indexerIds: number[];
tags: number[];
excludedTags: number[];
@@ -23,6 +25,8 @@ const NEW_RELEASE_PROFILE: ReleaseProfileModel = {
enabled: true,
required: [],
ignored: [],
+ airDateRestriction: false,
+ airDateGracePeriod: 0,
indexerIds: [],
tags: [],
excludedTags: [],
diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs
new file mode 100644
index 000000000..8190ea853
--- /dev/null
+++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FizzWare.NBuilder;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Profiles.Releases;
+using NzbDrone.Core.Test.Framework;
+using NzbDrone.Core.Tv;
+
+namespace NzbDrone.Core.Test.DecisionEngineTests
+{
+ [TestFixture]
+ public class AirDateSpecificationFixture : CoreTest
+ {
+ private RemoteEpisode _remoteEpisode;
+
+ [SetUp]
+ public void Setup()
+ {
+ _remoteEpisode = new RemoteEpisode
+ {
+ Series = new Series
+ {
+ Tags = new HashSet()
+ },
+ Episodes = Builder.CreateListOfSize(1)
+ .All()
+ .With(e => e.AirDateUtc = DateTime.UtcNow)
+ .Build()
+ .ToList(),
+ Release = new ReleaseInfo
+ {
+ PublishDate = DateTime.UtcNow.AddDays(-1)
+ }
+ };
+ }
+
+ private void GivenSettings(bool airDateRestriction, int gracePeriod)
+ {
+ Mocker.GetMock()
+ .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny()))
+ .Returns(new List
+ {
+ new()
+ {
+ AirDateRestriction = airDateRestriction,
+ AirDateGracePeriod = gracePeriod
+ }
+ });
+ }
+
+ [Test]
+ public void should_be_true_if_profile_does_not_enforce_air_date_restriction()
+ {
+ GivenSettings(false, 0);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
+ }
+
+ [Test]
+ public void should_be_true_if_release_date_is_after_air_date()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1);
+
+ GivenSettings(true, 0);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
+ }
+
+ [Test]
+ public void should_be_true_if_release_date_with_grace_period_is_after_air_date()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1);
+
+ GivenSettings(true, -2);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
+ }
+
+ [Test]
+ public void should_be_true_if_release_date_is_the_same_as_air_date()
+ {
+ var airDate = DateTime.UtcNow;
+ _remoteEpisode.Episodes.First().AirDateUtc = airDate;
+ _remoteEpisode.Release.PublishDate = airDate;
+
+ GivenSettings(true, 0);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
+ }
+
+ [Test]
+ public void should_be_false_if_air_date_is_null()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = null;
+
+ GivenSettings(true, -2);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
+ }
+
+ [Test]
+ public void should_be_false_if_release_date_is_before_air_date()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1);
+
+ GivenSettings(true, 0);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
+ }
+
+ [Test]
+ public void should_be_false_if_release_date_with_grace_period_is_before_air_date()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-3);
+
+ GivenSettings(true, -2);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
+ }
+
+ [Test]
+ public void should_be_false_if_release_date_is_after_air_date_and_grace_period_is_positive()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1);
+
+ GivenSettings(true, 2);
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
+ }
+
+ [Test]
+ public void should_be_false_if_release_date_with_highest_grace_period_is_before_air_date()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1);
+
+ Mocker.GetMock()
+ .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny()))
+ .Returns(new List
+ {
+ new()
+ {
+ AirDateRestriction = true,
+ AirDateGracePeriod = 0
+ },
+ new()
+ {
+ AirDateRestriction = true,
+ AirDateGracePeriod = -5
+ }
+ });
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
+ }
+
+ [Test]
+ public void should_be_false_if_one_release_profile_does_not_allow_grabbing_before_air_date()
+ {
+ _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
+ _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1);
+
+ Mocker.GetMock()
+ .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny()))
+ .Returns(new List
+ {
+ new()
+ {
+ AirDateRestriction = true,
+ AirDateGracePeriod = 0
+ },
+ new()
+ {
+ AirDateRestriction = false,
+ AirDateGracePeriod = 0
+ }
+ });
+
+ Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs
new file mode 100644
index 000000000..b7889a3ec
--- /dev/null
+++ b/src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs
@@ -0,0 +1,14 @@
+using FluentMigrator;
+using NzbDrone.Core.Datastore.Migration.Framework;
+
+namespace NzbDrone.Core.Datastore.Migration;
+
+[Migration(226)]
+public class add_air_date_filtering_to_release_profiles : NzbDroneMigrationBase
+{
+ protected override void MainDbUpgrade()
+ {
+ Alter.Table("ReleaseProfiles").AddColumn("AirDateRestriction").AsBoolean().WithDefaultValue(false);
+ Alter.Table("ReleaseProfiles").AddColumn("AirDateGracePeriod").AsInt32().WithDefaultValue(0);
+ }
+}
diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs b/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs
index 5283f33ce..f4e36ed4f 100644
--- a/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs
+++ b/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs
@@ -75,5 +75,6 @@ public enum DownloadRejectionReason
DiskCustomFormatScore,
DiskCustomFormatScoreIncrement,
DiskUpgradesNotAllowed,
- DiskNotUpgrade
+ DiskNotUpgrade,
+ BeforeAirDate
}
diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs
new file mode 100644
index 000000000..cea857b84
--- /dev/null
+++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs
@@ -0,0 +1,83 @@
+using System.Collections.Generic;
+using System.Linq;
+using NLog;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Profiles.Releases;
+
+namespace NzbDrone.Core.DecisionEngine.Specifications
+{
+ public class AirDateSpecification : IDownloadDecisionEngineSpecification
+ {
+ private readonly Logger _logger;
+ private readonly IReleaseProfileService _releaseProfileService;
+ private readonly ITermMatcherService _termMatcherService;
+
+ public AirDateSpecification(ITermMatcherService termMatcherService, IReleaseProfileService releaseProfileService, Logger logger)
+ {
+ _logger = logger;
+ _releaseProfileService = releaseProfileService;
+ _termMatcherService = termMatcherService;
+ }
+
+ public SpecificationPriority Priority => SpecificationPriority.Database;
+ public RejectionType Type => RejectionType.Permanent;
+
+ public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, ReleaseDecisionInformation information)
+ {
+ _logger.Debug("Checking if release meets air date restrictions: {0}", subject);
+
+ var releaseProfiles = _releaseProfileService.EnabledForTags(subject.Series.Tags, subject.Release.IndexerId);
+
+ if (releaseProfiles.Empty())
+ {
+ _logger.Debug("No Release Profile, accepting");
+ return DownloadSpecDecision.Accept();
+ }
+
+ var bestProfile = releaseProfiles
+ .OrderByDescending(p => p.AirDateRestriction ? 1 : 0)
+ .ThenByDescending(p => p.AirDateGracePeriod)
+ .First();
+
+ if (!bestProfile.AirDateRestriction)
+ {
+ _logger.Debug("Release Profile does not prevent grabbing before release date, accepting");
+ return DownloadSpecDecision.Accept();
+ }
+
+ var releaseDate = subject.Release.PublishDate;
+ var gracePeriod = bestProfile.AirDateGracePeriod;
+
+ foreach (var episode in subject.Episodes)
+ {
+ var airDate = episode.AirDateUtc;
+
+ if (!airDate.HasValue)
+ {
+ _logger.Debug("No air date available, rejecting");
+ return DownloadSpecDecision.Reject(DownloadRejectionReason.BeforeAirDate, "No air date available");
+ }
+
+ var adjustedAirDate = airDate.Value.AddDays(gracePeriod);
+
+ if (releaseDate < adjustedAirDate)
+ {
+ return DownloadSpecDecision.Reject(DownloadRejectionReason.BeforeAirDate, "Release date {0} is before adjusted air date of {1} (Air Date: {2}. Grace period {3} days)", releaseDate, adjustedAirDate, airDate, gracePeriod);
+ }
+ }
+
+ _logger.Debug("All episodes within air date limitations, allowing");
+ return DownloadSpecDecision.Accept();
+ }
+
+ private ReleaseProfile FindBestProfile(List releaseProfiles)
+ {
+ return releaseProfiles
+ .OrderBy(p => p.AirDateRestriction ? 0 : 1)
+ .ThenBy(p => p.AirDateGracePeriod)
+ .ThenBy(p => p.AirDateRestriction ? 0 : 1)
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 975ca5aed..e2ff3b88c 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -62,6 +62,10 @@
"AgeWhenGrabbed": "Age (when grabbed)",
"Agenda": "Agenda",
"AirDate": "Air Date",
+ "AirDateGracePeriod": "Air Date Grace Period",
+ "AirDateGracePeriodHelpText": "Negative values allow grabbing before the air date, positive values prevent grabbing after the air date.",
+ "AirDateRestriction": "Reject Unaired Releases",
+ "AirDateRestrictionHelpText": "Prevents {appName} from grabbing releases that contain episodes that have not yet aired.",
"Airs": "Airs",
"AirsDateAtTimeOn": "{date} at {time} on {networkLabel}",
"AirsTbaOn": "TBA on {networkLabel}",
diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs
index c740dd48b..ee4b77447 100644
--- a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs
+++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs
@@ -9,6 +9,8 @@ namespace NzbDrone.Core.Profiles.Releases
public bool Enabled { get; set; }
public List Required { get; set; }
public List Ignored { get; set; }
+ public bool AirDateRestriction { get; set; }
+ public int AirDateGracePeriod { get; set; }
public List IndexerIds { get; set; }
public HashSet Tags { get; set; }
public HashSet ExcludedTags { get; set; }
diff --git a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs
index f1ffc58b9..af0b44b7d 100644
--- a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs
+++ b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs
@@ -23,7 +23,7 @@ namespace Sonarr.Api.V3.Profiles.Release
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
- if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty())
+ if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty() && !restriction.AirDateRestriction)
{
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required");
}
diff --git a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs
index 127f1b265..254f1a20a 100644
--- a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs
+++ b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs
@@ -15,6 +15,8 @@ namespace Sonarr.Api.V3.Profiles.Release
// Is List, string or JArray, we accept 'string' with POST for backward compatibility
public object Required { get; set; }
public object Ignored { get; set; }
+ public bool AirDateRestriction { get; set; }
+ public int AirDateGracePeriod { get; set; }
public int IndexerId { get; set; }
public HashSet Tags { get; set; }
public HashSet ExcludedTags { get; set; }
@@ -42,6 +44,8 @@ namespace Sonarr.Api.V3.Profiles.Release
Enabled = model.Enabled,
Required = model.Required ?? new List(),
Ignored = model.Ignored ?? new List(),
+ AirDateRestriction = model.AirDateRestriction,
+ AirDateGracePeriod = model.AirDateGracePeriod,
IndexerId = model.IndexerIds.FirstOrDefault(0),
Tags = new HashSet(model.Tags),
ExcludedTags = new HashSet(model.ExcludedTags)
@@ -62,6 +66,8 @@ namespace Sonarr.Api.V3.Profiles.Release
Enabled = resource.Enabled,
Required = resource.MapRequired(),
Ignored = resource.MapIgnored(),
+ AirDateRestriction = resource.AirDateRestriction,
+ AirDateGracePeriod = resource.AirDateGracePeriod,
IndexerIds = new List { resource.IndexerId },
Tags = new HashSet(resource.Tags),
ExcludedTags = new HashSet(resource.ExcludedTags)
diff --git a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs
index cdbd144a1..2f51a9010 100644
--- a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs
+++ b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs
@@ -21,7 +21,7 @@ public class ReleaseProfileController : RestController
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
- if (restriction.Required.Empty() && restriction.Ignored.Empty())
+ if (restriction.Required.Empty() && restriction.Ignored.Empty() && !restriction.AirDateRestriction)
{
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required");
}
diff --git a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs
index 2a1448b8a..a04101137 100644
--- a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs
+++ b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs
@@ -9,6 +9,8 @@ public class ReleaseProfileResource : RestResource
public bool Enabled { get; set; }
public List Required { get; set; } = [];
public List Ignored { get; set; } = [];
+ public bool AirDateRestriction { get; set; }
+ public int AirDateGracePeriod { get; set; }
public List IndexerIds { get; set; } = [];
public HashSet Tags { get; set; } = [];
public HashSet ExcludedTags { get; set; } = [];
@@ -25,6 +27,8 @@ public static class RestrictionResourceMapper
Enabled = model.Enabled,
Required = model.Required ?? [],
Ignored = model.Ignored ?? [],
+ AirDateRestriction = model.AirDateRestriction,
+ AirDateGracePeriod = model.AirDateGracePeriod,
IndexerIds = model.IndexerIds ?? [],
Tags = model.Tags ?? [],
ExcludedTags = model.ExcludedTags ?? [],
@@ -40,6 +44,8 @@ public static class RestrictionResourceMapper
Enabled = resource.Enabled,
Required = resource.Required,
Ignored = resource.Ignored,
+ AirDateRestriction = resource.AirDateRestriction,
+ AirDateGracePeriod = resource.AirDateGracePeriod,
IndexerIds = resource.IndexerIds,
Tags = resource.Tags,
ExcludedTags = resource.ExcludedTags