refactor: Overhaul config validation logic

- Validation of custom formats in Sonarr v4 is now performed
pull/201/head
Robert Dailey 1 year ago
parent 6997f4ca6a
commit 350fd21358

@ -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<IServiceCommand>();
builder.RegisterMockFor<IGitRepository>();
builder.RegisterMockFor<IGitRepositoryFactory>();
builder.RegisterMockFor<IServiceConfiguration>();
builder.RegisterMockFor<IServiceInformation>(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);

@ -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<ILifetimeScope> Instantiate);
public static class FactoryForService<TService>
{
public static ServiceFactoryWrapper WithArgs<TP1>(TP1 arg1 = default!)
{
return new ServiceFactoryWrapper(typeof(TService),
c => c.Resolve<Func<TP1, TService>>().Invoke(arg1));
}
}
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CompositionRootTest : IntegrationFixture
public class CompositionRootTest
{
private static readonly List<ServiceFactoryWrapper> FactoryTests = new()
{
FactoryForService<IGitRepository>.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<TypedService>()
.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<IFileSystem>();
builder.RegisterInstance(new AppPaths(fs.CurrentDirectory())).As<IAppPaths>();
builder.RegisterInstance(Substitute.For<IConsole>());
builder.RegisterInstance(Substitute.For<ILogger>());
builder.RegisterInstance(Substitute.For<IServiceCommand>());
builder.RegisterInstance(Substitute.For<IServiceConfiguration>());
}
[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();
}
}

@ -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<ConfigValidationExecutor>();
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<ConfigValidationExecutor>();
var config = new TestConfig {ApiKey = "good", BaseUrl = "good"}; // Use good data
var result = sut.Validate(config);
result.Should().BeTrue();
}
}

@ -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<IConfigurationLoader<SonarrConfiguration>>();
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<TestConfig> validator,
// ConfigurationLoader<TestConfig> configLoader)
{
var validator = Resolve<IValidator<TestConfig>>();
var sut = Resolve<ConfigurationLoader<TestConfig>>();
// force the validator to return a validation error
validator.Validate(Arg.Any<TestConfig>()).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<ConfigurationLoader<TestConfig>>();
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]

@ -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<RadarrCompatibility>();
await compatibility.Capabilities.LastAsync();
var validator = scope.Resolve<ConfigValidationExecutor>();
if (!validator.Validate(config))
{
log.Error("Due to validation failure, this instance will be skipped");
continue;
}
// ReSharper disable InvertIf

@ -107,8 +107,12 @@ Processing {serverName} Server: [{instanceName}]
log.Debug("Processing {Server} server {Name}", serverName, instanceName);
var versionEnforcement = scope.Resolve<ISonarrVersionEnforcement>();
await versionEnforcement.DoVersionEnforcement(config);
var validator = scope.Resolve<ConfigValidationExecutor>();
if (!validator.Validate(config))
{
log.Error("Due to validation failure, this instance will be skipped");
continue;
}
// ReSharper disable InvertIf

@ -92,6 +92,7 @@ public static class CompositionRoot
builder.RegisterType<DefaultObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();
builder.RegisterType<ConfigValidationExecutor>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>))
.WithProperty(new AutowiringParameter())

@ -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<ServiceConfiguration> _validator;
public ConfigValidationExecutor(
ILogger log,
IValidator<ServiceConfiguration> validator)
{
_log = log;
_validator = validator;
}
public bool Validate<T>(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;
}
}

@ -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<T> : IConfigurationLoader<T>
private readonly ILogger _log;
private readonly IDeserializer _deserializer;
private readonly IFileSystem _fs;
private readonly IValidator<T> _validator;
public ConfigurationLoader(
ILogger log,
IFileSystem fs,
IYamlSerializerFactory yamlFactory,
IValidator<T> validator)
IYamlSerializerFactory yamlFactory)
{
_log = log;
_fs = fs;
_validator = validator;
_deserializer = yamlFactory.CreateDeserializer();
}
@ -159,16 +154,7 @@ public class ConfigurationLoader<T> : IConfigurationLoader<T>
var newConfig = _deserializer.Deserialize<T>(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);
}
}

