feat: New delete custom-formats command

For deleting one or many custom formats from a specific Sonarr or Radarr
service.
json-serializing-nullable-fields-issue
Robert Dailey 9 months ago
parent a84c8a0efc
commit f6465316d2

@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- New `delete` command added for deleting one, many, or all custom formats from Radarr or Sonarr.
### Changed
- Program now exits when invalid instances are specified.

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="delete custom-formats" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="delete custom-formats radarr_develop --all --preview" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -28,5 +28,11 @@ public static class CliSetup
config.AddCommand<ConfigCreateCommand>("create");
config.AddCommand<ConfigListCommand>("list");
});
cli.AddBranch("delete", delete =>
{
delete.SetDescription("Delete operations for remote services (e.g. Radarr, Sonarr)");
delete.AddCommand<DeleteCustomFormatsCommand>("custom-formats");
});
}
}

@ -1,6 +1,69 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Processors;
using Recyclarr.Cli.Processors.Delete;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands;
public class DeleteCustomFormatsCommand
[Description("Delete things from services like Radarr & Sonarr")]
[UsedImplicitly]
public class DeleteCustomFormatsCommand : AsyncCommand<DeleteCustomFormatsCommand.CliSettings>
{
private readonly IDeleteCustomFormatsProcessor _processor;
private readonly ConsoleExceptionHandler _exceptionHandler;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
[SuppressMessage("Performance", "CA1819:Properties should not return arrays",
Justification = "Spectre.Console requires it")]
public class CliSettings : ServiceCommandSettings, IDeleteCustomFormatSettings
{
[CommandArgument(0, "<instance_name>")]
[Description("The name of the instance to delete CFs from.")]
public string InstanceName { get; init; } = "";
[CommandArgument(0, "[cf_names]")]
[Description("One or more custom format names to delete. Optional only if `--all` is used.")]
public string[] CustomFormatNamesOption { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> CustomFormatNames => CustomFormatNamesOption;
[CommandOption("-a|--all")]
[Description("Delete ALL custom formats.")]
public bool All { get; init; } = false;
[CommandOption("-f|--force")]
[Description("Perform the delete operation with NO confirmation prompt.")]
public bool Force { get; init; } = false;
[CommandOption("-p|--preview")]
[Description("Preview what custom formats will be deleted without actually deleting them.")]
public bool Preview { get; init; } = false;
}
public DeleteCustomFormatsCommand(
IDeleteCustomFormatsProcessor processor,
ConsoleExceptionHandler exceptionHandler)
{
_processor = processor;
_exceptionHandler = exceptionHandler;
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
try
{
await _processor.Process(settings);
}
catch (Exception e)
{
await _exceptionHandler.HandleException(e);
return (int) ExitStatus.Failed;
}
return (int) ExitStatus.Succeeded;
}
}

@ -0,0 +1,10 @@
namespace Recyclarr.Cli.Console.Settings;
public interface IDeleteCustomFormatSettings
{
public string InstanceName { get; }
public IReadOnlyCollection<string> CustomFormatNames { get; }
public bool All { get; }
public bool Force { get; }
bool Preview { get; }
}

@ -33,9 +33,12 @@ public class CustomFormatService : ICustomFormatService
.PutJsonAsync(cf);
}
public async Task DeleteCustomFormat(IServiceConfiguration config, int customFormatId)
public async Task DeleteCustomFormat(
IServiceConfiguration config,
int customFormatId,
CancellationToken cancellationToken = default)
{
await _service.Request(config, "customformat", customFormatId)
.DeleteAsync();
.DeleteAsync(cancellationToken);
}
}

@ -8,5 +8,9 @@ public interface ICustomFormatService
Task<IList<CustomFormatData>> GetCustomFormats(IServiceConfiguration config);
Task<CustomFormatData?> CreateCustomFormat(IServiceConfiguration config, CustomFormatData cf);
Task UpdateCustomFormat(IServiceConfiguration config, CustomFormatData cf);
Task DeleteCustomFormat(IServiceConfiguration config, int customFormatId);
Task DeleteCustomFormat(
IServiceConfiguration config,
int customFormatId,
CancellationToken cancellationToken = default);
}

