refactor: YAML configuration relies less on custom parsing code

This change was necessary to facilitate the ability to serialize
(create/modify) YAML configuration data. This is a prerequisite to
creating config templates and also GUI work in the future.
pull/201/head
Robert Dailey 1 year ago
parent 81bbc50ef7
commit 5c98949edc

@ -61,7 +61,7 @@ public abstract class IntegrationFixture : IDisposable
private static ILogger CreateLogger()
{
return new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Debug)
.MinimumLevel.Is(LogEventLevel.Verbose)
.WriteTo.TestCorrelator()
.WriteTo.Console()
.CreateLogger();

@ -11,9 +11,11 @@ public class CacheStoragePathTest
[Test, AutoMockData]
public void Use_guid_when_no_name(CacheStoragePath sut)
{
var config = Substitute.ForPartsOf<ServiceConfiguration>();
config.BaseUrl = new Uri("http://something");
config.InstanceName = null;
var config = new SonarrConfiguration
{
BaseUrl = new Uri("http://something"),
InstanceName = null
};
var result = sut.CalculatePath(config, "obj");
@ -23,9 +25,11 @@ public class CacheStoragePathTest
[Test, AutoMockData]
public void Use_name_when_not_null(CacheStoragePath sut)
{
var config = Substitute.ForPartsOf<ServiceConfiguration>();
config.BaseUrl = new Uri("http://something");
config.InstanceName = "thename";
var config = new SonarrConfiguration
{
BaseUrl = new Uri("http://something"),
InstanceName = "thename"
};
var result = sut.CalculatePath(config, "obj");

@ -9,7 +9,6 @@ using Recyclarr.Cli.Console.Setup;
using Recyclarr.Cli.Logging;
using Recyclarr.Cli.Migration;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.ApiServices;
using Recyclarr.TrashLib.Cache;
using Recyclarr.TrashLib.Compatibility;
@ -33,9 +32,13 @@ public static class CompositionRoot
{
public static void Setup(ContainerBuilder builder)
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(x => x.FullName?.StartsWithIgnoreCase("Recyclarr") ?? false)
.ToArray();
var assemblies = new[]
{
Assembly.GetExecutingAssembly(),
Assembly.Load("Recyclarr.Common"),
Assembly.Load("Recyclarr.Config.Data"),
Assembly.Load("Recyclarr.TrashLib")
};
RegisterAppPaths(builder);
RegisterLogger(builder);
@ -59,11 +62,7 @@ public static class CompositionRoot
CommandRegistrations(builder);
PipelineRegistrations(builder);
builder.RegisterAutoMapper(c =>
{
c.AddCollectionMappers();
},
false, assemblies);
builder.RegisterAutoMapper(c => c.AddCollectionMappers(), false, assemblies);
builder.RegisterType<FlurlClientFactory>().As<IFlurlClientFactory>().SingleInstance();
}

@ -19,8 +19,6 @@ public class JsonUtilsTest
.WriteTo.TestCorrelator()
.CreateLogger();
using var context = TestCorrelator.CreateContext();
var path = fs.CurrentDirectory().SubDirectory("doesnt_exist");
var result = JsonUtils.GetJsonFilesInDirectories(new[] {path}, log);

@ -1,5 +1,6 @@
using System.Reflection;
using Autofac;
using Recyclarr.Common.FluentValidation;
using Module = Autofac.Module;
namespace Recyclarr.Common;
@ -17,6 +18,7 @@ public class CommonAutofacModule : Module
{
builder.RegisterType<DefaultEnvironment>().As<IEnvironment>();
builder.RegisterType<FileUtilities>().As<IFileUtilities>();
builder.RegisterType<RuntimeValidationService>();
builder.Register(_ => new ResourceDataReader(_rootAssembly))
.As<IResourceDataReader>();

@ -68,9 +68,9 @@ public static class CollectionExtensions
return collection is null or {Count: 0};
}
public static bool IsNotEmpty<T>(this ICollection<T>? collection)
public static bool IsNotEmpty<T>(this IEnumerable<T>? collection)
{
return collection is {Count: > 0};
return collection is not null && collection.Any();
}
public static IList<T>? ToListOrNull<T>(this IEnumerable<T> source)

@ -0,0 +1,20 @@
using FluentValidation;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Common.FluentValidation;
public abstract class CustomValidator<T> : AbstractValidator<T>
{
public bool OnlyOneHasElements<TC1, TC2>(
IEnumerable<TC1>? c1,
IEnumerable<TC2>? c2)
{
var notEmpty = new[]
{
c1.IsNotEmpty(),
c2.IsNotEmpty()
};
return notEmpty.Count(x => x) <= 1;
}
}

@ -0,0 +1,34 @@
using FluentValidation;
using FluentValidation.Results;
namespace Recyclarr.Common.FluentValidation;
public class RuntimeValidationService
{
private readonly Dictionary<Type, IValidator> _validators;
private static Type? GetValidatorInterface(Type type)
{
return type.GetInterfaces()
.FirstOrDefault(i
=> i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>));
}
public RuntimeValidationService(IEnumerable<IValidator> validators)
{
_validators = validators
.Select(x => (x, GetValidatorInterface(x.GetType())))
.Where(x => x.Item2 is not null)
.ToDictionary(x => x.Item2!.GetGenericArguments()[0], x => x.Item1);
}
public ValidationResult Validate(object instance)
{
if (!_validators.TryGetValue(instance.GetType(), out var validator))
{
throw new ValidationException($"No validator is available for type: {instance.GetType().FullName}");
}
return validator.Validate(new ValidationContext<object>(instance));
}
}

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Config.Data\Recyclarr.Config.Data.csproj" />
<ProjectReference Include="..\Recyclarr.TrashLib\Recyclarr.TrashLib.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Recyclarr.TrashLib\Config\Parsing\ConfigYamlDataObjectsLatest.cs">
<Link>ConfigYamlDataObjectsLatest.cs</Link>
</Compile>
</ItemGroup>
</Project>

@ -0,0 +1,84 @@
using FluentValidation.TestHelper;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Config.Data.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigYamlValidatorTest
{
[Test]
public void Validation_failure_when_rps_and_cfs_used_together()
{
var config = new SonarrConfigYamlLatest
{
ReleaseProfiles = new[] {new ReleaseProfileConfigYamlLatest()},
CustomFormats = new[] {new CustomFormatConfigYamlLatest()}
};
var validator = new SonarrConfigYamlValidatorLatest();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x)
.WithErrorMessage("`custom_formats` and `release_profiles` may not be used together");
}
[Test]
public void Sonarr_release_profile_failures()
{
var config = new ReleaseProfileConfigYamlLatest
{
TrashIds = Array.Empty<string>(),
Filter = new ReleaseProfileFilterConfigYamlLatest
{
Include = new[] {"include"},
Exclude = new[] {"exclude"}
}
};
var validator = new ReleaseProfileConfigYamlValidatorLatest();
var result = validator.TestValidate(config);
result.Errors.Should().HaveCount(2);
// Release profile trash IDs cannot be empty
result.ShouldHaveValidationErrorFor(x => x.TrashIds);
// Cannot use include + exclude filters together
result.ShouldHaveValidationErrorFor(nameof(ReleaseProfileConfig.Filter));
}
[Test]
public void Filter_include_can_not_be_empty()
{
var config = new ReleaseProfileFilterConfigYamlLatest
{
Include = Array.Empty<string>(),
Exclude = new[] {"exclude"}
};
var validator = new ReleaseProfileFilterConfigYamlValidatorLatest();
var result = validator.TestValidate(config);
result.Errors.Should().HaveCount(1);
result.ShouldHaveValidationErrorFor(x => x.Include);
}
[Test]
public void Filter_exclude_can_not_be_empty()
{
var config = new ReleaseProfileFilterConfigYamlLatest
{
Exclude = Array.Empty<string>(),
Include = new[] {"exclude"}
};
var validator = new ReleaseProfileFilterConfigYamlValidatorLatest();
var result = validator.TestValidate(config);
result.Errors.Should().HaveCount(1);
result.ShouldHaveValidationErrorFor(x => x.Exclude);
}
}

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AutoMapper" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="JetBrains.Annotations" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
</ItemGroup>
</Project>

@ -0,0 +1,51 @@
using AutoMapper;
using JetBrains.Annotations;
namespace Recyclarr.Config.Data.V1;
[UsedImplicitly]
public class ConfigMapperProfileV1ToV2 : Profile
{
private static int _instanceNameCounter = 1;
private static string BuildInstanceName()
{
return $"instance{_instanceNameCounter++}";
}
private sealed class ListToMapConverter<TOld, TNew>
: IValueConverter<IReadOnlyCollection<TOld>, IReadOnlyDictionary<string, TNew>>
{
public IReadOnlyDictionary<string, TNew> Convert(
IReadOnlyCollection<TOld>? sourceMember,
ResolutionContext context)
{
return sourceMember?.ToDictionary(_ => BuildInstanceName(), y => context.Mapper.Map<TNew>(y)) ??
new Dictionary<string, TNew>();
}
}
public ConfigMapperProfileV1ToV2()
{
CreateMap<QualityScoreConfigYaml, V2.QualityScoreConfigYaml>();
CreateMap<CustomFormatConfigYaml, V2.CustomFormatConfigYaml>();
CreateMap<QualitySizeConfigYaml, V2.QualitySizeConfigYaml>();
CreateMap<QualityProfileConfigYaml, V2.QualityProfileConfigYaml>();
CreateMap<ServiceConfigYaml, V2.ServiceConfigYaml>();
CreateMap<ReleaseProfileFilterConfigYaml, V2.ReleaseProfileFilterConfigYaml>();
CreateMap<ReleaseProfileConfigYaml, V2.ReleaseProfileConfigYaml>();
CreateMap<RadarrConfigYaml, V2.RadarrConfigYaml>();
CreateMap<SonarrConfigYaml, V2.SonarrConfigYaml>();
// Backward Compatibility: Convert list-based instances to mapping-based ones.
// The key is auto-generated.
CreateMap<RootConfigYaml, V2.RootConfigYaml>()
.ForMember(x => x.Radarr, o => o
.ConvertUsing(new ListToMapConverter<RadarrConfigYaml, V2.RadarrConfigYaml>()))
.ForMember(x => x.Sonarr, o => o
.ConvertUsing(new ListToMapConverter<SonarrConfigYaml, V2.SonarrConfigYaml>()))
.ForMember(x => x.RadarrValues, o => o.Ignore())
.ForMember(x => x.SonarrValues, o => o.Ignore())
;
}
}

@ -0,0 +1,70 @@
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
namespace Recyclarr.Config.Data.V1;
public record QualityScoreConfigYaml
{
public string? Name { get; [UsedImplicitly] init; }
public int? Score { get; [UsedImplicitly] init; }
public bool? ResetUnmatchedScores { get; [UsedImplicitly] init; }
}
public record CustomFormatConfigYaml
{
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
public IReadOnlyCollection<QualityScoreConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
}
public record QualitySizeConfigYaml
{
public string? Type { get; [UsedImplicitly] init; }
public decimal? PreferredRatio { get; [UsedImplicitly] init; }
}
public record QualityProfileConfigYaml
{
public string? Name { get; [UsedImplicitly] init; }
public bool ResetUnmatchedScores { get; [UsedImplicitly] init; }
}
public record ServiceConfigYaml
{
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings")]
public string? BaseUrl { get; [UsedImplicitly] init; }
public string? ApiKey { get; [UsedImplicitly] init; }
public bool DeleteOldCustomFormats { get; [UsedImplicitly] init; }
public bool ReplaceExistingCustomFormats { get; [UsedImplicitly] init; } = true;
public IReadOnlyCollection<CustomFormatConfigYaml>? CustomFormats { get; [UsedImplicitly] init; }
public QualitySizeConfigYaml? QualityDefinition { get; [UsedImplicitly] init; }
public IReadOnlyCollection<QualityProfileConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
}
public record ReleaseProfileFilterConfigYaml
{
public IReadOnlyCollection<string>? Include { get; [UsedImplicitly] init; }
public IReadOnlyCollection<string>? Exclude { get; [UsedImplicitly] init; }
}
public record ReleaseProfileConfigYaml
{
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
public bool StrictNegativeScores { get; [UsedImplicitly] init; }
public IReadOnlyCollection<string>? Tags { get; [UsedImplicitly] init; }
public ReleaseProfileFilterConfigYaml? Filter { get; [UsedImplicitly] init; }
}
// This is usually empty (or the same as ServiceConfigYaml) on purpose.
// If empty, it is kept around to make it clear that this one is dedicated to Radarr.
public record RadarrConfigYaml : ServiceConfigYaml;
public record SonarrConfigYaml : ServiceConfigYaml
{
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; [UsedImplicitly] init; }
}
public record RootConfigYaml
{
public IReadOnlyCollection<RadarrConfigYaml>? Radarr { get; [UsedImplicitly] init; }
public IReadOnlyCollection<SonarrConfigYaml>? Sonarr { get; [UsedImplicitly] init; }
}

