feat: Add Allowed flag for QP upgrades

pull/201/head
Robert Dailey 10 months ago
parent 0652cfd800
commit 8596168757

@ -116,11 +116,14 @@
"description": "If set to true, enables setting scores to 0 in quality profiles where either a CF was not mentioned in the trash_ids array or it was in that list but did not get a score (e.g. no score in guide).", "description": "If set to true, enables setting scores to 0 in quality profiles where either a CF was not mentioned in the trash_ids array or it was in that list but did not get a score (e.g. no score in guide).",
"default": false "default": false
}, },
"upgrades_allowed": { "upgrade": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["until_quality"], "required": ["allowed"],
"properties": { "properties": {
"allowed": {
"type": "boolean"
},
"until_quality": { "until_quality": {
"type": "string" "type": "string"
}, },

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="sync" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="sync" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr" />
<option name="PROGRAM_PARAMETERS" value="sync" /> <option name="PROGRAM_PARAMETERS" value="sync" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />

@ -2,7 +2,6 @@ using System.IO.Abstractions;
using System.Reflection; using System.Reflection;
using Autofac; using Autofac;
using Autofac.Extras.Ordering; using Autofac.Extras.Ordering;
using FluentValidation;
using Recyclarr.Cli.Cache; using Recyclarr.Cli.Cache;
using Recyclarr.Cli.Console.Helpers; using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.Console.Setup; using Recyclarr.Cli.Console.Setup;
@ -16,7 +15,6 @@ using Recyclarr.Cli.Pipelines.ReleaseProfile;
using Recyclarr.Cli.Pipelines.Tags; using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Processors; using Recyclarr.Cli.Processors;
using Recyclarr.Common; using Recyclarr.Common;
using Recyclarr.Common.FluentValidation;
using Recyclarr.TrashLib; using Recyclarr.TrashLib;
using Recyclarr.TrashLib.Interfaces; using Recyclarr.TrashLib.Interfaces;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
@ -44,11 +42,6 @@ public static class CompositionRoot
CommandRegistrations(builder); CommandRegistrations(builder);
PipelineRegistrations(builder); PipelineRegistrations(builder);
builder.RegisterAssemblyTypes(thisAssembly)
.AsClosedTypesOf(typeof(IValidator<>))
.Where(x => !typeof(IManualValidator).IsAssignableFrom(x))
.As<IValidator>();
} }
private static void PipelineRegistrations(ContainerBuilder builder) private static void PipelineRegistrations(ContainerBuilder builder)

@ -74,16 +74,16 @@ public class QualityProfileTransactionPhase
continue; continue;
} }
var organizer = new QualityItemOrganizer();
var newDto = dto ?? serviceData.Schema; var newDto = dto ?? serviceData.Schema;
var updatedProfile = new UpdatedQualityProfile
transactions.UpdatedProfiles.Add(new UpdatedQualityProfile
{ {
ProfileConfig = config, ProfileConfig = config,
ProfileDto = newDto, ProfileDto = newDto,
UpdateReason = dto is null ? QualityProfileUpdateReason.New : QualityProfileUpdateReason.Changed, UpdateReason = dto is null ? QualityProfileUpdateReason.New : QualityProfileUpdateReason.Changed,
UpdatedQualities = newDto.BuildUpdatedQualityItems(config.Profile) UpdatedQualities = organizer.OrganizeItems(newDto, config.Profile)
}; });
transactions.UpdatedProfiles.Add(updatedProfile);
} }
} }