@ -0,0 +1,80 @@
using Flurl.Http;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Repo.VersionControl;
namespace Recyclarr.Cli.Processors;
public class ConsoleExceptionHandler
{
private readonly ILogger _log;
public ConsoleExceptionHandler(ILogger log)
{
_log = log;
}
public async Task HandleException(Exception sourceException)
{
switch (sourceException)
{
case GitCmdException e:
_log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
e.ExitCode, e.Error);
break;
case FlurlHttpException e:
_log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
foreach (var error in await GetValidationErrorsAsync(e))
{
_log.Error("Reason: {Error}", error);
}
break;
case NoConfigurationFilesException:
_log.Error("No configuration files found");
break;
case InvalidInstancesException e:
_log.Error("The following instances do not exist: {Names}", e.InstanceNames);
break;
case SplitInstancesException e:
_log.Error("The following configs share the same `base_url`, which isn't allowed: {Instances}",
e.InstanceNames);
_log.Error(
"Consolidate the config files manually to fix. " +
"See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance");
break;
case InvalidConfigurationFilesException e:
_log.Error("Manually-specified configuration files do not exist: {Files}", e.InvalidFiles);
break;
case CommandException e:
_log.Error(e.Message);
break;
// This handles non-deterministic/unexpected exceptions.
default:
throw sourceException;
}
}
private static async Task<IReadOnlyCollection<string>> GetValidationErrorsAsync(FlurlHttpException e)
{
var response = await e.GetResponseJsonAsync<List<dynamic>>();
if (response is null)
{
return Array.Empty<string>();
}
return response
.Select(x => (string) x.errorMessage)
.NotNull(x => !string.IsNullOrEmpty(x))
.ToList();
}
}