@ -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<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
{
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
: base(validator, validatorType)
{
}
public override bool IsValid(ValidationContext<T> context, TProperty? value)
{
return base.IsValid(context, value!);
}
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
CancellationToken cancellation)
{
return base.IsValidAsync(context, value!, cancellation);
}
}
public static IEnumerable<TSource> IsValid<TSource, TValidator>(
this IEnumerable<TSource> source, TValidator validator,
Action<List<ValidationFailure>, TSource>? handleInvalid = null)

@ -9,4 +9,11 @@ public static class AutofacTestExtensions
{
builder.RegisterInstance(Substitute.For<T>()).As<T>();
}
public static void RegisterMockFor<T>(this ContainerBuilder builder, Action<T> mockSetup) where T : class
{
var mock = Substitute.For<T>();
mockSetup(mock);
builder.RegisterInstance(mock).As<T>();
}
}

@ -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<CustomFormatConfig>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
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<CustomFormatConfig>
{
new()
{
TrashIds = new[] {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
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<CustomFormatConfig>
{
new()
{
TrashIds = new[] {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
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<CustomFormatConfig>
{
new()
{
TrashIds = Array.Empty<string>(),
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
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<CustomFormatConfig>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = ""
}
};
var validator = Resolve<ServiceConfigurationValidator>();
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<CustomFormatConfig>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = ""
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(FirstCf +
$"{nameof(CustomFormatConfig.QualityProfiles)}[0].{nameof(QualityProfileScoreConfig.Name)}");
}
}

@ -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<IValidator<ServiceConfiguration>>();
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<CustomFormatConfig>
{
new() // Empty to force validation failure
}
};
var validator = Container.Resolve<IValidator<ServiceConfiguration>>();
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);
}
}

@ -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<CustomFormatConfig>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
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<ReleaseProfileConfig>
{
new()
{
TrashIds = new List<string> {"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<ReleaseProfileConfig> {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<CustomFormatConfig> {new()},
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = Array.Empty<string>(),
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)}");
}
}

@ -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<CustomFormatConfig>
{
new() {TrashIds = new Collection<string> {"trash_id"}}
}
};
var validator = Container.Resolve<IValidator<RadarrConfiguration>>();
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<IValidator<RadarrConfiguration>>();
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<CustomFormatConfig>
{
new()
{
TrashIds = new List<string> {"required value"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new() {Name = "required value"}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "movie"
}
};
var validator = Container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}

@ -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<ISonarrCompatibility>();
static SonarrCapabilities Compat() => new();
var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), compat, ctx.Mapper);
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), 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<ISonarrCompatibility>();
static SonarrCapabilities Compat() => new();
var dataV2 = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), compat, ctx.Mapper);
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), 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<ISonarrCompatibility>();
compat.Capabilities.Returns(new[]
{
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = false}
}.ToObservable());
static SonarrCapabilities Compat() => new() {ArraysNeededForReleaseProfileRequiredAndIgnored = false};
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), compat, ctx.Mapper);
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), 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<ISonarrCompatibility>();
compat.Capabilities.Returns(new[]
{
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = true}
}.ToObservable());
static SonarrCapabilities Compat() => new() {ArraysNeededForReleaseProfileRequiredAndIgnored = true};
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), compat, ctx.Mapper);
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), 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<ReleaseProfileConfig> {new()}
};
var act = () => enforcement.DoVersionEnforcement(config);
await act.Should().ThrowAsync<VersionException>().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<ReleaseProfileConfig> {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<CustomFormatConfig> {new()}
};
var act = () => enforcement.DoVersionEnforcement(config);
await act.Should().ThrowAsync<VersionException>().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<CustomFormatConfig> {new()}
};
var act = () => enforcement.DoVersionEnforcement(config);
await act.Should().NotThrowAsync();
}
}

@ -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<IValidator<SonarrConfiguration>>();
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<ReleaseProfileConfig>
{
new() {TrashIds = new[] {"123"}}
}
};
var validator = Container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}