@ -1,6 +1,5 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api; using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Pipelines.QualityProfile; namespace Recyclarr.Cli.Pipelines.QualityProfile;
@ -93,6 +92,11 @@ public static class QualityProfileExtensions
return result.Name; return result.Name;
} }
public static int? FirstCutoffId(this IEnumerable<ProfileItemDto> items)
{
return GetEligibleCutoffs(items).FirstOrDefault().Id;
}
public static int NewItemId(this IEnumerable<ProfileItemDto> items) public static int NewItemId(this IEnumerable<ProfileItemDto> items)
{ {
// This implementation is based on how the Radarr frontend calculates IDs. // This implementation is based on how the Radarr frontend calculates IDs.
@ -108,14 +112,6 @@ public static class QualityProfileExtensions
return Math.Max(1000, maxExisting) + 1; return Math.Max(1000, maxExisting) + 1;
} }
public static UpdatedQualities BuildUpdatedQualityItems(
this QualityProfileDto dto,
QualityProfileConfig configProfile)
{
var organizer = new QualityItemOrganizer();
return organizer.OrganizeItems(dto, configProfile);
}
public static QualityProfileDto ReverseItems(this QualityProfileDto dto) public static QualityProfileDto ReverseItems(this QualityProfileDto dto)
{ {
static ICollection<ProfileItemDto> ReverseItemsImpl(IEnumerable<ProfileItemDto> items) static ICollection<ProfileItemDto> ReverseItemsImpl(IEnumerable<ProfileItemDto> items)

@ -54,9 +54,7 @@ public record UpdatedQualityProfile
// //
// Also: It's important that we assign the cutoff *after* we set Items. Because we pull from a different list of // Also: It's important that we assign the cutoff *after* we set Items. Because we pull from a different list of
// items depending on if the `qualities` property is set in config. // items depending on if the `qualities` property is set in config.
newDto.Cutoff = config.UpgradeAllowed newDto.Cutoff = newDto.Items.FindCutoff(config.UpgradeUntilQuality) ?? newDto.Items.FirstCutoffId();
? newDto.Items.FindCutoff(config.UpgradeUntilQuality)
: newDto.Items.FirstOrDefault()?.Id;
return newDto; return newDto;
} }

@ -1,5 +1,6 @@
using FluentValidation; using FluentValidation;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases; using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Cli.Pipelines.QualityProfile; namespace Recyclarr.Cli.Pipelines.QualityProfile;
@ -7,31 +8,48 @@ public class UpdatedQualityProfileValidator : AbstractValidator<UpdatedQualityPr
{ {
public UpdatedQualityProfileValidator() public UpdatedQualityProfileValidator()
{ {
RuleFor(x => x.ProfileConfig.Profile.MinFormatScore).Custom((minScore, context) => RuleFor(x => x.ProfileConfig.Profile.MinFormatScore)
{ .Custom(ValidateMinScoreSatisfied);
var scores = context.InstanceToValidate.UpdatedScores;
var totalScores = scores.Select(x => x.NewScore).Where(x => x > 0).Sum();
if (totalScores < minScore)
{
context.AddFailure(
$"Minimum Custom Format Score of {minScore} can never be satisfied because the total of all " +
$"positive scores is {totalScores}");
}
});
RuleFor(x => x.ProfileConfig.Profile.UpgradeUntilQuality)
.Must((o, x) => o.ProfileDto.Items.FindCutoff(x) is not null)
.When(x => x.ProfileConfig.Profile is {UpgradeUntilQuality: not null, Qualities.Count: 0})
.WithMessage("'until_quality' must refer to an existing and enabled quality or group");
RuleFor(x => x.ProfileConfig.Profile.UpgradeUntilQuality) RuleFor(x => x.ProfileConfig.Profile.UpgradeUntilQuality)
.Must((o, x) .Custom(ValidateCutoff!)
=> !o.UpdatedQualities.InvalidQualityNames.Contains(x, StringComparer.InvariantCultureIgnoreCase)) .When(x => x.ProfileConfig.Profile.UpgradeUntilQuality is not null);
.WithMessage((_, x) => $"`until_quality` references invalid quality '{x}'");
RuleFor(x => x.ProfileConfig.Profile.Qualities) RuleFor(x => x.ProfileConfig.Profile.Qualities)
.NotEmpty() .NotEmpty()
.When(x => x.UpdateReason == QualityProfileUpdateReason.New) .When(x => x.UpdateReason == QualityProfileUpdateReason.New)
.WithMessage("`qualities` is required when creating profiles for the first time"); .WithMessage("`qualities` is required when creating profiles for the first time");
} }
private static void ValidateMinScoreSatisfied(int? minScore, ValidationContext<UpdatedQualityProfile> context)
{
var scores = context.InstanceToValidate.UpdatedScores;
var totalScores = scores.Select(x => x.NewScore).Where(x => x > 0).Sum();
if (totalScores < minScore)
{
context.AddFailure(
$"Minimum Custom Format Score of {minScore} can never be satisfied because the total of all " +
$"positive scores is {totalScores}");
}
}
private static void ValidateCutoff(string untilQuality, ValidationContext<UpdatedQualityProfile> context)
{
var profile = context.InstanceToValidate;
if (profile.UpdatedQualities.InvalidQualityNames.Any(x => x.EqualsIgnoreCase(untilQuality)))
{
context.AddFailure($"`until_quality` references invalid quality '{untilQuality}'");
return;
}
var items = profile.UpdatedQualities.NumWantedItems > 0
? profile.UpdatedQualities.Items
: profile.ProfileDto.Items;
if (items.FindCutoff(untilQuality) is null)
{
context.AddFailure("'until_quality' must refer to an existing and enabled quality or group");
}
}
} }