@ -0,0 +1,179 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Pipelines.CustomFormat.Api;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Models;
using Spectre.Console;
namespace Recyclarr.Cli.Processors.Delete;
public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{
private readonly ILogger _log;
private readonly IAnsiConsole _console;
private readonly ICustomFormatService _api;
private readonly IConfigurationRegistry _configRegistry;
public DeleteCustomFormatsProcessor(
ILogger log,
IAnsiConsole console,
ICustomFormatService api,
IConfigurationRegistry configRegistry)
{
_log = log;
_console = console;
_api = api;
_configRegistry = configRegistry;
}
public async Task Process(IDeleteCustomFormatSettings settings)
{
var config = GetTargetConfig(settings);
var cfs = await ObtainCustomFormats(config);
if (!settings.All)
{
if (!settings.CustomFormatNames.Any())
{
throw new CommandException("Custom format names must be specified if the `--all` option is not used.");
}
cfs = ProcessManuallySpecifiedFormats(settings, cfs);
}
if (!cfs.Any())
{
_console.MarkupLine("[yellow]Done[/]: No custom formats found or specified to delete.");
return;
}
PrintPreview(cfs);
if (settings.Preview)
{
_console.MarkupLine("This is a preview! [u]No actual deletions will be performed.[/]");
return;
}
if (!settings.Force &&
!_console.Confirm("\nAre you sure you want to [bold red]permanently delete[/] the above custom formats?"))
{
_console.WriteLine("Aborted!");
return;
}
await DeleteCustomFormats(cfs, config);
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
private async Task DeleteCustomFormats(ICollection<CustomFormatData> cfs, IServiceConfiguration config)
{
await _console.Progress().StartAsync(async ctx =>
{
var task = ctx.AddTask("Deleting Custom Formats").MaxValue(cfs.Count);
var options = new ParallelOptions {MaxDegreeOfParallelism = 8};
await Parallel.ForEachAsync(cfs, options, async (cf, token) =>
{
try
{
await _api.DeleteCustomFormat(config, cf.Id, token);
_log.Debug("Deleted {Name}", cf.Name);
}
catch (Exception e)
{
_log.Debug(e, "Failed to delete CF");
_console.WriteLine($"Failed to delete CF: {cf.Name}");
}
task.Increment(1);
});
});
}
private async Task<IList<CustomFormatData>> ObtainCustomFormats(IServiceConfiguration config)
{
IList<CustomFormatData> cfs = new List<CustomFormatData>();
await _console.Status().StartAsync("Obtaining custom formats...", async _ =>
{
cfs = await _api.GetCustomFormats(config);
});
return cfs;
}
private IList<CustomFormatData> ProcessManuallySpecifiedFormats(
IDeleteCustomFormatSettings settings,
IList<CustomFormatData> cfs)
{
ILookup<bool, (string Name, IEnumerable<CustomFormatData> Cfs)> result = settings.CustomFormatNames
.GroupJoin(cfs,
x => x,
x => x.Name,
(x, y) => (Name: x, Cf: y),
StringComparer.InvariantCultureIgnoreCase)
.ToLookup(x => x.Cf.Any());
// 'false' means there were no CFs matched to this CF name
if (result[false].Any())
{
var cfNames = result[false].Select(x => x.Name).ToList();
_log.Debug("Unmatched CFs: {Names}", cfNames);
foreach (var name in cfNames)
{
_console.MarkupLineInterpolated($"[yellow]Warning[/]: Unmatched CF Name: [teal]{name}[/]");
}
}
// 'true' represents CFs that match names provided in user-input (if provided)
cfs = result[true].SelectMany(x => x.Cfs).ToList();
return cfs;
}
[SuppressMessage("ReSharper", "CoVariantArrayConversion")]
private void PrintPreview(ICollection<CustomFormatData> cfs)
{
_console.MarkupLine("The following custom formats will be [bold red]DELETED[/]:");
_console.WriteLine();
var cfNames = cfs
.Select(x => x.Name)
.Order(StringComparer.InvariantCultureIgnoreCase)
.Chunk(Math.Max(15, cfs.Count / 3)) // Minimum row size is 15 for the table
.ToList();
var grid = new Grid().AddColumns(cfNames.Count);
foreach (var rowItems in cfNames.Transpose())
{
grid.AddRow(rowItems
.Select(x => Markup.FromInterpolated($"[bold white]{x}[/]"))
.ToArray());
}
_console.Write(grid);
_console.WriteLine();
}
private IServiceConfiguration GetTargetConfig(IDeleteCustomFormatSettings settings)
{
var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
{
Instances = new[] {settings.InstanceName}
});
switch (configs.Count)
{
case 0:
throw new ArgumentException($"No configuration found with name: {settings.InstanceName}");
case > 1:
throw new ArgumentException($"More than one instance found with this name: {settings.InstanceName}");
}
return configs.Single();
}
}

@ -0,0 +1,8 @@
using Recyclarr.Cli.Console.Settings;
namespace Recyclarr.Cli.Processors.Delete;
public interface IDeleteCustomFormatsProcessor
{
Task Process(IDeleteCustomFormatSettings settings);
}

@ -1,42 +0,0 @@
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Processors;
public static class ProcessorExtensions
{
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 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 ISyncSettings settings,
IEnumerable<IServiceConfiguration> configs)
{
if (settings.Instances is null)
{
return Array.Empty<string>();
}
var configInstances = configs.Select(x => x.InstanceName).ToList();
return settings.Instances
.Where(x => !configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase));
}
}