@ -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<SettingsProvider>().As<ISettingsProvider>().SingleInstance();
builder.RegisterType<SecretsProvider>().As<ISecretsProvider>().SingleInstance();
builder.RegisterType<YamlSerializerFactory>().As<IYamlSerializerFactory>();
builder.RegisterType<ServiceValidationMessages>().As<IServiceValidationMessages>();
}
}

@ -1,8 +0,0 @@
namespace Recyclarr.TrashLib.Config.Services;
public interface IServiceValidationMessages
{
string BaseUrl { get; }
string ApiKey { get; }
string CustomFormatTrashIds { get; }
}

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

@ -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<ServiceConfiguration>
{
public ServiceConfigurationValidator(
IServiceValidationMessages messages,
IValidator<CustomFormatConfig> customFormatConfigValidator)
IValidator<SonarrConfiguration> sonarrValidator,
IValidator<RadarrConfiguration> 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<CustomFormatConfig>
{
public CustomFormatConfigValidator(
IServiceValidationMessages messages,
IValidator<QualityProfileScoreConfig> 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<QualityProfileScoreConfig>
{
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<QualityDefinitionConfig>
{
public QualityDefinitionConfigValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage(messages.QualityProfileName);
RuleFor(x => x.Type).NotEmpty().WithMessage("'type' is required for 'quality_definition'");
}
}

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

@ -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<T> where T : class
{
private readonly IObservable<T> _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);
}

@ -1,7 +0,0 @@
namespace Recyclarr.TrashLib.Services.Radarr.Config;
public interface IRadarrValidationMessages
{
string QualityProfileName { get; }
string QualityDefinitionType { get; }
}

@ -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<RadarrConfiguration>
{
public RadarrConfigurationValidator(
IValidator<ServiceConfiguration> serviceConfigValidator,
IValidator<QualityDefinitionConfig> qualityDefinitionConfigValidator)
{
Include(serviceConfigValidator);
RuleFor(x => x.QualityDefinition).SetNonNullableValidator(qualityDefinitionConfigValidator);
}
}
[UsedImplicitly]
internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig>
{
public QualityDefinitionConfigValidator(IRadarrValidationMessages messages)
{
RuleFor(x => x.Type).NotEmpty().WithMessage(messages.QualityDefinitionType);
}
}

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

@ -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<QualityDefinitionService>().As<IQualityDefinitionService>();
builder.RegisterType<RadarrGuideDataLister>().As<IRadarrGuideDataLister>();
builder.RegisterType<RadarrValidationMessages>().As<IRadarrValidationMessages>();
builder.RegisterType<QualitySizeUpdater>().As<IQualitySizeUpdater>();
builder.RegisterType<LocalRepoRadarrGuideService>().As<IRadarrGuideService>();
builder.RegisterType<RadarrGuideDataLister>().As<IRadarrGuideDataLister>();
builder.RegisterType<RadarrCompatibility>();
builder.RegisterType<RadarrCompatibility>().InstancePerLifetimeScope();
builder.Register(c => c.Resolve<RadarrCompatibility>().Capabilities);
}
}