@ -0,0 +1,20 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Config.Data.V1;
[UsedImplicitly]
public class RootConfigYamlValidator : CustomValidator<RootConfigYaml>
{
public RootConfigYamlValidator()
{
RuleFor(x => x).Must(x => x.Radarr.IsEmpty() && x.Sonarr.IsEmpty())
.WithSeverity(Severity.Warning)
.WithMessage(
"Found array-style list of instances instead of named-style. " +
"Array-style lists of Sonarr/Radarr instances are deprecated. " +
"See: https://recyclarr.dev/wiki/upgrade-guide/v5.0#instances-must-now-be-named");
}
}

@ -0,0 +1,84 @@
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using YamlDotNet.Serialization;
namespace Recyclarr.Config.Data.V2;
public record QualityScoreConfigYaml
{
public string? Name { get; [UsedImplicitly] init; }
public int? Score { get; [UsedImplicitly] init; }
public bool? ResetUnmatchedScores { get; [UsedImplicitly] init; }
}
public record CustomFormatConfigYaml
{
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
public IReadOnlyCollection<QualityScoreConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
}
public record QualitySizeConfigYaml
{
public string? Type { get; [UsedImplicitly] init; }
public decimal? PreferredRatio { get; [UsedImplicitly] init; }
}
public record QualityProfileConfigYaml
{
public string? Name { get; [UsedImplicitly] init; }
public bool ResetUnmatchedScores { get; [UsedImplicitly] init; }
}
public record ServiceConfigYaml
{
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings")]
public string? BaseUrl { get; [UsedImplicitly] init; }
public string? ApiKey { get; [UsedImplicitly] init; }
public bool DeleteOldCustomFormats { get; [UsedImplicitly] init; }
// todo: In v5.0, this will change to false.
public bool ReplaceExistingCustomFormats { get; [UsedImplicitly] init; } = true;
public IReadOnlyCollection<CustomFormatConfigYaml>? CustomFormats { get; [UsedImplicitly] init; }
public QualitySizeConfigYaml? QualityDefinition { get; [UsedImplicitly] init; }
public IReadOnlyCollection<QualityProfileConfigYaml>? QualityProfiles { get; [UsedImplicitly] init; }
}
public record ReleaseProfileFilterConfigYaml
{
public IReadOnlyCollection<string>? Include { get; [UsedImplicitly] init; }
public IReadOnlyCollection<string>? Exclude { get; [UsedImplicitly] init; }
}
public record ReleaseProfileConfigYaml
{
public IReadOnlyCollection<string>? TrashIds { get; [UsedImplicitly] init; }
public bool StrictNegativeScores { get; [UsedImplicitly] init; }
public IReadOnlyCollection<string>? Tags { get; [UsedImplicitly] init; }
public ReleaseProfileFilterConfigYaml? Filter { get; [UsedImplicitly] init; }
}
// This is usually empty (or the same as ServiceConfigYaml) on purpose.
// If empty, it is kept around to make it clear that this one is dedicated to Radarr.
public record RadarrConfigYaml : ServiceConfigYaml;
public record SonarrConfigYaml : ServiceConfigYaml
{
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; [UsedImplicitly] init; }
}
public record RootConfigYaml
{
public IReadOnlyDictionary<string, RadarrConfigYaml>? Radarr { get; [UsedImplicitly] init; }
public IReadOnlyDictionary<string, SonarrConfigYaml>? Sonarr { get; [UsedImplicitly] init; }
// This exists for validation purposes only.
[YamlIgnore]
public IEnumerable<RadarrConfigYaml> RadarrValues
=> Radarr?.Select(x => x.Value) ?? Array.Empty<RadarrConfigYaml>();
// This exists for validation purposes only.
[YamlIgnore]
public IEnumerable<SonarrConfigYaml> SonarrValues
=> Sonarr?.Select(x => x.Value) ?? Array.Empty<SonarrConfigYaml>();
}

@ -0,0 +1,161 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Config.Data.V2;
[UsedImplicitly]
public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml>
{
public ServiceConfigYamlValidator()
{
RuleFor(x => x.BaseUrl).NotEmpty().NotNull()
.WithMessage("'base_url' is required and must not be empty");
RuleFor(x => x.BaseUrl).NotEmpty().Must(x => x is not null && x.StartsWith("http"))
.WithMessage("'base_url' must start with 'http' or 'https'");
RuleFor(x => x.ApiKey).NotEmpty()
.WithMessage("'api_key' is required");
RuleFor(x => x.CustomFormats).NotEmpty()
.When(x => x.CustomFormats is not null)
.WithName("custom_formats")
.ForEach(x => x.SetValidator(new CustomFormatConfigYamlValidator()));
RuleFor(x => x.QualityDefinition)
.SetNonNullableValidator(new QualitySizeConfigYamlValidator());
RuleFor(x => x.QualityProfiles).NotEmpty()
.When(x => x.QualityProfiles != null)
.WithName("quality_profiles")
.ForEach(x => x.SetValidator(new QualityProfileConfigYamlValidator()));
}
}
[UsedImplicitly]
public class CustomFormatConfigYamlValidator : AbstractValidator<CustomFormatConfigYaml>
{
public CustomFormatConfigYamlValidator()
{
RuleFor(x => x.TrashIds).NotEmpty()
.When(x => x.TrashIds is not null)
.WithName("trash_ids")
.ForEach(x => x.Length(32).Matches(@"^[0-9a-fA-F]+$"));
RuleForEach(x => x.QualityProfiles).NotEmpty()
.When(x => x.QualityProfiles is not null)
.WithName("quality_profiles")
.SetValidator(new QualityScoreConfigYamlValidator());
}
}
[UsedImplicitly]
public class QualityScoreConfigYamlValidator : AbstractValidator<QualityScoreConfigYaml>
{
public QualityScoreConfigYamlValidator()
{
RuleFor(x => x.Name).NotEmpty()
.WithMessage("'name' is required for elements under 'quality_profiles'");
RuleFor(x => x.ResetUnmatchedScores).Null()
.WithSeverity(Severity.Warning)
.WithMessage(
"Usage of 'reset_unmatched_scores' inside 'quality_profiles' under 'custom_formats' is deprecated. " +
"Use the root-level 'quality_profiles' instead. " +
"See: https://recyclarr.dev/wiki/upgrade-guide/v5.0#reset-unmatched-scores");
}
}
[UsedImplicitly]
public class QualitySizeConfigYamlValidator : AbstractValidator<QualitySizeConfigYaml>
{
public QualitySizeConfigYamlValidator()
{
RuleFor(x => x.Type).NotEmpty()
.WithMessage("'type' is required for 'quality_definition'");
RuleFor(x => x.PreferredRatio).InclusiveBetween(0, 1)
.When(x => x.PreferredRatio is not null)
.WithName("preferred_ratio");
}
}
[UsedImplicitly]
public class QualityProfileConfigYamlValidator : AbstractValidator<QualityProfileConfigYaml>
{
public QualityProfileConfigYamlValidator()
{
RuleFor(x => x.Name).NotEmpty()
.WithMessage("'name' is required for root-level 'quality_profiles' elements");
}
}
[UsedImplicitly]
public class RadarrConfigYamlValidator : CustomValidator<RadarrConfigYaml>
{
public RadarrConfigYamlValidator()
{
Include(new ServiceConfigYamlValidator());
}
}
[UsedImplicitly]
public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
{
public SonarrConfigYamlValidator()
{
Include(new ServiceConfigYamlValidator());
RuleFor(x => x)
.Must(x => OnlyOneHasElements(x.ReleaseProfiles, x.CustomFormats))
.WithMessage("`custom_formats` and `release_profiles` may not be used together");
RuleForEach(x => x.ReleaseProfiles).SetValidator(new ReleaseProfileConfigYamlValidator());
}
}
[UsedImplicitly]
public class ReleaseProfileConfigYamlValidator : CustomValidator<ReleaseProfileConfigYaml>
{
public ReleaseProfileConfigYamlValidator()
{
RuleFor(x => x.TrashIds).NotEmpty()
.WithMessage("'trash_ids' is required for 'release_profiles' elements");
RuleFor(x => x.Filter)
.SetNonNullableValidator(new ReleaseProfileFilterConfigYamlValidator());
}
}
[UsedImplicitly]
public class ReleaseProfileFilterConfigYamlValidator : CustomValidator<ReleaseProfileFilterConfigYaml>
{
public ReleaseProfileFilterConfigYamlValidator()
{
// Include & Exclude may not be used together
RuleFor(x => x)
.Must(x => OnlyOneHasElements(x.Include, x.Exclude))
.WithMessage("'include' and 'exclude' may not be used together")
.DependentRules(() =>
{
RuleFor(x => x.Include).NotEmpty()
.When(x => x.Include is not null)
.WithMessage("'include' under 'filter' must have at least 1 Trash ID");
RuleFor(x => x.Exclude).NotEmpty()
.When(x => x.Exclude is not null)
.WithMessage("'exclude' under 'filter' must have at least 1 Trash ID");
});
}
}
[UsedImplicitly]
public class RootConfigYamlValidator : CustomValidator<RootConfigYaml>
{
public RootConfigYamlValidator()
{
RuleForEach(x => x.RadarrValues).SetValidator(new RadarrConfigYamlValidator());
RuleForEach(x => x.SonarrValues).SetValidator(new SonarrConfigYamlValidator());
}
}

@ -9,15 +9,18 @@ public static class NewQp
string profileName,
params (int FormatId, int Score)[] scores)
{
return Processed(profileName, false, scores);
return Processed(profileName, null, scores);
}
public static ProcessedQualityProfileData Processed(
string profileName,
bool resetUnmatchedScores,
bool? resetUnmatchedScores,
params (int FormatId, int Score)[] scores)
{
return new ProcessedQualityProfileData(new QualityProfileConfig(profileName, resetUnmatchedScores))
return new ProcessedQualityProfileData(new QualityProfileConfig
{
Name = profileName, ResetUnmatchedScores = resetUnmatchedScores
})
{
CfScores = scores.ToDictionary(x => x.FormatId, x => x.Score)
};

@ -1,9 +0,0 @@
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.TestLibrary;
public class TestConfig : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Sonarr;
}

@ -0,0 +1,106 @@
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Parsing;
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class BackwardCompatibleConfigParserTest : IntegrationFixture
{
[Test]
public void Load_v1_into_latest()
{
var sut = Resolve<BackwardCompatibleConfigParser>();
var yaml = @"
sonarr:
- api_key: key1
base_url: url1
- api_key: key2
base_url: url2
radarr:
- api_key: key3
base_url: url3
";
var result = sut.ParseYamlConfig(() => new StringReader(yaml));
result.Should().NotBeNull();
result!.Radarr!.Keys.Concat(result.Sonarr!.Keys)
.Should().BeEquivalentTo("instance1", "instance2", "instance3");
result.Sonarr.Values.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = "url1",
ApiKey = "key1"
},
new
{
BaseUrl = "url2",
ApiKey = "key2"
}
});
result.Radarr.Values.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = "url3",
ApiKey = "key3"
}
});
}
[Test]
public void Load_v2_into_latest()
{
var sut = Resolve<BackwardCompatibleConfigParser>();
var yaml = @"
sonarr:
instance1:
api_key: key1
base_url: url1
instance2:
api_key: key2
base_url: url2
radarr:
instance3:
api_key: key3
base_url: url3
";
var result = sut.ParseYamlConfig(() => new StringReader(yaml));
result.Should().BeEquivalentTo(new RootConfigYamlLatest
{
Sonarr = new Dictionary<string, SonarrConfigYamlLatest>
{
{
"instance1", new SonarrConfigYamlLatest
{
BaseUrl = "url1",
ApiKey = "key1"
}
},
{
"instance2", new SonarrConfigYamlLatest
{
BaseUrl = "url2",
ApiKey = "key2"
}
}
},
Radarr = new Dictionary<string, RadarrConfigYamlLatest>
{
{
"instance3", new RadarrConfigYamlLatest
{
BaseUrl = "url3",
ApiKey = "key3"
}
}
}
});
}
}