@ -1,6 +1,7 @@
using Autofac;
using Autofac.Extras.Ordering;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.Processors.Delete;
using Recyclarr.Cli.Processors.Sync;
namespace Recyclarr.Cli.Processors;
@ -11,6 +12,8 @@ public class ServiceProcessorsAutofacModule : Module
{
base.Load(builder);
builder.RegisterType<ConsoleExceptionHandler>();
// Sync
builder.RegisterType<SyncProcessor>().As<ISyncProcessor>();
builder.RegisterType<SyncPipelineExecutor>();
@ -20,6 +23,9 @@ public class ServiceProcessorsAutofacModule : Module
builder.RegisterType<ConfigCreationProcessor>().As<IConfigCreationProcessor>();
builder.RegisterType<ConfigListProcessor>();
// Delete
builder.RegisterType<DeleteCustomFormatsProcessor>().As<IDeleteCustomFormatsProcessor>();
builder.RegisterTypes(
typeof(TemplateConfigCreator),
typeof(LocalConfigCreator))

@ -1,16 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Flurl.Http;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Compatibility;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Repo.VersionControl;
using Spectre.Console;
namespace Recyclarr.Cli.Processors.Sync;
@ -20,28 +12,25 @@ public class SyncProcessor : ISyncProcessor
{
private readonly IAnsiConsole _console;
private readonly ILogger _log;
private readonly IConfigurationFinder _configFinder;
private readonly IConfigurationLoader _configLoader;
private readonly IConfigurationRegistry _configRegistry;
private readonly SyncPipelineExecutor _pipelines;
private readonly ServiceAgnosticCapabilityEnforcer _capabilityEnforcer;
private readonly IFileSystem _fs;
private readonly ConsoleExceptionHandler _exceptionHandler;
public SyncProcessor(
IAnsiConsole console,
ILogger log,
IConfigurationFinder configFinder,
IConfigurationLoader configLoader,
IConfigurationRegistry configRegistry,
SyncPipelineExecutor pipelines,
ServiceAgnosticCapabilityEnforcer capabilityEnforcer,
IFileSystem fs)
ConsoleExceptionHandler exceptionHandler)
{
_console = console;
_log = log;
_configFinder = configFinder;
_configLoader = configLoader;
_configRegistry = configRegistry;
_pipelines = pipelines;
_capabilityEnforcer = capabilityEnforcer;
_fs = fs;
_exceptionHandler = exceptionHandler;
}
public async Task<ExitStatus> ProcessConfigs(ISyncSettings settings)
@ -49,55 +38,24 @@ public class SyncProcessor : ISyncProcessor
bool failureDetected;
try
{
var configFiles = settings.Configs
.Select(x => _fs.FileInfo.New(x))
.ToLookup(x => x.Exists);
if (configFiles[false].Any())
var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
{
foreach (var file in configFiles[false])
{
_log.Error("Manually-specified configuration file does not exist: {File}", file);
}
_log.Error("Exiting due to non-existent configuration files");
return ExitStatus.Failed;
}
var configs = LoadAndFilterConfigs(_configFinder.GetConfigFiles(configFiles[true].ToList()), settings);
ManualConfigFiles = settings.Configs,
Instances = settings.Instances,
Service = settings.Service
});
failureDetected = await ProcessService(settings, configs);
}
catch (Exception e)
{
await HandleException(e);
await _exceptionHandler.HandleException(e);
failureDetected = true;
}
return failureDetected ? ExitStatus.Failed : ExitStatus.Succeeded;
}
private IEnumerable<IServiceConfiguration> LoadAndFilterConfigs(
IEnumerable<IFileInfo> configs,
ISyncSettings settings)
{
var loadedConfigs = configs.SelectMany(x => _configLoader.Load(x)).ToList();
var invalidInstances = settings.GetInvalidInstanceNames(loadedConfigs).ToList();
if (invalidInstances.Any())
{
throw new InvalidInstancesException(invalidInstances);
}
var splitInstances = loadedConfigs.GetSplitInstances().ToList();
if (splitInstances.Any())
{
throw new SplitInstancesException(splitInstances);
}
return loadedConfigs.GetConfigsBasedOnSettings(settings);
}
private async Task<bool> ProcessService(ISyncSettings settings, IEnumerable<IServiceConfiguration> configs)
{
var failureDetected = false;
@ -112,7 +70,7 @@ public class SyncProcessor : ISyncProcessor
}
catch (Exception e)
{
await HandleException(e);
await _exceptionHandler.HandleException(e);
failureDetected = true;
}
}
@ -120,59 +78,6 @@ public class SyncProcessor : ISyncProcessor
return failureDetected;
}
private async Task HandleException(Exception sourceException)
{
switch (sourceException)
{
case GitCmdException e:
_log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
e.ExitCode, e.Error);
break;
case FlurlHttpException e:
_log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
foreach (var error in await GetValidationErrorsAsync(e))
{
_log.Error("Reason: {Error}", error);
}
break;
case NoConfigurationFilesException:
_log.Error("No configuration files found");
break;
case InvalidInstancesException e:
_log.Error("The following instances do not exist: {Names}", e.InstanceNames);
break;
case SplitInstancesException e:
_log.Error("The following configs share the same `base_url`, which isn't allowed: {Instances}",
e.InstanceNames);
_log.Error(
"Consolidate the config files manually to fix. " +
"See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance");
break;
default:
throw sourceException;
}
}
private static async Task<IReadOnlyCollection<string>> GetValidationErrorsAsync(FlurlHttpException e)
{
var response = await e.GetResponseJsonAsync<List<dynamic>>();
if (response is null)
{
return Array.Empty<string>();
}
return response
.Select(x => (string) x.errorMessage)
.NotNull(x => !string.IsNullOrEmpty(x))
.ToList();
}
private void PrintProcessingHeader(SupportedServices serviceType, IServiceConfiguration config)
{
var instanceName = config.InstanceName;

@ -24,6 +24,7 @@ public class ConfigAutofacModule : Module
builder.RegisterType<SecretsProvider>().As<ISecretsProvider>().SingleInstance();
builder.RegisterType<YamlSerializerFactory>().As<IYamlSerializerFactory>();
builder.RegisterType<ConfigurationRegistry>().As<IConfigurationRegistry>();
builder.RegisterType<DefaultObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ConfigurationLoader>().As<IConfigurationLoader>();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();

@ -1,3 +1,4 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services;
@ -18,4 +19,37 @@ public static class ConfigExtensions
var radarr = config?.Radarr?.Count ?? 0;
return sonarr + radarr == 0;
}
public static IEnumerable<IServiceConfiguration> GetConfigsBasedOnSettings(
this IEnumerable<IServiceConfiguration> configs,
ConfigFilterCriteria criteria)
{
// 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(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.Any())
{
return Array.Empty<string>();
}
var configInstances = configs.Select(x => x.InstanceName).ToList();
return criteria.Instances
.Where(x => !configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase));
}
}

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