@ -1,9 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.Common.FluentValidation;
[SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification =
"Used by AutoFac to exclude IValidator implementations from DI registration")]
public interface IManualValidator
{
}

@ -47,7 +47,7 @@ public class SonarrCapabilityEnforcer
if (config.QualityProfiles.Any(x => x.UpgradeUntilScore is not null)) if (config.QualityProfiles.Any(x => x.UpgradeUntilScore is not null))
{ {
throw new ServiceIncompatibilityException( throw new ServiceIncompatibilityException(
"`until_score` under `upgrades_allowed` is not supported by Sonarr v3. " + "`until_score` under `upgrade` is not supported by Sonarr v3. " +
"Remove the until_score property or use Sonarr v4."); "Remove the until_score property or use Sonarr v4.");
} }

@ -16,10 +16,6 @@ public class ConfigAutofacModule : Module
{ {
protected override void Load(ContainerBuilder builder) protected override void Load(ContainerBuilder builder)
{ {
builder.RegisterAssemblyTypes(ThisAssembly)
.AsClosedTypesOf(typeof(IValidator<>))
.As<IValidator>();
builder.RegisterAssemblyTypes(ThisAssembly) builder.RegisterAssemblyTypes(ThisAssembly)
.AssignableTo<IYamlBehavior>() .AssignableTo<IYamlBehavior>()
.As<IYamlBehavior>(); .As<IYamlBehavior>();
@ -42,5 +38,8 @@ public class ConfigAutofacModule : Module
// Config Post Processors // Config Post Processors
builder.RegisterType<ImplicitUrlAndKeyPostProcessor>().As<IConfigPostProcessor>(); builder.RegisterType<ImplicitUrlAndKeyPostProcessor>().As<IConfigPostProcessor>();
// Validators
builder.RegisterType<RootConfigYamlValidator>().As<IValidator>();
} }
} }