@ -1,110 +1,110 @@
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Services.Radarr;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Processors;
using Recyclarr.TrashLib.TestLibrary;
// using Recyclarr.TrashLib.Config;
// using Recyclarr.TrashLib.Config.Parsing;
// using Recyclarr.TrashLib.Config.Services;
// using Recyclarr.TrashLib.Processors;
// using Recyclarr.TrashLib.TestLibrary;
//
// namespace Recyclarr.TrashLib.Tests.Config.Parsing;
//
// [TestFixture]
// [Parallelizable(ParallelScope.All)]
// public class ConfigRegistryTest
// {
// [Test]
// public void Get_configs_by_type()
// {
// var configs = new IServiceConfiguration[]
// {
// new SonarrConfiguration {InstanceName = "one"},
// new SonarrConfiguration {InstanceName = "two"},
// new RadarrConfiguration {InstanceName = "three"}
// };
//
// var sut = new ConfigExtensions();
// foreach (var c in configs)
// {
// sut.Add(c);
// }
//
// var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
//
// result.Should().Equal(configs.Take(2));
// }
//
// [Test]
// public void Null_service_type_returns_configs_of_all_types()
// {
// var configs = new IServiceConfiguration[]
// {
// new SonarrConfiguration {InstanceName = "one"},
// new SonarrConfiguration {InstanceName = "two"},
// new RadarrConfiguration {InstanceName = "three"}
// };
//
// var sut = new ConfigExtensions();
// foreach (var c in configs)
// {
// sut.Add(c);
// }
//
// var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.AnyService());
//
// result.Should().Equal(configs);
// }
//
// [Test]
// public void Get_empty_collection_when_no_configs_of_type()
// {
// var sut = new ConfigExtensions();
// sut.Add(new SonarrConfiguration());
//
// var settings = Substitute.For<ISyncSettings>();
// settings.Service.Returns(SupportedServices.Radarr);
//
// var result = sut.GetConfigsBasedOnSettings(settings);
//
// result.Should().BeEmpty();
// }
//
// [Test]
// public void Get_configs_by_type_and_instance_name()
// {
// var configs = new IServiceConfiguration[]
// {
// new SonarrConfiguration {InstanceName = "one"},
// new SonarrConfiguration {InstanceName = "two"},
// new RadarrConfiguration {InstanceName = "three"}
// };
//
// var sut = new ConfigExtensions();
// foreach (var c in configs)
// {
// sut.Add(c);
// }
//
// var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr("one"));
//
// result.Should().Equal(configs.Take(1));
// }
//
// [Test]
// public void Instance_matching_should_be_case_insensitive()
// {
// var configs = new IServiceConfiguration[]
// {
// new SonarrConfiguration {InstanceName = "one"}
// };
//
// var sut = new ConfigExtensions();
// foreach (var c in configs)
// {
// sut.Add(c);
// }
//
// var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.AnyService("ONE"));
//
// result.Should().Equal(configs);
// }
// }
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigRegistryTest
{
[Test]
public void Get_configs_by_type()
{
var configs = new IServiceConfiguration[]
{
new SonarrConfiguration {InstanceName = "one"},
new SonarrConfiguration {InstanceName = "two"},
new RadarrConfiguration {InstanceName = "three"}
};
var sut = new ConfigRegistry();
foreach (var c in configs)
{
sut.Add(c);
}
var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
result.Should().Equal(configs.Take(2));
}
[Test]
public void Null_service_type_returns_configs_of_all_types()
{
var configs = new IServiceConfiguration[]
{
new SonarrConfiguration {InstanceName = "one"},
new SonarrConfiguration {InstanceName = "two"},
new RadarrConfiguration {InstanceName = "three"}
};
var sut = new ConfigRegistry();
foreach (var c in configs)
{
sut.Add(c);
}
var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.AnyService());
result.Should().Equal(configs);
}
[Test]
public void Get_empty_collection_when_no_configs_of_type()
{
var sut = new ConfigRegistry();
sut.Add(new SonarrConfiguration());
var settings = Substitute.For<ISyncSettings>();
settings.Service.Returns(SupportedServices.Radarr);
var result = sut.GetConfigsBasedOnSettings(settings);
result.Should().BeEmpty();
}
[Test]
public void Get_configs_by_type_and_instance_name()
{
var configs = new IServiceConfiguration[]
{
new SonarrConfiguration {InstanceName = "one"},
new SonarrConfiguration {InstanceName = "two"},
new RadarrConfiguration {InstanceName = "three"}
};
var sut = new ConfigRegistry();
foreach (var c in configs)
{
sut.Add(c);
}
var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr("one"));
result.Should().Equal(configs.Take(1));
}
[Test]
public void Instance_matching_should_be_case_insensitive()
{
var configs = new IServiceConfiguration[]
{
new SonarrConfiguration {InstanceName = "one"}
};
var sut = new ConfigRegistry();
foreach (var c in configs)
{
sut.Add(c);
}
var result = sut.GetConfigsBasedOnSettings(MockSyncSettings.AnyService("ONE"));
result.Should().Equal(configs);
}
}

@ -1,59 +1,60 @@
using System.Diagnostics.CodeAnalysis;
using Autofac;
using FluentValidation;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.TestLibrary;
// using System.Diagnostics.CodeAnalysis;
// using Autofac;
// using FluentValidation;
// using Recyclarr.Cli.TestLibrary;
// using Recyclarr.TrashLib.Config.Parsing;
// using Recyclarr.TrashLib.Config.Services;
//
// namespace Recyclarr.TrashLib.Tests.Config.Parsing;
//
// [TestFixture]
// [Parallelizable(ParallelScope.All)]
// public class ConfigValidationExecutorTest : IntegrationFixture
// {
// [SuppressMessage("Design", "CA1812", Justification = "Instantiated via reflection in unit test")]
// private sealed class TestValidator : AbstractValidator<ServiceConfiguration>
// {
// public bool ShouldSucceed { get; set; }
//
// public TestValidator()
// {
// RuleFor(x => x).Must(_ => ShouldSucceed);
// }
// }
//
// protected override void RegisterExtraTypes(ContainerBuilder builder)
// {
// builder.RegisterType<TestValidator>()
// .AsSelf()
// .As<IValidator<ServiceConfiguration>>()
// .SingleInstance();
// }
//
// [Test]
// public void Return_false_on_validation_failure()
// {
// var validator = Resolve<TestValidator>();
// validator.ShouldSucceed = false;
//
// var sut = Resolve<ConfigValidationExecutor>();
//
// var result = sut.Validate(new RadarrConfiguration());
//
// result.Should().BeFalse();
// }
//
// [Test]
// public void Valid_returns_true()
// {
// var validator = Resolve<TestValidator>();
// validator.ShouldSucceed = true;
//
// var sut = Resolve<ConfigValidationExecutor>();
//
// var result = sut.Validate(new RadarrConfiguration());
//
// result.Should().BeTrue();
// }
// }
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigValidationExecutorTest : IntegrationFixture
{
[SuppressMessage("Design", "CA1812", Justification = "Instantiated via reflection in unit test")]
private sealed class TestValidator : AbstractValidator<ServiceConfiguration>
{
public bool ShouldSucceed { get; set; }
public TestValidator()
{
RuleFor(x => x).Must(_ => ShouldSucceed);
}
}
protected override void RegisterExtraTypes(ContainerBuilder builder)
{
builder.RegisterType<TestValidator>()
.AsSelf()
.As<IValidator<ServiceConfiguration>>()
.SingleInstance();
}
[Test]
public void Return_false_on_validation_failure()
{
var validator = Resolve<TestValidator>();
validator.ShouldSucceed = false;
var sut = Resolve<ConfigValidationExecutor>();
var result = sut.Validate(new TestConfig());
result.Should().BeFalse();
}
[Test]
public void Valid_returns_true()
{
var validator = Resolve<TestValidator>();
validator.ShouldSucceed = true;
var sut = Resolve<ConfigValidationExecutor>();
var result = sut.Validate(new TestConfig());
result.Should().BeTrue();
}
}

@ -1,187 +1,189 @@
using Recyclarr.Cli.TestLibrary;
using Recyclarr.Common;
using Recyclarr.TrashLib.Config.EnvironmentVariables;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.TestLibrary;
using YamlDotNet.Core;
// using Recyclarr.Cli.TestLibrary;
// using Recyclarr.Common;
// using Recyclarr.TrashLib.Config.EnvironmentVariables;
// using Recyclarr.TrashLib.Config.Parsing;
// using Recyclarr.TrashLib.TestLibrary;
// using YamlDotNet.Core;
//
// namespace Recyclarr.TrashLib.Tests.Config.Parsing;
//
// [TestFixture]
// [Parallelizable(ParallelScope.All)]
// public class ConfigurationLoaderEnvVarTest : IntegrationFixture
// {
// [Test]
// public void Test_successful_environment_variable_loading()
// {
// var env = Resolve<IEnvironment>();
// env.GetEnvironmentVariable("SONARR_API_KEY").Returns("the_api_key");
// env.GetEnvironmentVariable("SONARR_URL").Returns("http://the_url");
//
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance:
// api_key: !env_var SONARR_API_KEY
// base_url: !env_var SONARR_URL http://sonarr:1233
// ";
//
// var configCollection = sut.Load(new StringReader(testYml));
//
// var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
// config.Should().BeEquivalentTo(new[]
// {
// new
// {
// BaseUrl = new Uri("http://the_url"),
// ApiKey = "the_api_key"
// }
// });
// }
//
// [Test]
// public void Use_default_value_if_env_var_not_defined()
// {
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance:
// base_url: !env_var SONARR_URL http://sonarr:1233
// api_key: value
// ";
//
// var configCollection = sut.Load(new StringReader(testYml));
//
// var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
// config.Should().BeEquivalentTo(new[]
// {
// new
// {
// BaseUrl = new Uri("http://sonarr:1233")
// }
// });
// }
//
// [Test]
// public void Default_value_with_spaces_is_allowed()
// {
// var env = Resolve<IEnvironment>();
// env.GetEnvironmentVariable("SONARR_URL").Returns("");
//
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance:
// base_url: !env_var SONARR_URL http://somevalue
// api_key: value
// ";
//
// var configCollection = sut.Load(new StringReader(testYml));
//
// var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
// config.Should().BeEquivalentTo(new[]
// {
// new
// {
// BaseUrl = new Uri("http://somevalue")
// }
// });
// }
//
// [Test]
// public void Quotation_characters_are_stripped_from_default_value()
// {
// var env = Resolve<IEnvironment>();
// env.GetEnvironmentVariable("SONARR_URL").Returns("");
//
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance:
// base_url: !env_var SONARR_URL ""http://theurl""
// api_key: !env_var SONARR_API 'the key'
// ";
//
// var configCollection = sut.Load(new StringReader(testYml));
//
// var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
// config.Should().BeEquivalentTo(new[]
// {
// new
// {
// BaseUrl = new Uri("http://theurl"),
// ApiKey = "the key"
// }
// });
// }
//
// [Test]
// public void Multiple_spaces_between_default_and_env_var_work()
// {
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance:
// base_url: !env_var SONARR_URL http://somevalue
// api_key: value
// ";
//
// var configCollection = sut.Load(new StringReader(testYml));
//
// var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
// config.Should().BeEquivalentTo(new[]
// {
// new
// {
// BaseUrl = new Uri("http://somevalue")
// }
// });
// }
//
// [Test]
// public void Tab_characters_are_stripped()
// {
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = $@"
// sonarr:
// instance:
// base_url: !env_var SONARR_URL {"\t"}http://somevalue
// api_key: value
// ";
//
// var configCollection = sut.Load(new StringReader(testYml));
//
// var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
// config.Should().BeEquivalentTo(new[]
// {
// new
// {
// BaseUrl = new Uri("http://somevalue")
// }
// });
// }
//
// [Test]
// public void Throw_when_no_env_var_and_no_default()
// {
// var sut = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance:
// base_url: !env_var SONARR_URL
// api_key: value
// ";
//
// var act = () => sut.Load(new StringReader(testYml));
//
// act.Should().Throw<YamlException>()
// .WithInnerException<EnvironmentVariableNotDefinedException>();
// }
// }
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderEnvVarTest : IntegrationFixture
{
[Test]
public void Test_successful_environment_variable_loading()
{
var env = Resolve<IEnvironment>();
env.GetEnvironmentVariable("SONARR_API_KEY").Returns("the_api_key");
env.GetEnvironmentVariable("SONARR_URL").Returns("http://the_url");
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance:
api_key: !env_var SONARR_API_KEY
base_url: !env_var SONARR_URL http://sonarr:1233
";
var configCollection = sut.LoadFromStream(new StringReader(testYml));
var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
config.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = new Uri("http://the_url"),
ApiKey = "the_api_key"
}
});
}
[Test]
public void Use_default_value_if_env_var_not_defined()
{
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance:
base_url: !env_var SONARR_URL http://sonarr:1233
api_key: value
";
var configCollection = sut.LoadFromStream(new StringReader(testYml));
var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
config.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = new Uri("http://sonarr:1233")
}
});
}
[Test]
public void Default_value_with_spaces_is_allowed()
{
var env = Resolve<IEnvironment>();
env.GetEnvironmentVariable("SONARR_URL").Returns("");
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance:
base_url: !env_var SONARR_URL http://somevalue
api_key: value
";
var configCollection = sut.LoadFromStream(new StringReader(testYml));
var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
config.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = new Uri("http://somevalue")
}
});
}
[Test]
public void Quotation_characters_are_stripped_from_default_value()
{
var env = Resolve<IEnvironment>();
env.GetEnvironmentVariable("SONARR_URL").Returns("");
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance:
base_url: !env_var SONARR_URL ""http://theurl""
api_key: !env_var SONARR_API 'the key'
";
var configCollection = sut.LoadFromStream(new StringReader(testYml));
var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
config.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = new Uri("http://theurl"),
ApiKey = "the key"
}
});
}
[Test]
public void Multiple_spaces_between_default_and_env_var_work()
{
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance:
base_url: !env_var SONARR_URL http://somevalue
api_key: value
";
var configCollection = sut.LoadFromStream(new StringReader(testYml));
var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
config.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = new Uri("http://somevalue")
}
});
}
[Test]
public void Tab_characters_are_stripped()
{
var sut = Resolve<ConfigurationLoader>();
const string testYml = $@"
sonarr:
instance:
base_url: !env_var SONARR_URL {"\t"}http://somevalue
api_key: value
";
var configCollection = sut.LoadFromStream(new StringReader(testYml));
var config = configCollection.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr());
config.Should().BeEquivalentTo(new[]
{
new
{
BaseUrl = new Uri("http://somevalue")
}
});
}
[Test]
public void Throw_when_no_env_var_and_no_default()
{
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance:
base_url: !env_var SONARR_URL
api_key: value
";
var act = () => sut.LoadFromStream(new StringReader(testYml));
act.Should().Throw<YamlException>()
.WithInnerException<EnvironmentVariableNotDefinedException>();
}
}

