fix: Greatly improved log filtering and validation behavior

- Dedicated "Filter" system for YAML configuration that selectively
  excludes instances with issues such as duplicate, invalid, or split
  instances.
- Greatly improved console output for configuration errors.
- Recyclarr is now better about not completely exiting when there's an
  issue in a single configuration instance.

Fixes #396
pull/415/head
Robert Dailey 3 weeks ago
parent ca8572b3a0
commit cf075dabbf

@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Improved error handling in YAML configuration as well as how those errors are rendered to console
output. (#396)
## [7.4.0] - 2024-11-11
### Added

@ -78,5 +78,6 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=radarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Recyclarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Recyclarr_0027s/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=renderables/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Servarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sonarr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

@ -14,6 +14,7 @@ using Recyclarr.Cli.Processors.Delete;
using Recyclarr.Cli.Processors.ErrorHandling;
using Recyclarr.Cli.Processors.Sync;
using Recyclarr.Common;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Logging;
using Serilog.Core;
using Spectre.Console;
@ -54,7 +55,6 @@ public static class CompositionRoot
builder.RegisterType<SyncProcessor>().As<ISyncProcessor>();
// Configuration
builder.RegisterType<ConfigManipulator>().As<IConfigManipulator>();
builder.RegisterType<ConfigCreationProcessor>().As<IConfigCreationProcessor>();
builder.RegisterType<ConfigListLocalProcessor>();
builder.RegisterType<ConfigListTemplateProcessor>();
@ -89,6 +89,7 @@ public static class CompositionRoot
builder.RegisterType<IndirectLoggerDecorator>().As<ILogger>();
builder.RegisterType<LogJanitor>();
builder.RegisterType<ValidationLogger>();
}
private static void CliRegistrations(ContainerBuilder builder)

