1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-05 13:20:20 -05:00

New: Add option to not download before air date to Release Profiles

Closes #969
This commit is contained in:
Mark McDowall
2026-02-08 19:33:36 -08:00
parent 93713c3827
commit 8fa16e3542
12 changed files with 352 additions and 5 deletions

View File

@@ -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({
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AirDateRestriction')}</FormLabel>
<FormInputGroup
{...airDateRestriction}
type={inputTypes.CHECK}
name="airDateRestriction"
helpText={translate('AirDateRestrictionHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
{airDateRestriction.value ? (
<FormGroup>
<FormLabel>{translate('AirDateGracePeriod')}</FormLabel>
<FormInputGroup
{...airDateGracePeriod}
type={inputTypes.NUMBER}
unit="days"
name="airDateGracePeriod"
helpText={translate('AirDateGracePeriodHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
) : null}
<FormGroup>
<FormLabel>{translate('Indexer')}</FormLabel>

View File

@@ -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: [],

View File

@@ -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<AirDateSpecification>
{
private RemoteEpisode _remoteEpisode;
[SetUp]
public void Setup()
{
_remoteEpisode = new RemoteEpisode
{
Series = new Series
{
Tags = new HashSet<int>()
},
Episodes = Builder<Episode>.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<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile>
{
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<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile>
{
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<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile>
{
new()
{
AirDateRestriction = true,
AirDateGracePeriod = 0
},
new()
{
AirDateRestriction = false,
AirDateGracePeriod = 0
}
});
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
}
}

View File

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

View File

@@ -75,5 +75,6 @@ public enum DownloadRejectionReason
DiskCustomFormatScore,
DiskCustomFormatScoreIncrement,
DiskUpgradesNotAllowed,
DiskNotUpgrade
DiskNotUpgrade,
BeforeAirDate
}

View File

@@ -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<ReleaseProfile> releaseProfiles)
{
return releaseProfiles
.OrderBy(p => p.AirDateRestriction ? 0 : 1)
.ThenBy(p => p.AirDateGracePeriod)
.ThenBy(p => p.AirDateRestriction ? 0 : 1)
.FirstOrDefault();
}
}
}

View File

@@ -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}",

View File

@@ -9,6 +9,8 @@ namespace NzbDrone.Core.Profiles.Releases
public bool Enabled { get; set; }
public List<string> Required { get; set; }
public List<string> Ignored { get; set; }
public bool AirDateRestriction { get; set; }
public int AirDateGracePeriod { get; set; }
public List<int> IndexerIds { get; set; }
public HashSet<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }

View File

@@ -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");
}

View File

@@ -15,6 +15,8 @@ namespace Sonarr.Api.V3.Profiles.Release
// Is List<string>, 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<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }
@@ -42,6 +44,8 @@ namespace Sonarr.Api.V3.Profiles.Release
Enabled = model.Enabled,
Required = model.Required ?? new List<string>(),
Ignored = model.Ignored ?? new List<string>(),
AirDateRestriction = model.AirDateRestriction,
AirDateGracePeriod = model.AirDateGracePeriod,
IndexerId = model.IndexerIds.FirstOrDefault(0),
Tags = new HashSet<int>(model.Tags),
ExcludedTags = new HashSet<int>(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<int> { resource.IndexerId },
Tags = new HashSet<int>(resource.Tags),
ExcludedTags = new HashSet<int>(resource.ExcludedTags)

View File

@@ -21,7 +21,7 @@ public class ReleaseProfileController : RestController<ReleaseProfileResource>
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");
}

View File

@@ -9,6 +9,8 @@ public class ReleaseProfileResource : RestResource
public bool Enabled { get; set; }
public List<string> Required { get; set; } = [];
public List<string> Ignored { get; set; } = [];
public bool AirDateRestriction { get; set; }
public int AirDateGracePeriod { get; set; }
public List<int> IndexerIds { get; set; } = [];
public HashSet<int> Tags { get; set; } = [];
public HashSet<int> 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