@ -1,136 +1,138 @@
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Secrets;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.TestLibrary;
using Serilog.Sinks.TestCorrelator;
using YamlDotNet.Core;
// using Recyclarr.Cli.TestLibrary;
// using Recyclarr.TrashLib.Config.Parsing;
// using Recyclarr.TrashLib.Config.Secrets;
// using Recyclarr.TrashLib.Config.Services;
// using Recyclarr.TrashLib.TestLibrary;
// using Serilog.Sinks.TestCorrelator;
// using YamlDotNet.Core;
//
// namespace Recyclarr.TrashLib.Tests.Config.Parsing;
//
// [TestFixture]
// [Parallelizable(ParallelScope.All)]
// public class ConfigurationLoaderSecretsTest : IntegrationFixture
// {
// [Test]
// public void Test_secret_loading()
// {
// var configLoader = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance1:
// api_key: !secret api_key
// base_url: !secret 123GARBAGE_
// release_profiles:
// - trash_ids:
// - !secret secret_rp
// ";
//
// const string secretsYml = @"
// api_key: 95283e6b156c42f3af8a9b16173f876b
// 123GARBAGE_: 'https://radarr:7878'
// secret_rp: 1234567
// ";
//
// Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
// var expected = new List<SonarrConfiguration>
// {
// new()
// {
// InstanceName = "instance1",
// ApiKey = "95283e6b156c42f3af8a9b16173f876b",
// BaseUrl = new Uri("https://radarr:7878"),
// ReleaseProfiles = new List<ReleaseProfileConfig>
// {
// new()
// {
// TrashIds = new[] {"1234567"}
// }
// }
// }
// };
//
// var parsedSecret = configLoader.Load(new StringReader(testYml), "sonarr");
// parsedSecret.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr())
// .Should().BeEquivalentTo(expected);
// }
//
// [Test]
// public void Throw_when_referencing_invalid_secret()
// {
// using var logContext = TestCorrelator.CreateContext();
// var configLoader = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance2:
// api_key: !secret api_key
// base_url: fake_url
// ";
//
// const string secretsYml = "no_api_key: 95283e6b156c42f3af8a9b16173f876b";
//
// Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
//
// var act = () => configLoader.Load(new StringReader(testYml), "sonarr");
//
// act.Should().Throw<YamlException>()
// .WithInnerException<SecretNotFoundException>()
// .WithMessage("*api_key is not defined in secrets.yml");
// }
//
// [Test]
// public void Throw_when_referencing_secret_without_secrets_file()
// {
// var configLoader = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance3:
// api_key: !secret api_key
// base_url: fake_url
// ";
//
// Action act = () => configLoader.Load(new StringReader(testYml), "sonarr");
// act.Should().Throw<YamlException>()
// .WithInnerException<SecretNotFoundException>()
// .WithMessage("*api_key is not defined in secrets.yml");
// }
//
// [Test]
// public void Throw_when_secret_value_is_not_scalar()
// {
// var configLoader = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance4:
// api_key: !secret { property: value }
// base_url: fake_url
// ";
//
// Action act = () => configLoader.Load(new StringReader(testYml), "sonarr");
// act.Should().Throw<YamlException>().WithMessage("Expected 'Scalar'*");
// }
//
// [Test]
// public void Throw_when_expected_value_is_not_scalar()
// {
// var configLoader = Resolve<ConfigurationLoader>();
//
// const string testYml = @"
// sonarr:
// instance5:
// api_key: fake_key
// base_url: fake_url
// release_profiles: !secret bogus_profile
// ";
//
// const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b";
//
// Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
// Action act = () => configLoader.Load(new StringReader(testYml), "sonarr");
// act.Should().Throw<YamlException>().WithMessage("Exception during deserialization");
// }
// }
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderSecretsTest : IntegrationFixture
{
[Test]
public void Test_secret_loading()
{
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance1:
api_key: !secret api_key
base_url: !secret 123GARBAGE_
release_profiles:
- trash_ids:
- !secret secret_rp
";
const string secretsYml = @"
api_key: 95283e6b156c42f3af8a9b16173f876b
123GARBAGE_: 'https://radarr:7878'
secret_rp: 1234567
";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
var expected = new List<SonarrConfiguration>
{
new()
{
InstanceName = "instance1",
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("https://radarr:7878"),
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = new[] {"1234567"}
}
}
}
};
var parsedSecret = configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
parsedSecret.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr())
.Should().BeEquivalentTo(expected, o => o.Excluding(x => x.LineNumber));
}
[Test]
public void Throw_when_referencing_invalid_secret()
{
using var logContext = TestCorrelator.CreateContext();
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance2:
api_key: !secret api_key
base_url: fake_url
";
const string secretsYml = "no_api_key: 95283e6b156c42f3af8a9b16173f876b";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
var act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>()
.WithInnerException<SecretNotFoundException>()
.WithMessage("*api_key is not defined in secrets.yml");
}
[Test]
public void Throw_when_referencing_secret_without_secrets_file()
{
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance3:
api_key: !secret api_key
base_url: fake_url
";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>()
.WithInnerException<SecretNotFoundException>()
.WithMessage("*api_key is not defined in secrets.yml");
}
[Test]
public void Throw_when_secret_value_is_not_scalar()
{
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance4:
api_key: !secret { property: value }
base_url: fake_url
";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>().WithMessage("Expected 'Scalar'*");
}
[Test]
public void Throw_when_expected_value_is_not_scalar()
{
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
instance5:
api_key: fake_key
base_url: fake_url
release_profiles: !secret bogus_profile
";
const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b";
Fs.AddFile(Paths.SecretsPath.FullName, new MockFileData(secretsYml));
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
act.Should().Throw<YamlException>().WithMessage("Exception during deserialization");
}
}

@ -7,10 +7,11 @@ using Recyclarr.Cli.TestLibrary;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.TestLibrary.Autofac;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Yaml;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.TestLibrary;
using Serilog.Sinks.TestCorrelator;
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
@ -18,16 +19,17 @@ namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderTest : IntegrationFixture
{
private static TextReader GetResourceData(string file)
private static Func<TextReader> GetResourceData(string file)
{
var testData = new ResourceDataReader(typeof(ConfigurationLoaderTest), "Data");
return new StringReader(testData.ReadData(file));
return () => new StringReader(testData.ReadData(file));
}
protected override void RegisterExtraTypes(ContainerBuilder builder)
{
base.RegisterExtraTypes(builder);
builder.RegisterMockFor<IValidator<TestConfig>>();
builder.RegisterMockFor<IValidator<RadarrConfigYamlLatest>>();
builder.RegisterMockFor<IValidator<SonarrConfigYamlLatest>>();
}
[Test]
@ -86,7 +88,8 @@ public class ConfigurationLoaderTest : IntegrationFixture
public void Parse_using_stream()
{
var configLoader = Resolve<ConfigurationLoader>();
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
var configs = configLoader.Load(GetResourceData("Load_UsingStream_CorrectParsing.yml"),
SupportedServices.Sonarr);
configs.GetConfigsBasedOnSettings(MockSyncSettings.Sonarr())
.Should().BeEquivalentTo(new List<SonarrConfiguration>
@ -96,6 +99,7 @@ public class ConfigurationLoaderTest : IntegrationFixture
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("http://localhost:8989"),
InstanceName = "name",
ReplaceExistingCustomFormats = true,
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
@ -116,32 +120,15 @@ public class ConfigurationLoaderTest : IntegrationFixture
}
}
}
}, o => o.Excluding(x => x.LineNumber));
});
}
[Test, AutoMockData]
public void Throw_when_yaml_file_only_has_comment(ConfigurationLoader sut)
{
const string testYml = "# YAML with nothing but this comment";
var act = () => sut.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().Throw<EmptyYamlException>();
}
[Test, AutoMockData]
public void Throw_when_yaml_file_is_empty(ConfigurationLoader sut)
[Test]
public void No_log_when_file_not_empty_but_has_no_desired_sections()
{
const string testYml = "";
var act = () => sut.LoadFromStream(new StringReader(testYml), "fubar");
using var logContext = TestCorrelator.CreateContext();
act.Should().Throw<EmptyYamlException>();
}
[Test, AutoMockData]
public void No_throw_when_file_not_empty_but_has_no_desired_sections(ConfigurationLoader sut)
{
var sut = Resolve<ConfigurationLoader>();
const string testYml = @"
not_wanted:
instance:
@ -149,8 +136,10 @@ not_wanted:
api_key: xyz
";
var act = () => sut.LoadFromStream(new StringReader(testYml), "fubar");
sut.Load(testYml, SupportedServices.Sonarr);
act.Should().NotThrow();
TestCorrelator.GetLogEventsFromContextGuid(logContext.Guid)
.Select(x => x.RenderMessage())
.Should().NotContain("Configuration is empty");
}
}