@ -0,0 +1,68 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
namespace Recyclarr.TrashLib.Config;
public class ConfigurationRegistry : IConfigurationRegistry
{
private readonly IConfigurationLoader _loader;
private readonly IConfigurationFinder _finder;
private readonly IFileSystem _fs;
public ConfigurationRegistry(IConfigurationLoader loader, IConfigurationFinder finder, IFileSystem fs)
{
_loader = loader;
_finder = finder;
_fs = fs;
}
public IReadOnlyCollection<IServiceConfiguration> FindAndLoadConfigs(ConfigFilterCriteria? filterCriteria = null)
{
filterCriteria ??= new ConfigFilterCriteria();
var manualConfigs = filterCriteria.ManualConfigFiles;
var configs = manualConfigs is not null && manualConfigs.Any()
? PrepareManualConfigs(manualConfigs)
: _finder.GetConfigFiles();
return LoadAndFilterConfigs(configs, filterCriteria).ToList();
}
private IReadOnlyCollection<IFileInfo> PrepareManualConfigs(IEnumerable<string> manualConfigs)
{
var configFiles = manualConfigs
.Select(x => _fs.FileInfo.New(x))
.ToLookup(x => x.Exists);
if (configFiles[false].Any())
{
throw new InvalidConfigurationFilesException(configFiles[false].ToList());
}
return configFiles[true].ToList();
}
private IEnumerable<IServiceConfiguration> LoadAndFilterConfigs(
IEnumerable<IFileInfo> configs,
ConfigFilterCriteria filterCriteria)
{
var loadedConfigs = configs.SelectMany(x => _loader.Load(x)).ToList();
var invalidInstances = loadedConfigs.GetInvalidInstanceNames(filterCriteria).ToList();
if (invalidInstances.Any())
{
throw new InvalidInstancesException(invalidInstances);
}
var splitInstances = loadedConfigs.GetSplitInstances().ToList();
if (splitInstances.Any())
{
throw new SplitInstancesException(splitInstances);
}
return loadedConfigs.GetConfigsBasedOnSettings(filterCriteria);
}
}

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