@ -29,6 +29,7 @@ public record QualitySizeConfigYaml
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record QualityProfileFormatUpgradeYaml public record QualityProfileFormatUpgradeYaml
{ {
public bool? Allowed { get; init; }
public int? UntilScore { get; init; } public int? UntilScore { get; init; }
public string? UntilQuality { get; init; } public string? UntilQuality { get; init; }
} }
@ -45,7 +46,7 @@ public record QualityProfileQualityConfigYaml
public record QualityProfileConfigYaml public record QualityProfileConfigYaml
{ {
public string? Name { get; init; } public string? Name { get; init; }
public QualityProfileFormatUpgradeYaml? UpgradesAllowed { get; init; } public QualityProfileFormatUpgradeYaml? Upgrade { get; init; }
public int? MinFormatScore { get; init; } public int? MinFormatScore { get; init; }
public bool ResetUnmatchedScores { get; init; } public bool ResetUnmatchedScores { get; init; }
public QualitySortAlgorithm? QualitySort { get; init; } public QualitySortAlgorithm? QualitySort { get; init; }

@ -1,11 +1,9 @@
using FluentValidation; using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation; using Recyclarr.Common.FluentValidation;
namespace Recyclarr.TrashLib.Config.Parsing; namespace Recyclarr.TrashLib.Config.Parsing;
[UsedImplicitly]
public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml> public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml>
{ {
public ServiceConfigYamlValidator() public ServiceConfigYamlValidator()
@ -36,7 +34,6 @@ public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml>
} }
} }
[UsedImplicitly]
public class CustomFormatConfigYamlValidator : AbstractValidator<CustomFormatConfigYaml> public class CustomFormatConfigYamlValidator : AbstractValidator<CustomFormatConfigYaml>
{ {
public CustomFormatConfigYamlValidator() public CustomFormatConfigYamlValidator()
@ -53,7 +50,6 @@ public class CustomFormatConfigYamlValidator : AbstractValidator<CustomFormatCon
} }
} }
[UsedImplicitly]
public class QualityScoreConfigYamlValidator : AbstractValidator<QualityScoreConfigYaml> public class QualityScoreConfigYamlValidator : AbstractValidator<QualityScoreConfigYaml>
{ {
public QualityScoreConfigYamlValidator() public QualityScoreConfigYamlValidator()
@ -63,7 +59,6 @@ public class QualityScoreConfigYamlValidator : AbstractValidator<QualityScoreCon
} }
} }
[UsedImplicitly]
public class QualitySizeConfigYamlValidator : AbstractValidator<QualitySizeConfigYaml> public class QualitySizeConfigYamlValidator : AbstractValidator<QualitySizeConfigYaml>
{ {
public QualitySizeConfigYamlValidator() public QualitySizeConfigYamlValidator()
@ -77,59 +72,67 @@ public class QualitySizeConfigYamlValidator : AbstractValidator<QualitySizeConfi
} }
} }
[UsedImplicitly]
public class QualityProfileFormatUpgradeYamlValidator : AbstractValidator<QualityProfileFormatUpgradeYaml> public class QualityProfileFormatUpgradeYamlValidator : AbstractValidator<QualityProfileFormatUpgradeYaml>
{ {
public QualityProfileFormatUpgradeYamlValidator() public QualityProfileFormatUpgradeYamlValidator(QualityProfileConfigYaml config)
{ {
RuleFor(x => x.Allowed)
.NotNull()
.WithMessage(
$"For profile {config.Name}, 'allowed' under 'upgrade' is required. " +
$"If you don't want Recyclarr to manage upgrades, delete the whole 'upgrade' block.");
RuleFor(x => x.UntilQuality) RuleFor(x => x.UntilQuality)
.Cascade(CascadeMode.Stop) .NotNull()
.NotEmpty() .When(x => x.Allowed is true && config.Qualities is not null)
.WithMessage("'until_quality' is required when allowing profile upgrades"); .WithMessage(
$"For profile {config.Name}, 'until_quality' is required when 'allowed' is set to 'true' and " +
$"an explicit 'qualities' list is provided.");
} }
} }
[UsedImplicitly]
public class QualityProfileConfigYamlValidator : AbstractValidator<QualityProfileConfigYaml> public class QualityProfileConfigYamlValidator : AbstractValidator<QualityProfileConfigYaml>
{ {
public QualityProfileConfigYamlValidator() public QualityProfileConfigYamlValidator()
{ {
ClassLevelCascadeMode = CascadeMode.Stop;
RuleLevelCascadeMode = CascadeMode.Stop;
RuleFor(x => x.Name) RuleFor(x => x.Name)
.Cascade(CascadeMode.Stop) .Cascade(CascadeMode.Stop)
.NotEmpty() .NotEmpty()
.WithMessage(x => $"For profile {x.Name}, 'name' is required for root-level 'quality_profiles' elements"); .WithMessage(x => $"For profile {x.Name}, 'name' is required for root-level 'quality_profiles' elements");
RuleFor(x => x.UpgradesAllowed) RuleFor(x => x.Upgrade)
.SetNonNullableValidator(new QualityProfileFormatUpgradeYamlValidator()); .SetNonNullableValidator(x => new QualityProfileFormatUpgradeYamlValidator(x));
RuleFor(x => x.Qualities)
.Custom(ValidateHaveNoDuplicates!)
.Must(x => x!.Any(y => y.Enabled is true or null))
.WithMessage(x =>
$"For profile {x.Name}, at least one explicitly listed quality under 'qualities' must be enabled.")
.When(x => x is {Qualities.Count: > 0});
RuleFor(x => x.Qualities) RuleFor(x => x.Qualities)
.Cascade(CascadeMode.Stop)
.Must((o, x) => !x! .Must((o, x) => !x!
.Where(y => y.Qualities is not null) .Where(y => y.Qualities is not null)
.SelectMany(y => y.Qualities!) .SelectMany(y => y.Qualities!)
.Contains(o.UpgradesAllowed!.UntilQuality, StringComparer.InvariantCultureIgnoreCase)) .Contains(o.Upgrade!.UntilQuality, StringComparer.InvariantCultureIgnoreCase))
.WithMessage(o => .WithMessage(o =>
$"For profile {o.Name}, 'until_quality' must not refer to qualities contained within groups") $"For profile {o.Name}, 'until_quality' must not refer to qualities contained within groups")
.Must((o, x) => !x! .Must((o, x) => !x!
.Where(y => y is {Enabled: false, Name: not null}) .Where(y => y is {Enabled: false, Name: not null})
.Select(y => y.Name!) .Select(y => y.Name!)
.Contains(o.UpgradesAllowed!.UntilQuality, StringComparer.InvariantCultureIgnoreCase)) .Contains(o.Upgrade!.UntilQuality, StringComparer.InvariantCultureIgnoreCase))
.WithMessage(o => .WithMessage(o =>
$"For profile {o.Name}, 'until_quality' must not refer to explicitly disabled qualities") $"For profile {o.Name}, 'until_quality' must not refer to explicitly disabled qualities")
.Must((o, x) => x! .Must((o, x) => x!
.Select(y => y.Name) .Select(y => y.Name)
.Contains(o.UpgradesAllowed!.UntilQuality, StringComparer.InvariantCultureIgnoreCase)) .Contains(o.Upgrade!.UntilQuality, StringComparer.InvariantCultureIgnoreCase))
.WithMessage(o => .WithMessage(o =>
$"For profile {o.Name}, 'qualities' must contain the quality mentioned in 'until_quality', " + $"For profile {o.Name}, 'qualities' must contain the quality mentioned in 'until_quality', " +
$"which is '{o.UpgradesAllowed!.UntilQuality}'") $"which is '{o.Upgrade!.UntilQuality}'")
.When(x => x is {UpgradesAllowed: not null, Qualities.Count: > 0}); .When(x => x is {Upgrade.Allowed: not false, Qualities.Count: > 0});
RuleFor(x => x.Qualities)
.Custom(ValidateHaveNoDuplicates!)
.Must(x => x!.Any(y => y.Enabled is true or null))
.WithMessage(x =>
$"For profile {x.Name}, at least one explicitly listed quality under 'qualities' must be enabled.")
.When(x => x is {Qualities.Count: > 0});
} }
private static void ValidateHaveNoDuplicates( private static void ValidateHaveNoDuplicates(
@ -153,7 +156,6 @@ public class QualityProfileConfigYamlValidator : AbstractValidator<QualityProfil
} }
} }
[UsedImplicitly]
public class RadarrConfigYamlValidator : CustomValidator<RadarrConfigYaml> public class RadarrConfigYamlValidator : CustomValidator<RadarrConfigYaml>
{ {
public RadarrConfigYamlValidator() public RadarrConfigYamlValidator()
@ -162,7 +164,6 @@ public class RadarrConfigYamlValidator : CustomValidator<RadarrConfigYaml>
} }
} }
[UsedImplicitly]
public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml> public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
{ {
public SonarrConfigYamlValidator() public SonarrConfigYamlValidator()
@ -177,7 +178,6 @@ public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
} }
} }
[UsedImplicitly]
public class ReleaseProfileConfigYamlValidator : CustomValidator<ReleaseProfileConfigYaml> public class ReleaseProfileConfigYamlValidator : CustomValidator<ReleaseProfileConfigYaml>
{ {
public ReleaseProfileConfigYamlValidator() public ReleaseProfileConfigYamlValidator()
@ -190,7 +190,6 @@ public class ReleaseProfileConfigYamlValidator : CustomValidator<ReleaseProfileC
} }
} }
[UsedImplicitly]
public class ReleaseProfileFilterConfigYamlValidator : CustomValidator<ReleaseProfileFilterConfigYaml> public class ReleaseProfileFilterConfigYamlValidator : CustomValidator<ReleaseProfileFilterConfigYaml>
{ {
public ReleaseProfileFilterConfigYamlValidator() public ReleaseProfileFilterConfigYamlValidator()
@ -212,7 +211,6 @@ public class ReleaseProfileFilterConfigYamlValidator : CustomValidator<ReleasePr
} }
} }
[UsedImplicitly]
public class RootConfigYamlValidator : CustomValidator<RootConfigYaml> public class RootConfigYamlValidator : CustomValidator<RootConfigYaml>
{ {
public RootConfigYamlValidator() public RootConfigYamlValidator()

@ -18,8 +18,9 @@ public class ConfigYamlMapperProfile : Profile
.ForMember(x => x.Enabled, o => o.NullSubstitute(true)); .ForMember(x => x.Enabled, o => o.NullSubstitute(true));
CreateMap<QualityProfileConfigYaml, QualityProfileConfig>() CreateMap<QualityProfileConfigYaml, QualityProfileConfig>()
.ForMember(x => x.UpgradeUntilQuality, o => o.MapFrom(x => x.UpgradesAllowed!.UntilQuality)) .ForMember(x => x.UpgradeAllowed, o => o.MapFrom(x => x.Upgrade!.Allowed))
.ForMember(x => x.UpgradeUntilScore, o => o.MapFrom(x => x.UpgradesAllowed!.UntilScore)) .ForMember(x => x.UpgradeUntilQuality, o => o.MapFrom(x => x.Upgrade!.UntilQuality))
.ForMember(x => x.UpgradeUntilScore, o => o.MapFrom(x => x.Upgrade!.UntilScore))
.ForMember(x => x.QualitySort, o => o.NullSubstitute(QualitySortAlgorithm.Top)); .ForMember(x => x.QualitySort, o => o.NullSubstitute(QualitySortAlgorithm.Top));
CreateMap<ServiceConfigYaml, ServiceConfiguration>() CreateMap<ServiceConfigYaml, ServiceConfiguration>()

@ -62,7 +62,7 @@ public enum QualitySortAlgorithm
public record QualityProfileConfig public record QualityProfileConfig
{ {
public string Name { get; init; } = ""; public string Name { get; init; } = "";
public bool UpgradeAllowed => UpgradeUntilQuality is not null; public bool? UpgradeAllowed { get; init; }
public string? UpgradeUntilQuality { get; init; } public string? UpgradeUntilQuality { get; init; }
public int? UpgradeUntilScore { get; init; } public int? UpgradeUntilScore { get; init; }
public int? MinFormatScore { get; init; } public int? MinFormatScore { get; init; }

@ -63,8 +63,8 @@ public class UpdatedQualityProfileTest
{ {
Name = "config_name", Name = "config_name",
MinFormatScore = 110, MinFormatScore = 110,
UpgradeUntilScore = 220, UpgradeAllowed = true,
UpgradeUntilQuality = "Quality Item 3" UpgradeUntilScore = 220
}), }),
UpdatedQualities = new UpdatedQualities UpdatedQualities = new UpdatedQualities
{ {
@ -92,9 +92,8 @@ public class UpdatedQualityProfileTest
MinFormatScore = 110, MinFormatScore = 110,
CutoffFormatScore = 220, CutoffFormatScore = 220,
UpgradeAllowed = true, UpgradeAllowed = true,
Cutoff = 3,
Items = profile.UpdatedQualities.Items Items = profile.UpdatedQualities.Items
}); }, o => o.Excluding(x => x.Cutoff));
} }
[Test] [Test]
@ -151,4 +150,112 @@ public class UpdatedQualityProfileTest
dto.Name.Should().Be("config_name"); dto.Name.Should().Be("config_name");
} }
[Test]
public void Cutoff_obtained_from_updated_qualities()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(8, "Quality Item 8", true),
NewQp.QualityDto(9, "Quality Item 9", true)
}
},
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
UpgradeUntilQuality = "Quality Item 2"
}),
UpdatedQualities = new UpdatedQualities
{
NumWantedItems = 1,
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(1, "Quality Item 1", true),
NewQp.QualityDto(2, "Quality Item 2", true),
NewQp.GroupDto(3, "Quality Item 3", true,
NewQp.QualityDto(4, "Quality Item 4", true))
}
},
UpdateReason = QualityProfileUpdateReason.New
};
var dto = profile.BuildUpdatedDto();
dto.Cutoff.Should().Be(2);
}
[Test]
public void Cutoff_obtained_from_original_qualities()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(8, "Quality Item 8", true),
NewQp.QualityDto(9, "Quality Item 9", true)
}
},
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
UpgradeUntilQuality = "Quality Item 9"
}),
UpdatedQualities = new UpdatedQualities
{
NumWantedItems = 0, // zero forces cutoff search to fall back to original DTO items
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(1, "Quality Item 1", true),
NewQp.QualityDto(2, "Quality Item 2", true),
NewQp.GroupDto(3, "Quality Item 3", true,
NewQp.QualityDto(4, "Quality Item 4", true))
}
},
UpdateReason = QualityProfileUpdateReason.New
};
var dto = profile.BuildUpdatedDto();
dto.Cutoff.Should().Be(9);
}
[Test]
public void Cutoff_fall_back_to_first()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(8, "Quality Item 8", true),
NewQp.QualityDto(9, "Quality Item 9", true)
}
},
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
// UpgradeUntilQuality = "Quality Item 9"
}),
UpdatedQualities = new UpdatedQualities
{
NumWantedItems = 1,
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(1, "Quality Item 1", true),
NewQp.QualityDto(2, "Quality Item 2", true),
NewQp.GroupDto(3, "Quality Item 3", true,
NewQp.QualityDto(4, "Quality Item 4", true))
}
},
UpdateReason = QualityProfileUpdateReason.New
};
var dto = profile.BuildUpdatedDto();
dto.Cutoff.Should().Be(1);
}
} }