@ -1,29 +1,26 @@
using FluentValidation.TestHelper;
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
public class YamlConfigValidatorTest : IntegrationFixture
{
[Test]
public void Validation_succeeds()
{
var config = new TestConfig
var config = new ServiceConfigYamlLatest
{
ApiKey = "valid",
BaseUrl = new Uri("http://valid"),
InstanceName = "valid",
LineNumber = 1,
CustomFormats = new List<CustomFormatConfig>
BaseUrl = "http://valid",
CustomFormats = new List<CustomFormatConfigYamlLatest>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
TrashIds = new List<string> {"01234567890123456789012345678901"},
QualityProfiles = new List<QualityScoreConfigYamlLatest>
{
new()
{
@ -32,13 +29,13 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
}
}
},
QualityDefinition = new QualityDefinitionConfig
QualityDefinition = new QualitySizeConfigYamlLatest
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var validator = Resolve<ServiceConfigYamlValidatorLatest>();
var result = validator.TestValidate(config);
result.ShouldNotHaveAnyValidationErrors();
@ -47,16 +44,16 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
[Test]
public void Validation_failure_when_api_key_missing()
{
var config = new TestConfig
var config = new ServiceConfigYamlLatest
{
ApiKey = "", // Must not be empty
BaseUrl = new Uri("http://valid"),
CustomFormats = new List<CustomFormatConfig>
BaseUrl = "http://valid",
CustomFormats = new List<CustomFormatConfigYamlLatest>
{
new()
{
TrashIds = new[] {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
QualityProfiles = new List<QualityScoreConfigYamlLatest>
{
new()
{
@ -65,13 +62,13 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
}
}
},
QualityDefinition = new QualityDefinitionConfig
QualityDefinition = new QualitySizeConfigYamlLatest
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var validator = Resolve<ServiceConfigYamlValidatorLatest>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.ApiKey);
@ -80,16 +77,16 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
[Test]
public void Validation_failure_when_base_url_empty()
{
var config = new TestConfig
var config = new ServiceConfigYamlLatest
{
ApiKey = "valid",
BaseUrl = new Uri("about:empty"),
CustomFormats = new List<CustomFormatConfig>
BaseUrl = "about:empty",
CustomFormats = new List<CustomFormatConfigYamlLatest>
{
new()
{
TrashIds = new[] {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
QualityProfiles = new List<QualityScoreConfigYamlLatest>
{
new()
{
@ -98,33 +95,33 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
}
}
},
QualityDefinition = new QualityDefinitionConfig
QualityDefinition = new QualitySizeConfigYamlLatest
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var validator = Resolve<ServiceConfigYamlValidatorLatest>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.BaseUrl);
}
public static string FirstCf { get; } = $"{nameof(TestConfig.CustomFormats)}[0].";
public static string FirstCf { get; } = $"{nameof(ServiceConfigYamlLatest.CustomFormats)}[0].";
[Test]
public void Validation_failure_when_cf_trash_ids_empty()
{
var config = new TestConfig
var config = new ServiceConfigYamlLatest
{
ApiKey = "valid",
BaseUrl = new Uri("http://valid"),
CustomFormats = new List<CustomFormatConfig>
BaseUrl = "http://valid",
CustomFormats = new List<CustomFormatConfigYamlLatest>
{
new()
{
TrashIds = Array.Empty<string>(),
QualityProfiles = new List<QualityProfileScoreConfig>
QualityProfiles = new List<QualityScoreConfigYamlLatest>
{
new()
{
@ -133,13 +130,13 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
}
}
},
QualityDefinition = new QualityDefinitionConfig
QualityDefinition = new QualitySizeConfigYamlLatest
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var validator = Resolve<ServiceConfigYamlValidatorLatest>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(FirstCf + nameof(CustomFormatConfig.TrashIds));
@ -148,16 +145,16 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
[Test]
public void Validation_failure_when_quality_definition_type_empty()
{
var config = new TestConfig
var config = new ServiceConfigYamlLatest
{
ApiKey = "valid",
BaseUrl = new Uri("http://valid"),
CustomFormats = new List<CustomFormatConfig>
BaseUrl = "http://valid",
CustomFormats = new List<CustomFormatConfigYamlLatest>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
QualityProfiles = new List<QualityScoreConfigYamlLatest>
{
new()
{
@ -166,13 +163,13 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
}
}
},
QualityDefinition = new QualityDefinitionConfig
QualityDefinition = new QualitySizeConfigYamlLatest
{
Type = ""
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var validator = Resolve<ServiceConfigYamlValidatorLatest>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.QualityDefinition!.Type);
@ -181,16 +178,16 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
[Test]
public void Validation_failure_when_quality_profile_name_empty()
{
var config = new TestConfig
var config = new ServiceConfigYamlLatest
{
ApiKey = "valid",
BaseUrl = new Uri("http://valid"),
CustomFormats = new List<CustomFormatConfig>
BaseUrl = "http://valid",
CustomFormats = new List<CustomFormatConfigYamlLatest>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
QualityProfiles = new List<QualityScoreConfigYamlLatest>
{
new()
{
@ -199,44 +196,16 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
}
}
},
QualityDefinition = new QualityDefinitionConfig
QualityDefinition = new QualitySizeConfigYamlLatest
{
Type = "valid"
}
};
var validator = Resolve<ServiceConfigurationValidator>();
var validator = Resolve<ServiceConfigYamlValidatorLatest>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(FirstCf +
$"{nameof(CustomFormatConfig.QualityProfiles)}[0].{nameof(QualityProfileScoreConfig.Name)}");
}
[Test]
public void Validation_failure_when_instance_name_empty()
{
var config = new TestConfig
{
InstanceName = ""
};
var validator = Resolve<ServiceConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.InstanceName);
}
[Test]
public void Validation_failure_when_line_number_equals_zero()
{
var config = new TestConfig
{
LineNumber = 0
};
var validator = Resolve<ServiceConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.LineNumber);
}
}

@ -20,7 +20,7 @@ public class CustomFormatConfigPhaseTest
NewCf.Data("two", "cf2")
});
var config = new TestConfig
var config = new RadarrConfiguration
{
CustomFormats = new List<CustomFormatConfig>
{
@ -54,7 +54,7 @@ public class CustomFormatConfigPhaseTest
NewCf.Data("", "cf4")
});
var config = new TestConfig
var config = new RadarrConfiguration
{
CustomFormats = new List<CustomFormatConfig>
{

@ -1,4 +1,5 @@
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Pipelines.CustomFormat;
using Recyclarr.TrashLib.Pipelines.CustomFormat.Models;
using Recyclarr.TrashLib.Pipelines.CustomFormat.PipelinePhases;
@ -24,7 +25,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
var cache = new CustomFormatCache();
var config = new TestConfig();
var config = new RadarrConfiguration();
var result = sut.Execute(config, guideCfs, serviceData, cache);
@ -60,7 +61,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
var cache = new CustomFormatCache();
var config = new TestConfig();
var config = new RadarrConfiguration();
var result = sut.Execute(config, guideCfs, serviceData, cache);
@ -106,7 +107,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
};
var config = new TestConfig();
var config = new RadarrConfiguration();
var result = sut.Execute(config, guideCfs, serviceData, cache);
@ -147,7 +148,10 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
var cache = new CustomFormatCache();
var config = new TestConfig();
var config = new RadarrConfiguration
{
ReplaceExistingCustomFormats = true
};
var result = sut.Execute(config, guideCfs, serviceData, cache);
@ -183,7 +187,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
var cache = new CustomFormatCache();
var config = new TestConfig
var config = new RadarrConfiguration
{
ReplaceExistingCustomFormats = false
};
@ -223,7 +227,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
};
var config = new TestConfig
var config = new RadarrConfiguration
{
ReplaceExistingCustomFormats = false
};
@ -269,7 +273,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
};
var config = new TestConfig
var config = new RadarrConfiguration
{
ReplaceExistingCustomFormats = false
};
@ -286,7 +290,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
[Test]
public void Unchanged_cfs_with_replace()
public void Unchanged_cfs_with_replace_enabled()
{
var sut = Resolve<CustomFormatTransactionPhase>();
@ -302,7 +306,10 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
var cache = new CustomFormatCache();
var config = new TestConfig();
var config = new RadarrConfiguration
{
ReplaceExistingCustomFormats = true
};
var result = sut.Execute(config, guideCfs, serviceData, cache);
@ -335,7 +342,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
};
var config = new TestConfig
var config = new RadarrConfiguration
{
ReplaceExistingCustomFormats = false
};
@ -368,7 +375,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
};
var config = new TestConfig();
var config = new RadarrConfiguration();
var result = sut.Execute(config, guideCfs, serviceData, cache);
@ -404,7 +411,7 @@ public class CustomFormatTransactionPhaseTest : IntegrationFixture
}
};
var config = new TestConfig();
var config = new RadarrConfiguration();
var result = sut.Execute(config, guideCfs, serviceData, cache);

@ -13,7 +13,7 @@ public class QualityProfileConfigPhaseTest
public void Reset_unmatched_scores_promoted_to_quality_profiles_property_when_no_quality_profiles_in_config(
QualityProfileConfigPhase sut)
{
var config = new TestConfig
var config = new RadarrConfiguration
{
CustomFormats = new List<CustomFormatConfig>
{
@ -35,7 +35,7 @@ public class QualityProfileConfigPhaseTest
config.QualityProfiles.Should().BeEquivalentTo(new QualityProfileConfig[]
{
new("test_profile", true)
new() {Name = "test_profile", ResetUnmatchedScores = true}
});
}
@ -43,9 +43,16 @@ public class QualityProfileConfigPhaseTest
public void Reset_unmatched_scores_promoted_to_quality_profiles_property_when_quality_profile_in_config(
QualityProfileConfigPhase sut)
{
var config = new TestConfig
var config = new RadarrConfiguration
{
QualityProfiles = new[] {new QualityProfileConfig("test_profile", null)},
QualityProfiles = new[]
{
new QualityProfileConfig
{
Name = "test_profile",
ResetUnmatchedScores = null
}
},
CustomFormats = new List<CustomFormatConfig>
{
new()
@ -66,7 +73,7 @@ public class QualityProfileConfigPhaseTest
config.QualityProfiles.Should().BeEquivalentTo(new QualityProfileConfig[]
{
new("test_profile", true)
new() {Name = "test_profile", ResetUnmatchedScores = true}
});
}
@ -74,7 +81,7 @@ public class QualityProfileConfigPhaseTest
public void Reset_unmatched_scores_not_promoted_to_quality_profiles_property_when_false(
QualityProfileConfigPhase sut)
{
var config = new TestConfig
var config = new RadarrConfiguration
{
CustomFormats = new List<CustomFormatConfig>
{
@ -97,9 +104,9 @@ public class QualityProfileConfigPhaseTest
config.QualityProfiles.Should().BeEmpty();
}
private static TestConfig SetupCfs(params CustomFormatConfig[] cfConfigs)
private static RadarrConfiguration SetupCfs(params CustomFormatConfig[] cfConfigs)
{
return new TestConfig
return new RadarrConfiguration
{
CustomFormats = cfConfigs
};
@ -264,9 +271,16 @@ public class QualityProfileConfigPhaseTest
NewCf.DataWithScore("", "id1", 100, 1)
});
var config = new TestConfig
var config = new RadarrConfiguration
{
QualityProfiles = new[] {new QualityProfileConfig("test_profile", true)},
QualityProfiles = new[]
{
new QualityProfileConfig
{
Name = "test_profile",
ResetUnmatchedScores = true
}
},
CustomFormats = new List<CustomFormatConfig>
{
new()
@ -288,7 +302,7 @@ public class QualityProfileConfigPhaseTest
config.QualityProfiles.Should().BeEquivalentTo(new QualityProfileConfig[]
{
new("test_profile", true)
new() {Name = "test_profile", ResetUnmatchedScores = true}
});
}
}

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Pipelines.Tags.PipelinePhases;
namespace Recyclarr.TrashLib.Tests.Pipelines.Tags.PipelinePhases;

@ -5,4 +5,9 @@
<ProjectReference Include="..\Recyclarr.TrashLib.TestLibrary\Recyclarr.TrashLib.TestLibrary.csproj" />
<ProjectReference Include="..\Recyclarr.TrashLib\Recyclarr.TrashLib.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Recyclarr.TrashLib\Config\Parsing\ConfigYamlDataObjectsLatest.cs">
<Link>Config\Parsing\ConfigYamlDataObjectsLatest.cs</Link>
</Compile>
</ItemGroup>
</Project>

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,6 +1,5 @@
using Recyclarr.TrashLib.Compatibility.Sonarr;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.ExceptionTypes;
namespace Recyclarr.TrashLib.Tests.Sonarr;

@ -1,69 +0,0 @@
using FluentValidation.TestHelper;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Services.Sonarr;
namespace Recyclarr.TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationValidatorTest : IntegrationFixture
{
[Test]
public void No_validation_failure_for_service_name()
{
var config = new SonarrConfiguration();
var validator = Resolve<SonarrConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldNotHaveValidationErrorFor(x => x.ServiceType);
}
[Test]
public void Validation_failure_when_rps_and_cfs_used_together()
{
var config = new SonarrConfiguration
{
ReleaseProfiles = new[] {new ReleaseProfileConfig()},
CustomFormats = new[] {new CustomFormatConfig()}
};
var validator = Resolve<SonarrConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.ReleaseProfiles);
}
[Test]
public void Sonarr_release_profile_failures()
{
var config = new SonarrConfiguration
{
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = Array.Empty<string>(),
Filter = new SonarrProfileFilterConfig
{
Include = new[] {"include"},
Exclude = new[] {"exclude"}
}
}
}
};
var validator = new SonarrConfigurationValidator();
var result = validator.TestValidate(config);
var releaseProfiles = $"{nameof(config.ReleaseProfiles)}[0].";
// Release profile trash IDs cannot be empty
result.ShouldHaveValidationErrorFor(releaseProfiles + nameof(ReleaseProfileConfig.TrashIds));
// Cannot use include + exclude filters together
result.ShouldHaveValidationErrorFor(releaseProfiles +
$"{nameof(ReleaseProfileConfig.Filter)}.{nameof(SonarrProfileFilterConfig.Include)}");
}
}

@ -1,5 +1,5 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
namespace Recyclarr.TrashLib.Compatibility.Sonarr;

@ -26,7 +26,7 @@ public class ConfigAutofacModule : Module
{
builder.RegisterAssemblyTypes(_assemblies)
.AsClosedTypesOf(typeof(IValidator<>))
.AsImplementedInterfaces();
.As<IValidator>();
builder.RegisterAssemblyTypes(_assemblies)
.AssignableTo<IYamlBehavior>()
@ -39,9 +39,10 @@ public class ConfigAutofacModule : Module
builder.RegisterType<DefaultObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ConfigurationLoader>().As<IConfigurationLoader>();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();
builder.RegisterType<ConfigTemplateGuideService>().As<IConfigTemplateGuideService>();
builder.RegisterType<ConfigValidationExecutor>();
builder.RegisterType<ConfigParser>();
builder.RegisterType<ConfigTemplateGuideService>().As<IConfigTemplateGuideService>();
builder.RegisterType<BackwardCompatibleConfigParser>();
// Config Listers
builder.RegisterType<ConfigTemplateLister>().Keyed<IConfigLister>(ConfigListCategory.Templates);

@ -0,0 +1,38 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Processors;
namespace Recyclarr.TrashLib.Config;
public static class ConfigExtensions
{
public static IEnumerable<IServiceConfiguration> GetConfigsOfType(
this IEnumerable<IServiceConfiguration> configs,
SupportedServices? serviceType)
{
return configs.Where(x => serviceType is null || serviceType.Value == x.ServiceType);
}
public static IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(
this IEnumerable<IServiceConfiguration> configs,
ISyncSettings settings)
{
// later, if we filter by "operation type" (e.g. release profiles, CFs, quality sizes) it's just another
// ".Where()" in the LINQ expression below.
return configs.GetConfigsOfType(settings.Service)
.Where(x => settings.Instances.IsEmpty() ||
settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName)));
}
public static bool DoesConfigExist(this IEnumerable<IServiceConfiguration> configs, string name)
{
return configs.Any(x => x.InstanceName.EqualsIgnoreCase(name));
}
public static bool IsConfigEmpty(this RootConfigYamlLatest config)
{
var sonarr = config.Sonarr?.Count ?? 0;
var radarr = config.Radarr?.Count ?? 0;
return sonarr + radarr == 0;
}
}

@ -1,5 +1,6 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Startup;
using Spectre.Console;
using Spectre.Console.Rendering;
@ -65,7 +66,7 @@ public class ConfigLocalLister : IConfigLister
private static void BuildInstanceTree(
ICollection<IRenderable> rows,
IConfigRegistry registry,
IReadOnlyCollection<IServiceConfiguration> registry,
SupportedServices service)
{
var configs = registry.GetConfigsOfType(service).ToList();

@ -1,4 +1,3 @@
using MoreLinq;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Repo;
using Spectre.Console;

@ -0,0 +1,91 @@
using AutoMapper;
using FluentValidation;
using Recyclarr.Config.Data.V1;
using Recyclarr.TrashLib.Config.Yaml;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace Recyclarr.TrashLib.Config.Parsing;
using RootConfigYamlV1 = RootConfigYaml;
public class BackwardCompatibleConfigParser
{
private readonly ILogger _log;
private readonly IMapper _mapper;
private readonly ConfigValidationExecutor _validator;
private readonly IDeserializer _deserializer;
// Order matters here. Types are mapped from top to bottom (front to back).
// Newer types should be added to the top/start of the list.
private readonly IReadOnlyList<Type> _configTypes = new[]
{
typeof(RootConfigYamlLatest),
typeof(RootConfigYamlV1)
};
public BackwardCompatibleConfigParser(
ILogger log,
IYamlSerializerFactory yamlFactory,
IMapper mapper,
ConfigValidationExecutor validator)
{
_log = log;
_mapper = mapper;
_validator = validator;
_deserializer = yamlFactory.CreateDeserializer();
}
private (int Index, object? Data) TryParseConfig(Func<TextReader> streamFactory)
{
Exception? firstException = null;
// step 1: Iterate from NEWEST -> OLDEST until we successfully deserialize
for (var i = 0; i < _configTypes.Count; ++i)
{
var configType = _configTypes[i];
_log.Debug("Attempting deserialization using config type: {Type}", configType);
try
{
using var stream = streamFactory();
return (i, _deserializer.Deserialize(stream, configType));
}
catch (YamlException e)
{
_log.Debug(e.InnerException, "Exception during deserialization");
firstException ??= e;
// Ignore this exception and continue; we should continue to try older types
}
}
throw firstException ?? new InvalidOperationException("Parsing failed for unknown reason");
}
private RootConfigYamlLatest MapConfigDataToLatest(int index, object data)
{
var currentType = _configTypes[index];
// step 2: Using the same index, now go the other direction: OLDEST -> NEWEST, using IMapper to map
// all the way up to the latest
foreach (var nextType in _configTypes.Slice(0, index).Reverse())
{
if (!_validator.Validate(data))
{
throw new ValidationException($"Validation Failed for type: {data.GetType().Name}");
}
// If any mapping fails, the whole chain fails. Let the exception leak out and get handled outside.
data = _mapper.Map(data, currentType, nextType);
currentType = nextType;
}
return (RootConfigYamlLatest) data;
}
public RootConfigYamlLatest? ParseYamlConfig(Func<TextReader> streamFactory)
{
var (index, data) = TryParseConfig(streamFactory);
return data is null ? null : MapConfigDataToLatest(index, data);
}
}

@ -1,13 +1,7 @@
using System.IO.Abstractions;
using JetBrains.Annotations;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Services.Radarr;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Yaml;
using Serilog.Context;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace Recyclarr.TrashLib.Config.Parsing;
@ -15,44 +9,38 @@ namespace Recyclarr.TrashLib.Config.Parsing;
public class ConfigParser
{
private readonly ILogger _log;
private readonly ConfigValidationExecutor _validator;
private readonly IDeserializer _deserializer;
private readonly ConfigRegistry _configs = new();
private SupportedServices? _currentSection;
private readonly BackwardCompatibleConfigParser _parser;
private readonly Dictionary<SupportedServices, Type> _configTypes = new()
{
{SupportedServices.Sonarr, typeof(SonarrConfiguration)},
{SupportedServices.Radarr, typeof(RadarrConfiguration)}
};
public IConfigRegistry Configs => _configs;
public ConfigParser(
ILogger log,
IYamlSerializerFactory yamlFactory,
ConfigValidationExecutor validator)
public ConfigParser(ILogger log, BackwardCompatibleConfigParser parser)
{
_log = log;
_validator = validator;
_deserializer = yamlFactory.CreateDeserializer();
_parser = parser;
}
public void Load(IFileInfo file, string? desiredSection = null)
public RootConfigYamlLatest? Load(IFileInfo file)
{
_log.Debug("Loading config file: {File}", file);
using var logScope = LogContext.PushProperty(LogProperty.Scope, file.Name);
return Load(file.OpenText);
}
public RootConfigYamlLatest? Load(string yaml)
{
_log.Debug("Loading config from string data");
return Load(() => new StringReader(yaml));
}
public RootConfigYamlLatest? Load(Func<TextReader> streamFactory)
{
try
{
using var stream = file.OpenText();
LoadFromStream(stream, desiredSection);
return;
}
catch (EmptyYamlException)
{
_log.Warning("Configuration file yielded no usable configuration (is it empty?)");
return;
var config = _parser.ParseYamlConfig(streamFactory);
if (config != null && config.IsConfigEmpty())
{
_log.Warning("Configuration is empty");
}
return config;
}
catch (YamlException e)
{
@ -68,142 +56,10 @@ public class ConfigParser
_log.Error("Exception at line {Line}: {Msg}", line, e.InnerException?.Message ?? e.Message);
break;
}
}
_log.Error("Due to previous exception, this file will be skipped: {File}", file);
}
public void LoadFromStream(TextReader stream, string? desiredSection)
{
var parser = new Parser(stream);
parser.Consume<StreamStart>();
if (parser.Current is StreamEnd)
{
_log.Debug("Skipping this config due to StreamEnd");
throw new EmptyYamlException();
}
parser.Consume<DocumentStart>();
if (parser.Current is DocumentEnd)
{
_log.Debug("Skipping this config due to DocumentEnd");
throw new EmptyYamlException();
}
ParseAllSections(parser, desiredSection);
if (Configs.Count == 0)
{
_log.Debug("Document isn't empty, but still yielded no configs");
}
}
private void ParseAllSections(Parser parser, string? desiredSection)
{
parser.Consume<MappingStart>();
while (parser.TryConsume<Scalar>(out var section))
{
if (desiredSection is not null && desiredSection != section.Value)
{
_log.Debug("Skipping section {Section} because it doesn't match {DesiredSection}",
section.Value, desiredSection);
continue;
}
if (!SetCurrentSection(section.Value))
{
_log.Warning("Unknown service type {Type} at line {Line}; skipping",
section.Value, section.Start.Line);
parser.SkipThisAndNestedEvents();
continue;
}
if (!ParseSingleSection(parser))
{
parser.SkipThisAndNestedEvents();
}
}
}
private bool ParseSingleSection(Parser parser)
{
switch (parser.Current)
{
case MappingStart:
ParseAndAdd<MappingStart, MappingEnd>(parser);
break;
case SequenceStart:
ParseAndAdd<SequenceStart, SequenceEnd>(parser);
break;
case Scalar:
_log.Debug("End of section");
return false;
default:
_log.Warning("Unexpected YAML type at line {Line}; skipping this section", parser.Current?.Start.Line);
return false;
}
return true;
}
private void ParseAndAdd<TStart, TEnd>(Parser parser)
where TStart : ParsingEvent
where TEnd : ParsingEvent
{
parser.Consume<TStart>();
while (!parser.TryConsume<TEnd>(out _))
{
ParseAndAddConfig(parser);
}
}
private bool SetCurrentSection(string name)
{
if (!Enum.TryParse(name, true, out SupportedServices key) || !_configTypes.ContainsKey(key))
{
return false;
}
_currentSection = key;
return true;
}
private void ParseAndAddConfig(Parser parser)
{
var lineNumber = parser.Current?.Start.Line;
string? instanceName = null;
if (parser.TryConsume<Scalar>(out var key))
{
instanceName = key.Value;
}
if (_currentSection is null)
{
throw new YamlException("SetCurrentSection() must be set before parsing");
}
var configType = _configTypes[_currentSection.Value];
var newConfig = (ServiceConfiguration?) _deserializer.Deserialize(parser, configType);
if (newConfig is null)
{
throw new YamlException(
$"Unable to deserialize instance at line {lineNumber} using configuration type {_currentSection}");
}
newConfig.InstanceName = instanceName;
newConfig.LineNumber = lineNumber ?? 0;
if (!_validator.Validate(newConfig))
{
throw new YamlException("Validation failed");
_log.Error("Due to previous exception, this config will be skipped");
}
_configs.Add(newConfig);
return null;
}
}

@ -1,43 +0,0 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Processors;
namespace Recyclarr.TrashLib.Config.Parsing;
public class ConfigRegistry : IConfigRegistry
{
private readonly Dictionary<SupportedServices, List<IServiceConfiguration>> _configs = new();
public void Add(IServiceConfiguration config)
{
_configs.GetOrCreate(config.ServiceType).Add(config);
}
public IEnumerable<IServiceConfiguration> GetAllConfigs()
{
return GetConfigsOfType(null);
}
public IEnumerable<IServiceConfiguration> GetConfigsOfType(SupportedServices? serviceType)
{
return _configs
.Where(x => serviceType is null || serviceType.Value == x.Key)
.SelectMany(x => x.Value);
}
public IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(ISyncSettings settings)
{
// later, if we filter by "operation type" (e.g. release profiles, CFs, quality sizes) it's just another
// ".Where()" in the LINQ expression below.
return GetConfigsOfType(settings.Service)
.Where(x => settings.Instances.IsEmpty() ||
settings.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName)));
}
public int Count => _configs.Count;
public bool DoesConfigExist(string name)
{
return _configs.Values.Any(x => x.Any(y => y.InstanceName.EqualsIgnoreCase(name)));
}
}