@ -34,14 +34,9 @@ public class ConfigurationFinder : IConfigurationFinder
return configs;
}
public IReadOnlyCollection<IFileInfo> GetConfigFiles(IReadOnlyCollection<IFileInfo>? configs = null)
public IReadOnlyCollection<IFileInfo> GetConfigFiles()
{
if (configs is not null && configs.Any())
{
return configs;
}
configs = FindDefaultConfigFiles();
var configs = FindDefaultConfigFiles();
if (configs.Count == 0)
{
throw new NoConfigurationFilesException();

@ -0,0 +1,13 @@
using System.IO.Abstractions;
namespace Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
public class InvalidConfigurationFilesException : Exception
{
public IReadOnlyCollection<IFileInfo> InvalidFiles { get; }
public InvalidConfigurationFilesException(IReadOnlyCollection<IFileInfo> invalidFiles)
{
InvalidFiles = invalidFiles;
}
}

@ -4,5 +4,5 @@ namespace Recyclarr.TrashLib.Config.Parsing;
public interface IConfigurationFinder
{
IReadOnlyCollection<IFileInfo> GetConfigFiles(IReadOnlyCollection<IFileInfo>? configs = null);
IReadOnlyCollection<IFileInfo> GetConfigFiles();
}

@ -0,0 +1,9 @@
namespace Recyclarr.TrashLib.ExceptionTypes;
public class CommandException : Exception
{
public CommandException(string? message)
: base(message)
{
}
}

@ -1,6 +1,3 @@
using NSubstitute.ReturnsExtensions;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Processors;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Services;
@ -21,10 +18,10 @@ public class ProcessorExtensionsTest
}
};
var settings = Substitute.For<ISyncSettings>();
settings.Instances.Returns(new[] {"valid_name", "invalid_name"});
var invalidInstanceNames = settings.GetInvalidInstanceNames(configs);
var invalidInstanceNames = configs.GetInvalidInstanceNames(new ConfigFilterCriteria
{
Instances = new[] {"valid_name", "invalid_name"}
});
invalidInstanceNames.Should().BeEquivalentTo("invalid_name");
}
@ -40,10 +37,10 @@ public class ProcessorExtensionsTest
}
};
var settings = Substitute.For<ISyncSettings>();
settings.Instances.ReturnsNull();
var invalidInstanceNames = settings.GetInvalidInstanceNames(configs);
var invalidInstanceNames = configs.GetInvalidInstanceNames(new ConfigFilterCriteria
{
Instances = null
});
invalidInstanceNames.Should().BeEmpty();
}
@ -63,11 +60,11 @@ public class ProcessorExtensionsTest
new SonarrConfiguration {InstanceName = "sonarr4"}
};
var settings = Substitute.For<ISyncSettings>();
settings.Service.Returns(SupportedServices.Radarr);
settings.Instances.Returns(new[] {"radarr2", "radarr4", "radarr5", "sonarr2"});
var result = configs.GetConfigsBasedOnSettings(settings);
var result = configs.GetConfigsBasedOnSettings(new ConfigFilterCriteria
{
Service = SupportedServices.Radarr,
Instances = new[] {"radarr2", "radarr4", "radarr5", "sonarr2"}
});
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr2", "radarr4");
}
@ -81,10 +78,10 @@ public class ProcessorExtensionsTest
new SonarrConfiguration {InstanceName = "sonarr1"}
};
var settings = Substitute.For<ISyncSettings>();
settings.Instances.Returns(Array.Empty<string>());
var result = configs.GetConfigsBasedOnSettings(settings);
var result = configs.GetConfigsBasedOnSettings(new ConfigFilterCriteria
{
Instances = Array.Empty<string>()
});
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr1", "sonarr1");
}

