From 350fd21358ee073dc55d05eaa083fc238c7ba8dd Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sat, 31 Dec 2022 14:02:20 -0600 Subject: [PATCH] refactor: Overhaul config validation logic - Validation of custom formats in Sonarr v4 is now performed --- .../IntegrationFixture.cs | 10 + .../CompositionRootTest.cs | 76 +------ .../Config/ConfigValidationExecutorTest.cs | 34 +++ .../Config/ConfigurationLoaderTest.cs | 50 +--- src/Recyclarr.Cli/Command/RadarrCommand.cs | 12 +- src/Recyclarr.Cli/Command/SonarrCommand.cs | 8 +- src/Recyclarr.Cli/CompositionRoot.cs | 1 + .../Config/ConfigValidationExecutor.cs | 42 ++++ .../Config/ConfigurationLoader.cs | 18 +- .../FluentValidationExtensions.cs | 21 ++ .../AutofacTestExtensions.cs | 7 + .../ServiceConfigurationValidatorTest.cs | 213 ++++++++++++++++++ .../Services/ServiceConfigurationTest.cs | 64 ------ .../SonarrConfigurationValidatorTest.cs | 144 ++++++++++++ .../Radarr/RadarrConfigurationTest.cs | 89 -------- .../Sonarr/SonarrCompatibilityTest.cs | 112 +-------- .../Sonarr/SonarrConfigurationTest.cs | 60 ----- .../Config/ConfigAutofacModule.cs | 2 - .../Services/IServiceValidationMessages.cs | 8 - .../Config/Services/ServiceConfiguration.cs | 3 + .../Services/ServiceConfigurationValidator.cs | 43 +++- .../Services/ServiceValidationMessages.cs | 13 -- .../Services/Common/ServiceCompatibility.cs | 24 ++ .../Config/IRadarrValidationMessages.cs | 7 - .../Config/RadarrConfigurationValidator.cs | 18 -- .../Radarr/Config/RadarrValidationMessages.cs | 10 - .../Services/Radarr/RadarrAutofacModule.cs | 6 +- .../Services/Radarr/RadarrCompatibility.cs | 6 +- ...onarrReleaseProfileCompatibilityHandler.cs | 2 +- .../Sonarr/Api/ReleaseProfileApiService.cs | 4 +- ...onarrReleaseProfileCompatibilityHandler.cs | 11 +- .../Config/ISonarrValidationMessages.cs | 6 - .../Config/SonarrConfigurationValidator.cs | 47 +++- .../Sonarr/Config/SonarrValidationMessages.cs | 10 - .../Services/Sonarr/ISonarrCompatibility.cs | 3 +- .../Sonarr/ISonarrVersionEnforcement.cs | 8 - .../Services/Sonarr/SonarrAutofacModule.cs | 10 +- .../Services/Sonarr/SonarrCapabilities.cs | 2 + .../Services/Sonarr/SonarrCompatibility.cs | 10 +- .../Sonarr/SonarrVersionEnforcement.cs | 37 --- .../Services/System/IServiceInformation.cs | 6 + .../Services/System/ServiceCompatibility.cs | 32 --- .../Services/System/ServiceInformation.cs | 41 ++++ .../System/SystemServiceAutofacModule.cs | 2 + 44 files changed, 680 insertions(+), 652 deletions(-) create mode 100644 src/Recyclarr.Cli.Tests/Config/ConfigValidationExecutorTest.cs create mode 100644 src/Recyclarr.Cli/Config/ConfigValidationExecutor.cs create mode 100644 src/Recyclarr.TrashLib.Tests/Config/ServiceConfigurationValidatorTest.cs delete mode 100644 src/Recyclarr.TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs create mode 100644 src/Recyclarr.TrashLib.Tests/Config/SonarrConfigurationValidatorTest.cs delete mode 100644 src/Recyclarr.TrashLib.Tests/Radarr/RadarrConfigurationTest.cs delete mode 100644 src/Recyclarr.TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs delete mode 100644 src/Recyclarr.TrashLib/Config/Services/IServiceValidationMessages.cs delete mode 100644 src/Recyclarr.TrashLib/Config/Services/ServiceValidationMessages.cs create mode 100644 src/Recyclarr.TrashLib/Services/Common/ServiceCompatibility.cs delete mode 100644 src/Recyclarr.TrashLib/Services/Radarr/Config/IRadarrValidationMessages.cs delete mode 100644 src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrValidationMessages.cs delete mode 100644 src/Recyclarr.TrashLib/Services/Sonarr/Config/ISonarrValidationMessages.cs delete mode 100644 src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrValidationMessages.cs delete mode 100644 src/Recyclarr.TrashLib/Services/Sonarr/ISonarrVersionEnforcement.cs delete mode 100644 src/Recyclarr.TrashLib/Services/Sonarr/SonarrVersionEnforcement.cs create mode 100644 src/Recyclarr.TrashLib/Services/System/IServiceInformation.cs delete mode 100644 src/Recyclarr.TrashLib/Services/System/ServiceCompatibility.cs create mode 100644 src/Recyclarr.TrashLib/Services/System/ServiceInformation.cs diff --git a/src/Recyclarr.Cli.TestLibrary/IntegrationFixture.cs b/src/Recyclarr.Cli.TestLibrary/IntegrationFixture.cs index 1efccd5d..10690818 100644 --- a/src/Recyclarr.Cli.TestLibrary/IntegrationFixture.cs +++ b/src/Recyclarr.Cli.TestLibrary/IntegrationFixture.cs @@ -1,15 +1,19 @@ using System.IO.Abstractions; using System.IO.Abstractions.Extensions; using System.IO.Abstractions.TestingHelpers; +using System.Reactive.Linq; using Autofac; using Autofac.Features.ResolveAnything; using CliFx.Infrastructure; +using NSubstitute; using NUnit.Framework; using Recyclarr.Cli.Command; using Recyclarr.Common.TestLibrary; using Recyclarr.TestLibrary; using Recyclarr.TrashLib; +using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Repo.VersionControl; +using Recyclarr.TrashLib.Services.System; using Recyclarr.TrashLib.Startup; using Serilog; using Serilog.Events; @@ -34,6 +38,12 @@ public abstract class IntegrationFixture : IDisposable builder.RegisterMockFor(); builder.RegisterMockFor(); builder.RegisterMockFor(); + builder.RegisterMockFor(); + builder.RegisterMockFor(m => + { + // By default, choose some extremely high number so that all the newest features are enabled. + m.Version.Returns(_ => Observable.Return(new Version("99.0.0.0"))); + }); RegisterExtraTypes(builder); diff --git a/src/Recyclarr.Cli.Tests/CompositionRootTest.cs b/src/Recyclarr.Cli.Tests/CompositionRootTest.cs index e17fe48c..5d6d3bbc 100644 --- a/src/Recyclarr.Cli.Tests/CompositionRootTest.cs +++ b/src/Recyclarr.Cli.Tests/CompositionRootTest.cs @@ -1,99 +1,39 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; -using System.IO.Abstractions; -using System.IO.Abstractions.Extensions; -using System.IO.Abstractions.TestingHelpers; using Autofac; using Autofac.Core; -using CliFx.Infrastructure; using FluentAssertions; -using NSubstitute; using NUnit.Framework; -using Recyclarr.Cli.Command; +using NUnit.Framework.Internal; using Recyclarr.Cli.TestLibrary; -using Recyclarr.TrashLib; -using Recyclarr.TrashLib.Config.Services; -using Recyclarr.TrashLib.Repo.VersionControl; -using Recyclarr.TrashLib.Startup; -using Serilog; namespace Recyclarr.Cli.Tests; -public record ServiceFactoryWrapper(Type Service, Action Instantiate); - -public static class FactoryForService -{ - public static ServiceFactoryWrapper WithArgs(TP1 arg1 = default!) - { - return new ServiceFactoryWrapper(typeof(TService), - c => c.Resolve>().Invoke(arg1)); - } -} - [TestFixture] [Parallelizable(ParallelScope.All)] -public class CompositionRootTest : IntegrationFixture +public class CompositionRootTest { - private static readonly List FactoryTests = new() - { - FactoryForService.WithArgs("path") - }; - - [TestCaseSource(typeof(CompositionRootTest), nameof(FactoryTests))] - public void Service_requiring_factory_should_be_instantiable(ServiceFactoryWrapper service) - { - var act = () => - { - service.Instantiate(Container); - }; - - // Do not use `NotThrow()` here because fluent assertions doesn't show the full exception details - // See: https://github.com/fluentassertions/fluentassertions/issues/2015 - act(); //.Should().NotThrow(); - } - // Warning CA1812 : CompositionRootTest.ConcreteTypeEnumerator is an internal class that is apparently never // instantiated. - [SuppressMessage("Performance", "CA1812", - Justification = "Created via reflection by TestCaseSource attribute" - )] - private sealed class ConcreteTypeEnumerator : IEnumerable + [SuppressMessage("Performance", "CA1812", Justification = "Created via reflection by TestCaseSource attribute")] + private sealed class ConcreteTypeEnumerator : IntegrationFixture, IEnumerable { - private readonly ILifetimeScope _container; - - public ConcreteTypeEnumerator() - { - _container = CompositionRoot.Setup(); - } - public IEnumerator GetEnumerator() { - return _container.ComponentRegistry.Registrations + return Container.ComponentRegistry.Registrations .SelectMany(x => x.Services) .OfType() .Select(x => x.ServiceType) .Distinct() - .Except(FactoryTests.Select(x => x.Service)) .Where(x => x.FullName == null || !x.FullName.StartsWith("Autofac.")) + .Select(x => new TestCaseParameters(new object[] {Container, x}) {TestName = x.FullName}) .GetEnumerator(); } } - private static void RegisterAdditionalServices(ContainerBuilder builder) - { - var fs = new MockFileSystem(); - builder.RegisterInstance(fs).As(); - builder.RegisterInstance(new AppPaths(fs.CurrentDirectory())).As(); - builder.RegisterInstance(Substitute.For()); - builder.RegisterInstance(Substitute.For()); - builder.RegisterInstance(Substitute.For()); - builder.RegisterInstance(Substitute.For()); - } - [TestCaseSource(typeof(ConcreteTypeEnumerator))] - public void Service_should_be_instantiable(Type service) + public void Service_should_be_instantiable(ILifetimeScope scope, Type service) { - using var container = CompositionRoot.Setup(RegisterAdditionalServices); - container.Resolve(service).Should().NotBeNull(); + scope.Resolve(service).Should().NotBeNull(); } } diff --git a/src/Recyclarr.Cli.Tests/Config/ConfigValidationExecutorTest.cs b/src/Recyclarr.Cli.Tests/Config/ConfigValidationExecutorTest.cs new file mode 100644 index 00000000..2e584e38 --- /dev/null +++ b/src/Recyclarr.Cli.Tests/Config/ConfigValidationExecutorTest.cs @@ -0,0 +1,34 @@ +using FluentAssertions; +using NUnit.Framework; +using Recyclarr.Cli.Config; +using Recyclarr.Cli.TestLibrary; +using Recyclarr.TrashLib.TestLibrary; + +namespace Recyclarr.Cli.Tests.Config; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ConfigValidationExecutorTest : IntegrationFixture +{ + [Test] + public void Invalid_returns_false() + { + var sut = Resolve(); + var config = new TestConfig {ApiKey = ""}; // Use bad data + + var result = sut.Validate(config); + + result.Should().BeFalse(); + } + + [Test] + public void Valid_returns_true() + { + var sut = Resolve(); + var config = new TestConfig {ApiKey = "good", BaseUrl = "good"}; // Use good data + + var result = sut.Validate(config); + + result.Should().BeTrue(); + } +} diff --git a/src/Recyclarr.Cli.Tests/Config/ConfigurationLoaderTest.cs b/src/Recyclarr.Cli.Tests/Config/ConfigurationLoaderTest.cs index 3e498dda..9df657e7 100644 --- a/src/Recyclarr.Cli.Tests/Config/ConfigurationLoaderTest.cs +++ b/src/Recyclarr.Cli.Tests/Config/ConfigurationLoaderTest.cs @@ -5,8 +5,6 @@ using System.Text; using Autofac; using FluentAssertions; using FluentValidation; -using FluentValidation.Results; -using NSubstitute; using NUnit.Framework; using Recyclarr.Cli.Config; using Recyclarr.Cli.TestLibrary; @@ -72,7 +70,7 @@ public class ConfigurationLoaderTest : IntegrationFixture var loader = Resolve>(); var actual = loader.LoadMany(fileData.Select(x => x.Item1), "sonarr").ToList(); - actual.Should().BeEquivalentTo(expected); + actual.Should().BeEquivalentTo(expected, o => o.Excluding(x => x.LineNumber)); } [Test] @@ -108,49 +106,7 @@ public class ConfigurationLoaderTest : IntegrationFixture } } } - }); - } - - [Test] - public void Skip_when_validation_fails() - // [Frozen] IValidator validator, - // ConfigurationLoader configLoader) - { - var validator = Resolve>(); - var sut = Resolve>(); - - // force the validator to return a validation error - validator.Validate(Arg.Any()).Returns(new ValidationResult - { - Errors = - { - new ValidationFailure("PropertyName", "Test Validation Failure"), - new ValidationFailure("Another", "This is yet another failure") - } - }); - - const string testYml = @" -fubar: - instance1: - api_key: abc -"; - var result = sut.LoadFromStream(new StringReader(testYml), "fubar"); - - result.Should().BeEmpty(); - } - - [Test] - public void Validation_success_does_not_throw() - { - var configLoader = Resolve>(); - - const string testYml = @" -fubar: - instanceA: - api_key: abc -"; - Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar"); - act.Should().NotThrow(); + }, o => o.Excluding(x => x.LineNumber)); } [Test] @@ -193,7 +149,7 @@ secret_rp: 1234567 }; var parsedSecret = configLoader.LoadFromStream(new StringReader(testYml), "sonarr"); - parsedSecret.Should().BeEquivalentTo(expected); + parsedSecret.Should().BeEquivalentTo(expected, o => o.Excluding(x => x.LineNumber)); } [Test] diff --git a/src/Recyclarr.Cli/Command/RadarrCommand.cs b/src/Recyclarr.Cli/Command/RadarrCommand.cs index a6cd6375..6e68e1be 100644 --- a/src/Recyclarr.Cli/Command/RadarrCommand.cs +++ b/src/Recyclarr.Cli/Command/RadarrCommand.cs @@ -1,4 +1,3 @@ -using System.Reactive.Linq; using Autofac; using CliFx.Attributes; using CliFx.Infrastructure; @@ -73,11 +72,12 @@ Processing {serverName} Server: [{instanceName}] log.Debug("Processing {Server} server {Name}", serverName, instanceName); - // There's no actual compatibility checks to perform yet. We directly access the RadarrCompatibility class, - // as opposed to a IRadarrVersionEnforcement object (like Sonarr does), simply to force the API invocation - // in Radarr to acquire and log version information. - var compatibility = scope.Resolve(); - await compatibility.Capabilities.LastAsync(); + var validator = scope.Resolve(); + if (!validator.Validate(config)) + { + log.Error("Due to validation failure, this instance will be skipped"); + continue; + } // ReSharper disable InvertIf diff --git a/src/Recyclarr.Cli/Command/SonarrCommand.cs b/src/Recyclarr.Cli/Command/SonarrCommand.cs index deb0cc91..d232ede5 100644 --- a/src/Recyclarr.Cli/Command/SonarrCommand.cs +++ b/src/Recyclarr.Cli/Command/SonarrCommand.cs @@ -107,8 +107,12 @@ Processing {serverName} Server: [{instanceName}] log.Debug("Processing {Server} server {Name}", serverName, instanceName); - var versionEnforcement = scope.Resolve(); - await versionEnforcement.DoVersionEnforcement(config); + var validator = scope.Resolve(); + if (!validator.Validate(config)) + { + log.Error("Due to validation failure, this instance will be skipped"); + continue; + } // ReSharper disable InvertIf diff --git a/src/Recyclarr.Cli/CompositionRoot.cs b/src/Recyclarr.Cli/CompositionRoot.cs index b1e10473..b5e0d835 100644 --- a/src/Recyclarr.Cli/CompositionRoot.cs +++ b/src/Recyclarr.Cli/CompositionRoot.cs @@ -92,6 +92,7 @@ public static class CompositionRoot builder.RegisterType().As(); builder.RegisterType().As(); + builder.RegisterType(); builder.RegisterGeneric(typeof(ConfigurationLoader<>)) .WithProperty(new AutowiringParameter()) diff --git a/src/Recyclarr.Cli/Config/ConfigValidationExecutor.cs b/src/Recyclarr.Cli/Config/ConfigValidationExecutor.cs new file mode 100644 index 00000000..f44005e1 --- /dev/null +++ b/src/Recyclarr.Cli/Config/ConfigValidationExecutor.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using JetBrains.Annotations; +using Recyclarr.TrashLib.Config.Services; +using Recyclarr.TrashLib.Http; +using Serilog; + +namespace Recyclarr.Cli.Config; + +[UsedImplicitly] +public class ConfigValidationExecutor +{ + private readonly ILogger _log; + private readonly IValidator _validator; + + public ConfigValidationExecutor( + ILogger log, + IValidator validator) + { + _log = log; + _validator = validator; + } + + public bool Validate(T config) where T : ServiceConfiguration + { + var result = _validator.Validate(config); + if (result is not {IsValid: false}) + { + return true; + } + + var printableName = config.Name ?? FlurlLogging.SanitizeUrl(config.BaseUrl); + _log.Error("Validation failed for instance config {Instance} at line {Line} with {Count} errors", + printableName, config.LineNumber, result.Errors.Count); + + foreach (var error in result.Errors) + { + _log.Error("Validation error: {Msg}", error.ErrorMessage); + } + + return false; + } +} diff --git a/src/Recyclarr.Cli/Config/ConfigurationLoader.cs b/src/Recyclarr.Cli/Config/ConfigurationLoader.cs index c00c082d..76b64dbb 100644 --- a/src/Recyclarr.Cli/Config/ConfigurationLoader.cs +++ b/src/Recyclarr.Cli/Config/ConfigurationLoader.cs @@ -1,9 +1,7 @@ using System.IO.Abstractions; -using FluentValidation; using Recyclarr.Cli.Logging; using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Yaml; -using Recyclarr.TrashLib.Http; using Serilog; using Serilog.Context; using YamlDotNet.Core; @@ -18,17 +16,14 @@ public class ConfigurationLoader : IConfigurationLoader private readonly ILogger _log; private readonly IDeserializer _deserializer; private readonly IFileSystem _fs; - private readonly IValidator _validator; public ConfigurationLoader( ILogger log, IFileSystem fs, - IYamlSerializerFactory yamlFactory, - IValidator validator) + IYamlSerializerFactory yamlFactory) { _log = log; _fs = fs; - _validator = validator; _deserializer = yamlFactory.CreateDeserializer(); } @@ -159,16 +154,7 @@ public class ConfigurationLoader : IConfigurationLoader var newConfig = _deserializer.Deserialize(parser); newConfig.Name = instanceName; - - var result = _validator.Validate(newConfig); - if (result is {IsValid: false}) - { - var printableName = instanceName ?? FlurlLogging.SanitizeUrl(newConfig.BaseUrl); - _log.Error("Validation failed for instance config {Instance} at line {Line} with errors {Errors}", - printableName, lineNumber, result.Errors); - continue; - } - + newConfig.LineNumber = lineNumber ?? 0; configs.Add(newConfig); } } diff --git a/src/Recyclarr.Common/FluentValidation/FluentValidationExtensions.cs b/src/Recyclarr.Common/FluentValidation/FluentValidationExtensions.cs index 1002041d..7d679eae 100644 --- a/src/Recyclarr.Common/FluentValidation/FluentValidationExtensions.cs +++ b/src/Recyclarr.Common/FluentValidation/FluentValidationExtensions.cs @@ -1,5 +1,6 @@ using FluentValidation; using FluentValidation.Results; +using FluentValidation.Validators; namespace Recyclarr.Common.FluentValidation; @@ -18,6 +19,26 @@ public static class FluentValidationExtensions return ruleBuilder.SetAsyncValidator(adapter); } + private sealed class NullableChildValidatorAdaptor : ChildValidatorAdaptor, + IPropertyValidator, IAsyncPropertyValidator + { + public NullableChildValidatorAdaptor(IValidator validator, Type validatorType) + : base(validator, validatorType) + { + } + + public override bool IsValid(ValidationContext context, TProperty? value) + { + return base.IsValid(context, value!); + } + + public override Task IsValidAsync(ValidationContext context, TProperty? value, + CancellationToken cancellation) + { + return base.IsValidAsync(context, value!, cancellation); + } + } + public static IEnumerable IsValid( this IEnumerable source, TValidator validator, Action, TSource>? handleInvalid = null) diff --git a/src/Recyclarr.TestLibrary/AutofacTestExtensions.cs b/src/Recyclarr.TestLibrary/AutofacTestExtensions.cs index aad5b053..1d477dc7 100644 --- a/src/Recyclarr.TestLibrary/AutofacTestExtensions.cs +++ b/src/Recyclarr.TestLibrary/AutofacTestExtensions.cs @@ -9,4 +9,11 @@ public static class AutofacTestExtensions { builder.RegisterInstance(Substitute.For()).As(); } + + public static void RegisterMockFor(this ContainerBuilder builder, Action mockSetup) where T : class + { + var mock = Substitute.For(); + mockSetup(mock); + builder.RegisterInstance(mock).As(); + } } diff --git a/src/Recyclarr.TrashLib.Tests/Config/ServiceConfigurationValidatorTest.cs b/src/Recyclarr.TrashLib.Tests/Config/ServiceConfigurationValidatorTest.cs new file mode 100644 index 00000000..8fbfb48a --- /dev/null +++ b/src/Recyclarr.TrashLib.Tests/Config/ServiceConfigurationValidatorTest.cs @@ -0,0 +1,213 @@ +using FluentValidation.TestHelper; +using NUnit.Framework; +using Recyclarr.Cli.TestLibrary; +using Recyclarr.TrashLib.Config.Services; +using Recyclarr.TrashLib.TestLibrary; + +namespace Recyclarr.TrashLib.Tests.Config; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ServiceConfigurationValidatorTest : IntegrationFixture +{ + [Test] + public void Validation_succeeds() + { + var config = new TestConfig + { + ApiKey = "valid", + BaseUrl = "valid", + CustomFormats = new List + { + new() + { + TrashIds = new List {"valid"}, + QualityProfiles = new List + { + new() + { + Name = "valid" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var validator = Resolve(); + var result = validator.TestValidate(config); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + public void Validation_failure_when_api_key_missing() + { + var config = new TestConfig + { + ApiKey = "", // Must not be empty + BaseUrl = "valid", + CustomFormats = new List + { + new() + { + TrashIds = new[] {"valid"}, + QualityProfiles = new List + { + new() + { + Name = "valid" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var validator = Resolve(); + var result = validator.TestValidate(config); + + result.ShouldHaveValidationErrorFor(x => x.ApiKey); + } + + [Test] + public void Validation_failure_when_base_url_empty() + { + var config = new TestConfig + { + ApiKey = "valid", + BaseUrl = "", + CustomFormats = new List + { + new() + { + TrashIds = new[] {"valid"}, + QualityProfiles = new List + { + new() + { + Name = "valid" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var validator = Resolve(); + var result = validator.TestValidate(config); + + result.ShouldHaveValidationErrorFor(x => x.BaseUrl); + } + + public static string FirstCf { get; } = $"{nameof(TestConfig.CustomFormats)}[0]."; + + [Test] + public void Validation_failure_when_cf_trash_ids_empty() + { + var config = new TestConfig + { + ApiKey = "valid", + BaseUrl = "valid", + CustomFormats = new List + { + new() + { + TrashIds = Array.Empty(), + QualityProfiles = new List + { + new() + { + Name = "valid" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var validator = Resolve(); + var result = validator.TestValidate(config); + + result.ShouldHaveValidationErrorFor(FirstCf + nameof(CustomFormatConfig.TrashIds)); + } + + [Test] + public void Validation_failure_when_quality_definition_type_empty() + { + var config = new TestConfig + { + ApiKey = "valid", + BaseUrl = "valid", + CustomFormats = new List + { + new() + { + TrashIds = new List {"valid"}, + QualityProfiles = new List + { + new() + { + Name = "valid" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "" + } + }; + + var validator = Resolve(); + var result = validator.TestValidate(config); + + result.ShouldHaveValidationErrorFor(x => x.QualityDefinition!.Type); + } + + [Test] + public void Validation_failure_when_quality_profile_name_empty() + { + var config = new TestConfig + { + ApiKey = "valid", + BaseUrl = "valid", + CustomFormats = new List + { + new() + { + TrashIds = new List {"valid"}, + QualityProfiles = new List + { + new() + { + Name = "" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var validator = Resolve(); + var result = validator.TestValidate(config); + + result.ShouldHaveValidationErrorFor(FirstCf + + $"{nameof(CustomFormatConfig.QualityProfiles)}[0].{nameof(QualityProfileScoreConfig.Name)}"); + } +} diff --git a/src/Recyclarr.TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs b/src/Recyclarr.TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs deleted file mode 100644 index 08f2e974..00000000 --- a/src/Recyclarr.TrashLib.Tests/Config/Services/ServiceConfigurationTest.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Autofac; -using FluentAssertions; -using FluentValidation; -using NUnit.Framework; -using Recyclarr.Cli.TestLibrary; -using Recyclarr.TrashLib.Config.Services; -using Recyclarr.TrashLib.TestLibrary; - -namespace Recyclarr.TrashLib.Tests.Config.Services; - -[TestFixture] -[Parallelizable(ParallelScope.All)] -public class ServiceConfigurationTest : IntegrationFixture -{ - [Test] - public void Validation_fails_for_all_missing_required_properties() - { - // default construct which should yield default values (invalid) for all required properties - var config = new TestConfig(); - - var validator = Container.Resolve>(); - - var result = validator.Validate(config); - - var messages = new ServiceValidationMessages(); - var expectedErrorMessageSubstrings = new[] - { - messages.ApiKey, - messages.BaseUrl - }; - - result.IsValid.Should().BeFalse(); - result.Errors.Select(e => e.ErrorMessage) - .Should().BeEquivalentTo(expectedErrorMessageSubstrings); - } - - [Test] - public void Fail_when_trash_ids_missing() - { - var config = new TestConfig - { - BaseUrl = "valid", - ApiKey = "valid", - CustomFormats = new List - { - new() // Empty to force validation failure - } - }; - - var validator = Container.Resolve>(); - - var result = validator.Validate(config); - - var messages = new ServiceValidationMessages(); - var expectedErrorMessageSubstrings = new[] - { - messages.CustomFormatTrashIds - }; - - result.IsValid.Should().BeFalse(); - result.Errors.Select(e => e.ErrorMessage) - .Should().BeEquivalentTo(expectedErrorMessageSubstrings); - } -} diff --git a/src/Recyclarr.TrashLib.Tests/Config/SonarrConfigurationValidatorTest.cs b/src/Recyclarr.TrashLib.Tests/Config/SonarrConfigurationValidatorTest.cs new file mode 100644 index 00000000..cacdfd70 --- /dev/null +++ b/src/Recyclarr.TrashLib.Tests/Config/SonarrConfigurationValidatorTest.cs @@ -0,0 +1,144 @@ +using FluentValidation.TestHelper; +using NUnit.Framework; +using Recyclarr.TrashLib.Config.Services; +using Recyclarr.TrashLib.Services.Sonarr; +using Recyclarr.TrashLib.Services.Sonarr.Config; + +namespace Recyclarr.TrashLib.Tests.Config; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class SonarrConfigurationValidatorTest +{ + [Test] + public void Sonarr_v4_succeeds() + { + var config = new SonarrConfiguration + { + ApiKey = "valid", + BaseUrl = "valid", + CustomFormats = new List + { + new() + { + TrashIds = new List {"valid"}, + QualityProfiles = new List + { + new() + { + Name = "valid" + } + } + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var capabilities = new SonarrCapabilities + { + SupportsCustomFormats = true, + SupportsNamedReleaseProfiles = true + }; + var validator = new SonarrConfigurationValidator(capabilities); + var result = validator.TestValidate(config); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + public void Sonarr_v3_succeeds() + { + var config = new SonarrConfiguration + { + ApiKey = "valid", + BaseUrl = "valid", + ReleaseProfiles = new List + { + new() + { + TrashIds = new List {"valid"}, + Filter = new SonarrProfileFilterConfig {Include = new[] {"valid"}}, + Tags = new[] {"valid"} + } + }, + QualityDefinition = new QualityDefinitionConfig + { + Type = "valid" + } + }; + + var capabilities = new SonarrCapabilities + { + SupportsCustomFormats = false, + SupportsNamedReleaseProfiles = true + }; + var validator = new SonarrConfigurationValidator(capabilities); + var result = validator.TestValidate(config); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + public void Sonarr_v4_failures() + { + var config = new SonarrConfiguration + { + ReleaseProfiles = new List {new()} + }; + + var capabilities = new SonarrCapabilities {SupportsCustomFormats = true}; + var validator = new SonarrConfigurationValidator(capabilities); + var result = validator.TestValidate(config); + + // Release profiles not allowed in v4 + result.ShouldHaveValidationErrorFor(x => x.ReleaseProfiles); + } + + [Test] + public void Sonarr_v3_failures() + { + var config = new SonarrConfiguration + { + CustomFormats = new List {new()}, + ReleaseProfiles = new List + { + new() + { + TrashIds = Array.Empty(), + Filter = new SonarrProfileFilterConfig + { + Include = new[] {"include"}, + Exclude = new[] {"exclude"} + } + } + } + }; + + var capabilities = new SonarrCapabilities + { + SupportsCustomFormats = false, + SupportsNamedReleaseProfiles = false + }; + + var validator = new SonarrConfigurationValidator(capabilities); + var result = validator.TestValidate(config); + + // Custom formats not allowed in v3 + result.ShouldHaveValidationErrorFor(x => x.CustomFormats); + + // Due to named release profiles not being supported (minimum version requirement not met) + result.ShouldHaveValidationErrorFor(x => x); + + 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)}"); + } +} diff --git a/src/Recyclarr.TrashLib.Tests/Radarr/RadarrConfigurationTest.cs b/src/Recyclarr.TrashLib.Tests/Radarr/RadarrConfigurationTest.cs deleted file mode 100644 index f488be31..00000000 --- a/src/Recyclarr.TrashLib.Tests/Radarr/RadarrConfigurationTest.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.ObjectModel; -using Autofac; -using FluentAssertions; -using FluentValidation; -using NUnit.Framework; -using Recyclarr.Cli.TestLibrary; -using Recyclarr.TrashLib.Config.Services; -using Recyclarr.TrashLib.Services.Radarr.Config; - -namespace Recyclarr.TrashLib.Tests.Radarr; - -[TestFixture] -[Parallelizable(ParallelScope.All)] -public class RadarrConfigurationTest : IntegrationFixture -{ - [Test] - public void Custom_format_is_valid_with_trash_id() - { - var config = new RadarrConfiguration - { - ApiKey = "required value", - BaseUrl = "required value", - CustomFormats = new List - { - new() {TrashIds = new Collection {"trash_id"}} - } - }; - - var validator = Container.Resolve>(); - var result = validator.Validate(config); - - result.IsValid.Should().BeTrue(); - result.Errors.Should().BeEmpty(); - } - - [Test] - public void Validation_fails_for_all_missing_required_properties() - { - // default construct which should yield default values (invalid) for all required properties - var config = new RadarrConfiguration(); - var validator = Container.Resolve>(); - - var result = validator.Validate(config); - - var expectedErrorMessageSubstrings = new[] - { - "Property 'base_url' is required", - "Property 'api_key' is required", - "'custom_formats' elements must contain at least one element under 'trash_ids'", - "'name' is required for elements under 'quality_profiles'", - "'type' is required for 'quality_definition'" - }; - - result.IsValid.Should().BeFalse(); - result.Errors.Select(e => e.ErrorMessage).Should() - .OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains)); - } - - [Test] - public void Validation_succeeds_when_no_missing_required_properties() - { - var config = new RadarrConfiguration - { - ApiKey = "required value", - BaseUrl = "required value", - CustomFormats = new List - { - new() - { - TrashIds = new List {"required value"}, - QualityProfiles = new List - { - new() {Name = "required value"} - } - } - }, - QualityDefinition = new QualityDefinitionConfig - { - Type = "movie" - } - }; - - var validator = Container.Resolve>(); - var result = validator.Validate(config); - - result.IsValid.Should().BeTrue(); - result.Errors.Should().BeEmpty(); - } -} diff --git a/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrCompatibilityTest.cs b/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrCompatibilityTest.cs index 91a8b0fb..245c4baf 100644 --- a/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrCompatibilityTest.cs +++ b/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrCompatibilityTest.cs @@ -1,5 +1,3 @@ -using System.Reactive.Linq; -using AutoFixture.NUnit3; using AutoMapper; using FluentAssertions; using Newtonsoft.Json; @@ -7,15 +5,9 @@ using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using NSubstitute; using NUnit.Framework; -using Recyclarr.TestLibrary.AutoFixture; -using Recyclarr.TrashLib.Config.Services; -using Recyclarr.TrashLib.ExceptionTypes; using Recyclarr.TrashLib.Services.Sonarr; using Recyclarr.TrashLib.Services.Sonarr.Api; using Recyclarr.TrashLib.Services.Sonarr.Api.Objects; -using Recyclarr.TrashLib.Services.Sonarr.Config; -using Recyclarr.TrashLib.Services.System; -using Recyclarr.TrashLib.Services.System.Dto; using Recyclarr.TrashLib.Startup; using Serilog; @@ -56,9 +48,9 @@ public class SonarrCompatibilityTest { using var ctx = new TestContext(); - var compat = Substitute.For(); + static SonarrCapabilities Compat() => new(); var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"}; - var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), compat, ctx.Mapper); + var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), Compat, ctx.Mapper); var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV1))); @@ -73,9 +65,9 @@ public class SonarrCompatibilityTest { using var ctx = new TestContext(); - var compat = Substitute.For(); + static SonarrCapabilities Compat() => new(); var dataV2 = new SonarrReleaseProfile {Ignored = new List {"one", "two", "three"}}; - var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), compat, ctx.Mapper); + var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), Compat, ctx.Mapper); var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV2))); @@ -83,112 +75,32 @@ public class SonarrCompatibilityTest } [Test] - public async Task Send_v2_to_v1() + public void Send_v2_to_v1() { using var ctx = new TestContext(); - var compat = Substitute.For(); - compat.Capabilities.Returns(new[] - { - new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = false} - }.ToObservable()); + static SonarrCapabilities Compat() => new() {ArraysNeededForReleaseProfileRequiredAndIgnored = false}; var data = new SonarrReleaseProfile {Ignored = new List {"one", "two", "three"}}; - var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), compat, ctx.Mapper); + var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), Compat, ctx.Mapper); - var result = await sut.CompatibleReleaseProfileForSendingAsync(data); + var result = sut.CompatibleReleaseProfileForSending(data); result.Should().BeEquivalentTo(new SonarrReleaseProfileV1 {Ignored = "one,two,three"}); } [Test] - public async Task Send_v2_to_v2() + public void Send_v2_to_v2() { using var ctx = new TestContext(); - var compat = Substitute.For(); - compat.Capabilities.Returns(new[] - { - new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = true} - }.ToObservable()); + static SonarrCapabilities Compat() => new() {ArraysNeededForReleaseProfileRequiredAndIgnored = true}; var data = new SonarrReleaseProfile {Ignored = new List {"one", "two", "three"}}; - var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), compat, ctx.Mapper); + var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For(), Compat, ctx.Mapper); - var result = await sut.CompatibleReleaseProfileForSendingAsync(data); + var result = sut.CompatibleReleaseProfileForSending(data); result.Should().BeEquivalentTo(data); } - - [Test, AutoMockData] - public async Task Failure_when_release_profiles_used_with_sonarr_v4( - [Frozen] ISystemApiService api, - [Frozen(Matching.ImplementedInterfaces)] SonarrCompatibility compatibility, - SonarrVersionEnforcement enforcement) - { - api.GetStatus().Returns(new SystemStatus("Sonarr", "4.0")); - - var config = new SonarrConfiguration - { - ReleaseProfiles = new List {new()} - }; - - var act = () => enforcement.DoVersionEnforcement(config); - - await act.Should().ThrowAsync().WithMessage("Sonarr v4*"); - } - - [Test, AutoMockData] - public async Task No_failure_when_release_profiles_used_with_sonarr_v3( - [Frozen] ISystemApiService api, - [Frozen(Matching.ImplementedInterfaces)] SonarrCompatibility compatibility, - SonarrVersionEnforcement enforcement) - { - api.GetStatus().Returns(new SystemStatus("Sonarr", "3.9")); - - var config = new SonarrConfiguration - { - ReleaseProfiles = new List {new()} - }; - - var act = () => enforcement.DoVersionEnforcement(config); - - await act.Should().NotThrowAsync(); - } - - [Test, AutoMockData] - public async Task Failure_when_custom_formats_used_with_sonarr_v3( - [Frozen] ISystemApiService api, - [Frozen(Matching.ImplementedInterfaces)] SonarrCompatibility compatibility, - SonarrVersionEnforcement enforcement) - { - api.GetStatus().Returns(new SystemStatus("Sonarr", "3.9")); - - var config = new SonarrConfiguration - { - CustomFormats = new List {new()} - }; - - var act = () => enforcement.DoVersionEnforcement(config); - - await act.Should().ThrowAsync().WithMessage("Sonarr v3*"); - } - - [Test, AutoMockData] - public async Task No_failure_when_custom_formats_used_with_sonarr_v4( - [Frozen] ISystemApiService api, - [Frozen(Matching.ImplementedInterfaces)] SonarrCompatibility compatibility, - SonarrVersionEnforcement enforcement) - { - api.GetStatus().Returns(new SystemStatus("Sonarr", "4.0")); - - var config = new SonarrConfiguration - { - CustomFormats = new List {new()} - }; - - var act = () => enforcement.DoVersionEnforcement(config); - - await act.Should().NotThrowAsync(); - } } diff --git a/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs b/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs deleted file mode 100644 index 7f5ac163..00000000 --- a/src/Recyclarr.TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Autofac; -using FluentAssertions; -using FluentValidation; -using NUnit.Framework; -using Recyclarr.Cli.TestLibrary; -using Recyclarr.TrashLib.Services.Sonarr.Config; - -namespace Recyclarr.TrashLib.Tests.Sonarr; - -[TestFixture] -[Parallelizable(ParallelScope.All)] -public class SonarrConfigurationTest : IntegrationFixture -{ - [Test] - public void Validation_fails_for_all_missing_required_properties() - { - // default construct which should yield default values (invalid) for all required properties - var config = new SonarrConfiguration - { - BaseUrl = "valid", - ApiKey = "valid", - // validation is only applied to actual release profile elements. Not if it's empty. - ReleaseProfiles = new[] {new ReleaseProfileConfig()} - }; - - var validator = Container.Resolve>(); - - var result = validator.Validate(config); - - var messages = new SonarrValidationMessages(); - var expectedErrorMessageSubstrings = new[] - { - messages.ReleaseProfileTrashIds - }; - - result.IsValid.Should().BeFalse(); - result.Errors.Select(e => e.ErrorMessage) - .Should().BeEquivalentTo(expectedErrorMessageSubstrings); - } - - [Test] - public void Validation_succeeds_when_no_missing_required_properties() - { - var config = new SonarrConfiguration - { - ApiKey = "required value", - BaseUrl = "required value", - ReleaseProfiles = new List - { - new() {TrashIds = new[] {"123"}} - } - }; - - var validator = Container.Resolve>(); - var result = validator.Validate(config); - - result.IsValid.Should().BeTrue(); - result.Errors.Should().BeEmpty(); - } -} diff --git a/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs b/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs index cfc4d3ab..94735a26 100644 --- a/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs +++ b/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs @@ -2,7 +2,6 @@ using System.Reflection; using Autofac; using FluentValidation; using Recyclarr.TrashLib.Config.Secrets; -using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Settings; using Recyclarr.TrashLib.Config.Yaml; using Module = Autofac.Module; @@ -31,6 +30,5 @@ public class ConfigAutofacModule : Module builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As(); - builder.RegisterType().As(); } } diff --git a/src/Recyclarr.TrashLib/Config/Services/IServiceValidationMessages.cs b/src/Recyclarr.TrashLib/Config/Services/IServiceValidationMessages.cs deleted file mode 100644 index f68ce5d9..00000000 --- a/src/Recyclarr.TrashLib/Config/Services/IServiceValidationMessages.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Recyclarr.TrashLib.Config.Services; - -public interface IServiceValidationMessages -{ - string BaseUrl { get; } - string ApiKey { get; } - string CustomFormatTrashIds { get; } -} diff --git a/src/Recyclarr.TrashLib/Config/Services/ServiceConfiguration.cs b/src/Recyclarr.TrashLib/Config/Services/ServiceConfiguration.cs index 7752acfb..b4110795 100644 --- a/src/Recyclarr.TrashLib/Config/Services/ServiceConfiguration.cs +++ b/src/Recyclarr.TrashLib/Config/Services/ServiceConfiguration.cs @@ -9,6 +9,9 @@ public abstract class ServiceConfiguration : IServiceConfiguration [YamlIgnore] public string? Name { get; set; } + [YamlIgnore] + public int LineNumber { get; set; } + public string BaseUrl { get; set; } = ""; public string ApiKey { get; set; } = ""; diff --git a/src/Recyclarr.TrashLib/Config/Services/ServiceConfigurationValidator.cs b/src/Recyclarr.TrashLib/Config/Services/ServiceConfigurationValidator.cs index 29396ff3..45329b3a 100644 --- a/src/Recyclarr.TrashLib/Config/Services/ServiceConfigurationValidator.cs +++ b/src/Recyclarr.TrashLib/Config/Services/ServiceConfigurationValidator.cs @@ -1,6 +1,8 @@ using FluentValidation; using JetBrains.Annotations; +using Recyclarr.Common.FluentValidation; using Recyclarr.TrashLib.Services.Radarr.Config; +using Recyclarr.TrashLib.Services.Sonarr.Config; namespace Recyclarr.TrashLib.Config.Services; @@ -8,32 +10,49 @@ namespace Recyclarr.TrashLib.Config.Services; internal class ServiceConfigurationValidator : AbstractValidator { public ServiceConfigurationValidator( - IServiceValidationMessages messages, - IValidator customFormatConfigValidator) + IValidator sonarrValidator, + IValidator radarrValidator) { - RuleFor(x => x.BaseUrl).NotEmpty().WithMessage(messages.BaseUrl); - RuleFor(x => x.ApiKey).NotEmpty().WithMessage(messages.ApiKey); - RuleForEach(x => x.CustomFormats).SetValidator(customFormatConfigValidator); + ClassLevelCascadeMode = CascadeMode.Stop; + + RuleFor(x => x.BaseUrl).NotEmpty().WithMessage("Property 'base_url' is required"); + 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 { - public CustomFormatConfigValidator( - IServiceValidationMessages messages, - IValidator qualityProfileScoreConfigValidator) + public CustomFormatConfigValidator() { - RuleFor(x => x.TrashIds).NotEmpty().WithMessage(messages.CustomFormatTrashIds); - RuleForEach(x => x.QualityProfiles).SetValidator(qualityProfileScoreConfigValidator); + 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 { - public QualityProfileScoreConfigValidator(IRadarrValidationMessages messages) + public QualityProfileScoreConfigValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("'name' is required for elements under 'quality_profiles'"); + } +} + +[UsedImplicitly] +internal class QualityDefinitionConfigValidator : AbstractValidator +{ + public QualityDefinitionConfigValidator() { - RuleFor(x => x.Name).NotEmpty().WithMessage(messages.QualityProfileName); + RuleFor(x => x.Type).NotEmpty().WithMessage("'type' is required for 'quality_definition'"); } } diff --git a/src/Recyclarr.TrashLib/Config/Services/ServiceValidationMessages.cs b/src/Recyclarr.TrashLib/Config/Services/ServiceValidationMessages.cs deleted file mode 100644 index d8841fb7..00000000 --- a/src/Recyclarr.TrashLib/Config/Services/ServiceValidationMessages.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Recyclarr.TrashLib.Config.Services; - -internal /*abstract*/ class ServiceValidationMessages : IServiceValidationMessages -{ - public string BaseUrl => - "Property 'base_url' is required"; - - public string ApiKey => - "Property 'api_key' is required"; - - public string CustomFormatTrashIds => - "'custom_formats' elements must contain at least one element under 'trash_ids'"; -} diff --git a/src/Recyclarr.TrashLib/Services/Common/ServiceCompatibility.cs b/src/Recyclarr.TrashLib/Services/Common/ServiceCompatibility.cs new file mode 100644 index 00000000..149ef990 --- /dev/null +++ b/src/Recyclarr.TrashLib/Services/Common/ServiceCompatibility.cs @@ -0,0 +1,24 @@ +using System.Reactive.Linq; +using Recyclarr.Common.Extensions; +using Recyclarr.TrashLib.Services.System; + +namespace Recyclarr.TrashLib.Services.Common; + +public abstract class ServiceCompatibility where T : class +{ + private readonly IObservable _capabilities; + + public T Capabilities => _capabilities.Wait(); + + protected ServiceCompatibility(IServiceInformation compatibility) + { + _capabilities = compatibility.Version + .Select(BuildCapabilitiesObject) + .Replay(1) + .AutoConnect() + .NotNull() + .LastAsync(); + } + + protected abstract T BuildCapabilitiesObject(Version version); +} diff --git a/src/Recyclarr.TrashLib/Services/Radarr/Config/IRadarrValidationMessages.cs b/src/Recyclarr.TrashLib/Services/Radarr/Config/IRadarrValidationMessages.cs deleted file mode 100644 index 4ac20d3d..00000000 --- a/src/Recyclarr.TrashLib/Services/Radarr/Config/IRadarrValidationMessages.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Recyclarr.TrashLib.Services.Radarr.Config; - -public interface IRadarrValidationMessages -{ - string QualityProfileName { get; } - string QualityDefinitionType { get; } -} diff --git a/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrConfigurationValidator.cs b/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrConfigurationValidator.cs index e4b8caca..04fa468a 100644 --- a/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrConfigurationValidator.cs +++ b/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrConfigurationValidator.cs @@ -1,27 +1,9 @@ using FluentValidation; using JetBrains.Annotations; -using Recyclarr.Common.FluentValidation; -using Recyclarr.TrashLib.Config.Services; namespace Recyclarr.TrashLib.Services.Radarr.Config; [UsedImplicitly] internal class RadarrConfigurationValidator : AbstractValidator { - public RadarrConfigurationValidator( - IValidator serviceConfigValidator, - IValidator qualityDefinitionConfigValidator) - { - Include(serviceConfigValidator); - RuleFor(x => x.QualityDefinition).SetNonNullableValidator(qualityDefinitionConfigValidator); - } -} - -[UsedImplicitly] -internal class QualityDefinitionConfigValidator : AbstractValidator -{ - public QualityDefinitionConfigValidator(IRadarrValidationMessages messages) - { - RuleFor(x => x.Type).NotEmpty().WithMessage(messages.QualityDefinitionType); - } } diff --git a/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrValidationMessages.cs b/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrValidationMessages.cs deleted file mode 100644 index ace2528b..00000000 --- a/src/Recyclarr.TrashLib/Services/Radarr/Config/RadarrValidationMessages.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Recyclarr.TrashLib.Services.Radarr.Config; - -internal class RadarrValidationMessages : IRadarrValidationMessages -{ - public string QualityProfileName => - "'name' is required for elements under 'quality_profiles'"; - - public string QualityDefinitionType => - "'type' is required for 'quality_definition'"; -} diff --git a/src/Recyclarr.TrashLib/Services/Radarr/RadarrAutofacModule.cs b/src/Recyclarr.TrashLib/Services/Radarr/RadarrAutofacModule.cs index a49cd7cd..d2d2cc10 100644 --- a/src/Recyclarr.TrashLib/Services/Radarr/RadarrAutofacModule.cs +++ b/src/Recyclarr.TrashLib/Services/Radarr/RadarrAutofacModule.cs @@ -1,7 +1,6 @@ using Autofac; using Recyclarr.TrashLib.Services.QualitySize; using Recyclarr.TrashLib.Services.QualitySize.Api; -using Recyclarr.TrashLib.Services.Radarr.Config; namespace Recyclarr.TrashLib.Services.Radarr; @@ -11,10 +10,11 @@ public class RadarrAutofacModule : Module { builder.RegisterType().As(); builder.RegisterType().As(); - builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); - builder.RegisterType(); + + builder.RegisterType().InstancePerLifetimeScope(); + builder.Register(c => c.Resolve().Capabilities); } } diff --git a/src/Recyclarr.TrashLib/Services/Radarr/RadarrCompatibility.cs b/src/Recyclarr.TrashLib/Services/Radarr/RadarrCompatibility.cs index 8c2290e2..85012ef0 100644 --- a/src/Recyclarr.TrashLib/Services/Radarr/RadarrCompatibility.cs +++ b/src/Recyclarr.TrashLib/Services/Radarr/RadarrCompatibility.cs @@ -1,12 +1,12 @@ +using Recyclarr.TrashLib.Services.Common; using Recyclarr.TrashLib.Services.System; -using Serilog; namespace Recyclarr.TrashLib.Services.Radarr; public class RadarrCompatibility : ServiceCompatibility { - public RadarrCompatibility(ISystemApiService api, ILogger log) - : base(api, log) + public RadarrCompatibility(IServiceInformation compatibility) + : base(compatibility) { } diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/Api/ISonarrReleaseProfileCompatibilityHandler.cs b/src/Recyclarr.TrashLib/Services/Sonarr/Api/ISonarrReleaseProfileCompatibilityHandler.cs index 84ff8989..4332c49b 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/Api/ISonarrReleaseProfileCompatibilityHandler.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/Api/ISonarrReleaseProfileCompatibilityHandler.cs @@ -5,6 +5,6 @@ namespace Recyclarr.TrashLib.Services.Sonarr.Api; public interface ISonarrReleaseProfileCompatibilityHandler { - Task CompatibleReleaseProfileForSendingAsync(SonarrReleaseProfile profile); + object CompatibleReleaseProfileForSending(SonarrReleaseProfile profile); SonarrReleaseProfile CompatibleReleaseProfileForReceiving(JObject profile); } diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/Api/ReleaseProfileApiService.cs b/src/Recyclarr.TrashLib/Services/Sonarr/Api/ReleaseProfileApiService.cs index 30d7d1c4..633dc902 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/Api/ReleaseProfileApiService.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/Api/ReleaseProfileApiService.cs @@ -20,14 +20,14 @@ public class ReleaseProfileApiService : IReleaseProfileApiService public async Task UpdateReleaseProfile(SonarrReleaseProfile profile) { - var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile); + var profileToSend = _profileHandler.CompatibleReleaseProfileForSending(profile); await _service.Request("releaseprofile", profile.Id) .PutJsonAsync(profileToSend); } public async Task CreateReleaseProfile(SonarrReleaseProfile profile) { - var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile); + var profileToSend = _profileHandler.CompatibleReleaseProfileForSending(profile); var response = await _service.Request("releaseprofile") .PostJsonAsync(profileToSend) diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/Api/SonarrReleaseProfileCompatibilityHandler.cs b/src/Recyclarr.TrashLib/Services/Sonarr/Api/SonarrReleaseProfileCompatibilityHandler.cs index f3c4ecb2..77e513b3 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/Api/SonarrReleaseProfileCompatibilityHandler.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/Api/SonarrReleaseProfileCompatibilityHandler.cs @@ -1,4 +1,3 @@ -using System.Reactive.Linq; using AutoMapper; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Schema; @@ -11,22 +10,22 @@ namespace Recyclarr.TrashLib.Services.Sonarr.Api; public class SonarrReleaseProfileCompatibilityHandler : ISonarrReleaseProfileCompatibilityHandler { private readonly ILogger _log; - private readonly ISonarrCompatibility _compatibility; + private readonly Func _capabilitiesFactory; private readonly IMapper _mapper; public SonarrReleaseProfileCompatibilityHandler( ILogger log, - ISonarrCompatibility compatibility, + Func capabilitiesFactory, IMapper mapper) { _log = log; - _compatibility = compatibility; + _capabilitiesFactory = capabilitiesFactory; _mapper = mapper; } - public async Task CompatibleReleaseProfileForSendingAsync(SonarrReleaseProfile profile) + public object CompatibleReleaseProfileForSending(SonarrReleaseProfile profile) { - var capabilities = await _compatibility.Capabilities.LastAsync(); + var capabilities = _capabilitiesFactory(); return capabilities.ArraysNeededForReleaseProfileRequiredAndIgnored ? profile : _mapper.Map(profile); diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/Config/ISonarrValidationMessages.cs b/src/Recyclarr.TrashLib/Services/Sonarr/Config/ISonarrValidationMessages.cs deleted file mode 100644 index 9d8fabb7..00000000 --- a/src/Recyclarr.TrashLib/Services/Sonarr/Config/ISonarrValidationMessages.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Recyclarr.TrashLib.Services.Sonarr.Config; - -public interface ISonarrValidationMessages -{ - string ReleaseProfileTrashIds { get; } -} diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrConfigurationValidator.cs b/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrConfigurationValidator.cs index fa9db5b6..7a94edef 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrConfigurationValidator.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrConfigurationValidator.cs @@ -1,27 +1,54 @@ using FluentValidation; using JetBrains.Annotations; -using Recyclarr.TrashLib.Config.Services; +using Recyclarr.Common.FluentValidation; namespace Recyclarr.TrashLib.Services.Sonarr.Config; [UsedImplicitly] -internal class SonarrConfigurationValidator : AbstractValidator +public class SonarrConfigurationValidator : AbstractValidator { - public SonarrConfigurationValidator( - ISonarrValidationMessages messages, - IValidator serviceConfigValidator, - IValidator releaseProfileConfigValidator) + public SonarrConfigurationValidator(SonarrCapabilities capabilities) { - Include(serviceConfigValidator); - RuleForEach(x => x.ReleaseProfiles).SetValidator(releaseProfileConfigValidator); + RuleForEach(x => x.ReleaseProfiles).SetValidator(new ReleaseProfileConfigValidator()); + + // Release profiles may not be used with Sonarr v4 + RuleFor(x => x) + .Must(_ => capabilities.SupportsNamedReleaseProfiles) + .WithMessage( + $"Your Sonarr version {capabilities.Version} does not meet the minimum " + + $"required version of {SonarrCapabilities.MinimumVersion}."); + + // Release profiles may not be used with Sonarr v4 + RuleFor(x => x.ReleaseProfiles).Empty() + .When(_ => capabilities.SupportsCustomFormats) + .WithMessage("Release profiles require Sonarr v3. " + + "Please use `custom_formats` instead or use the right version of Sonarr."); + + // Custom formats may not be used with Sonarr v3 + RuleFor(x => x.CustomFormats).Empty() + .When(_ => !capabilities.SupportsCustomFormats) + .WithMessage("Custom formats require Sonarr v4 or greater. " + + "Please use `release_profiles` instead or use the right version of Sonarr."); } } [UsedImplicitly] internal class ReleaseProfileConfigValidator : AbstractValidator { - public ReleaseProfileConfigValidator(ISonarrValidationMessages messages) + 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 +{ + public SonarrProfileFilterConfigValidator() { - RuleFor(x => x.TrashIds).NotEmpty().WithMessage(messages.ReleaseProfileTrashIds); + // 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."); } } diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrValidationMessages.cs b/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrValidationMessages.cs deleted file mode 100644 index e264a5b5..00000000 --- a/src/Recyclarr.TrashLib/Services/Sonarr/Config/SonarrValidationMessages.cs +++ /dev/null @@ -1,10 +0,0 @@ -using JetBrains.Annotations; - -namespace Recyclarr.TrashLib.Services.Sonarr.Config; - -[UsedImplicitly] -internal class SonarrValidationMessages : ISonarrValidationMessages -{ - public string ReleaseProfileTrashIds => - "'trash_ids' is required for 'release_profiles' elements"; -} diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrCompatibility.cs b/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrCompatibility.cs index 62f61a57..10c9964c 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrCompatibility.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrCompatibility.cs @@ -2,6 +2,5 @@ namespace Recyclarr.TrashLib.Services.Sonarr; public interface ISonarrCompatibility { - IObservable Capabilities { get; } - Version MinimumVersion { get; } + SonarrCapabilities Capabilities { get; } } diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrVersionEnforcement.cs b/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrVersionEnforcement.cs deleted file mode 100644 index 0b99a855..00000000 --- a/src/Recyclarr.TrashLib/Services/Sonarr/ISonarrVersionEnforcement.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Recyclarr.TrashLib.Services.Sonarr.Config; - -namespace Recyclarr.TrashLib.Services.Sonarr; - -public interface ISonarrVersionEnforcement -{ - Task DoVersionEnforcement(SonarrConfiguration config); -} diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrAutofacModule.cs b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrAutofacModule.cs index 86854583..99fc7dd4 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrAutofacModule.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrAutofacModule.cs @@ -1,7 +1,6 @@ using Autofac; using Autofac.Extras.Ordering; using Recyclarr.TrashLib.Services.Sonarr.Api; -using Recyclarr.TrashLib.Services.Sonarr.Config; using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile; using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile.Filters; using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile.Guide; @@ -14,9 +13,12 @@ public class SonarrAutofacModule : Module { builder.RegisterType().As(); builder.RegisterType().As(); - builder.RegisterType().As(); - builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As(); + + builder.RegisterType().As() + .InstancePerLifetimeScope(); + + builder.Register(c => c.Resolve().Capabilities); + builder.RegisterType().As(); // Release Profile Support diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCapabilities.cs b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCapabilities.cs index e53af4ed..1be7c865 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCapabilities.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCapabilities.cs @@ -7,6 +7,8 @@ public record SonarrCapabilities(Version Version) { } + public static Version MinimumVersion => new("3.0.4.1098"); + public bool SupportsNamedReleaseProfiles { get; init; } // Background: Issue #16 filed which points to a backward-breaking API diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCompatibility.cs b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCompatibility.cs index f3af167b..75de4ee7 100644 --- a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCompatibility.cs +++ b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrCompatibility.cs @@ -1,23 +1,21 @@ +using Recyclarr.TrashLib.Services.Common; using Recyclarr.TrashLib.Services.System; -using Serilog; namespace Recyclarr.TrashLib.Services.Sonarr; public class SonarrCompatibility : ServiceCompatibility, ISonarrCompatibility { - public SonarrCompatibility(ISystemApiService api, ILogger log) - : base(api, log) + public SonarrCompatibility(IServiceInformation compatibility) + : base(compatibility) { } - public Version MinimumVersion => new("3.0.4.1098"); - protected override SonarrCapabilities BuildCapabilitiesObject(Version version) { return new SonarrCapabilities(version) { SupportsNamedReleaseProfiles = - version >= MinimumVersion, + version >= SonarrCapabilities.MinimumVersion, ArraysNeededForReleaseProfileRequiredAndIgnored = version >= new Version("3.0.6.1355"), diff --git a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrVersionEnforcement.cs b/src/Recyclarr.TrashLib/Services/Sonarr/SonarrVersionEnforcement.cs deleted file mode 100644 index fcb8c611..00000000 --- a/src/Recyclarr.TrashLib/Services/Sonarr/SonarrVersionEnforcement.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Reactive.Linq; -using Recyclarr.TrashLib.ExceptionTypes; -using Recyclarr.TrashLib.Services.Sonarr.Config; - -namespace Recyclarr.TrashLib.Services.Sonarr; - -public class SonarrVersionEnforcement : ISonarrVersionEnforcement -{ - private readonly ISonarrCompatibility _compatibility; - - public SonarrVersionEnforcement(ISonarrCompatibility compatibility) - { - _compatibility = compatibility; - } - - public async Task DoVersionEnforcement(SonarrConfiguration config) - { - var capabilities = await _compatibility.Capabilities.LastAsync(); - if (!capabilities.SupportsNamedReleaseProfiles) - { - throw new VersionException( - $"Your Sonarr version {capabilities.Version} does not meet the minimum " + - $"required version of {_compatibility.MinimumVersion} to use this program"); - } - - switch (capabilities.SupportsCustomFormats) - { - case true when config.ReleaseProfiles.Any(): - throw new VersionException( - "Sonarr v4 does not support Release Profiles. Please use Custom Formats instead."); - - case false when config.CustomFormats.Any(): - throw new VersionException( - "Sonarr v3 does not support Custom Formats. Please use Release Profiles instead."); - } - } -} diff --git a/src/Recyclarr.TrashLib/Services/System/IServiceInformation.cs b/src/Recyclarr.TrashLib/Services/System/IServiceInformation.cs new file mode 100644 index 00000000..10b9cc93 --- /dev/null +++ b/src/Recyclarr.TrashLib/Services/System/IServiceInformation.cs @@ -0,0 +1,6 @@ +namespace Recyclarr.TrashLib.Services.System; + +public interface IServiceInformation +{ + IObservable Version { get; } +} diff --git a/src/Recyclarr.TrashLib/Services/System/ServiceCompatibility.cs b/src/Recyclarr.TrashLib/Services/System/ServiceCompatibility.cs deleted file mode 100644 index 37853a78..00000000 --- a/src/Recyclarr.TrashLib/Services/System/ServiceCompatibility.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using Recyclarr.TrashLib.Services.System.Dto; -using Serilog; - -namespace Recyclarr.TrashLib.Services.System; - -public abstract class ServiceCompatibility where T : new() -{ - private readonly ILogger _log; - - protected ServiceCompatibility(ISystemApiService api, ILogger log) - { - _log = log; - Capabilities = Observable.FromAsync(async () => await api.GetStatus(), NewThreadScheduler.Default) - .Timeout(TimeSpan.FromSeconds(15)) - .Do(LogServiceInfo) - .Select(x => new Version(x.Version)) - .Select(BuildCapabilitiesObject) - .Replay(1) - .AutoConnect(); - } - - public IObservable Capabilities { get; } - - private void LogServiceInfo(SystemStatus status) - { - _log.Debug("{Service} Version: {Version}", status.AppName, status.Version); - } - - protected abstract T BuildCapabilitiesObject(Version version); -} diff --git a/src/Recyclarr.TrashLib/Services/System/ServiceInformation.cs b/src/Recyclarr.TrashLib/Services/System/ServiceInformation.cs new file mode 100644 index 00000000..de3cebee --- /dev/null +++ b/src/Recyclarr.TrashLib/Services/System/ServiceInformation.cs @@ -0,0 +1,41 @@ +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using Flurl.Http; +using Recyclarr.TrashLib.Http; +using Recyclarr.TrashLib.Services.System.Dto; +using Serilog; + +namespace Recyclarr.TrashLib.Services.System; + +public class ServiceInformation : IServiceInformation +{ + private readonly ILogger _log; + + public ServiceInformation(ISystemApiService api, ILogger log) + { + _log = log; + Version = Observable.FromAsync(async () => await api.GetStatus(), ThreadPoolScheduler.Instance) + .Timeout(TimeSpan.FromSeconds(15)) + .Do(LogServiceInfo) + .Select(x => new Version(x.Version)) + .Catch((Exception ex) => + { + log.Error("Exception trying to obtain service version: {Message}", ex switch + { + FlurlHttpException flex => flex.SanitizedExceptionMessage(), + _ => ex.Message + }); + + return Observable.Return(new Version()); + }) + .Replay(1) + .AutoConnect(); + } + + public IObservable Version { get; } + + private void LogServiceInfo(SystemStatus status) + { + _log.Debug("{Service} Version: {Version}", status.AppName, status.Version); + } +} diff --git a/src/Recyclarr.TrashLib/Services/System/SystemServiceAutofacModule.cs b/src/Recyclarr.TrashLib/Services/System/SystemServiceAutofacModule.cs index 1f75b3df..a7760526 100644 --- a/src/Recyclarr.TrashLib/Services/System/SystemServiceAutofacModule.cs +++ b/src/Recyclarr.TrashLib/Services/System/SystemServiceAutofacModule.cs @@ -8,5 +8,7 @@ public class SystemServiceAutofacModule : Module { base.Load(builder); builder.RegisterType().As(); + builder.RegisterType().As() + .InstancePerLifetimeScope(); } }