@ -1,7 +1,7 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Http;
using Recyclarr.Common.FluentValidation;
using Serilog.Events;
namespace Recyclarr.TrashLib.Config.Parsing;
@ -9,33 +9,43 @@ namespace Recyclarr.TrashLib.Config.Parsing;
public class ConfigValidationExecutor
{
private readonly ILogger _log;
private readonly IValidator<ServiceConfiguration> _validator;
private readonly RuntimeValidationService _validationService;
public ConfigValidationExecutor(
ILogger log,
IValidator<ServiceConfiguration> validator)
public ConfigValidationExecutor(ILogger log, RuntimeValidationService validationService)
{
_log = log;
_validator = validator;
_validationService = validationService;
}
public bool Validate(ServiceConfiguration config)
public bool Validate(object config)
{
var result = _validator.Validate(config);
if (result is not {IsValid: false})
var result = _validationService.Validate(config);
if (result.IsValid)
{
return true;
}
var printableName = config.InstanceName ?? FlurlLogging.SanitizeUrl(config.BaseUrl);
_log.Error("Validation failed for instance config {Instance} at line {Line} with {Count} errors",
printableName, config.LineNumber, result.Errors.Count);
var anyErrorsDetected = false;
foreach (var error in result.Errors)
{
_log.Error("Validation error: {Msg}", error.ErrorMessage);
var level = error.Severity switch
{
Severity.Error => LogEventLevel.Error,
Severity.Warning => LogEventLevel.Warning,
Severity.Info => LogEventLevel.Information,
_ => LogEventLevel.Debug
};
anyErrorsDetected |= level == LogEventLevel.Error;
_log.Write(level, "Config Validation: {Msg}", error.ErrorMessage);
}
if (anyErrorsDetected)
{
_log.Error("Config validation failed with {Count} errors", result.Errors.Count);
}
return false;
return !anyErrorsDetected;
}
}

