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