@ -8,28 +8,52 @@ namespace Recyclarr.TrashLib.Tests.Config.Parsing;
public class ConfigYamlDataObjectsValidationTest public class ConfigYamlDataObjectsValidationTest
{ {
[Test] [Test]
public void Quality_profile_name_required() public void Quality_profile_format_upgrade_allowed_required()
{ {
var data = new QualityProfileConfigYaml(); var data = new QualityProfileConfigYaml
{
Name = "My QP",
Upgrade = new QualityProfileFormatUpgradeYaml()
};
var validator = new QualityProfileConfigYamlValidator(); var validator = new QualityProfileFormatUpgradeYamlValidator(data);
var result = validator.TestValidate(data); var result = validator.TestValidate(data.Upgrade);
result.ShouldHaveValidationErrorFor(x => x.Name); result.ShouldHaveValidationErrorFor(x => x.Allowed).WithErrorMessage(
$"For profile {data.Name}, 'allowed' under 'upgrade' is required. " +
$"If you don't want Recyclarr to manage upgrades, delete the whole 'upgrade' block.");
} }
[Test] [Test]
public void Quality_profile_until_quality_required() public void Quality_profile_format_upgrade_until_quality_required()
{ {
var data = new QualityProfileConfigYaml var data = new QualityProfileConfigYaml
{ {
UpgradesAllowed = new QualityProfileFormatUpgradeYaml() Name = "My QP",
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true
},
Qualities = new List<QualityProfileQualityConfigYaml>()
}; };
var validator = new QualityProfileFormatUpgradeYamlValidator(data);
var result = validator.TestValidate(data.Upgrade);
result.ShouldHaveValidationErrorFor(x => x.UntilQuality).WithErrorMessage(
$"For profile {data.Name}, 'until_quality' is required when 'allowed' is set to 'true' and " +
$"an explicit 'qualities' list is provided.");
}
[Test]
public void Quality_profile_name_required()
{
var data = new QualityProfileConfigYaml();
var validator = new QualityProfileConfigYamlValidator(); var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data); var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.UpgradesAllowed!.UntilQuality); result.ShouldHaveValidationErrorFor(x => x.Name);
} }
[Test] [Test]
@ -38,8 +62,9 @@ public class ConfigYamlDataObjectsValidationTest
var data = new QualityProfileConfigYaml var data = new QualityProfileConfigYaml
{ {
Name = "My QP", Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml Upgrade = new QualityProfileFormatUpgradeYaml
{ {
Allowed = true,
UntilQuality = "Test Quality" UntilQuality = "Test Quality"
}, },
Qualities = new[] Qualities = new[]
@ -55,23 +80,7 @@ public class ConfigYamlDataObjectsValidationTest
result.Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo( result.Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(
$"For profile {data.Name}, 'qualities' must contain the quality mentioned in 'until_quality', " + $"For profile {data.Name}, 'qualities' must contain the quality mentioned in 'until_quality', " +
$"which is '{data.UpgradesAllowed!.UntilQuality}'"); $"which is '{data.Upgrade!.UntilQuality}'");
}
[Test]
public void Quality_profile_qualities_cutoff_required()
{
var data = new QualityProfileConfigYaml
{
Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml()
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.UpgradesAllowed!.UntilQuality)
.WithErrorMessage("'until_quality' is required when allowing profile upgrades");
} }
[Test] [Test]
@ -80,8 +89,9 @@ public class ConfigYamlDataObjectsValidationTest
var data = new QualityProfileConfigYaml var data = new QualityProfileConfigYaml
{ {
Name = "My QP", Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml Upgrade = new QualityProfileFormatUpgradeYaml
{ {
Allowed = true,
UntilQuality = "Child Quality" UntilQuality = "Child Quality"
}, },
Qualities = new[] Qualities = new[]
@ -166,8 +176,9 @@ public class ConfigYamlDataObjectsValidationTest
var data = new QualityProfileConfigYaml var data = new QualityProfileConfigYaml
{ {
Name = "My QP", Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml Upgrade = new QualityProfileFormatUpgradeYaml
{ {
Allowed = true,
UntilQuality = "Disabled Quality" UntilQuality = "Disabled Quality"
}, },
Qualities = new[] Qualities = new[]

Loading…
Cancel
Save