diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js index 08d424cf05..88870aacda 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -125,6 +125,7 @@ class EditQualityProfileModalContent extends Component { upgradeAllowed, cutoff, minFormatScore, + minUpgradeFormatScore, cutoffFormatScore, language, items, @@ -249,6 +250,25 @@ class EditQualityProfileModalContent extends Component { } + { + upgradeAllowed.value && formatItems.value.length > 0 ? + + + {translate('MinimumCustomFormatScoreIncrement')} + + + + : + null + } + {translate('Language')} diff --git a/frontend/src/typings/QualityProfile.ts b/frontend/src/typings/QualityProfile.ts index ec4e46648b..41063cb3e2 100644 --- a/frontend/src/typings/QualityProfile.ts +++ b/frontend/src/typings/QualityProfile.ts @@ -16,6 +16,7 @@ interface QualityProfile { items: QualityProfileQualityItem[]; minFormatScore: number; cutoffFormatScore: number; + minUpgradeFormatScore: number; formatItems: QualityProfileFormatItem[]; id: number; } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs index d1f90577e7..a3116aaf96 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser; +using NzbDrone.Core.Profiles; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.CustomFormats; @@ -160,5 +161,95 @@ public void should_return_false_if_release_has_higher_quality_and_cutoff_is_alre new List()) .Should().Be(UpgradeableRejectReason.QualityCutoff); } + + [Test] + public void should_return_false_if_minimum_custom_score_is_not_met() + { + var customFormatOne = new CustomFormat + { + Id = 1, + Name = "One" + }; + + var customFormatTwo = new CustomFormat + { + Id = 2, + Name = "Two" + }; + + var profile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = true, + MinUpgradeFormatScore = 11, + CutoffFormatScore = 100, + FormatItems = new List + { + new ProfileFormatItem + { + Format = customFormatOne, + Score = 10 + }, + new ProfileFormatItem + { + Format = customFormatTwo, + Score = 20 + } + } + }; + + Subject.IsUpgradable( + profile, + new QualityModel(Quality.DVD), + new List { customFormatOne }, + new QualityModel(Quality.DVD), + new List { customFormatTwo }) + .Should().Be(UpgradeableRejectReason.MinCustomFormatScore); + } + + [Test] + public void should_return_true_if_minimum_custom_score_is_met() + { + var customFormatOne = new CustomFormat + { + Id = 1, + Name = "One" + }; + + var customFormatTwo = new CustomFormat + { + Id = 2, + Name = "Two" + }; + + var profile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = true, + MinUpgradeFormatScore = 10, + CutoffFormatScore = 100, + FormatItems = new List + { + new ProfileFormatItem + { + Format = customFormatOne, + Score = 10 + }, + new ProfileFormatItem + { + Format = customFormatTwo, + Score = 20 + } + } + }; + + Subject.IsUpgradable( + profile, + new QualityModel(Quality.DVD), + new List { customFormatOne }, + new QualityModel(Quality.DVD), + new List { customFormatTwo }) + .Should().Be(UpgradeableRejectReason.None); + } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/239_add_minimum_upgrade_format_score_to_quality_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/239_add_minimum_upgrade_format_score_to_quality_profiles.cs new file mode 100644 index 0000000000..6c5e197fa2 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/239_add_minimum_upgrade_format_score_to_quality_profiles.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(239)] + public class add_minimum_upgrade_format_score_to_quality_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("QualityProfiles").AddColumn("MinUpgradeFormatScore").AsInt32().WithDefaultValue(1); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs index af62c7f211..7ce091bbbd 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs @@ -95,6 +95,17 @@ public UpgradeableRejectReason IsUpgradable(QualityProfile qualityProfile, Quali return UpgradeableRejectReason.CustomFormatCutoff; } + if (newFormatScore < currentFormatScore + qualityProfile.MinUpgradeFormatScore) + { + _logger.Debug("New item's custom formats [{0}] ({1}) do not meet minimum custom format score increment of {3} required for upgrade, skipping. Existing: [{4}] ({5}).", + newCustomFormats.ConcatToString(), + newFormatScore, + qualityProfile.MinUpgradeFormatScore, + currentCustomFormats.ConcatToString(), + currentFormatScore); + return UpgradeableRejectReason.MinCustomFormatScore; + } + _logger.Debug("New item's custom formats [{0}] ({1}) improve on [{2}] ({3}), accepting", newCustomFormats.ConcatToString(), newFormatScore, diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs index e6a159dbca..3056f768c0 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs @@ -78,6 +78,9 @@ public virtual Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase se case UpgradeableRejectReason.CustomFormatScore: return Decision.Reject("Existing file on disk has a equal or higher custom format score: {0}", qualityProfile.CalculateCustomFormatScore(customFormats)); + + case UpgradeableRejectReason.MinCustomFormatScore: + return Decision.Reject("Existing file differential between new release does not meet minimum Custom Format score increment: {0}", qualityProfile.MinFormatScore); } return Decision.Accept(); diff --git a/src/NzbDrone.Core/DecisionEngine/UpgradeableRejectReason.cs b/src/NzbDrone.Core/DecisionEngine/UpgradeableRejectReason.cs index 7ed6d6a0f6..2b1b1cfe97 100644 --- a/src/NzbDrone.Core/DecisionEngine/UpgradeableRejectReason.cs +++ b/src/NzbDrone.Core/DecisionEngine/UpgradeableRejectReason.cs @@ -7,6 +7,7 @@ public enum UpgradeableRejectReason BetterRevision, QualityCutoff, CustomFormatScore, - CustomFormatCutoff + CustomFormatCutoff, + MinCustomFormatScore } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupQualityProfileFormatItems.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupQualityProfileFormatItems.cs index 3c8cef5818..a08f52aa7c 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupQualityProfileFormatItems.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupQualityProfileFormatItems.cs @@ -65,6 +65,7 @@ public void Clean() { profile.MinFormatScore = 0; profile.CutoffFormatScore = 0; + profile.MinUpgradeFormatScore = 1; } updatedProfiles.Add(profile); @@ -73,7 +74,7 @@ public void Clean() if (updatedProfiles.Any()) { - _repository.SetFields(updatedProfiles, p => p.FormatItems, p => p.MinFormatScore, p => p.CutoffFormatScore); + _repository.SetFields(updatedProfiles, p => p.FormatItems, p => p.MinFormatScore, p => p.CutoffFormatScore, p => p.MinUpgradeFormatScore); } } } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c7054ab8ea..df13e3e010 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -910,6 +910,8 @@ "MinimumAvailability": "Minimum Availability", "MinimumCustomFormatScore": "Minimum Custom Format Score", "MinimumCustomFormatScoreHelpText": "Minimum custom format score allowed to download", + "MinimumCustomFormatScoreIncrement": "Minimum Custom Format Score Increment", + "MinimumCustomFormatScoreIncrementHelpText": "Minimum required improvement of the custom format score between existing and new releases before {appName} considers it an upgrade", "MinimumFreeSpace": "Minimum Free Space", "MinimumFreeSpaceHelpText": "Prevent import if it would leave less than this amount of disk space available", "MinimumLimits": "Minimum Limits", diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs index 98d1629ad9..c4447e0049 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs @@ -19,6 +19,7 @@ public QualityProfile() public List Items { get; set; } public int MinFormatScore { get; set; } public int CutoffFormatScore { get; set; } + public int MinUpgradeFormatScore { get; set; } public List FormatItems { get; set; } public Language Language { get; set; } public bool UpgradeAllowed { get; set; } diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs index 6eee18938f..bb65471b25 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs @@ -112,6 +112,7 @@ public void Handle(CustomFormatDeletedEvent message) { profile.MinFormatScore = 0; profile.CutoffFormatScore = 0; + profile.MinUpgradeFormatScore = 1; } Update(profile); @@ -262,6 +263,7 @@ public QualityProfile GetDefaultProfile(string name, Quality cutoff = null, para Language = Language.English, MinFormatScore = 0, CutoffFormatScore = 0, + MinUpgradeFormatScore = 1, FormatItems = formatItems }; diff --git a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileController.cs b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileController.cs index 5b924849cb..6306912459 100644 --- a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileController.cs +++ b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileController.cs @@ -15,22 +15,22 @@ namespace Radarr.Api.V3.Profiles.Quality public class QualityProfileController : RestController { private readonly IQualityProfileService _qualityProfileService; - private readonly ICustomFormatService _formatService; public QualityProfileController(IQualityProfileService qualityProfileService, ICustomFormatService formatService) { _qualityProfileService = qualityProfileService; - _formatService = formatService; + SharedValidator.RuleFor(c => c.Name).NotEmpty(); // TODO: Need to validate the cutoff is allowed and the ID/quality ID exists // TODO: Need to validate the Items to ensure groups have names and at no item has no name, no items and no quality + SharedValidator.RuleFor(c => c.MinUpgradeFormatScore).GreaterThanOrEqualTo(1); SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff(); SharedValidator.RuleFor(c => c.Items).ValidItems(); SharedValidator.RuleFor(c => c.FormatItems).Must(items => { - var all = _formatService.All().Select(f => f.Id).ToList(); + var all = formatService.All().Select(f => f.Id).ToList(); var ids = items.Select(i => i.Format); return all.Except(ids).Empty(); diff --git a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs index 5bcfda0b9f..75c13b1be4 100644 --- a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs +++ b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs @@ -16,6 +16,7 @@ public class QualityProfileResource : RestResource public List Items { get; set; } public int MinFormatScore { get; set; } public int CutoffFormatScore { get; set; } + public int MinUpgradeFormatScore { get; set; } public List FormatItems { get; set; } public Language Language { get; set; } } @@ -58,6 +59,7 @@ public static QualityProfileResource ToResource(this QualityProfile model) Items = model.Items.ConvertAll(ToResource), MinFormatScore = model.MinFormatScore, CutoffFormatScore = model.CutoffFormatScore, + MinUpgradeFormatScore = model.MinUpgradeFormatScore, FormatItems = model.FormatItems.ConvertAll(ToResource), Language = model.Language }; @@ -106,6 +108,7 @@ public static QualityProfile ToModel(this QualityProfileResource resource) Items = resource.Items.ConvertAll(ToModel), MinFormatScore = resource.MinFormatScore, CutoffFormatScore = resource.CutoffFormatScore, + MinUpgradeFormatScore = resource.MinUpgradeFormatScore, FormatItems = resource.FormatItems.ConvertAll(ToModel), Language = resource.Language };