This change was necessary to facilitate the ability to serialize (create/modify) YAML configuration data. This is a prerequisite to creating config templates and also GUI work in the future.pull/201/head
parent
81bbc50ef7
commit
5c98949edc
@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using Recyclarr.Common.Extensions;
|
||||
|
||||
namespace Recyclarr.Common.FluentValidation;
|
||||
|
||||
public abstract class CustomValidator<T> : AbstractValidator<T>
|
||||
{
|
||||
public bool OnlyOneHasElements<TC1, TC2>(
|
||||
IEnumerable<TC1>? c1,
|
||||
IEnumerable<TC2>? c2)
|
||||
{
|
||||
var notEmpty = new[]
|
||||
{
|
||||
c1.IsNotEmpty(),
|
||||
c2.IsNotEmpty()
|
||||
};
|
||||
|
||||
return notEmpty.Count(x => x) <= 1;
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
|
||||
namespace Recyclarr.Common.FluentValidation;
|
||||
|
||||
public class RuntimeValidationService
|
||||
{
|
||||
private readonly Dictionary<Type, IValidator> _validators;
|
||||
|
||||
private static Type? GetValidatorInterface(Type type)
|
||||
{
|
||||
return type.GetInterfaces()
|
||||
.FirstOrDefault(i
|
||||
=> i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>));
|
||||
}
|
||||
|
||||
public RuntimeValidationService(IEnumerable<IValidator> validators)
|
||||
{
|
||||
_validators = validators
|
||||
.Select(x => (x, GetValidatorInterface(x.GetType())))
|
||||
.Where(x => x.Item2 is not null)
|
||||
.ToDictionary(x => x.Item2!.GetGenericArguments()[0], x => x.Item1);
|
||||
}
|
||||
|
||||
public ValidationResult Validate(object instance)
|
||||
{
|
||||
if (!_validators.TryGetValue(instance.GetType(), out var validator))
|
||||
{
|
||||
throw new ValidationException($"No validator is available for type: {instance.GetType().FullName}");
|
||||
}
|
||||
|
||||
return validator.Validate(new ValidationContext<object>(instance));
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Recyclarr.Config.Data\Recyclarr.Config.Data.csproj" />
|
||||
<ProjectReference Include="..\Recyclarr.TrashLib\Recyclarr.TrashLib.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Recyclarr.TrashLib\Config\Parsing\ConfigYamlDataObjectsLatest.cs">
|
||||
<Link>ConfigYamlDataObjectsLatest.cs</Link>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
</Project>
|
@ -0,0 +1,84 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
namespace Recyclarr.Config.Data.Tests;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class SonarrConfigYamlValidatorTest
|
||||
{
|
||||
[Test]
|
||||
public void Validation_failure_when_rps_and_cfs_used_together()
|
||||
{
|
||||
var config = new SonarrConfigYamlLatest
|
||||
{
|
||||
ReleaseProfiles = new[] {new ReleaseProfileConfigYamlLatest()},
|
||||
CustomFormats = new[] {new CustomFormatConfigYamlLatest()}
|
||||
};
|
||||
|
||||
var validator = new SonarrConfigYamlValidatorLatest();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(x => x)
|
||||
.WithErrorMessage("`custom_formats` and `release_profiles` may not be used together");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sonarr_release_profile_failures()
|
||||
{
|
||||
var config = new ReleaseProfileConfigYamlLatest
|
||||
{
|
||||
TrashIds = Array.Empty<string>(),
|
||||
Filter = new ReleaseProfileFilterConfigYamlLatest
|
||||
{
|
||||
Include = new[] {"include"},
|
||||
Exclude = new[] {"exclude"}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReleaseProfileConfigYamlValidatorLatest();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
result.Errors.Should().HaveCount(2);
|
||||
|
||||
// Release profile trash IDs cannot be empty
|
||||
result.ShouldHaveValidationErrorFor(x => x.TrashIds);
|
||||
|
||||
// Cannot use include + exclude filters together
|
||||
result.ShouldHaveValidationErrorFor(nameof(ReleaseProfileConfig.Filter));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Filter_include_can_not_be_empty()
|
||||
{
|
||||
var config = new ReleaseProfileFilterConfigYamlLatest
|
||||
{
|
||||
Include = Array.Empty<string>(),
|
||||
Exclude = new[] {"exclude"}
|
||||
};
|
||||
|
||||
var validator = new ReleaseProfileFilterConfigYamlValidatorLatest();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
result.Errors.Should().HaveCount(1);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(x => x.Include);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Filter_exclude_can_not_be_empty()
|
||||
{
|
||||
var config = new ReleaseProfileFilterConfigYamlLatest
|
||||
{
|
||||
Exclude = Array.Empty<string>(),
|
||||
Include = new[] {"exclude"}
|
||||
};
|
||||
|
||||
var validator = new ReleaseProfileFilterConfigYamlValidatorLatest();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
result.Errors.Should().HaveCount(1);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(x => x.Exclude);
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" />
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="JetBrains.Annotations" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
@ -0,0 +1,70 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Recyclarr.Config.Data.V1;
|
||||
|
||||
public record QualityScoreConfigYaml
|
||||
{
|
||||
public string? Name { get; [UsedImplicitly] init; }
|
||||
public int? Score { get; [UsedImplicitly] init; }
|
||||
public bool? ResetUnmatchedScores { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record CustomFormatConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<QualityScoreConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record QualitySizeConfigYaml
|
||||
{
|
||||
public string? Type { get; [UsedImplicitly] init; }
|
||||
public decimal? PreferredRatio { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record QualityProfileConfigYaml
|
||||
{
|
||||
public string? Name { get; [UsedImplicitly] init; }
|
||||
public bool ResetUnmatchedScores { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record ServiceConfigYaml
|
||||
{
|
||||
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings")]
|
||||
public string? BaseUrl { get; [UsedImplicitly] init; }
|
||||
public string? ApiKey { get; [UsedImplicitly] init; }
|
||||
public bool DeleteOldCustomFormats { get; [UsedImplicitly] init; }
|
||||
public bool ReplaceExistingCustomFormats { get; [UsedImplicitly] init; } = true;
|
||||
public IReadOnlyCollection<CustomFormatConfigYaml>? CustomFormats { get; [UsedImplicitly] init; }
|
||||
public QualitySizeConfigYaml? QualityDefinition { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<QualityProfileConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record ReleaseProfileFilterConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<string>? Include { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<string>? Exclude { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record ReleaseProfileConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
|
||||
public bool StrictNegativeScores { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<string>? Tags { get; [UsedImplicitly] init; }
|
||||
public ReleaseProfileFilterConfigYaml? Filter { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
// This is usually empty (or the same as ServiceConfigYaml) on purpose.
|
||||
// If empty, it is kept around to make it clear that this one is dedicated to Radarr.
|
||||
public record RadarrConfigYaml : ServiceConfigYaml;
|
||||
|
||||
public record SonarrConfigYaml : ServiceConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record RootConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<RadarrConfigYaml>? Radarr { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<SonarrConfigYaml>? Sonarr { get; [UsedImplicitly] init; }
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
using JetBrains.Annotations;
|
||||
using Recyclarr.Common.Extensions;
|
||||
using Recyclarr.Common.FluentValidation;
|
||||
|
||||
namespace Recyclarr.Config.Data.V1;
|
||||
|
||||
[UsedImplicitly]
|
||||
public class RootConfigYamlValidator : CustomValidator<RootConfigYaml>
|
||||
{
|
||||
public RootConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x).Must(x => x.Radarr.IsEmpty() && x.Sonarr.IsEmpty())
|
||||
.WithSeverity(Severity.Warning)
|
||||
.WithMessage(
|
||||
"Found array-style list of instances instead of named-style. " +
|
||||
"Array-style lists of Sonarr/Radarr instances are deprecated. " +
|
||||
"See: https://recyclarr.dev/wiki/upgrade-guide/v5.0#instances-must-now-be-named");
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using JetBrains.Annotations;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace Recyclarr.Config.Data.V2;
|
||||
|
||||
public record QualityScoreConfigYaml
|
||||
{
|
||||
public string? Name { get; [UsedImplicitly] init; }
|
||||
public int? Score { get; [UsedImplicitly] init; }
|
||||
public bool? ResetUnmatchedScores { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record CustomFormatConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<QualityScoreConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record QualitySizeConfigYaml
|
||||
{
|
||||
public string? Type { get; [UsedImplicitly] init; }
|
||||
public decimal? PreferredRatio { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record QualityProfileConfigYaml
|
||||
{
|
||||
public string? Name { get; [UsedImplicitly] init; }
|
||||
public bool ResetUnmatchedScores { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record ServiceConfigYaml
|
||||
{
|
||||
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings")]
|
||||
public string? BaseUrl { get; [UsedImplicitly] init; }
|
||||
public string? ApiKey { get; [UsedImplicitly] init; }
|
||||
public bool DeleteOldCustomFormats { get; [UsedImplicitly] init; }
|
||||
|
||||
// todo: In v5.0, this will change to false.
|
||||
public bool ReplaceExistingCustomFormats { get; [UsedImplicitly] init; } = true;
|
||||
|
||||
public IReadOnlyCollection<CustomFormatConfigYaml>? CustomFormats { get; [UsedImplicitly] init; }
|
||||
public QualitySizeConfigYaml? QualityDefinition { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<QualityProfileConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record ReleaseProfileFilterConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<string>? Include { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<string>? Exclude { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record ReleaseProfileConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
|
||||
public bool StrictNegativeScores { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyCollection<string>? Tags { get; [UsedImplicitly] init; }
|
||||
public ReleaseProfileFilterConfigYaml? Filter { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
// This is usually empty (or the same as ServiceConfigYaml) on purpose.
|
||||
// If empty, it is kept around to make it clear that this one is dedicated to Radarr.
|
||||
public record RadarrConfigYaml : ServiceConfigYaml;
|
||||
|
||||
public record SonarrConfigYaml : ServiceConfigYaml
|
||||
{
|
||||
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; [UsedImplicitly] init; }
|
||||
}
|
||||
|
||||
public record RootConfigYaml
|
||||
{
|
||||
public IReadOnlyDictionary<string, RadarrConfigYaml>? Radarr { get; [UsedImplicitly] init; }
|
||||
public IReadOnlyDictionary<string, SonarrConfigYaml>? Sonarr { get; [UsedImplicitly] init; }
|
||||
|
||||
// This exists for validation purposes only.
|
||||
[YamlIgnore]
|
||||
public IEnumerable<RadarrConfigYaml> RadarrValues
|
||||
=> Radarr?.Select(x => x.Value) ?? Array.Empty<RadarrConfigYaml>();
|
||||
|
||||
// This exists for validation purposes only.
|
||||
[YamlIgnore]
|
||||
public IEnumerable<SonarrConfigYaml> SonarrValues
|
||||
=> Sonarr?.Select(x => x.Value) ?? Array.Empty<SonarrConfigYaml>();
|
||||
}
|
@ -0,0 +1,161 @@
|
||||
using FluentValidation;
|
||||
using JetBrains.Annotations;
|
||||
using Recyclarr.Common.FluentValidation;
|
||||
|
||||
namespace Recyclarr.Config.Data.V2;
|
||||
|
||||
[UsedImplicitly]
|
||||
public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml>
|
||||
{
|
||||
public ServiceConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x.BaseUrl).NotEmpty().NotNull()
|
||||
.WithMessage("'base_url' is required and must not be empty");
|
||||
|
||||
RuleFor(x => x.BaseUrl).NotEmpty().Must(x => x is not null && x.StartsWith("http"))
|
||||
.WithMessage("'base_url' must start with 'http' or 'https'");
|
||||
|
||||
RuleFor(x => x.ApiKey).NotEmpty()
|
||||
.WithMessage("'api_key' is required");
|
||||
|
||||
RuleFor(x => x.CustomFormats).NotEmpty()
|
||||
.When(x => x.CustomFormats is not null)
|
||||
.WithName("custom_formats")
|
||||
.ForEach(x => x.SetValidator(new CustomFormatConfigYamlValidator()));
|
||||
|
||||
RuleFor(x => x.QualityDefinition)
|
||||
.SetNonNullableValidator(new QualitySizeConfigYamlValidator());
|
||||
|
||||
RuleFor(x => x.QualityProfiles).NotEmpty()
|
||||
.When(x => x.QualityProfiles != null)
|
||||
.WithName("quality_profiles")
|
||||
.ForEach(x => x.SetValidator(new QualityProfileConfigYamlValidator()));
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class CustomFormatConfigYamlValidator : AbstractValidator<CustomFormatConfigYaml>
|
||||
{
|
||||
public CustomFormatConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x.TrashIds).NotEmpty()
|
||||
.When(x => x.TrashIds is not null)
|
||||
.WithName("trash_ids")
|
||||
.ForEach(x => x.Length(32).Matches(@"^[0-9a-fA-F]+$"));
|
||||
|
||||
RuleForEach(x => x.QualityProfiles).NotEmpty()
|
||||
.When(x => x.QualityProfiles is not null)
|
||||
.WithName("quality_profiles")
|
||||
.SetValidator(new QualityScoreConfigYamlValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class QualityScoreConfigYamlValidator : AbstractValidator<QualityScoreConfigYaml>
|
||||
{
|
||||
public QualityScoreConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty()
|
||||
.WithMessage("'name' is required for elements under 'quality_profiles'");
|
||||
|
||||
RuleFor(x => x.ResetUnmatchedScores).Null()
|
||||
.WithSeverity(Severity.Warning)
|
||||
.WithMessage(
|
||||
"Usage of 'reset_unmatched_scores' inside 'quality_profiles' under 'custom_formats' is deprecated. " +
|
||||
"Use the root-level 'quality_profiles' instead. " +
|
||||
"See: https://recyclarr.dev/wiki/upgrade-guide/v5.0#reset-unmatched-scores");
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class QualitySizeConfigYamlValidator : AbstractValidator<QualitySizeConfigYaml>
|
||||
{
|
||||
public QualitySizeConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x.Type).NotEmpty()
|
||||
.WithMessage("'type' is required for 'quality_definition'");
|
||||
|
||||
RuleFor(x => x.PreferredRatio).InclusiveBetween(0, 1)
|
||||
.When(x => x.PreferredRatio is not null)
|
||||
.WithName("preferred_ratio");
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class QualityProfileConfigYamlValidator : AbstractValidator<QualityProfileConfigYaml>
|
||||
{
|
||||
public QualityProfileConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty()
|
||||
.WithMessage("'name' is required for root-level 'quality_profiles' elements");
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class RadarrConfigYamlValidator : CustomValidator<RadarrConfigYaml>
|
||||
{
|
||||
public RadarrConfigYamlValidator()
|
||||
{
|
||||
Include(new ServiceConfigYamlValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
|
||||
{
|
||||
public SonarrConfigYamlValidator()
|
||||
{
|
||||
Include(new ServiceConfigYamlValidator());
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => OnlyOneHasElements(x.ReleaseProfiles, x.CustomFormats))
|
||||
.WithMessage("`custom_formats` and `release_profiles` may not be used together");
|
||||
|
||||
RuleForEach(x => x.ReleaseProfiles).SetValidator(new ReleaseProfileConfigYamlValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class ReleaseProfileConfigYamlValidator : CustomValidator<ReleaseProfileConfigYaml>
|
||||
{
|
||||
public ReleaseProfileConfigYamlValidator()
|
||||
{
|
||||
RuleFor(x => x.TrashIds).NotEmpty()
|
||||
.WithMessage("'trash_ids' is required for 'release_profiles' elements");
|
||||
|
||||
RuleFor(x => x.Filter)
|
||||
.SetNonNullableValidator(new ReleaseProfileFilterConfigYamlValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class ReleaseProfileFilterConfigYamlValidator : CustomValidator<ReleaseProfileFilterConfigYaml>
|
||||
{
|
||||
public ReleaseProfileFilterConfigYamlValidator()
|
||||
{
|
||||
// Include & Exclude may not be used together
|
||||
RuleFor(x => x)
|
||||
.Must(x => OnlyOneHasElements(x.Include, x.Exclude))
|
||||
.WithMessage("'include' and 'exclude' may not be used together")
|
||||
.DependentRules(() =>
|
||||
{
|
||||
RuleFor(x => x.Include).NotEmpty()
|
||||
.When(x => x.Include is not null)
|
||||
.WithMessage("'include' under 'filter' must have at least 1 Trash ID");
|
||||
|
||||
RuleFor(x => x.Exclude).NotEmpty()
|
||||
.When(x => x.Exclude is not null)
|
||||
.WithMessage("'exclude' under 'filter' must have at least 1 Trash ID");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class RootConfigYamlValidator : CustomValidator<RootConfigYaml>
|
||||
{
|
||||
public RootConfigYamlValidator()
|
||||
{
|
||||
RuleForEach(x => x.RadarrValues).SetValidator(new RadarrConfigYamlValidator());
|
||||
RuleForEach(x => x.SonarrValues).SetValidator(new SonarrConfigYamlValidator());
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
using Recyclarr.TrashLib.Config;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
namespace Recyclarr.TrashLib.TestLibrary;
|
||||
|
||||
public class TestConfig : ServiceConfiguration
|
||||
{
|
||||
public override SupportedServices ServiceType => SupportedServices.Sonarr;
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
using Recyclarr.Cli.TestLibrary;
|
||||
using Recyclarr.TrashLib.Config.Parsing;
|
||||
|
||||
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class BackwardCompatibleConfigParserTest : IntegrationFixture
|
||||
{
|
||||
[Test]
|
||||
public void Load_v1_into_latest()
|
||||
{
|
||||
var sut = Resolve<BackwardCompatibleConfigParser>();
|
||||
|
||||
var yaml = @"
|
||||
sonarr:
|
||||
- api_key: key1
|
||||
base_url: url1
|
||||
- api_key: key2
|
||||
base_url: url2
|
||||
radarr:
|
||||
- api_key: key3
|
||||
base_url: url3
|
||||
";
|
||||
var result = sut.ParseYamlConfig(() => new StringReader(yaml));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Radarr!.Keys.Concat(result.Sonarr!.Keys)
|
||||
.Should().BeEquivalentTo("instance1", "instance2", "instance3");
|
||||
|
||||
result.Sonarr.Values.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
BaseUrl = "url1",
|
||||
ApiKey = "key1"
|
||||
},
|
||||
new
|
||||
{
|
||||
BaseUrl = "url2",
|
||||
ApiKey = "key2"
|
||||
}
|
||||
});
|
||||
|
||||
result.Radarr.Values.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
BaseUrl = "url3",
|
||||
ApiKey = "key3"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Load_v2_into_latest()
|
||||
{
|
||||
var sut = Resolve<BackwardCompatibleConfigParser>();
|
||||
|
||||
var yaml = @"
|
||||
sonarr:
|
||||
instance1:
|
||||
api_key: key1
|
||||
base_url: url1
|
||||
instance2:
|
||||
api_key: key2
|
||||
base_url: url2
|
||||
radarr:
|
||||
instance3:
|
||||
api_key: key3
|
||||
base_url: url3
|
||||
";
|
||||
var result = sut.ParseYamlConfig(() => new StringReader(yaml));
|
||||
|
||||
result.Should().BeEquivalentTo(new RootConfigYamlLatest
|
||||
{
|
||||
Sonarr = new Dictionary<string, SonarrConfigYamlLatest>
|
||||
{
|
||||
{
|
||||
"instance1", new SonarrConfigYamlLatest
|
||||
{
|
||||
BaseUrl = "url1",
|
||||
ApiKey = "key1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"instance2", new SonarrConfigYamlLatest
|
||||
{
|
||||
BaseUrl = "url2",
|
||||
ApiKey = "key2"
|
||||
}
|
||||
}
|
||||
},
|
||||
Radarr = new Dictionary<string, RadarrConfigYamlLatest>
|
||||
{
|
||||
{
|
||||
"instance3", new RadarrConfigYamlLatest
|
||||
{
|
||||
BaseUrl = "url3",
|
||||
ApiKey = "key3"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using Recyclarr.Cli.TestLibrary;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
using Recyclarr.TrashLib.Config.Services.Sonarr;
|
||||
|
||||
namespace Recyclarr.TrashLib.Tests.Sonarr;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class SonarrConfigurationValidatorTest : IntegrationFixture
|
||||
{
|
||||
[Test]
|
||||
public void No_validation_failure_for_service_name()
|
||||
{
|
||||
var config = new SonarrConfiguration();
|
||||
|
||||
var validator = Resolve<SonarrConfigurationValidator>();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
result.ShouldNotHaveValidationErrorFor(x => x.ServiceType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validation_failure_when_rps_and_cfs_used_together()
|
||||
{
|
||||
var config = new SonarrConfiguration
|
||||
{
|
||||
ReleaseProfiles = new[] {new ReleaseProfileConfig()},
|
||||
CustomFormats = new[] {new CustomFormatConfig()}
|
||||
};
|
||||
|
||||
var validator = Resolve<SonarrConfigurationValidator>();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(x => x.ReleaseProfiles);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sonarr_release_profile_failures()
|
||||
{
|
||||
var config = new SonarrConfiguration
|
||||
{
|
||||
ReleaseProfiles = new List<ReleaseProfileConfig>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TrashIds = Array.Empty<string>(),
|
||||
Filter = new SonarrProfileFilterConfig
|
||||
{
|
||||
Include = new[] {"include"},
|
||||
Exclude = new[] {"exclude"}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new SonarrConfigurationValidator();
|
||||
var result = validator.TestValidate(config);
|
||||
|
||||
var releaseProfiles = $"{nameof(config.ReleaseProfiles)}[0].";
|
||||
|
||||
// Release profile trash IDs cannot be empty
|
||||
result.ShouldHaveValidationErrorFor(releaseProfiles + nameof(ReleaseProfileConfig.TrashIds));
|
||||
|
||||
// Cannot use include + exclude filters together
|
||||
result.ShouldHaveValidationErrorFor(releaseProfiles +
|
||||
$"{nameof(ReleaseProfileConfig.Filter)}.{nameof(SonarrProfileFilterConfig.Include)}");
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
using Recyclarr.Common.Extensions;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
using Recyclarr.TrashLib.Processors;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config;
|
||||
|
||||
public static class ConfigExtensions
|
||||
{
|
||||
public static IEnumerable<IServiceConfiguration> GetConfigsOfType(
|
||||
this IEnumerable<IServiceConfiguration> configs,
|
||||
SupportedServices? serviceType)
|
||||
{
|
||||
return configs.Where(x => serviceType is null || serviceType.Value == x.ServiceType);
|
||||
}
|
||||
|
||||
public static IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(
|
||||
this IEnumerable<IServiceConfiguration> configs,
|
||||
ISyncSettings settings)
|
||||
{
|
||||
// later, if we filter by "operation type" (e.g. release profiles, CFs, quality sizes) it's just another
|
||||
// ".Where()" in the LINQ expression below.
|
||||
return configs.GetConfigsOfType(settings.Service)
|
||||
.Where(x => settings.Instances.IsEmpty() ||
|
||||
settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName)));
|
||||
}
|
||||
|
||||
public static bool DoesConfigExist(this IEnumerable<IServiceConfiguration> configs, string name)
|
||||
{
|
||||
return configs.Any(x => x.InstanceName.EqualsIgnoreCase(name));
|
||||
}
|
||||
|
||||
public static bool IsConfigEmpty(this RootConfigYamlLatest config)
|
||||
{
|
||||
var sonarr = config.Sonarr?.Count ?? 0;
|
||||
var radarr = config.Radarr?.Count ?? 0;
|
||||
return sonarr + radarr == 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
using AutoMapper;
|
||||
using FluentValidation;
|
||||
using Recyclarr.Config.Data.V1;
|
||||
using Recyclarr.TrashLib.Config.Yaml;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Parsing;
|
||||
|
||||
using RootConfigYamlV1 = RootConfigYaml;
|
||||
|
||||
public class BackwardCompatibleConfigParser
|
||||
{
|
||||
private readonly ILogger _log;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ConfigValidationExecutor _validator;
|
||||
private readonly IDeserializer _deserializer;
|
||||
|
||||
// Order matters here. Types are mapped from top to bottom (front to back).
|
||||
// Newer types should be added to the top/start of the list.
|
||||
private readonly IReadOnlyList<Type> _configTypes = new[]
|
||||
{
|
||||
typeof(RootConfigYamlLatest),
|
||||
typeof(RootConfigYamlV1)
|
||||
};
|
||||
|
||||
public BackwardCompatibleConfigParser(
|
||||
ILogger log,
|
||||
IYamlSerializerFactory yamlFactory,
|
||||
IMapper mapper,
|
||||
ConfigValidationExecutor validator)
|
||||
{
|
||||
_log = log;
|
||||
_mapper = mapper;
|
||||
_validator = validator;
|
||||
_deserializer = yamlFactory.CreateDeserializer();
|
||||
}
|
||||
|
||||
private (int Index, object? Data) TryParseConfig(Func<TextReader> streamFactory)
|
||||
{
|
||||
Exception? firstException = null;
|
||||
|
||||
// step 1: Iterate from NEWEST -> OLDEST until we successfully deserialize
|
||||
for (var i = 0; i < _configTypes.Count; ++i)
|
||||
{
|
||||
var configType = _configTypes[i];
|
||||
_log.Debug("Attempting deserialization using config type: {Type}", configType);
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = streamFactory();
|
||||
return (i, _deserializer.Deserialize(stream, configType));
|
||||
}
|
||||
catch (YamlException e)
|
||||
{
|
||||
_log.Debug(e.InnerException, "Exception during deserialization");
|
||||
firstException ??= e;
|
||||
// Ignore this exception and continue; we should continue to try older types
|
||||
}
|
||||
}
|
||||
|
||||
throw firstException ?? new InvalidOperationException("Parsing failed for unknown reason");
|
||||
}
|
||||
|
||||
private RootConfigYamlLatest MapConfigDataToLatest(int index, object data)
|
||||
{
|
||||
var currentType = _configTypes[index];
|
||||
|
||||
// step 2: Using the same index, now go the other direction: OLDEST -> NEWEST, using IMapper to map
|
||||
// all the way up to the latest
|
||||
foreach (var nextType in _configTypes.Slice(0, index).Reverse())
|
||||
{
|
||||
if (!_validator.Validate(data))
|
||||
{
|
||||
throw new ValidationException($"Validation Failed for type: {data.GetType().Name}");
|
||||
}
|
||||
|
||||
// If any mapping fails, the whole chain fails. Let the exception leak out and get handled outside.
|
||||
data = _mapper.Map(data, currentType, nextType);
|
||||
currentType = nextType;
|
||||
}
|
||||
|
||||
return (RootConfigYamlLatest) data;
|
||||
}
|
||||
|
||||
public RootConfigYamlLatest? ParseYamlConfig(Func<TextReader> streamFactory)
|
||||
{
|
||||
var (index, data) = TryParseConfig(streamFactory);
|
||||
return data is null ? null : MapConfigDataToLatest(index, data);
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
using Recyclarr.Common.Extensions;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
using Recyclarr.TrashLib.Processors;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Parsing;
|
||||
|
||||
public class ConfigRegistry : IConfigRegistry
|
||||
{
|
||||
private readonly Dictionary<SupportedServices, List<IServiceConfiguration>> _configs = new();
|
||||
|
||||
public void Add(IServiceConfiguration config)
|
||||
{
|
||||
_configs.GetOrCreate(config.ServiceType).Add(config);
|
||||
}
|
||||
|
||||
public IEnumerable<IServiceConfiguration> GetAllConfigs()
|
||||
{
|
||||
return GetConfigsOfType(null);
|
||||
}
|
||||
|
||||
public IEnumerable<IServiceConfiguration> GetConfigsOfType(SupportedServices? serviceType)
|
||||
{
|
||||
return _configs
|
||||
.Where(x => serviceType is null || serviceType.Value == x.Key)
|
||||
.SelectMany(x => x.Value);
|
||||
}
|
||||
|
||||
public IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(ISyncSettings settings)
|
||||
{
|
||||
// later, if we filter by "operation type" (e.g. release profiles, CFs, quality sizes) it's just another
|
||||
// ".Where()" in the LINQ expression below.
|
||||
return GetConfigsOfType(settings.Service)
|
||||
.Where(x => settings.Instances.IsEmpty() ||
|
||||
settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName)));
|
||||
}
|
||||
|
||||
public int Count => _configs.Count;
|
||||
|
||||
public bool DoesConfigExist(string name)
|
||||
{
|
||||
return _configs.Values.Any(x => x.Any(y => y.InstanceName.EqualsIgnoreCase(name)));
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
// ReSharper disable RedundantUsingDirective.Global
|
||||
|
||||
// YAML Object Mappings
|
||||
|
||||
global using RootConfigYamlLatest = Recyclarr.Config.Data.V2.RootConfigYaml;
|
||||
global using ServiceConfigYamlLatest = Recyclarr.Config.Data.V2.ServiceConfigYaml;
|
||||
global using SonarrConfigYamlLatest = Recyclarr.Config.Data.V2.SonarrConfigYaml;
|
||||
global using RadarrConfigYamlLatest = Recyclarr.Config.Data.V2.RadarrConfigYaml;
|
||||
global using QualityScoreConfigYamlLatest = Recyclarr.Config.Data.V2.QualityScoreConfigYaml;
|
||||
global using CustomFormatConfigYamlLatest = Recyclarr.Config.Data.V2.CustomFormatConfigYaml;
|
||||
global using QualitySizeConfigYamlLatest = Recyclarr.Config.Data.V2.QualitySizeConfigYaml;
|
||||
global using QualityProfileConfigYamlLatest = Recyclarr.Config.Data.V2.QualityProfileConfigYaml;
|
||||
global using ReleaseProfileConfigYamlLatest = Recyclarr.Config.Data.V2.ReleaseProfileConfigYaml;
|
||||
global using ReleaseProfileFilterConfigYamlLatest = Recyclarr.Config.Data.V2.ReleaseProfileFilterConfigYaml;
|
||||
|
||||
// Validators
|
||||
global using SonarrConfigYamlValidatorLatest = Recyclarr.Config.Data.V2.SonarrConfigYamlValidator;
|
||||
global using ServiceConfigYamlValidatorLatest = Recyclarr.Config.Data.V2.ServiceConfigYamlValidator;
|
||||
global using ReleaseProfileConfigYamlValidatorLatest = Recyclarr.Config.Data.V2.ReleaseProfileConfigYamlValidator;
|
||||
global using ReleaseProfileFilterConfigYamlValidatorLatest =
|
||||
Recyclarr.Config.Data.V2.ReleaseProfileFilterConfigYamlValidator;
|
@ -0,0 +1,28 @@
|
||||
using AutoMapper;
|
||||
using JetBrains.Annotations;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Parsing;
|
||||
|
||||
[UsedImplicitly]
|
||||
public class ConfigurationMapperProfile : Profile
|
||||
{
|
||||
public ConfigurationMapperProfile()
|
||||
{
|
||||
CreateMap<QualityScoreConfigYamlLatest, QualityProfileScoreConfig>();
|
||||
CreateMap<CustomFormatConfigYamlLatest, CustomFormatConfig>();
|
||||
CreateMap<QualitySizeConfigYamlLatest, QualityDefinitionConfig>();
|
||||
CreateMap<QualityProfileConfigYamlLatest, QualityProfileConfig>();
|
||||
CreateMap<ReleaseProfileConfigYamlLatest, ReleaseProfileConfig>();
|
||||
CreateMap<ReleaseProfileFilterConfigYamlLatest, SonarrProfileFilterConfig>();
|
||||
|
||||
CreateMap<ServiceConfigYamlLatest, ServiceConfiguration>()
|
||||
.ForMember(x => x.InstanceName, o => o.Ignore());
|
||||
|
||||
CreateMap<RadarrConfigYamlLatest, RadarrConfiguration>()
|
||||
.IncludeBase<ServiceConfigYamlLatest, ServiceConfiguration>();
|
||||
|
||||
CreateMap<SonarrConfigYamlLatest, SonarrConfiguration>()
|
||||
.IncludeBase<ServiceConfigYamlLatest, ServiceConfiguration>();
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
using Recyclarr.TrashLib.Processors;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Parsing;
|
||||
|
||||
public interface IConfigRegistry
|
||||
{
|
||||
int Count { get; }
|
||||
bool DoesConfigExist(string name);
|
||||
IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(ISyncSettings settings);
|
||||
IEnumerable<IServiceConfiguration> GetAllConfigs();
|
||||
IEnumerable<IServiceConfiguration> GetConfigsOfType(SupportedServices? serviceType);
|
||||
}
|
@ -1,10 +1,14 @@
|
||||
using System.IO.Abstractions;
|
||||
using Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Parsing;
|
||||
|
||||
public interface IConfigurationLoader
|
||||
{
|
||||
IConfigRegistry LoadMany(IEnumerable<IFileInfo> configFiles, string? desiredSection = null);
|
||||
IConfigRegistry Load(IFileInfo file, string? desiredSection = null);
|
||||
IConfigRegistry LoadFromStream(TextReader stream, string? desiredSection = null);
|
||||
ICollection<IServiceConfiguration> LoadMany(
|
||||
IEnumerable<IFileInfo> configFiles,
|
||||
SupportedServices? desiredServiceType = null);
|
||||
|
||||
IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file, SupportedServices? desiredServiceType = null);
|
||||
IReadOnlyCollection<IServiceConfiguration> Load(string yaml, SupportedServices? desiredServiceType = null);
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
namespace Recyclarr.TrashLib.Config.Services.Radarr;
|
||||
|
||||
public class RadarrConfiguration : ServiceConfiguration
|
||||
{
|
||||
public override SupportedServices ServiceType => SupportedServices.Radarr;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
using FluentValidation;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Services.Radarr;
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration>
|
||||
{
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
public record RadarrConfiguration : ServiceConfiguration
|
||||
{
|
||||
public override SupportedServices ServiceType => SupportedServices.Radarr;
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
using FluentValidation;
|
||||
using JetBrains.Annotations;
|
||||
using Recyclarr.Common.FluentValidation;
|
||||
using Recyclarr.TrashLib.Config.Services.Radarr;
|
||||
using Recyclarr.TrashLib.Config.Services.Sonarr;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class ServiceConfigurationValidator : AbstractValidator<ServiceConfiguration>
|
||||
{
|
||||
public ServiceConfigurationValidator(
|
||||
IValidator<SonarrConfiguration> sonarrValidator,
|
||||
IValidator<RadarrConfiguration> radarrValidator)
|
||||
{
|
||||
RuleFor(x => x.InstanceName).NotEmpty();
|
||||
RuleFor(x => x.LineNumber).NotEqual(0);
|
||||
RuleFor(x => x.BaseUrl).Must(x => x.Scheme is "http" or "https")
|
||||
.WithMessage("Property 'base_url' is required and must be a valid URL");
|
||||
RuleFor(x => x.ApiKey).NotEmpty().WithMessage("Property 'api_key' is required");
|
||||
RuleForEach(x => x.CustomFormats).SetValidator(new CustomFormatConfigValidator());
|
||||
RuleFor(x => x.QualityDefinition).SetNonNullableValidator(new QualityDefinitionConfigValidator());
|
||||
|
||||
RuleFor(x => x).SetInheritanceValidator(x =>
|
||||
{
|
||||
x.Add(sonarrValidator);
|
||||
x.Add(radarrValidator);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig>
|
||||
{
|
||||
public CustomFormatConfigValidator()
|
||||
{
|
||||
RuleFor(x => x.TrashIds).NotEmpty()
|
||||
.WithMessage("'custom_formats' elements must contain at least one element under 'trash_ids'");
|
||||
RuleForEach(x => x.QualityProfiles).SetValidator(new QualityProfileScoreConfigValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class QualityProfileScoreConfigValidator : AbstractValidator<QualityProfileScoreConfig>
|
||||
{
|
||||
public QualityProfileScoreConfigValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().WithMessage("'name' is required for elements under 'quality_profiles'");
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig>
|
||||
{
|
||||
public QualityDefinitionConfigValidator()
|
||||
{
|
||||
RuleFor(x => x.Type).NotEmpty().WithMessage("'type' is required for 'quality_definition'");
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
using FluentValidation;
|
||||
using JetBrains.Annotations;
|
||||
using Recyclarr.Common.Extensions;
|
||||
using Recyclarr.Common.FluentValidation;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Services.Sonarr;
|
||||
|
||||
[UsedImplicitly]
|
||||
public class SonarrConfigurationValidator : AbstractValidator<SonarrConfiguration>
|
||||
{
|
||||
public SonarrConfigurationValidator()
|
||||
{
|
||||
RuleForEach(x => x.ReleaseProfiles)
|
||||
.Empty()
|
||||
.When(x => x.CustomFormats.IsNotEmpty())
|
||||
.WithMessage("`custom_formats` and `release_profiles` may not be used together");
|
||||
|
||||
RuleForEach(x => x.ReleaseProfiles).SetValidator(new ReleaseProfileConfigValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class ReleaseProfileConfigValidator : AbstractValidator<ReleaseProfileConfig>
|
||||
{
|
||||
public ReleaseProfileConfigValidator()
|
||||
{
|
||||
RuleFor(x => x.TrashIds).NotEmpty().WithMessage("'trash_ids' is required for 'release_profiles' elements");
|
||||
RuleFor(x => x.Filter).SetNonNullableValidator(new SonarrProfileFilterConfigValidator());
|
||||
}
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
internal class SonarrProfileFilterConfigValidator : AbstractValidator<SonarrProfileFilterConfig>
|
||||
{
|
||||
public SonarrProfileFilterConfigValidator()
|
||||
{
|
||||
// Include & Exclude may not be used together
|
||||
RuleFor(x => x.Include).Empty().When(x => x.Exclude.Any())
|
||||
.WithMessage("`include` and `exclude` may not be used together.");
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Recyclarr.TrashLib.Config.Services.Sonarr;
|
||||
namespace Recyclarr.TrashLib.Config.Services;
|
||||
|
||||
public class SonarrConfiguration : ServiceConfiguration
|
||||
public record SonarrConfiguration : ServiceConfiguration
|
||||
{
|
||||
public override SupportedServices ServiceType => SupportedServices.Sonarr;
|
||||
|
@ -1,5 +0,0 @@
|
||||
namespace Recyclarr.TrashLib.Config.Yaml;
|
||||
|
||||
public class EmptyYamlException : Exception
|
||||
{
|
||||
}
|
@ -0,0 +1 @@
|
||||
global using MoreLinq;
|
Loading…
Reference in new issue