@ -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<RadarrCapabilities>
{
public RadarrCompatibility(ISystemApiService api, ILogger log)
: base(api, log)
public RadarrCompatibility(IServiceInformation compatibility)
: base(compatibility)
{
}

@ -5,6 +5,6 @@ namespace Recyclarr.TrashLib.Services.Sonarr.Api;
public interface ISonarrReleaseProfileCompatibilityHandler
{
Task<object> CompatibleReleaseProfileForSendingAsync(SonarrReleaseProfile profile);
object CompatibleReleaseProfileForSending(SonarrReleaseProfile profile);
SonarrReleaseProfile CompatibleReleaseProfileForReceiving(JObject profile);
}

@ -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<SonarrReleaseProfile> CreateReleaseProfile(SonarrReleaseProfile profile)
{
var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile);
var profileToSend = _profileHandler.CompatibleReleaseProfileForSending(profile);
var response = await _service.Request("releaseprofile")
.PostJsonAsync(profileToSend)

@ -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<SonarrCapabilities> _capabilitiesFactory;
private readonly IMapper _mapper;
public SonarrReleaseProfileCompatibilityHandler(
ILogger log,
ISonarrCompatibility compatibility,
Func<SonarrCapabilities> capabilitiesFactory,
IMapper mapper)
{
_log = log;
_compatibility = compatibility;
_capabilitiesFactory = capabilitiesFactory;
_mapper = mapper;
}
public async Task<object> CompatibleReleaseProfileForSendingAsync(SonarrReleaseProfile profile)
public object CompatibleReleaseProfileForSending(SonarrReleaseProfile profile)
{
var capabilities = await _compatibility.Capabilities.LastAsync();
var capabilities = _capabilitiesFactory();
return capabilities.ArraysNeededForReleaseProfileRequiredAndIgnored
? profile
: _mapper.Map<SonarrReleaseProfileV1>(profile);

@ -1,6 +0,0 @@
namespace Recyclarr.TrashLib.Services.Sonarr.Config;
public interface ISonarrValidationMessages
{
string ReleaseProfileTrashIds { get; }
}

@ -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<SonarrConfiguration>
public class SonarrConfigurationValidator : AbstractValidator<SonarrConfiguration>
{
public SonarrConfigurationValidator(
ISonarrValidationMessages messages,
IValidator<ServiceConfiguration> serviceConfigValidator,
IValidator<ReleaseProfileConfig> 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<ReleaseProfileConfig>
{
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<SonarrProfileFilterConfig>
{
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.");
}
}

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

@ -2,6 +2,5 @@ namespace Recyclarr.TrashLib.Services.Sonarr;
public interface ISonarrCompatibility
{
IObservable<SonarrCapabilities> Capabilities { get; }
Version MinimumVersion { get; }
SonarrCapabilities Capabilities { get; }
}

@ -1,8 +0,0 @@
using Recyclarr.TrashLib.Services.Sonarr.Config;
namespace Recyclarr.TrashLib.Services.Sonarr;
public interface ISonarrVersionEnforcement
{
Task DoVersionEnforcement(SonarrConfiguration config);
}

@ -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<SonarrApi>().As<ISonarrApi>();
builder.RegisterType<ReleaseProfileApiService>().As<IReleaseProfileApiService>();
builder.RegisterType<SonarrValidationMessages>().As<ISonarrValidationMessages>();
builder.RegisterType<SonarrCompatibility>().As<ISonarrCompatibility>().InstancePerLifetimeScope();
builder.RegisterType<SonarrVersionEnforcement>().As<ISonarrVersionEnforcement>();
builder.RegisterType<SonarrCompatibility>().As<ISonarrCompatibility>()
.InstancePerLifetimeScope();
builder.Register<SonarrCapabilities>(c => c.Resolve<ISonarrCompatibility>().Capabilities);
builder.RegisterType<SonarrGuideDataLister>().As<ISonarrGuideDataLister>();
// Release Profile Support

@ -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

@ -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<SonarrCapabilities>, 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"),

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

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Services.System;
public interface IServiceInformation
{
IObservable<Version> Version { get; }
}

@ -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<T> 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<T> Capabilities { get; }
private void LogServiceInfo(SystemStatus status)
{
_log.Debug("{Service} Version: {Version}", status.AppName, status.Version);
}
protected abstract T BuildCapabilitiesObject(Version version);
}

@ -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> Version { get; }
private void LogServiceInfo(SystemStatus status)
{
_log.Debug("{Service} Version: {Version}", status.AppName, status.Version);
}
}

@ -8,5 +8,7 @@ public class SystemServiceAutofacModule : Module
{
base.Load(builder);
builder.RegisterType<SystemApiService>().As<ISystemApiService>();
builder.RegisterType<ServiceInformation>().As<IServiceInformation>()
.InstancePerLifetimeScope();
}
}

Loading…
Cancel
Save