@ -0,0 +1,102 @@
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.TestLibrary;
namespace Recyclarr.TrashLib.Tests.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationRegistryTest : TrashLibIntegrationFixture
{
[Test]
public void Use_explicit_paths_instead_of_default()
{
var sut = Resolve<ConfigurationRegistry>();
Fs.AddFile("manual.yml", new MockFileData(
"""
radarr:
instance1:
base_url: http://localhost:7878
api_key: asdf
"""));
var result = sut.FindAndLoadConfigs(new ConfigFilterCriteria
{
ManualConfigFiles = new[] {"manual.yml"}
});
result.Should().BeEquivalentTo(new[]
{
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 = new[] {"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
{
ManualConfigFiles = new[] {"manual.yml"},
Instances = new[] {"instance1", "instance2"}
});
act.Should().ThrowExactly<InvalidInstancesException>()
.Which.InstanceNames.Should().BeEquivalentTo("instance2");
}
[Test]
public void Throw_on_split_instances()
{
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 = new[] {"manual.yml"}
});
act.Should().ThrowExactly<SplitInstancesException>()
.Which.InstanceNames.Should().BeEquivalentTo("instance1", "instance2");
}
}

@ -1,5 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Startup;
@ -51,32 +50,11 @@ public class ConfigurationFinderTest
fs.AddEmptyFile(path);
}
var result = sut.GetConfigFiles(new List<IFileInfo>());
var result = sut.GetConfigFiles();
result.Should().BeEquivalentTo(yamlPaths, o => o.Including(x => x.FullName));
}
[Test, AutoMockData]
public void Use_explicit_paths_instead_of_default(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut)
{
var yamlPaths = GetYamlPaths(paths);
foreach (var path in yamlPaths)
{
fs.AddFile(path.FullName, new MockFileData(""));
}
var manualConfig = fs.CurrentDirectory().File("manual-config.yml");
fs.AddEmptyFile(manualConfig);
var result = sut.GetConfigFiles(new[] {manualConfig});
result.Should().ContainSingle(x => x.FullName == manualConfig.FullName);
}
[Test, AutoMockData]
public void No_recyclarr_yml_when_not_exists(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
@ -86,7 +64,7 @@ public class ConfigurationFinderTest
var testFile = paths.ConfigsDirectory.File("test.yml");
fs.AddEmptyFile(testFile);
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>());
var result = sut.GetConfigFiles();
result.Should().ContainSingle(x => x.FullName == testFile.FullName);
}
@ -100,7 +78,7 @@ public class ConfigurationFinderTest
var configFile = paths.AppDataDirectory.File("recyclarr.yml");
fs.AddEmptyFile(configFile);
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>());
var result = sut.GetConfigFiles();
result.Should().ContainSingle(x => x.FullName == configFile.FullName);
}
@ -111,7 +89,7 @@ public class ConfigurationFinderTest
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut)
{
var act = () => sut.GetConfigFiles(Array.Empty<IFileInfo>());
var act = () => sut.GetConfigFiles();
act.Should().Throw<NoConfigurationFilesException>();
}

Loading…
Cancel
Save