@ -0,0 +1,21 @@
// ReSharper disable RedundantUsingDirective.Global
// YAML Object Mappings
global using RootConfigYamlLatest = Recyclarr.Config.Data.V2.RootConfigYaml;
global using ServiceConfigYamlLatest = Recyclarr.Config.Data.V2.ServiceConfigYaml;
global using SonarrConfigYamlLatest = Recyclarr.Config.Data.V2.SonarrConfigYaml;
global using RadarrConfigYamlLatest = Recyclarr.Config.Data.V2.RadarrConfigYaml;
global using QualityScoreConfigYamlLatest = Recyclarr.Config.Data.V2.QualityScoreConfigYaml;
global using CustomFormatConfigYamlLatest = Recyclarr.Config.Data.V2.CustomFormatConfigYaml;
global using QualitySizeConfigYamlLatest = Recyclarr.Config.Data.V2.QualitySizeConfigYaml;
global using QualityProfileConfigYamlLatest = Recyclarr.Config.Data.V2.QualityProfileConfigYaml;
global using ReleaseProfileConfigYamlLatest = Recyclarr.Config.Data.V2.ReleaseProfileConfigYaml;
global using ReleaseProfileFilterConfigYamlLatest = Recyclarr.Config.Data.V2.ReleaseProfileFilterConfigYaml;
// Validators
global using SonarrConfigYamlValidatorLatest = Recyclarr.Config.Data.V2.SonarrConfigYamlValidator;
global using ServiceConfigYamlValidatorLatest = Recyclarr.Config.Data.V2.ServiceConfigYamlValidator;
global using ReleaseProfileConfigYamlValidatorLatest = Recyclarr.Config.Data.V2.ReleaseProfileConfigYamlValidator;
global using ReleaseProfileFilterConfigYamlValidatorLatest =
Recyclarr.Config.Data.V2.ReleaseProfileFilterConfigYamlValidator;

@ -1,39 +1,82 @@
using System.IO.Abstractions;
using AutoMapper;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Config.Parsing;
public class ConfigurationLoader : IConfigurationLoader
{
private readonly Func<ConfigParser> _parserFactory;
private readonly ConfigParser _parser;
private readonly IMapper _mapper;
public ConfigurationLoader(Func<ConfigParser> parserFactory)
public ConfigurationLoader(ConfigParser parser, IMapper mapper)
{
_parserFactory = parserFactory;
_parser = parser;
_mapper = mapper;
}
public IConfigRegistry LoadMany(IEnumerable<IFileInfo> configFiles, string? desiredSection = null)
public ICollection<IServiceConfiguration> LoadMany(
IEnumerable<IFileInfo> configFiles,
SupportedServices? desiredServiceType = null)
{
var parser = _parserFactory();
return configFiles
.SelectMany(x => Load(x, desiredServiceType))
.ToList();
}
foreach (var file in configFiles)
{
parser.Load(file, desiredSection);
}
public IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file, SupportedServices? desiredServiceType = null)
{
return ProcessLoadedConfigs(_parser.Load(file), desiredServiceType);
}
return parser.Configs;
public IReadOnlyCollection<IServiceConfiguration> Load(string yaml, SupportedServices? desiredServiceType = null)
{
return ProcessLoadedConfigs(_parser.Load(yaml), desiredServiceType);
}
public IConfigRegistry Load(IFileInfo file, string? desiredSection = null)
public IReadOnlyCollection<IServiceConfiguration> Load(
Func<TextReader> streamFactory,
SupportedServices? desiredServiceType = null)
{
var parser = _parserFactory();
parser.Load(file, desiredSection);
return parser.Configs;
return ProcessLoadedConfigs(_parser.Load(streamFactory), desiredServiceType);
}
private IReadOnlyCollection<IServiceConfiguration> ProcessLoadedConfigs(
RootConfigYamlLatest? configs,
SupportedServices? desiredServiceType)
{
if (configs is null)
{
return Array.Empty<IServiceConfiguration>();
}
var convertedConfigs = new List<IServiceConfiguration>();
if (desiredServiceType is null or SupportedServices.Radarr)
{
convertedConfigs.AddRange(
ValidateAndMap<RadarrConfigYamlLatest, RadarrConfiguration>(configs.Radarr));
}
if (desiredServiceType is null or SupportedServices.Sonarr)
{
convertedConfigs.AddRange(
ValidateAndMap<SonarrConfigYamlLatest, SonarrConfiguration>(configs.Sonarr));
}
return convertedConfigs;
}
public IConfigRegistry LoadFromStream(TextReader stream, string? desiredSection = null)
private IEnumerable<IServiceConfiguration> ValidateAndMap<TConfigYaml, TServiceConfig>(
IReadOnlyDictionary<string, TConfigYaml>? configs)
where TServiceConfig : ServiceConfiguration
where TConfigYaml : ServiceConfigYamlLatest
{
var parser = _parserFactory();
parser.LoadFromStream(stream, desiredSection);
return parser.Configs;
if (configs is null)
{
return Array.Empty<IServiceConfiguration>();
}
return configs.Select(x => _mapper.Map<TServiceConfig>(x.Value) with {InstanceName = x.Key});
}
}

@ -0,0 +1,28 @@
using AutoMapper;
using JetBrains.Annotations;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Config.Parsing;
[UsedImplicitly]
public class ConfigurationMapperProfile : Profile
{
public ConfigurationMapperProfile()
{
CreateMap<QualityScoreConfigYamlLatest, QualityProfileScoreConfig>();
CreateMap<CustomFormatConfigYamlLatest, CustomFormatConfig>();
CreateMap<QualitySizeConfigYamlLatest, QualityDefinitionConfig>();
CreateMap<QualityProfileConfigYamlLatest, QualityProfileConfig>();
CreateMap<ReleaseProfileConfigYamlLatest, ReleaseProfileConfig>();
CreateMap<ReleaseProfileFilterConfigYamlLatest, SonarrProfileFilterConfig>();
CreateMap<ServiceConfigYamlLatest, ServiceConfiguration>()
.ForMember(x => x.InstanceName, o => o.Ignore());
CreateMap<RadarrConfigYamlLatest, RadarrConfiguration>()
.IncludeBase<ServiceConfigYamlLatest, ServiceConfiguration>();
CreateMap<SonarrConfigYamlLatest, SonarrConfiguration>()
.IncludeBase<ServiceConfigYamlLatest, ServiceConfiguration>();
}
}

@ -1,13 +0,0 @@
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Processors;
namespace Recyclarr.TrashLib.Config.Parsing;
public interface IConfigRegistry
{
int Count { get; }
bool DoesConfigExist(string name);
IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(ISyncSettings settings);
IEnumerable<IServiceConfiguration> GetAllConfigs();
IEnumerable<IServiceConfiguration> GetConfigsOfType(SupportedServices? serviceType);
}

@ -1,10 +1,14 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Config.Parsing;
public interface IConfigurationLoader
{
IConfigRegistry LoadMany(IEnumerable<IFileInfo> configFiles, string? desiredSection = null);
IConfigRegistry Load(IFileInfo file, string? desiredSection = null);
IConfigRegistry LoadFromStream(TextReader stream, string? desiredSection = null);
ICollection<IServiceConfiguration> LoadMany(
IEnumerable<IFileInfo> configFiles,
SupportedServices? desiredServiceType = null);
IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file, SupportedServices? desiredServiceType = null);
IReadOnlyCollection<IServiceConfiguration> Load(string yaml, SupportedServices? desiredServiceType = null);
}

@ -1,6 +0,0 @@
namespace Recyclarr.TrashLib.Config.Services.Radarr;
public class RadarrConfiguration : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Radarr;
}

@ -1,9 +0,0 @@
using FluentValidation;
using JetBrains.Annotations;
namespace Recyclarr.TrashLib.Config.Services.Radarr;
[UsedImplicitly]
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration>
{
}

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Config.Services;
public record RadarrConfiguration : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Radarr;
}