@ -2,7 +2,6 @@ using System.Globalization;
using System.IO.Abstractions;
using Recyclarr.Config;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
using Recyclarr.Platform;
using Recyclarr.TrashGuide;
using Spectre.Console;
@ -12,18 +11,21 @@ namespace Recyclarr.Cli.Processors.Config;
public class ConfigListLocalProcessor(
IAnsiConsole console,
IConfigurationFinder configFinder,
IConfigurationLoader configLoader,
ConfigurationRegistry configRegistry,
IAppPaths paths
)
{
public void Process()
{
var tree = new Tree(paths.AppDataDirectory.ToString()!);
var allConfigs = configRegistry
.FindAndLoadConfigs()
.ToLookup(x => MakeRelative(x.YamlPath));
foreach (var configPath in configFinder.GetConfigFiles())
foreach (var pair in allConfigs)
{
var configs = configLoader.Load(configPath);
var path = pair.Key;
var configs = pair.ToList();
var rows = new List<IRenderable>();
BuildInstanceTree(rows, configs, SupportedServices.Radarr);
@ -35,10 +37,7 @@ public class ConfigListLocalProcessor(
}
var configTree = new Tree(
Markup.FromInterpolated(
CultureInfo.InvariantCulture,
$"[b]{MakeRelative(configPath)}[/]"
)
Markup.FromInterpolated(CultureInfo.InvariantCulture, $"[b]{path}[/]")
);
foreach (var r in rows)
{
@ -52,8 +51,13 @@ public class ConfigListLocalProcessor(
console.Write(tree);
}
private string MakeRelative(IFileInfo path)
private string MakeRelative(IFileInfo? path)
{
if (path is null)
{
return "<no path>";
}
var configPath = new Uri(path.FullName, UriKind.Absolute);
var configDir = new Uri(paths.ConfigsDirectory.FullName, UriKind.Absolute);
return configDir.MakeRelativeUri(configPath).ToString();
@ -65,7 +69,7 @@ public class ConfigListLocalProcessor(
SupportedServices service
)
{
var configs = registry.GetConfigsOfType(service).ToList();
var configs = registry.Where(x => x.ServiceType == service).ToList();
if (configs.Count == 0)
{
return;

@ -1,76 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Config.Parsing;
using Spectre.Console;
namespace Recyclarr.Cli.Processors.Config;
/// <remarks>
/// This was originally intended to be used by `config create`, but YamlDotNet cannot serialize
/// comments so this
/// class was not used. I kept it around in case I want to revisit later. There might be an
/// opportunity to use this
/// with the GUI.
/// </remarks>
public class ConfigManipulator(
IAnsiConsole console,
ConfigParser configParser,
ConfigSaver configSaver,
ConfigValidationExecutor validator
) : IConfigManipulator
{
private static Dictionary<string, TConfig> InvokeCallbackForEach<TConfig>(
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback,
IReadOnlyDictionary<string, TConfig>? configs
)
where TConfig : ServiceConfigYaml
{
var newConfigs = new Dictionary<string, TConfig>();
if (configs is null)
{
return newConfigs;
}
foreach (var (instanceName, config) in configs)
{
newConfigs[instanceName] = (TConfig)editCallback(instanceName, config);
}
return newConfigs;
}
public void LoadAndSave(
IFileInfo source,
IFileInfo destinationFile,
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback
)
{
// Parse & save the template file to address the following:
// - Find & report any syntax errors
// - Run validation & report issues
// - Consistently reformat the output file (when it is saved again)
// - Ignore stuff for diffing purposes, such as comments.
var config = configParser.Load<RootConfigYaml>(source);
if (config is null)
{
// Do not log here, since ConfigParser already has substantial logging
throw new FileLoadException("Problem while loading config template");
}
config = new RootConfigYaml
{
Radarr = InvokeCallbackForEach(editCallback, config.Radarr),
Sonarr = InvokeCallbackForEach(editCallback, config.Sonarr),
};
if (!validator.Validate(config, YamlValidatorRuleSets.RootConfig))
{
console.WriteLine(
"The configuration file will still be created, despite the previous validation errors. "
+ "You must open the file and correct the above issues before running a sync command."
);
}
configSaver.Save(config, destinationFile);
}
}

@ -1,13 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Config.Parsing;
namespace Recyclarr.Cli.Processors.Config;
public interface IConfigManipulator
{
void LoadAndSave(
IFileInfo source,
IFileInfo destinationFile,
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback
);
}

@ -4,6 +4,7 @@ using Autofac;
using Recyclarr.Cli.Console;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Config;
using Recyclarr.Config.Filtering;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.CustomFormat;
using Recyclarr.TrashGuide.CustomFormat;
@ -21,7 +22,7 @@ internal class CustomFormatConfigurationScope(ILifetimeScope scope) : Configurat
public class DeleteCustomFormatsProcessor(
ILogger log,
IAnsiConsole console,
IConfigurationRegistry configRegistry,
ConfigurationRegistry configRegistry,
ConfigurationScopeFactory scopeFactory
) : IDeleteCustomFormatsProcessor
{

@ -63,6 +63,10 @@ public class ConsoleExceptionHandler(ILogger log)
);
break;
case InvalidConfigurationException:
log.Error("One or more invalid configurations were found");
break;
case PostProcessingException e:
log.Error(e, "Configuration post-processing failed");
break;

@ -4,6 +4,7 @@ using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Pipelines;
using Recyclarr.Cli.Processors.ErrorHandling;
using Recyclarr.Config;
using Recyclarr.Config.Filtering;
using Recyclarr.Config.Models;
using Recyclarr.Notifications;
using Spectre.Console;
@ -19,7 +20,7 @@ public class SyncBasedConfigurationScope(ILifetimeScope scope) : ConfigurationSc
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public class SyncProcessor(
IAnsiConsole console,
IConfigurationRegistry configRegistry,
ConfigurationRegistry configRegistry,
ConfigurationScopeFactory configScopeFactory,
ConsoleExceptionHandler exceptionHandler,
NotificationService notify
@ -42,7 +43,7 @@ public class SyncProcessor(
new ConfigFilterCriteria
{
ManualConfigFiles = settings.Configs,
Instances = settings.Instances,
Instances = settings.Instances ?? [],
Service = settings.Service,
}
);

@ -26,7 +26,7 @@ internal static class Program
catch (Exception e)
{
var log = scope.Resolve<ILogger>();
var exceptionHandler = new ConsoleExceptionHandler(log);
var exceptionHandler = scope.Resolve<ConsoleExceptionHandler>();
if (!await exceptionHandler.HandleException(e))
{
log.Error(e, "Exiting due to fatal error");

@ -36,6 +36,7 @@ public class RuntimeValidationService : IRuntimeValidationService
var validatorSelector = new RulesetValidatorSelector(
[RulesetValidatorSelector.DefaultRuleSetName, .. additionalRuleSets]
);
return validator.Validate(
new ValidationContext<object>(instance, new PropertyChain(), validatorSelector)
);

@ -1,5 +1,7 @@
using FluentValidation;
using FluentValidation.Results;
using Recyclarr.Logging;
using Serilog.Context;
using Serilog.Events;
namespace Recyclarr.Common.FluentValidation;
@ -8,8 +10,15 @@ public class ValidationLogger(ILogger log)
{
private int _numErrors;
public bool LogValidationErrors(IEnumerable<ValidationFailure> errors, string errorPrefix)
public bool LogValidationErrors(
IEnumerable<ValidationFailure> errors,
string? errorPrefix = null
)
{
using var logScope = errorPrefix is not null
? LogContext.PushProperty(LogProperty.Scope, errorPrefix)
: null;
foreach (var error in errors)
{
var level = ToLogLevel(error.Severity);
@ -18,7 +27,7 @@ public class ValidationLogger(ILogger log)
++_numErrors;
}
log.Write(level, "{ErrorPrefix}: {Msg}", errorPrefix, error.ErrorMessage);
log.Write(level, "{Msg}", error.ErrorMessage);
}
return _numErrors > 0;

@ -1,20 +1,9 @@
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
using Recyclarr.TrashGuide;
namespace Recyclarr.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 bool IsConfigEmpty(this RootConfigYaml? config)
{
var sonarr = config?.Sonarr?.Count ?? 0;
@ -22,55 +11,41 @@ public static class ConfigExtensions
return sonarr + radarr == 0;
}
public static IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(
this IEnumerable<IServiceConfiguration> configs,
ConfigFilterCriteria criteria
)
{
// later, if we filter by "operation type" (e.g. CFs, quality sizes) it's just another
// ".Where()" in the LINQ expression below.
return configs
.GetConfigsOfType(criteria.Service)
.Where(x =>
criteria.Instances.IsEmpty()
|| criteria.Instances!.Any(y => y.EqualsIgnoreCase(x.InstanceName))
);
}
public static IEnumerable<string> GetSplitInstances(
this IEnumerable<IServiceConfiguration> configs
)
{
return configs
.GroupBy(x => x.BaseUrl)
.Where(x => x.Count() > 1)
.SelectMany(x => x.Select(y => y.InstanceName));
}
public static IEnumerable<string> GetInvalidInstanceNames(
this IEnumerable<IServiceConfiguration> configs,
ConfigFilterCriteria criteria
)
{
if (criteria.Instances is null || criteria.Instances.Count == 0)
{
return Array.Empty<string>();
}
var configInstances = configs.Select(x => x.InstanceName).ToList();
return criteria.Instances.Where(x =>
!configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase)
);
}
public static IEnumerable<string> GetDuplicateInstanceNames(
this IEnumerable<IServiceConfiguration> configs
)
{
return configs
.GroupBy(x => x.InstanceName, StringComparer.InvariantCultureIgnoreCase)
.Where(x => x.Count() > 1)
.Select(x => x.First().InstanceName)
.ToList();
}
// public static ICollection<string> GetSplitInstances(this IEnumerable<LoadedConfigYaml> configs)
// {
// return configs
// .GroupBy(x => x.Yaml.BaseUrl)
// .Where(x => x.Count() > 1)
// .SelectMany(x => x.Select(y => y.InstanceName))
// .ToList();
// }
// public static IEnumerable<string> GetNonExistentInstanceNames(
// this IEnumerable<LoadedConfigYaml> configs,
// ConfigFilterCriteria criteria
// )
// {
// if (criteria.Instances is not { Count: > 0 })
// {
// return [];
// }
//
// var names = configs.Select(x => x.InstanceName).ToList();
//
// return criteria.Instances.Where(x =>
// !names.Contains(x, StringComparer.InvariantCultureIgnoreCase)
// );
// }
// public static IEnumerable<string> GetDuplicateInstanceNames(
// this IReadOnlyCollection<LoadedConfigYaml> configs
// )
// {
// return configs
// .Select(x => x.InstanceName)
// .GroupBy(x => x, StringComparer.InvariantCultureIgnoreCase)
// .Where(x => x.Count() > 1)
// .Select(x => x.First())
// .ToList();
// }
}

@ -1,10 +0,0 @@
using Recyclarr.TrashGuide;
namespace Recyclarr.Config;
public record ConfigFilterCriteria
{
public IReadOnlyCollection<string>? ManualConfigFiles { get; init; }
public SupportedServices? Service { get; init; }
public IReadOnlyCollection<string>? Instances { get; init; }
}

@ -1,16 +1,21 @@
using System.IO.Abstractions;
using Recyclarr.Config.ExceptionTypes;
using AutoMapper;
using Recyclarr.Config.Filtering;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.ErrorHandling;
using Recyclarr.Logging;
using Serilog.Context;
namespace Recyclarr.Config;
public class ConfigurationRegistry(
IConfigurationLoader loader,
ConfigurationLoader loader,
IConfigurationFinder finder,
IFileSystem fs
) : IConfigurationRegistry
IFileSystem fs,
IMapper mapper,
ConfigFilterProcessor filterProcessor
)
{
public IReadOnlyCollection<IServiceConfiguration> FindAndLoadConfigs(
ConfigFilterCriteria? filterCriteria = null
@ -20,7 +25,7 @@ public class ConfigurationRegistry(
var manualConfigs = filterCriteria.ManualConfigFiles;
var configs =
manualConfigs is not null && manualConfigs.Count != 0
manualConfigs.Count != 0
? PrepareManualConfigs(manualConfigs)
: finder.GetConfigFiles();
@ -39,31 +44,39 @@ public class ConfigurationRegistry(
return configFiles[true].ToList();
}
private IEnumerable<IServiceConfiguration> LoadAndFilterConfigs(
private List<IServiceConfiguration> LoadAndFilterConfigs(
IEnumerable<IFileInfo> configs,
ConfigFilterCriteria filterCriteria
)
{
var loadedConfigs = configs.SelectMany(x => loader.Load(x)).ToList();
var loadedConfigs = configs
.SelectMany(loader.Load)
.Where(filterCriteria.InstanceMatchesCriteria)
.ToList();
var dupeInstances = loadedConfigs.GetDuplicateInstanceNames().ToList();
if (dupeInstances.Count != 0)
{
throw new DuplicateInstancesException(dupeInstances);
}
var filteredConfigs = filterProcessor.FilterAndRender(filterCriteria, loadedConfigs);
var invalidInstances = loadedConfigs.GetInvalidInstanceNames(filterCriteria).ToList();
if (invalidInstances.Count != 0)
{
throw new InvalidInstancesException(invalidInstances);
}
return filteredConfigs
.Select(x =>
{
using var logScope = LogContext.PushProperty(LogProperty.Scope, x.YamlPath);
return x.Yaml switch
{
RadarrConfigYaml => MapConfig<RadarrConfiguration>(x),
SonarrConfigYaml => MapConfig<SonarrConfiguration>(x),
_ => throw new InvalidOperationException("Unknown config type"),
};
})
.ToList();
}
var splitInstances = loadedConfigs.GetSplitInstances().ToList();
if (splitInstances.Count != 0)
private IServiceConfiguration MapConfig<TServiceConfig>(LoadedConfigYaml config)
where TServiceConfig : ServiceConfiguration
{
return mapper.Map<TServiceConfig>(config.Yaml) with
{
throw new SplitInstancesException(splitInstances);
}
return loadedConfigs.GetConfigsBasedOnSettings(filterCriteria);
InstanceName = config.InstanceName,
YamlPath = config.YamlPath,
};
}
}

@ -1,6 +1,7 @@
namespace Recyclarr.Config.ExceptionTypes;
public class DuplicateInstancesException(IReadOnlyCollection<string> instanceNames) : Exception
public class DuplicateInstancesException(IReadOnlyCollection<string> instanceNames)
: InvalidConfigurationException
{
public IReadOnlyCollection<string> InstanceNames { get; } = instanceNames;
}

@ -0,0 +1,3 @@
namespace Recyclarr.Config.ExceptionTypes;
public class InvalidConfigurationException : Exception;

@ -1,6 +1,7 @@
namespace Recyclarr.Config.ExceptionTypes;
public class InvalidInstancesException(IReadOnlyCollection<string> instanceNames) : Exception
public class InvalidInstancesException(IReadOnlyCollection<string> instanceNames)
: InvalidConfigurationException
{
public IReadOnlyCollection<string> InstanceNames { get; } = instanceNames;
}

@ -1,6 +1,7 @@
namespace Recyclarr.Config.ExceptionTypes;
public class SplitInstancesException(IReadOnlyCollection<string> instanceNames) : Exception
public class SplitInstancesException(IReadOnlyCollection<string> instanceNames)
: InvalidConfigurationException
{
public IReadOnlyCollection<string> InstanceNames { get; } = instanceNames;
}

@ -0,0 +1,20 @@
using Recyclarr.Config.Parsing;
using Recyclarr.TrashGuide;
namespace Recyclarr.Config.Filtering;
public record ConfigFilterCriteria
{
public IReadOnlyCollection<string> ManualConfigFiles { get; init; } = [];
public SupportedServices? Service { get; init; }
public IReadOnlyCollection<string> Instances { get; init; } = [];
public bool InstanceMatchesCriteria(LoadedConfigYaml loadedConfig)
{
return (Service is null || Service == loadedConfig.ServiceType)
&& (
Instances.Count == 0
|| Instances.Contains(loadedConfig.InstanceName, StringComparer.OrdinalIgnoreCase)
);
}
}

@ -0,0 +1,38 @@
using Recyclarr.Config.Parsing;
using Spectre.Console;
namespace Recyclarr.Config.Filtering;
public class ConfigFilterProcessor(IAnsiConsole console, IEnumerable<IConfigFilter> filters)
{
public IReadOnlyCollection<LoadedConfigYaml> FilterAndRender(
ConfigFilterCriteria criteria,
IReadOnlyCollection<LoadedConfigYaml> configs
)
{
var context = new FilterContext();
var filteredConfigs = filters.Aggregate(
configs,
(current, filter) => filter.Filter(criteria, current, context)
);
var renderables = context
.Results.Select(x => new Padder(x.Render()).Padding(0, 0, 0, 1))
.ToList();
if (renderables.Count != 0)
{
var main = new Panel(new Padder(new Rows(renderables).Collapse()).PadBottom(0))
.Collapse()
.Header("[red]Configuration Errors[/]")
.RoundedBorder();
var column = new Columns(main);
console.Write(column);
}
return filteredConfigs;
}
}

@ -0,0 +1,49 @@
using Recyclarr.Config.Parsing;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Config.Filtering;
public class DuplicateInstancesFilter(ILogger log) : IConfigFilter
{
public IReadOnlyCollection<LoadedConfigYaml> Filter(
ConfigFilterCriteria criteria,
IReadOnlyCollection<LoadedConfigYaml> configs,
FilterContext context
)
{
var duplicateInstances = configs
.Select(x => x.InstanceName)
.GroupBy(x => x, StringComparer.InvariantCultureIgnoreCase)
.Where(x => x.Count() > 1)
.Select(x => x.First())
.ToList();
if (duplicateInstances.Count != 0)
{
context.AddResult(new DuplicateInstancesFilterResult(duplicateInstances));
log.Debug("Duplicate instances: {Instances}", duplicateInstances);
}
return configs
.ExceptBy(
duplicateInstances,
x => x.InstanceName,
StringComparer.InvariantCultureIgnoreCase
)
.ToList();
}
}
public class DuplicateInstancesFilterResult(IReadOnlyCollection<string> duplicateInstances)
: IFilterResult
{
public IReadOnlyCollection<string> DuplicateInstances => duplicateInstances;
public IRenderable Render()
{
var tree = new Tree("[orange1]Duplicate Instances[/]");
tree.AddNodes(duplicateInstances);
return tree;
}
}

@ -0,0 +1,10 @@
namespace Recyclarr.Config.Filtering;
public class FilterContext
{
private readonly List<IFilterResult> _results = [];
public IReadOnlyCollection<IFilterResult> Results => _results;
public void AddResult(IFilterResult result) => _results.Add(result);
}

@ -0,0 +1,12 @@
using Recyclarr.Config.Parsing;
namespace Recyclarr.Config.Filtering;
public interface IConfigFilter
{
IReadOnlyCollection<LoadedConfigYaml> Filter(
ConfigFilterCriteria criteria,
IReadOnlyCollection<LoadedConfigYaml> configs,
FilterContext context
);
}

@ -0,0 +1,8 @@
using Spectre.Console.Rendering;
namespace Recyclarr.Config.Filtering;
public interface IFilterResult
{
IRenderable Render();
}

@ -0,0 +1,97 @@
using FluentValidation;
using FluentValidation.Results;
using Recyclarr.Config.Parsing;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Config.Filtering;
public record ConfigValidationErrorInfo(
string InstanceName,
IReadOnlyCollection<ValidationFailure> Failures
);
public class InvalidInstancesFilter(ILogger log, IValidator<ServiceConfigYaml> validator)
: IConfigFilter
{
public IReadOnlyCollection<LoadedConfigYaml> Filter(
ConfigFilterCriteria criteria,
IReadOnlyCollection<LoadedConfigYaml> configs,
FilterContext context
)
{
var invalid = configs
.Select(config =>
(
config.InstanceName,
Result: validator.Validate(
config.Yaml,
options =>
options
.IncludeRulesNotInRuleSet()
.IncludeRuleSets(YamlValidatorRuleSets.RootConfig)
)
)
)
.Where(x => !x.Result.IsValid)
.Select(r => new ConfigValidationErrorInfo(r.InstanceName, r.Result.Errors))
.ToList();
if (invalid.Count != 0)
{
context.AddResult(new InvalidInstancesFilterResult(invalid));
log.Debug(
"Invalid instances: {@Instances}",
invalid.Select(x => new
{
x.InstanceName,
Errors = x.Failures.Select(f => f.ErrorMessage),
})
);
}
return configs
.ExceptBy(
invalid.Select(x => x.InstanceName),
x => x.InstanceName,
StringComparer.InvariantCultureIgnoreCase
)
.ToList();
}
}
public class InvalidInstancesFilterResult(
IReadOnlyCollection<ConfigValidationErrorInfo> invalidInstances
) : IFilterResult
{
public IReadOnlyCollection<ConfigValidationErrorInfo> InvalidInstances => invalidInstances;
public IRenderable Render()
{
var tree = new Tree("[orange1]Invalid Instances[/]");
foreach (var (instanceName, failures) in invalidInstances)
{
var instanceNode = tree.AddNode($"[cornflowerblue]{instanceName}[/]");
foreach (var f in failures)
{
var prefix = GetSeverityPrefix(f.Severity);
instanceNode.AddNode($"{prefix} {f.ErrorMessage}");
}
}
return tree;
}
private static string GetSeverityPrefix(Severity severity)
{
return severity switch
{
Severity.Error => "[red]X[/]",
Severity.Warning => "[yellow]![/]",
Severity.Info => "[blue]i[/]",
_ => "[grey]?[/]",
};
}
}

@ -0,0 +1,42 @@
using Recyclarr.Config.Parsing;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Config.Filtering;
public class NonExistentInstancesFilter(ILogger log) : IConfigFilter
{
public IReadOnlyCollection<LoadedConfigYaml> Filter(
ConfigFilterCriteria criteria,
IReadOnlyCollection<LoadedConfigYaml> configs,
FilterContext context
)
{
if (criteria.Instances is { Count: > 0 })
{
var names = configs.Select(x => x.InstanceName).ToList();
var nonExistentInstances = criteria
.Instances.Where(x => !names.Contains(x, StringComparer.InvariantCultureIgnoreCase))
.ToList();
context.AddResult(new NonExistentInstancesFilterResult(nonExistentInstances));
log.Debug("Non-existent instances: {Instances}", nonExistentInstances);
}
return configs;
}
}
public class NonExistentInstancesFilterResult(IReadOnlyCollection<string> nonExistentInstances)
: IFilterResult
{
public IReadOnlyCollection<string> NonExistentInstances => nonExistentInstances;
public IRenderable Render()
{
var tree = new Tree("[orange1]Non-Existent Instances[/]");
tree.AddNodes(nonExistentInstances);
return tree;
}
}

@ -0,0 +1,65 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Config.Parsing;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Config.Filtering;
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings")]
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings")]
public record SplitInstanceErrorInfo(string BaseUrl, IReadOnlyCollection<string> InstanceNames);
public class SplitInstancesFilter(ILogger log) : IConfigFilter
{
public IReadOnlyCollection<LoadedConfigYaml> Filter(
ConfigFilterCriteria criteria,
IReadOnlyCollection<LoadedConfigYaml> configs,
FilterContext context
)
{
var splitInstances = configs
.Where(x => x.Yaml.BaseUrl is not null)
.GroupBy(x => x.Yaml.BaseUrl!)
.Where(x => x.Count() > 1)
.Select(x => new SplitInstanceErrorInfo(x.Key, x.Select(y => y.InstanceName).ToList()))
.ToList();
if (splitInstances.Count != 0)
{
context.AddResult(new SplitInstancesFilterResult(splitInstances));
log.Debug(
"Split instances: {@Instances}",
// Anonymous object to avoid "$type" property in logs
splitInstances.Select(x => new { x.BaseUrl, x.InstanceNames })
);
}
return configs
.ExceptBy(
splitInstances.SelectMany(x => x.InstanceNames),
x => x.InstanceName,
StringComparer.InvariantCultureIgnoreCase
)
.ToList();
}
}
public class SplitInstancesFilterResult(IReadOnlyCollection<SplitInstanceErrorInfo> splitInstances)
: IFilterResult
{
public IReadOnlyCollection<SplitInstanceErrorInfo> SplitInstances => splitInstances;
public IRenderable Render()
{
var tree = new Tree("[orange1]Split Instances[/]");
foreach (var (baseUrl, instanceNames) in splitInstances)
{
var instanceTree = new Tree($"[cornflowerblue]Base URL:[/] {baseUrl}");
instanceTree.AddNodes(instanceNames);
tree.AddNode(instanceTree);
}
return tree;
}
}

@ -1,10 +0,0 @@
using Recyclarr.Config.Models;
namespace Recyclarr.Config;
public interface IConfigurationRegistry
{
IReadOnlyCollection<IServiceConfiguration> FindAndLoadConfigs(
ConfigFilterCriteria? filterCriteria = null
);
}

@ -1,9 +1,11 @@
using System.IO.Abstractions;
using Recyclarr.TrashGuide;
namespace Recyclarr.Config.Models;
public interface IServiceConfiguration
{
IFileInfo? YamlPath { get; }
SupportedServices ServiceType { get; }
string InstanceName { get; }
Uri BaseUrl { get; }
@ -11,7 +13,6 @@ public interface IServiceConfiguration
bool DeleteOldCustomFormats { get; }
ICollection<CustomFormatConfig> CustomFormats { get; }
QualityDefinitionConfig? QualityDefinition { get; }
IReadOnlyCollection<QualityProfileConfig> QualityProfiles { get; }
bool ReplaceExistingCustomFormats { get; }
}

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.TrashGuide;
namespace Recyclarr.Config.Models;
@ -6,6 +7,7 @@ public abstract record ServiceConfiguration : IServiceConfiguration
{
public abstract SupportedServices ServiceType { get; }
public required string InstanceName { get; init; }
public IFileInfo? YamlPath { get; init; }
public Uri BaseUrl { get; set; } = new("about:empty");
public string ApiKey { get; init; } = "";

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
using YamlDotNet.Serialization;
@ -82,16 +83,14 @@ public record ServiceConfigYaml
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record RootConfigYaml
{
public IReadOnlyDictionary<string, RadarrConfigYaml>? Radarr { get; init; }
public IReadOnlyDictionary<string, SonarrConfigYaml>? Sonarr { get; init; }
public IReadOnlyDictionary<string, RadarrConfigYaml?>? Radarr { get; set; }
public IReadOnlyDictionary<string, SonarrConfigYaml?>? Sonarr { get; set; }
// This exists for validation purposes only.
[YamlIgnore]
public IEnumerable<RadarrConfigYaml> RadarrValues =>
Radarr?.Select(x => x.Value) ?? Array.Empty<RadarrConfigYaml>();
public IEnumerable<RadarrConfigYaml> RadarrValues => Radarr?.Values.NotNull() ?? [];
// This exists for validation purposes only.
[YamlIgnore]
public IEnumerable<SonarrConfigYaml> SonarrValues =>
Sonarr?.Select(x => x.Value) ?? Array.Empty<SonarrConfigYaml>();
public IEnumerable<SonarrConfigYaml> SonarrValues => Sonarr?.Values.NotNull() ?? [];
}

@ -237,12 +237,3 @@ public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
Include(new ServiceConfigYamlValidator());
}
}
public class RootConfigYamlValidator : CustomValidator<RootConfigYaml>
{
public RootConfigYamlValidator()
{
RuleForEach(x => x.RadarrValues).SetValidator(new RadarrConfigYamlValidator());
RuleForEach(x => x.SonarrValues).SetValidator(new SonarrConfigYamlValidator());
}
}

@ -30,7 +30,8 @@ public class ConfigYamlMapperProfile : Profile
.ForMember(x => x.ResetUnmatchedScores, o => o.UseDestinationValue());
CreateMap<ServiceConfigYaml, ServiceConfiguration>()
.ForMember(x => x.InstanceName, o => o.Ignore());
.ForMember(x => x.InstanceName, o => o.Ignore())
.ForMember(x => x.YamlPath, o => o.Ignore());
CreateMap<RadarrConfigYaml, RadarrConfiguration>()
.IncludeBase<ServiceConfigYaml, ServiceConfiguration>()

@ -1,41 +1,49 @@
using System.IO.Abstractions;
using AutoMapper;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing.PostProcessing;
using Recyclarr.Logging;
using Recyclarr.TrashGuide;
using Serilog.Context;
namespace Recyclarr.Config.Parsing;
public record LoadedConfigYaml(
string InstanceName,
SupportedServices ServiceType,
ServiceConfigYaml Yaml
)
{
public IFileInfo? YamlPath { get; init; }
}
public class ConfigurationLoader(
ILogger log,
ConfigParser parser,
IMapper mapper,
ConfigValidationExecutor validator,
IOrderedEnumerable<IConfigPostProcessor> postProcessors
) : IConfigurationLoader
)
{
public IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file)
public IReadOnlyCollection<LoadedConfigYaml> Load(IFileInfo file)
{
using var logScope = LogContext.PushProperty(LogProperty.Scope, file.Name);
return ProcessLoadedConfigs(parser.Load<RootConfigYaml>(file));
return ProcessLoadedConfigs(parser.Load<RootConfigYaml>(file))
.Select(x => x with { YamlPath = file })
.ToList();
}
public IReadOnlyCollection<IServiceConfiguration> Load(string yaml)
public IReadOnlyCollection<LoadedConfigYaml> Load(string yaml)
{
return ProcessLoadedConfigs(parser.Load<RootConfigYaml>(yaml));
}
public IReadOnlyCollection<IServiceConfiguration> Load(Func<TextReader> streamFactory)
public IReadOnlyCollection<LoadedConfigYaml> Load(Func<TextReader> streamFactory)
{
return ProcessLoadedConfigs(parser.Load<RootConfigYaml>(streamFactory));
}
private IReadOnlyCollection<IServiceConfiguration> ProcessLoadedConfigs(RootConfigYaml? config)
private List<LoadedConfigYaml> ProcessLoadedConfigs(RootConfigYaml? config)
{
if (config is null)
{
return Array.Empty<IServiceConfiguration>();
return [];
}
config = postProcessors.Aggregate(
@ -48,33 +56,21 @@ public class ConfigurationLoader(
log.Warning("Configuration is empty");
}
if (!validator.Validate(config, YamlValidatorRuleSets.RootConfig))
{
return Array.Empty<IServiceConfiguration>();
}
var convertedConfigs = new List<IServiceConfiguration>();
convertedConfigs.AddRange(MapConfigs<RadarrConfigYaml, RadarrConfiguration>(config.Radarr));
convertedConfigs.AddRange(MapConfigs<SonarrConfigYaml, SonarrConfiguration>(config.Sonarr));
return convertedConfigs;
}
return Enumerable
.Empty<LoadedConfigYaml>()
.Concat(AsLoadedConfig(config.Radarr, SupportedServices.Radarr))
.Concat(AsLoadedConfig(config.Sonarr, SupportedServices.Sonarr))
.ToList();
private IEnumerable<IServiceConfiguration> MapConfigs<TConfigYaml, TServiceConfig>(
IReadOnlyDictionary<string, TConfigYaml>? configs
)
where TServiceConfig : ServiceConfiguration
where TConfigYaml : ServiceConfigYaml
{
if (configs is null)
IEnumerable<LoadedConfigYaml> AsLoadedConfig<T>(
IReadOnlyDictionary<string, T?>? configs,
SupportedServices serviceType
)
where T : ServiceConfigYaml
{
return Array.Empty<IServiceConfiguration>();
return configs
?.Where(x => x.Value is not null)
.Select(kvp => new LoadedConfigYaml(kvp.Key, serviceType, kvp.Value!)) ?? [];
}
return configs.Select(x =>
mapper.Map<TServiceConfig>(x.Value) with
{
InstanceName = x.Key,
}
);
}
}

@ -1,10 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Config.Models;
namespace Recyclarr.Config.Parsing;
public interface IConfigurationLoader
{
IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file);
IReadOnlyCollection<IServiceConfiguration> Load(string yaml);
}

@ -9,9 +9,14 @@ public class ConfigDeprecations(IOrderedEnumerable<IConfigDeprecationCheck> depr
"S3267: Loops should be simplified with LINQ expressions",
Justification = "The 'Where' condition must happen after each Transform() call instead of all at once"
)]
public T CheckAndTransform<T>(T include)
public T? CheckAndTransform<T>(T? include)
where T : ServiceConfigYaml
{
if (include is null)
{
return include;
}
foreach (var check in deprecationChecks)
{
if (check.CheckIfNeeded(include))

@ -14,15 +14,20 @@ public class ImplicitUrlAndKeyPostProcessor(ILogger log, ISecretsProvider secret
};
}
private Dictionary<string, T>? ProcessService<T>(IReadOnlyDictionary<string, T>? services)
private Dictionary<string, T?>? ProcessService<T>(IReadOnlyDictionary<string, T?>? services)
where T : ServiceConfigYaml
{
return services?.ToDictionary(x => x.Key, x => FillUrlAndKey(x.Key, x.Value));
}
private T FillUrlAndKey<T>(string instanceName, T config)
private T? FillUrlAndKey<T>(string instanceName, T? config)
where T : ServiceConfigYaml
{
if (config is null)
{
return null;
}
return config with
{
ApiKey = config.ApiKey ?? GetSecret(instanceName, "api_key"),

@ -47,8 +47,8 @@ public sealed class IncludePostProcessor(
return config;
}
private Dictionary<string, T>? ProcessIncludes<T>(
IReadOnlyDictionary<string, T>? configs,
private Dictionary<string, T?>? ProcessIncludes<T>(
IReadOnlyDictionary<string, T?>? configs,
ServiceConfigMerger<T> merger,
SupportedServices serviceType
)
@ -59,11 +59,11 @@ public sealed class IncludePostProcessor(
return null;
}
var mergedConfigs = new Dictionary<string, T>();
var mergedConfigs = new Dictionary<string, T?>();
foreach (var (key, config) in configs)
{
if (config.Include is null)
if (config?.Include is null)
{
mergedConfigs.Add(key, config);
continue;
@ -76,7 +76,7 @@ public sealed class IncludePostProcessor(
var include = LoadYamlInclude<T>(x, serviceType);
return deprecations.CheckAndTransform(include);
})
.Aggregate(new T(), merger.Merge);
.Aggregate(new T(), merger.Merge!);
// Merge the config into the aggregated includes so that root config values overwrite included values.
mergedConfigs.Add(

@ -10,6 +10,7 @@ using Recyclarr.Compatibility;
using Recyclarr.Compatibility.Radarr;
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config;
using Recyclarr.Config.Filtering;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
@ -99,14 +100,26 @@ public class CoreAutofacModule : Module
builder.RegisterType<SecretsProvider>().As<ISecretsProvider>().SingleInstance();
builder.RegisterType<YamlIncludeResolver>().As<IYamlIncludeResolver>();
builder.RegisterType<ConfigurationRegistry>().As<IConfigurationRegistry>();
builder.RegisterType<ConfigurationLoader>().As<IConfigurationLoader>();
builder.RegisterType<ConfigurationRegistry>();
builder.RegisterType<ConfigurationLoader>();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();
builder.RegisterType<ConfigValidationExecutor>();
builder.RegisterType<ConfigParser>();
builder.RegisterType<ConfigSaver>();
builder.RegisterType<ConfigurationScopeFactory>();
// Filter Processors
builder.RegisterType<ConfigFilterProcessor>();
builder
.RegisterTypes(
typeof(NonExistentInstancesFilter),
typeof(DuplicateInstancesFilter),
typeof(SplitInstancesFilter),
typeof(InvalidInstancesFilter)
)
.As<IConfigFilter>()
.OrderByRegistration();
// Keyed include processors
builder
.RegisterType<ConfigIncludeProcessor>()
@ -136,11 +149,12 @@ public class CoreAutofacModule : Module
.As<IConfigDeprecationCheck>()
.OrderByRegistration();
builder.RegisterType<RootConfigYamlValidator>().As<IValidator>();
// These validators are required by IncludePostProcessor
builder.RegisterType<RadarrConfigYamlValidator>().As<IValidator>();
builder.RegisterType<SonarrConfigYamlValidator>().As<IValidator>();
// Required by ConfigurationRegistry
builder.RegisterType<ServiceConfigYamlValidator>().As<IValidator<ServiceConfigYaml>>();
}
private static void RegisterHttp(ContainerBuilder builder)

@ -1,49 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Cli.Processors.Config;
namespace Recyclarr.Cli.IntegrationTests;
[TestFixture]
internal class ConfigManipulatorTest : CliIntegrationFixture
{
[Test]
public void Create_file_when_no_file_already_exists()
{
var sut = Resolve<ConfigManipulator>();
var src = Fs.CurrentDirectory().File("template.yml");
var dst = Fs.CurrentDirectory().SubDirectory("one", "two", "three").File("config.yml");
const string yamlData = """
sonarr:
instance1:
base_url: http://localhost:80
api_key: 123abc
""";
Fs.AddFile(src, new MockFileData(yamlData));
sut.LoadAndSave(src, dst, (_, yaml) => yaml);
Fs.AllFiles.Should().Contain(dst.FullName);
}
[Test]
public void Throw_on_invalid_yaml()
{
var sut = Resolve<ConfigManipulator>();
var src = Fs.CurrentDirectory().File("template.yml");
var dst = Fs.CurrentDirectory().File("config.yml");
const string yamlData = """
sonarr:
instance1:
invalid: yaml
""";
Fs.AddFile(src, new MockFileData(yamlData));
var act = () => sut.LoadAndSave(src, dst, (_, yaml) => yaml);
act.Should().Throw<FileLoadException>();
}
}

@ -1,30 +0,0 @@
using Recyclarr.Cli.Console.Commands;
using Recyclarr.Repo;
namespace Recyclarr.Cli.Tests.Console.Commands;
[TestFixture]
public class ConfigCommandsTest
{
[Test, AutoMockData]
public async Task Repo_update_is_called_on_config_list(
[Frozen] IMultiRepoUpdater updater,
ConfigListLocalCommand sut
)
{
await sut.ExecuteAsync(default!, new ConfigListLocalCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
[Test, AutoMockData]
public async Task Repo_update_is_called_on_config_create(
[Frozen] IMultiRepoUpdater updater,
ConfigCreateCommand sut
)
{
await sut.ExecuteAsync(default!, new ConfigCreateCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
}

@ -1,30 +0,0 @@
using Recyclarr.Cli.Console.Commands;
using Recyclarr.Repo;
namespace Recyclarr.Cli.Tests.Console.Commands;
[TestFixture]
public class ListCommandsTest
{
[Test, AutoMockData]
public async Task Repo_update_is_called_on_list_custom_formats(
[Frozen] IMultiRepoUpdater updater,
ListCustomFormatsCommand sut
)
{
await sut.ExecuteAsync(default!, new ListCustomFormatsCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
[Test, AutoMockData]
public async Task Repo_update_is_called_on_list_qualities(
[Frozen] IMultiRepoUpdater updater,
ListQualitiesCommand sut
)
{
await sut.ExecuteAsync(default!, new ListQualitiesCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
}

@ -1,67 +0,0 @@
{
"name": "Optionals",
"trash_id": "76e060895c5b8a765c310933da0a5357",
"ignored": [{
"name": "Golden rule",
"trash_id": "cec8880b847dd5d31d29167ee0112b57",
"term": "/^(?=.*(1080|720))(?=.*((x|h)[ ._-]?265|hevc)).*/i"
}, {
"name": "Ignore Dolby Vision without HDR10 fallback.",
"trash_id": "436f5a7d08fbf02ba25cb5e5dfe98e55",
"term": "/^(?!.*(HDR|HULU|REMUX))(?=.*\\b(DV|Dovi|Dolby[- .]?Vision)\\b).*/i"
}, {
"name": "Ignore The Group -SCENE",
"trash_id": "f3f0f3691c6a1988d4a02963e69d11f2",
"term": "/\\b(-scene)\\b/i"
}, {
"name": "Ignore so called scene releases",
"trash_id": "5bc23c3a055a1a5d8bbe4fb49d80e0cb",
"term": "/^(?!.*(web[ ]dl|-deflate|-inflate))(?=.*([_. ]WEB[_. ]|-CAKES\\b|-GGEZ\\b|-GGWP\\b|-GLHF\\b|-GOSSIP\\b|-KOGI\\b|-PECULATE\\b)).*/i"
}, {
"name": "Dislike Bad Dual Audio Groups",
"trash_id": "538bad00ee6f8aced8e0db5218b8484c",
"term": "/\\b(-alfaHD|-BAT|-BNd|-C\\.A\\.A|-Cory|-FF|-FOXX|-G4RiS|-GUEIRA|-N3G4N|-PD|-RiPER|-RK|-SiGLA|-Tars|-WTV|-Yatogam1|-YusukeFLA)\\b/i"
}],
"required": [],
"preferred": [{
"score": 15,
"terms": [{
"name": "Prefer Season Packs",
"trash_id": "ea83f4740cec4df8112f3d6dd7c82751",
"term": "/\\bS\\d+\\b(?!E\\d+\\b)/i"
}]
}, {
"score": 10,
"terms": [{
"name": "Prefer HDR",
"trash_id": "bc7a6383cbe88c3ee2d6396e1aacc0b3",
"term": "/\\bHDR(\\b|\\d)/i"
}]
}, {
"score": 100,
"terms": [{
"name": "Prefer Dolby Vision",
"trash_id": "fa47da3377076d82d07c4e95b3f13d07",
"term": "/\\b(dv|dovi|dolby[ .]?vision)\\b/i"
}]
}, {
"score": -25,
"terms": [{
"name": "Dislike retags: rartv, rarbg, eztv, TGx",
"trash_id": "6f2aefa61342a63387f2a90489e90790",
"term": "/(\\[rartv\\]|\\[rarbg\\]|\\[eztv\\]|\\[TGx\\])/i"
}, {
"name": "Dislike retagged groups",
"trash_id": "19cd5ecc0a24bf493a75e80a51974cdd",
"term": "/(-4P|-4Planet|-AsRequested|-BUYMORE|-CAPTCHA|-Chamele0n|-GEROV|-iNC0GNiTO|-NZBGeek|-Obfuscated|-postbot|-Rakuv|-Scrambled|-WhiteRev|-WRTEAM|-xpost)\\b/i"
}, {
"name": "Dislike release ending: en",
"trash_id": "6a7b462c6caee4a991a9d8aa38ce2405",
"term": "/\\s?\\ben\\b$/i"
}, {
"name": "Dislike release containing: 1-",
"trash_id": "236a3626a07cacf5692c73cc947bc280",
"term": "/(?<!\\d\\.)(1-.+)$/i"
}]
}]
}

@ -13,7 +13,7 @@ public class ConfigSaverTest : IntegrationTestFixture
var sut = Resolve<ConfigSaver>();
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
@ -37,7 +37,7 @@ public class ConfigSaverTest : IntegrationTestFixture
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",

@ -27,7 +27,11 @@ public class ConfigurationLoaderEnvVarTest : IntegrationTestFixture
config
.Should()
.BeEquivalentTo([new { BaseUrl = new Uri("http://the_url"), ApiKey = "the_api_key" }]);
.ContainSingle()
.Which.Yaml.Should()
.BeEquivalentTo(
new ServiceConfigYaml { BaseUrl = "http://the_url", ApiKey = "the_api_key" }
);
}
[Test]
@ -43,7 +47,8 @@ public class ConfigurationLoaderEnvVarTest : IntegrationTestFixture
""";
var config = sut.Load(testYml);
config.Should().BeEquivalentTo([new { BaseUrl = new Uri("http://sonarr:1233") }]);
config.Should().ContainSingle().Which.Yaml.BaseUrl.Should().Be("http://sonarr:1233");
}
[Test]
@ -62,7 +67,8 @@ public class ConfigurationLoaderEnvVarTest : IntegrationTestFixture
""";
var config = sut.Load(testYml);
config.Should().BeEquivalentTo([new { BaseUrl = new Uri("http://somevalue") }]);
config.Should().ContainSingle().Which.Yaml.BaseUrl.Should().Be("http://somevalue");
}
[Test]
@ -83,7 +89,11 @@ public class ConfigurationLoaderEnvVarTest : IntegrationTestFixture
var config = sut.Load(testYml);
config
.Should()
.BeEquivalentTo([new { BaseUrl = new Uri("http://theurl"), ApiKey = "the key" }]);
.ContainSingle()
.Which.Yaml.Should()
.BeEquivalentTo(
new ServiceConfigYaml { BaseUrl = "http://theurl", ApiKey = "the key" }
);
}
[Test]
@ -99,7 +109,7 @@ public class ConfigurationLoaderEnvVarTest : IntegrationTestFixture
""";
var config = sut.Load(testYml);
config.Should().BeEquivalentTo([new { BaseUrl = new Uri("http://somevalue") }]);
config.Should().ContainSingle().Which.Yaml.BaseUrl.Should().Be("http://somevalue");
}
[Test]
@ -115,7 +125,7 @@ public class ConfigurationLoaderEnvVarTest : IntegrationTestFixture
""";
var config = sut.Load(testYml);
config.Should().BeEquivalentTo([new { BaseUrl = new Uri("http://somevalue") }]);
config.Should().ContainSingle().Which.Yaml.BaseUrl.Should().Be("http://somevalue");
}
[Test]

@ -1,5 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.Config;
using Recyclarr.Config.Parsing;
using Recyclarr.TestLibrary;
using Recyclarr.TrashGuide;
@ -34,22 +33,25 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
Paths.AppDataDirectory.File("secrets.yml").FullName,
new MockFileData(secretsYml)
);
var expected = new[]
{
new
{
InstanceName = "instance1",
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("https://radarr:7878"),
CustomFormats = new[] { new { TrashIds = new[] { "1234567" } } },
},
};
configLoader
.Load(() => new StringReader(testYml))
.GetConfigsOfType(SupportedServices.Sonarr)
var results = configLoader.Load(() => new StringReader(testYml));
results
.Should()
.BeEquivalentTo(expected);
.ContainSingle()
.Which.Should()
.BeEquivalentTo(
new LoadedConfigYaml(
"instance1",
SupportedServices.Sonarr,
new SonarrConfigYaml
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "https://radarr:7878",
CustomFormats = [new CustomFormatConfigYaml { TrashIds = ["1234567"] }],
}
)
);
}
[Test]
@ -73,9 +75,8 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
configLoader
.Load(() => new StringReader(testYml))
.GetConfigsOfType(SupportedServices.Sonarr)
.Should()
.BeEmpty();
.NotContain(x => x.ServiceType == SupportedServices.Sonarr);
}
[Test]
@ -92,9 +93,8 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
configLoader
.Load(() => new StringReader(testYml))
.GetConfigsOfType(SupportedServices.Sonarr)
.Should()
.BeEmpty();
.NotContain(x => x.ServiceType == SupportedServices.Sonarr);
}
[Test]
@ -111,9 +111,8 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
configLoader
.Load(() => new StringReader(testYml))
.GetConfigsOfType(SupportedServices.Sonarr)
.Should()
.BeEmpty();
.NotContain(x => x.ServiceType == SupportedServices.Sonarr);
}
[Test]
@ -137,8 +136,7 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
);
configLoader
.Load(() => new StringReader(testYml))
.GetConfigsOfType(SupportedServices.Sonarr)
.Should()
.BeEmpty();
.NotContain(x => x.ServiceType == SupportedServices.Sonarr);
}
}

@ -1,13 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Abstractions;
using System.Text;
using Autofac;
using FluentValidation;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.Config;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
using Recyclarr.TestLibrary;
using Recyclarr.TestLibrary.Autofac;
@ -18,12 +14,6 @@ namespace Recyclarr.IntegrationTests;
[TestFixture]
public class ConfigurationLoaderTest : IntegrationTestFixture
{
private static Func<TextReader> GetResourceData(string file)
{
var testData = new ResourceDataReader(typeof(ConfigurationLoaderTest), "Data");
return () => new StringReader(testData.ReadData(file));
}
protected override void RegisterStubsAndMocks(ContainerBuilder builder)
{
base.RegisterStubsAndMocks(builder);
@ -32,11 +22,6 @@ public class ConfigurationLoaderTest : IntegrationTestFixture
}
[Test]
[SuppressMessage(
"SonarLint",
"S3626",
Justification = "'return' used here is for separating local methods"
)]
public void Load_many_iterations_of_config()
{
var baseDir = Fs.CurrentDirectory();
@ -53,19 +38,27 @@ public class ConfigurationLoaderTest : IntegrationTestFixture
Fs.AddFile(file.FullName, new MockFileData(data));
}
var expectedSonarr = new[]
{
new { ApiKey = "abc", BaseUrl = new Uri("http://one") },
new { ApiKey = "abc", BaseUrl = new Uri("http://two") },
new { ApiKey = "abc", BaseUrl = new Uri("http://three") },
};
var loader = Resolve<ConfigurationLoader>();
var expectedRadarr = new[] { new { ApiKey = "abc", BaseUrl = new Uri("http://four") } };
var result = fileData.SelectMany(x => loader.Load(x.Item1)).ToList();
var loader = Resolve<IConfigurationLoader>();
result
.Where(x => x.ServiceType == SupportedServices.Sonarr)
.Select(x => x.Yaml)
.Should()
.BeEquivalentTo(
[
new { ApiKey = "abc", BaseUrl = "http://one" },
new { ApiKey = "abc", BaseUrl = "http://two" },
new { ApiKey = "abc", BaseUrl = "http://three" },
]
);
LoadMany(SupportedServices.Sonarr).Should().BeEquivalentTo(expectedSonarr);
LoadMany(SupportedServices.Radarr).Should().BeEquivalentTo(expectedRadarr);
result
.Where(x => x.ServiceType == SupportedServices.Radarr)
.Select(x => x.Yaml)
.Should()
.BeEquivalentTo([new { ApiKey = "abc", BaseUrl = "http://four" }]);
return;
@ -88,30 +81,36 @@ public class ConfigurationLoaderTest : IntegrationTestFixture
return str.ToString();
}
IEnumerable<IServiceConfiguration> LoadMany(SupportedServices service) =>
fileData.SelectMany(x => loader.Load(x.Item1)).GetConfigsOfType(service);
}
[Test]
public void Parse_using_stream()
{
var configLoader = Resolve<ConfigurationLoader>();
configLoader
.Load(GetResourceData("Load_UsingStream_CorrectParsing.yml"))
.GetConfigsOfType(SupportedServices.Sonarr)
var result = configLoader.Load(
"""
sonarr:
name:
base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b
"""
);
result
.Where(x => x.ServiceType == SupportedServices.Sonarr)
.Should()
.ContainSingle()
.Which.Should()
.BeEquivalentTo(
new List<SonarrConfiguration>
{
new()
new LoadedConfigYaml(
"name",
SupportedServices.Sonarr,
new SonarrConfigYaml
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("http://localhost:8989"),
InstanceName = "name",
ReplaceExistingCustomFormats = false,
},
}
BaseUrl = "http://localhost:8989",
}
)
);
}
@ -126,7 +125,7 @@ public class ConfigurationLoaderTest : IntegrationTestFixture
api_key: xyz
""";
sut.Load(testYml).GetConfigsOfType(SupportedServices.Sonarr);
sut.Load(testYml);
Logger.Messages.Should().NotContain("Configuration is empty");
}

@ -1,6 +1,5 @@
using Recyclarr.Config;
using Recyclarr.Config.ExceptionTypes;
using Recyclarr.Config.Models;
using Recyclarr.Config.Filtering;
using Recyclarr.Config.Parsing.ErrorHandling;
using Recyclarr.TestLibrary;
@ -32,133 +31,26 @@ public class ConfigurationRegistryTest : IntegrationTestFixture
result
.Should()
.ContainSingle()
.Which.Should()
.BeEquivalentTo(
[
new RadarrConfiguration
{
BaseUrl = new Uri("http://localhost:7878"),
ApiKey = "asdf",
InstanceName = "instance1",
},
]
);
}
[Test]
public void Throw_on_invalid_config_files()
{
var sut = Resolve<ConfigurationRegistry>();
var act = () =>
sut.FindAndLoadConfigs(new ConfigFilterCriteria { ManualConfigFiles = ["manual.yml"] });
act.Should().ThrowExactly<InvalidConfigurationFilesException>();
}
[Test]
public void Throw_on_invalid_instances()
{
var sut = Resolve<ConfigurationRegistry>();
Fs.AddFile(
"manual.yml",
new MockFileData(
"""
radarr:
instance1:
base_url: http://localhost:7878
api_key: asdf
"""
)
);
var act = () =>
sut.FindAndLoadConfigs(
new ConfigFilterCriteria
new
{
ManualConfigFiles = ["manual.yml"],
Instances = ["instance1", "instance2"],
BaseUrl = new Uri("http://localhost:7878"),
ApiKey = "asdf",
InstanceName = "instance1",
}
);
act.Should()
.ThrowExactly<InvalidInstancesException>()
.Which.InstanceNames.Should()
.BeEquivalentTo("instance2");
}
[Test]
public void Throw_on_split_instances()
public void Throw_on_invalid_config_files()
{
var sut = Resolve<ConfigurationRegistry>();
Fs.AddFile(
"manual.yml",
new MockFileData(
"""
radarr:
instance1:
base_url: http://localhost:7878
api_key: asdf
instance2:
base_url: http://localhost:7878
api_key: asdf
"""
)
);
var act = () =>
sut.FindAndLoadConfigs(new ConfigFilterCriteria { ManualConfigFiles = ["manual.yml"] });
act.Should()
.ThrowExactly<SplitInstancesException>()
.Which.InstanceNames.Should()
.BeEquivalentTo("instance1", "instance2");
}
[Test]
public void Duplicate_instance_names_are_prohibited()
{
var sut = Resolve<ConfigurationRegistry>();
Fs.AddFile(
"config1.yml",
new MockFileData(
"""
radarr:
unique_name1:
base_url: http://localhost:7879
api_key: fdsa
same_instance_name:
base_url: http://localhost:7878
api_key: asdf
"""
)
);
Fs.AddFile(
"config2.yml",
new MockFileData(
"""
radarr:
same_instance_name:
base_url: http://localhost:7879
api_key: fdsa
unique_name2:
base_url: http://localhost:7879
api_key: fdsa
"""
)
);
var act = () =>
sut.FindAndLoadConfigs(
new ConfigFilterCriteria { ManualConfigFiles = ["config1.yml", "config2.yml"] }
);
act.Should()
.ThrowExactly<DuplicateInstancesException>()
.Which.InstanceNames.Should()
.BeEquivalentTo("same_instance_name");
act.Should().ThrowExactly<InvalidConfigurationFilesException>();
}
}

@ -1,4 +0,0 @@
sonarr:
name:
base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b

@ -16,7 +16,7 @@ public class IncludePostProcessorIntegrationTest : IntegrationTestFixture
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
["service1"] = new() { ApiKey = "asdf", BaseUrl = "fdsa" },
},
@ -45,7 +45,7 @@ public class IncludePostProcessorIntegrationTest : IntegrationTestFixture
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
["service1"] = new()
{
@ -79,7 +79,7 @@ public class IncludePostProcessorIntegrationTest : IntegrationTestFixture
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
["service1"] = new()
{
@ -134,7 +134,7 @@ public class IncludePostProcessorIntegrationTest : IntegrationTestFixture
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
["service1"] = new()
{
@ -164,7 +164,7 @@ public class IncludePostProcessorIntegrationTest : IntegrationTestFixture
.BeEquivalentTo(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
["service1"] = new()
{

@ -1,157 +1,138 @@
using Recyclarr.Config;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide;
namespace Recyclarr.Tests.Config;
[TestFixture]
public class ConfigExtensionsTest
{
[Test]
public void Filter_invalid_instances()
{
var configs = new[]
{
new RadarrConfiguration
{
InstanceName = "valid_NAME", // Comparison should be case-insensitive
},
};
var invalidInstanceNames = configs.GetInvalidInstanceNames(
new ConfigFilterCriteria { Instances = ["valid_name", "invalid_name"] }
);
invalidInstanceNames.Should().BeEquivalentTo("invalid_name");
}
[Test]
public void Filter_invalid_instances_when_null()
{
var configs = new[]
{
new RadarrConfiguration
{
InstanceName = "valid_NAME", // Comparison should be case-insensitive
},
};
var invalidInstanceNames = configs.GetInvalidInstanceNames(
new ConfigFilterCriteria { Instances = null }
);
invalidInstanceNames.Should().BeEmpty();
}
[Test]
public void Get_configs_matching_service_type_and_instance_name()
{
var configs = new IServiceConfiguration[]
{
new RadarrConfiguration { InstanceName = "radarr1" },
new RadarrConfiguration { InstanceName = "radarr2" },
new RadarrConfiguration { InstanceName = "radarr3" },
new RadarrConfiguration { InstanceName = "radarr4" },
new SonarrConfiguration { InstanceName = "sonarr1" },
new SonarrConfiguration { InstanceName = "sonarr2" },
new SonarrConfiguration { InstanceName = "sonarr3" },
new SonarrConfiguration { InstanceName = "sonarr4" },
};
var result = configs.GetConfigsBasedOnSettings(
new ConfigFilterCriteria
{
Service = SupportedServices.Radarr,
Instances = ["radarr2", "radarr4", "radarr5", "sonarr2"],
}
);
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr2", "radarr4");
}
[Test]
public void Get_configs_based_on_settings_with_empty_instances()
{
var configs = new IServiceConfiguration[]
{
new RadarrConfiguration { InstanceName = "radarr1" },
new SonarrConfiguration { InstanceName = "sonarr1" },
};
var result = configs.GetConfigsBasedOnSettings(
new ConfigFilterCriteria { Instances = Array.Empty<string>() }
);
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr1", "sonarr1");
}
[Test]
public void Get_split_instance_names()
{
var configs = new IServiceConfiguration[]
{
new RadarrConfiguration
{
InstanceName = "radarr1",
BaseUrl = new Uri("http://radarr1"),
},
new RadarrConfiguration
{
InstanceName = "radarr2",
BaseUrl = new Uri("http://radarr1"),
},
new RadarrConfiguration
{
InstanceName = "radarr3",
BaseUrl = new Uri("http://radarr3"),
},
new RadarrConfiguration
{
InstanceName = "radarr4",
BaseUrl = new Uri("http://radarr4"),
},
new SonarrConfiguration
{
InstanceName = "sonarr1",
BaseUrl = new Uri("http://sonarr1"),
},
new SonarrConfiguration
{
InstanceName = "sonarr2",
BaseUrl = new Uri("http://sonarr2"),
},
new SonarrConfiguration
{
InstanceName = "sonarr3",
BaseUrl = new Uri("http://sonarr2"),
},
new SonarrConfiguration
{
InstanceName = "sonarr4",
BaseUrl = new Uri("http://sonarr4"),
},
};
var result = configs.GetSplitInstances();
result.Should().BeEquivalentTo("radarr1", "radarr2", "sonarr2", "sonarr3");
}
[Test]
public void Get_duplicate_instance_names()
{
var configs = new IServiceConfiguration[]
{
new RadarrConfiguration { InstanceName = "radarr1" },
new RadarrConfiguration { InstanceName = "radarr2" },
new RadarrConfiguration { InstanceName = "radarr2" },
new RadarrConfiguration { InstanceName = "radarr3" },
new SonarrConfiguration { InstanceName = "sonarr1" },
new SonarrConfiguration { InstanceName = "sonarr1" },
};
var result = configs.GetDuplicateInstanceNames();
result.Should().BeEquivalentTo("radarr2", "sonarr1");
}
}
// using Recyclarr.Config;
// using Recyclarr.Config.Models;
// using Recyclarr.Config.Parsing;
// using Recyclarr.TrashGuide;
//
// namespace Recyclarr.Tests.Config;
//
// [TestFixture]
// public class ConfigExtensionsTest
// {
// [Test]
// public void Filter_invalid_instances()
// {
// var configs = new[]
// {
// new LoadedConfigYaml("valid_NAME", SupportedServices.Sonarr, new ServiceConfigYaml()),
// };
//
// // Comparison should be case-insensitive
// var invalidInstanceNames = configs.GetNonExistentInstanceNames(
// new ConfigFilterCriteria { Instances = ["valid_name", "invalid_name"] }
// );
//
// invalidInstanceNames.Should().BeEquivalentTo("invalid_name");
// }
//
// [Test]
// public void Get_configs_matching_service_type_and_instance_name()
// {
// var configs = new IServiceConfiguration[]
// {
// new RadarrConfiguration { InstanceName = "radarr1" },
// new RadarrConfiguration { InstanceName = "radarr2" },
// new RadarrConfiguration { InstanceName = "radarr3" },
// new RadarrConfiguration { InstanceName = "radarr4" },
// new SonarrConfiguration { InstanceName = "sonarr1" },
// new SonarrConfiguration { InstanceName = "sonarr2" },
// new SonarrConfiguration { InstanceName = "sonarr3" },
// new SonarrConfiguration { InstanceName = "sonarr4" },
// };
//
// var result = configs.GetConfigsBasedOnSettings(
// new ConfigFilterCriteria
// {
// Service = SupportedServices.Radarr,
// Instances = ["radarr2", "radarr4", "radarr5", "sonarr2"],
// }
// );
//
// result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr2", "radarr4");
// }
//
// [Test]
// public void Get_configs_based_on_settings_with_empty_instances()
// {
// var configs = new IServiceConfiguration[]
// {
// new RadarrConfiguration { InstanceName = "radarr1" },
// new SonarrConfiguration { InstanceName = "sonarr1" },
// };
//
// var result = configs.GetConfigsBasedOnSettings(
// new ConfigFilterCriteria { Instances = Array.Empty<string>() }
// );
//
// result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr1", "sonarr1");
// }
//
// [Test]
// public void Get_split_instance_names()
// {
// var configs = new IServiceConfiguration[]
// {
// new RadarrConfiguration
// {
// InstanceName = "radarr1",
// BaseUrl = new Uri("http://radarr1"),
// },
// new RadarrConfiguration
// {
// InstanceName = "radarr2",
// BaseUrl = new Uri("http://radarr1"),
// },
// new RadarrConfiguration
// {
// InstanceName = "radarr3",
// BaseUrl = new Uri("http://radarr3"),
// },
// new RadarrConfiguration
// {
// InstanceName = "radarr4",
// BaseUrl = new Uri("http://radarr4"),
// },
// new SonarrConfiguration
// {
// InstanceName = "sonarr1",
// BaseUrl = new Uri("http://sonarr1"),
// },
// new SonarrConfiguration
// {
// InstanceName = "sonarr2",
// BaseUrl = new Uri("http://sonarr2"),
// },
// new SonarrConfiguration
// {
// InstanceName = "sonarr3",
// BaseUrl = new Uri("http://sonarr2"),
// },
// new SonarrConfiguration
// {
// InstanceName = "sonarr4",
// BaseUrl = new Uri("http://sonarr4"),
// },
// };
//
// var result = configs.GetSplitInstances();
//
// result.Should().BeEquivalentTo("radarr1", "radarr2", "sonarr2", "sonarr3");
// }
//
// [Test]
// public void Get_duplicate_instance_names()
// {
// var configs = new IServiceConfiguration[]
// {
// new RadarrConfiguration { InstanceName = "radarr1" },
// new RadarrConfiguration { InstanceName = "radarr2" },
// new RadarrConfiguration { InstanceName = "radarr2" },
// new RadarrConfiguration { InstanceName = "radarr3" },
// new SonarrConfiguration { InstanceName = "sonarr1" },
// new SonarrConfiguration { InstanceName = "sonarr1" },
// };
//
// var result = configs.GetDuplicateInstanceNames();
//
// result.Should().BeEquivalentTo("radarr2", "sonarr1");
// }
// }

@ -0,0 +1,151 @@
using Recyclarr.Config.Filtering;
using Recyclarr.Config.Parsing;
using Recyclarr.TestLibrary;
using Recyclarr.TrashGuide;
namespace Recyclarr.Tests.Config.Filtering;
[TestFixture]
public class ConfigFiltersTest : IntegrationTestFixture
{
[Test]
public void Filter_out_invalid_instances()
{
var sut = Resolve<InvalidInstancesFilter>();
var config = new RadarrConfigYaml { BaseUrl = "http://localhost:7878", ApiKey = "" };
var context = new FilterContext();
var result = sut.Filter(
new ConfigFilterCriteria { Instances = ["instance1"] },
[new LoadedConfigYaml("instance1", SupportedServices.Radarr, config)],
context
);
result.Should().BeEmpty();
var subject = context
.Results.Should()
.ContainSingle()
.Which.Should()
.BeOfType<InvalidInstancesFilterResult>()
.Which.InvalidInstances.Should()
.ContainSingle()
.Subject;
subject.InstanceName.Should().Be("instance1");
subject.Failures.Should().NotBeEmpty();
}
[Test]
public void Filter_out_split_instances()
{
var sut = Resolve<SplitInstancesFilter>();
var context = new FilterContext();
var result = sut.Filter(
new ConfigFilterCriteria { Instances = ["instance1"] },
[
new LoadedConfigYaml(
"instance1",
SupportedServices.Radarr,
new RadarrConfigYaml { BaseUrl = "http://same" }
),
new LoadedConfigYaml(
"instance2",
SupportedServices.Radarr,
new RadarrConfigYaml { BaseUrl = "http://same" }
),
],
context
);
result.Should().BeEmpty();
var subject = context
.Results.Should()
.ContainSingle()
.Which.Should()
.BeOfType<SplitInstancesFilterResult>()
.Which.SplitInstances.Should()
.ContainSingle()
.Subject;
subject.BaseUrl.Should().Be("http://same");
subject.InstanceNames.Should().BeEquivalentTo("instance1", "instance2");
}
[Test]
public void Filter_out_non_existent_instances()
{
var sut = Resolve<NonExistentInstancesFilter>();
var context = new FilterContext();
LoadedConfigYaml[] yaml =
[
new(
"instance1",
SupportedServices.Radarr,
new RadarrConfigYaml { BaseUrl = "http://myradarr.domain.com" }
),
];
var result = sut.Filter(
new ConfigFilterCriteria { Instances = ["instance_non_existent"] },
yaml,
context
);
result.Should().BeEquivalentTo(yaml);
var subject = context
.Results.Should()
.ContainSingle()
.Which.Should()
.BeOfType<NonExistentInstancesFilterResult>()
.Which.NonExistentInstances.Should()
.ContainSingle()
.Subject;
subject.Should().Be("instance_non_existent");
}
[Test]
public void Filter_out_duplicate_instances()
{
var sut = Resolve<DuplicateInstancesFilter>();
var context = new FilterContext();
LoadedConfigYaml[] yaml =
[
new(
"instance1",
SupportedServices.Radarr,
new RadarrConfigYaml { BaseUrl = "http://different2" }
),
new(
"instance1",
SupportedServices.Sonarr,
new RadarrConfigYaml { BaseUrl = "http://different1" }
),
];
var result = sut.Filter(
new ConfigFilterCriteria { Instances = ["instance1"] },
yaml,
context
);
result.Should().BeEmpty();
context
.Results.Should()
.ContainSingle()
.Which.Should()
.BeOfType<DuplicateInstancesFilterResult>()
.Which.DuplicateInstances.Should()
.BeEquivalentTo("instance1");
}
}

@ -26,14 +26,14 @@ public class ImplicitUrlAndKeyPostProcessorTest
var result = sut.Process(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
new RadarrConfigYaml { ApiKey = "explicit_base_url" }
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",
@ -48,7 +48,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
.BeEquivalentTo(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
@ -59,7 +59,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
}
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",
@ -93,14 +93,14 @@ public class ImplicitUrlAndKeyPostProcessorTest
var result = sut.Process(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
new RadarrConfigYaml { BaseUrl = "explicit_base_url" }
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",
@ -115,7 +115,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
.BeEquivalentTo(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
@ -126,7 +126,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
}
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",
@ -160,11 +160,11 @@ public class ImplicitUrlAndKeyPostProcessorTest
var result = sut.Process(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{ "instance1", new RadarrConfigYaml() },
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{ "instance2", new SonarrConfigYaml() },
},
@ -176,7 +176,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
.BeEquivalentTo(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
@ -187,7 +187,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
}
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",
@ -221,7 +221,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
var result = sut.Process(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
@ -232,7 +232,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
}
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",
@ -251,7 +251,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
.BeEquivalentTo(
new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
Radarr = new Dictionary<string, RadarrConfigYaml?>
{
{
"instance1",
@ -262,7 +262,7 @@ public class ImplicitUrlAndKeyPostProcessorTest
}
},
},
Sonarr = new Dictionary<string, SonarrConfigYaml>
Sonarr = new Dictionary<string, SonarrConfigYaml?>
{
{
"instance2",

Loading…
Cancel
Save