@ -1,20 +1,12 @@
using JetBrains.Annotations;
using YamlDotNet.Serialization;
namespace Recyclarr.TrashLib.Config.Services;
public abstract class ServiceConfiguration : IServiceConfiguration
public abstract record ServiceConfiguration : IServiceConfiguration
{
[YamlIgnore]
public abstract SupportedServices ServiceType { get; }
// Name is set dynamically by ConfigurationLoader
[YamlIgnore]
public string? InstanceName { get; set; }
[YamlIgnore]
public int LineNumber { get; set; }
public Uri BaseUrl { get; set; } = new("about:empty");
public string ApiKey { get; init; } = "";
@ -22,7 +14,7 @@ public abstract class ServiceConfiguration : IServiceConfiguration
new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats { get; [UsedImplicitly] init; }
public bool ReplaceExistingCustomFormats { get; init; } = true;
public bool ReplaceExistingCustomFormats { get; init; }
public QualityDefinitionConfig? QualityDefinition { get; init; }
@ -32,7 +24,7 @@ public abstract class ServiceConfiguration : IServiceConfiguration
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class CustomFormatConfig
public record CustomFormatConfig
{
public ICollection<string> TrashIds { get; init; } = new List<string>();
@ -41,15 +33,15 @@ public class CustomFormatConfig
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileScoreConfig
public record QualityProfileScoreConfig
{
public string Name { get; init; } = "";
public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; }
public bool? ResetUnmatchedScores { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig
public record QualityDefinitionConfig
{
public string Type { get; init; } = "";
public decimal? PreferredRatio { get; set; }
@ -57,17 +49,6 @@ public class QualityDefinitionConfig
public record QualityProfileConfig
{
[UsedImplicitly]
public QualityProfileConfig()
{
}
public QualityProfileConfig(string name, bool? resetUnmatchedScores)
{
Name = name;
ResetUnmatchedScores = resetUnmatchedScores;
}
// todo: Remove the setter later once reset_unmatched_scores is not in the cf.quality_profiles property anymore
public bool? ResetUnmatchedScores { get; set; }
public string Name { get; init; } = "";

@ -1,59 +0,0 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.FluentValidation;
using Recyclarr.TrashLib.Config.Services.Radarr;
using Recyclarr.TrashLib.Config.Services.Sonarr;
namespace Recyclarr.TrashLib.Config.Services;
[UsedImplicitly]
internal class ServiceConfigurationValidator : AbstractValidator<ServiceConfiguration>
{
public ServiceConfigurationValidator(
IValidator<SonarrConfiguration> sonarrValidator,
IValidator<RadarrConfiguration> radarrValidator)
{
RuleFor(x => x.InstanceName).NotEmpty();
RuleFor(x => x.LineNumber).NotEqual(0);
RuleFor(x => x.BaseUrl).Must(x => x.Scheme is "http" or "https")
.WithMessage("Property 'base_url' is required and must be a valid URL");
RuleFor(x => x.ApiKey).NotEmpty().WithMessage("Property 'api_key' is required");
RuleForEach(x => x.CustomFormats).SetValidator(new CustomFormatConfigValidator());
RuleFor(x => x.QualityDefinition).SetNonNullableValidator(new QualityDefinitionConfigValidator());
RuleFor(x => x).SetInheritanceValidator(x =>
{
x.Add(sonarrValidator);
x.Add(radarrValidator);
});
}
}
[UsedImplicitly]
internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig>
{
public CustomFormatConfigValidator()
{
RuleFor(x => x.TrashIds).NotEmpty()
.WithMessage("'custom_formats' elements must contain at least one element under 'trash_ids'");
RuleForEach(x => x.QualityProfiles).SetValidator(new QualityProfileScoreConfigValidator());
}
}
[UsedImplicitly]
internal class QualityProfileScoreConfigValidator : AbstractValidator<QualityProfileScoreConfig>
{
public QualityProfileScoreConfigValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("'name' is required for elements under 'quality_profiles'");
}
}
[UsedImplicitly]
internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig>
{
public QualityDefinitionConfigValidator()
{
RuleFor(x => x.Type).NotEmpty().WithMessage("'type' is required for 'quality_definition'");
}
}

@ -1,41 +0,0 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.TrashLib.Config.Services.Sonarr;
[UsedImplicitly]
public class SonarrConfigurationValidator : AbstractValidator<SonarrConfiguration>
{
public SonarrConfigurationValidator()
{
RuleForEach(x => x.ReleaseProfiles)
.Empty()
.When(x => x.CustomFormats.IsNotEmpty())
.WithMessage("`custom_formats` and `release_profiles` may not be used together");
RuleForEach(x => x.ReleaseProfiles).SetValidator(new ReleaseProfileConfigValidator());
}
}
[UsedImplicitly]
internal class ReleaseProfileConfigValidator : AbstractValidator<ReleaseProfileConfig>
{
public ReleaseProfileConfigValidator()
{
RuleFor(x => x.TrashIds).NotEmpty().WithMessage("'trash_ids' is required for 'release_profiles' elements");
RuleFor(x => x.Filter).SetNonNullableValidator(new SonarrProfileFilterConfigValidator());
}
}
[UsedImplicitly]
internal class SonarrProfileFilterConfigValidator : AbstractValidator<SonarrProfileFilterConfig>
{
public SonarrProfileFilterConfigValidator()
{
// Include & Exclude may not be used together
RuleFor(x => x.Include).Empty().When(x => x.Exclude.Any())
.WithMessage("`include` and `exclude` may not be used together.");
}
}

@ -1,8 +1,8 @@
using JetBrains.Annotations;
namespace Recyclarr.TrashLib.Config.Services.Sonarr;
namespace Recyclarr.TrashLib.Config.Services;
public class SonarrConfiguration : ServiceConfiguration
public record SonarrConfiguration : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Sonarr;

@ -1,5 +0,0 @@
namespace Recyclarr.TrashLib.Config.Yaml;
public class EmptyYamlException : Exception
{
}

@ -0,0 +1 @@
global using MoreLinq;

@ -1,5 +1,3 @@
using MoreLinq;
namespace Recyclarr.TrashLib.Pipelines.CustomFormat.Models;
public sealed class CustomFormatDataEqualityComparer : IEqualityComparer<CustomFormatData>

@ -51,7 +51,7 @@ public class QualityProfileConfigPhase
x => x.Name.EqualsIgnoreCase(profile.Name),
// If the user did not specify a quality profile in their config, we still create the QP object
// for consistency (at the very least for the name).
new QualityProfileConfig(profile.Name, false)));
new QualityProfileConfig {Name = profile.Name}));
allProfiles[profile.Name] = profileCfs;
}
@ -65,40 +65,46 @@ public class QualityProfileConfigPhase
private void ProcessLegacyResetUnmatchedScores(IServiceConfiguration config)
{
// todo: Remove this later; it is for backward compatibility
// Propagate the quality_profile version of ResetUnmatchedScores to the top-level quality_profile config.
var profilesThatNeedResetUnmatchedScores = config.CustomFormats
// todo: Remove this method later; it is for backward compatibility
var legacyResetUnmatchedScores = config.CustomFormats
.SelectMany(x => x.QualityProfiles)
.Where(x => x.ResetUnmatchedScores)
.Where(x => x.ResetUnmatchedScores is not null)
.ToList();
if (legacyResetUnmatchedScores.Count > 0)
{
_log.Warning(
"DEPRECATION: Support for using `reset_unmatched_scores` under `custom_formats.quality_profiles` " +
"will be removed in a future release. Move it to the top level `quality_profiles` instead");
}
// Propagate the quality_profile version of ResetUnmatchedScores to the top-level quality_profile config.
var profilesThatNeedResetUnmatchedScores = legacyResetUnmatchedScores
.Where(x => x.ResetUnmatchedScores is true)
.Select(x => x.Name)
.Distinct(StringComparer.InvariantCultureIgnoreCase);
var newQualityProfiles = config.QualityProfiles.ToList();
var logDeprecationMessage = false;
foreach (var profileName in profilesThatNeedResetUnmatchedScores)
{
var match = config.QualityProfiles.FirstOrDefault(x => x.Name.EqualsIgnoreCase(profileName));
if (match is null)
{
logDeprecationMessage = true;
newQualityProfiles.Add(new QualityProfileConfig(profileName, true));
_log.Debug(
"Root-level quality profile created to promote reset_unmatched_scores from CF score config: {Name}",
profileName);
newQualityProfiles.Add(new QualityProfileConfig {Name = profileName, ResetUnmatchedScores = true});
}
else if (match.ResetUnmatchedScores is null)
{
logDeprecationMessage = true;
_log.Debug(
"Score-based reset_unmatched_scores propagated to existing root-level " +
"quality profile config: {Name}", profileName);
match.ResetUnmatchedScores = true;
}
}
if (logDeprecationMessage)
{
_log.Warning(
"DEPRECATION: Support for using `reset_unmatched_scores` under `custom_formats.quality_profiles` " +
"will be removed in a future release. Move it to the top level `quality_profiles` instead");
}
// Down-cast to avoid having to make the property mutable in the interface
((ServiceConfiguration) config).QualityProfiles = newQualityProfiles;
}

@ -1,4 +1,3 @@
using MoreLinq;
using Recyclarr.TrashLib.Config;
using Spectre.Console;

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,5 +1,5 @@
using System.Collections.ObjectModel;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,4 +1,4 @@
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,5 +1,4 @@
using System.IO.Abstractions;
using MoreLinq;
using Newtonsoft.Json;
using Recyclarr.Common;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;

@ -1,5 +1,5 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile.Filters;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile.Guide;

@ -1,5 +1,4 @@
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Pipelines.ReleaseProfile.PipelinePhases;
using Recyclarr.TrashLib.Processors;

@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Pipelines.Tags.PipelinePhases;

@ -1,5 +1,4 @@
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Services.Sonarr;
using Recyclarr.TrashLib.Pipelines.Tags.PipelinePhases;
using Recyclarr.TrashLib.Processors;

@ -52,7 +52,7 @@ public class SyncProcessor : ISyncProcessor
return failureDetected ? ExitStatus.Failed : ExitStatus.Succeeded;
}
private void LogInvalidInstances(IEnumerable<string>? instanceNames, IConfigRegistry configs)
private void LogInvalidInstances(IEnumerable<string>? instanceNames, ICollection<IServiceConfiguration> configs)
{
var invalidInstances = instanceNames?
.Where(x => !configs.DoesConfigExist(x))
@ -64,18 +64,8 @@ public class SyncProcessor : ISyncProcessor
}
}
private async Task<bool> ProcessService(ISyncSettings settings, IConfigRegistry configs)
private async Task<bool> ProcessService(ISyncSettings settings, ICollection<IServiceConfiguration> configs)
{
var serviceConfigs = configs.GetConfigsBasedOnSettings(settings).ToList();
// If any config names are null, that means user specified array-style (deprecated) instances.
if (serviceConfigs.Any(x => x.InstanceName is null))
{
_log.Warning(
"Found array-style list of instances instead of named-style. " +
"Array-style lists of Sonarr/Radarr instances are deprecated");
}
foreach (var config in configs.GetConfigsBasedOnSettings(settings))
{
try

@ -22,5 +22,6 @@
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
<ProjectReference Include="..\Recyclarr.Config.Data\Recyclarr.Config.Data.csproj" />
</ItemGroup>
</Project>

@ -37,6 +37,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Recyclarr.Common.TestLibrar
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{18E17C53-F600-40AE-82C1-3CD1E547C307}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Config.Data", "Recyclarr.Config.Data\Recyclarr.Config.Data.csproj", "{32A46317-4D87-40BF-A83E-7F2CFFCDF70A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Config.Data.Tests", "Recyclarr.Config.Data.Tests\Recyclarr.Config.Data.Tests.csproj", "{D12D7A10-183F-4215-A3FE-207DC8CE30F5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -91,6 +95,14 @@ Global
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A92321B5-2796-467B-B5A5-2BFC41167A25}.Release|Any CPU.Build.0 = Release|Any CPU
{32A46317-4D87-40BF-A83E-7F2CFFCDF70A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{32A46317-4D87-40BF-A83E-7F2CFFCDF70A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{32A46317-4D87-40BF-A83E-7F2CFFCDF70A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{32A46317-4D87-40BF-A83E-7F2CFFCDF70A}.Release|Any CPU.Build.0 = Release|Any CPU
{D12D7A10-183F-4215-A3FE-207DC8CE30F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D12D7A10-183F-4215-A3FE-207DC8CE30F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D12D7A10-183F-4215-A3FE-207DC8CE30F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D12D7A10-183F-4215-A3FE-207DC8CE30F5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -107,5 +119,6 @@ Global
{BF105B2F-8E13-48AD-BF72-DF7EFEB018B6} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{33226068-65E3-4890-8671-59A56BA3F6F0} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{A4EC7E0D-C591-4874-B9AC-EB12A96F3E83} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{D12D7A10-183F-4215-A3FE-207DC8CE30F5} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
EndGlobalSection
EndGlobal

Loading…
Cancel
Save