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] ## [Unreleased]
### Added
- New `delete` command added for deleting one, many, or all custom formats from Radarr or Sonarr.
### Changed ### Changed
- Program now exits when invalid instances are specified. - 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<ConfigCreateCommand>("create");
config.AddCommand<ConfigListCommand>("list"); 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; 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); .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) await _service.Request(config, "customformat", customFormatId)
.DeleteAsync(); .DeleteAsync(cancellationToken);
} }
} }

@ -8,5 +8,9 @@ public interface ICustomFormatService
Task<IList<CustomFormatData>> GetCustomFormats(IServiceConfiguration config); Task<IList<CustomFormatData>> GetCustomFormats(IServiceConfiguration config);
Task<CustomFormatData?> CreateCustomFormat(IServiceConfiguration config, CustomFormatData cf); Task<CustomFormatData?> CreateCustomFormat(IServiceConfiguration config, CustomFormatData cf);
Task UpdateCustomFormat(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;
using Autofac.Extras.Ordering; using Autofac.Extras.Ordering;
using Recyclarr.Cli.Processors.Config; using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.Processors.Delete;
using Recyclarr.Cli.Processors.Sync; using Recyclarr.Cli.Processors.Sync;
namespace Recyclarr.Cli.Processors; namespace Recyclarr.Cli.Processors;
@ -11,6 +12,8 @@ public class ServiceProcessorsAutofacModule : Module
{ {
base.Load(builder); base.Load(builder);
builder.RegisterType<ConsoleExceptionHandler>();
// Sync // Sync
builder.RegisterType<SyncProcessor>().As<ISyncProcessor>(); builder.RegisterType<SyncProcessor>().As<ISyncProcessor>();
builder.RegisterType<SyncPipelineExecutor>(); builder.RegisterType<SyncPipelineExecutor>();
@ -20,6 +23,9 @@ public class ServiceProcessorsAutofacModule : Module
builder.RegisterType<ConfigCreationProcessor>().As<IConfigCreationProcessor>(); builder.RegisterType<ConfigCreationProcessor>().As<IConfigCreationProcessor>();
builder.RegisterType<ConfigListProcessor>(); builder.RegisterType<ConfigListProcessor>();
// Delete
builder.RegisterType<DeleteCustomFormatsProcessor>().As<IDeleteCustomFormatsProcessor>();
builder.RegisterTypes( builder.RegisterTypes(
typeof(TemplateConfigCreator), typeof(TemplateConfigCreator),
typeof(LocalConfigCreator)) typeof(LocalConfigCreator))

@ -1,16 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Flurl.Http;
using Recyclarr.Cli.Console.Settings; using Recyclarr.Cli.Console.Settings;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Compatibility; using Recyclarr.TrashLib.Compatibility;
using Recyclarr.TrashLib.Config; using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Repo.VersionControl;
using Spectre.Console; using Spectre.Console;
namespace Recyclarr.Cli.Processors.Sync; namespace Recyclarr.Cli.Processors.Sync;
@ -20,28 +12,25 @@ public class SyncProcessor : ISyncProcessor
{ {
private readonly IAnsiConsole _console; private readonly IAnsiConsole _console;
private readonly ILogger _log; private readonly ILogger _log;
private readonly IConfigurationFinder _configFinder; private readonly IConfigurationRegistry _configRegistry;
private readonly IConfigurationLoader _configLoader;
private readonly SyncPipelineExecutor _pipelines; private readonly SyncPipelineExecutor _pipelines;
private readonly ServiceAgnosticCapabilityEnforcer _capabilityEnforcer; private readonly ServiceAgnosticCapabilityEnforcer _capabilityEnforcer;
private readonly IFileSystem _fs; private readonly ConsoleExceptionHandler _exceptionHandler;
public SyncProcessor( public SyncProcessor(
IAnsiConsole console, IAnsiConsole console,
ILogger log, ILogger log,
IConfigurationFinder configFinder, IConfigurationRegistry configRegistry,
IConfigurationLoader configLoader,
SyncPipelineExecutor pipelines, SyncPipelineExecutor pipelines,
ServiceAgnosticCapabilityEnforcer capabilityEnforcer, ServiceAgnosticCapabilityEnforcer capabilityEnforcer,
IFileSystem fs) ConsoleExceptionHandler exceptionHandler)
{ {
_console = console; _console = console;
_log = log; _log = log;
_configFinder = configFinder; _configRegistry = configRegistry;
_configLoader = configLoader;
_pipelines = pipelines; _pipelines = pipelines;
_capabilityEnforcer = capabilityEnforcer; _capabilityEnforcer = capabilityEnforcer;
_fs = fs; _exceptionHandler = exceptionHandler;
} }
public async Task<ExitStatus> ProcessConfigs(ISyncSettings settings) public async Task<ExitStatus> ProcessConfigs(ISyncSettings settings)
@ -49,55 +38,24 @@ public class SyncProcessor : ISyncProcessor
bool failureDetected; bool failureDetected;
try try
{ {
var configFiles = settings.Configs var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
.Select(x => _fs.FileInfo.New(x))
.ToLookup(x => x.Exists);
if (configFiles[false].Any())
{ {
foreach (var file in configFiles[false]) ManualConfigFiles = settings.Configs,
{ Instances = settings.Instances,
_log.Error("Manually-specified configuration file does not exist: {File}", file); Service = settings.Service
} });
_log.Error("Exiting due to non-existent configuration files");
return ExitStatus.Failed;
}
var configs = LoadAndFilterConfigs(_configFinder.GetConfigFiles(configFiles[true].ToList()), settings);
failureDetected = await ProcessService(settings, configs); failureDetected = await ProcessService(settings, configs);
} }
catch (Exception e) catch (Exception e)
{ {
await HandleException(e); await _exceptionHandler.HandleException(e);
failureDetected = true; failureDetected = true;
} }
return failureDetected ? ExitStatus.Failed : ExitStatus.Succeeded; 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) private async Task<bool> ProcessService(ISyncSettings settings, IEnumerable<IServiceConfiguration> configs)
{ {
var failureDetected = false; var failureDetected = false;
@ -112,7 +70,7 @@ public class SyncProcessor : ISyncProcessor
} }
catch (Exception e) catch (Exception e)
{ {
await HandleException(e); await _exceptionHandler.HandleException(e);
failureDetected = true; failureDetected = true;
} }
} }
@ -120,59 +78,6 @@ public class SyncProcessor : ISyncProcessor
return failureDetected; 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) private void PrintProcessingHeader(SupportedServices serviceType, IServiceConfiguration config)
{ {
var instanceName = config.InstanceName; var instanceName = config.InstanceName;

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

@ -1,3 +1,4 @@
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
@ -18,4 +19,37 @@ public static class ConfigExtensions
var radarr = config?.Radarr?.Count ?? 0; var radarr = config?.Radarr?.Count ?? 0;
return sonarr + radarr == 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; return configs;
} }
public IReadOnlyCollection<IFileInfo> GetConfigFiles(IReadOnlyCollection<IFileInfo>? configs = null) public IReadOnlyCollection<IFileInfo> GetConfigFiles()
{ {
if (configs is not null && configs.Any()) var configs = FindDefaultConfigFiles();
{
return configs;
}
configs = FindDefaultConfigFiles();
if (configs.Count == 0) if (configs.Count == 0)
{ {
throw new NoConfigurationFilesException(); 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 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;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
@ -21,10 +18,10 @@ public class ProcessorExtensionsTest
} }
}; };
var settings = Substitute.For<ISyncSettings>(); var invalidInstanceNames = configs.GetInvalidInstanceNames(new ConfigFilterCriteria
settings.Instances.Returns(new[] {"valid_name", "invalid_name"}); {
Instances = new[] {"valid_name", "invalid_name"}
var invalidInstanceNames = settings.GetInvalidInstanceNames(configs); });
invalidInstanceNames.Should().BeEquivalentTo("invalid_name"); invalidInstanceNames.Should().BeEquivalentTo("invalid_name");
} }
@ -40,10 +37,10 @@ public class ProcessorExtensionsTest
} }
}; };
var settings = Substitute.For<ISyncSettings>(); var invalidInstanceNames = configs.GetInvalidInstanceNames(new ConfigFilterCriteria
settings.Instances.ReturnsNull(); {
Instances = null
var invalidInstanceNames = settings.GetInvalidInstanceNames(configs); });
invalidInstanceNames.Should().BeEmpty(); invalidInstanceNames.Should().BeEmpty();
} }
@ -63,11 +60,11 @@ public class ProcessorExtensionsTest
new SonarrConfiguration {InstanceName = "sonarr4"} new SonarrConfiguration {InstanceName = "sonarr4"}
}; };
var settings = Substitute.For<ISyncSettings>(); var result = configs.GetConfigsBasedOnSettings(new ConfigFilterCriteria
settings.Service.Returns(SupportedServices.Radarr); {
settings.Instances.Returns(new[] {"radarr2", "radarr4", "radarr5", "sonarr2"}); Service = SupportedServices.Radarr,
Instances = new[] {"radarr2", "radarr4", "radarr5", "sonarr2"}
var result = configs.GetConfigsBasedOnSettings(settings); });
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr2", "radarr4"); result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr2", "radarr4");
} }
@ -81,10 +78,10 @@ public class ProcessorExtensionsTest
new SonarrConfiguration {InstanceName = "sonarr1"} new SonarrConfiguration {InstanceName = "sonarr1"}
}; };
var settings = Substitute.For<ISyncSettings>(); var result = configs.GetConfigsBasedOnSettings(new ConfigFilterCriteria
settings.Instances.Returns(Array.Empty<string>()); {
Instances = Array.Empty<string>()
var result = configs.GetConfigsBasedOnSettings(settings); });
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr1", "sonarr1"); 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;
using System.IO.Abstractions.Extensions;
using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Startup; using Recyclarr.TrashLib.Startup;
@ -51,32 +50,11 @@ public class ConfigurationFinderTest
fs.AddEmptyFile(path); fs.AddEmptyFile(path);
} }
var result = sut.GetConfigFiles(new List<IFileInfo>()); var result = sut.GetConfigFiles();
result.Should().BeEquivalentTo(yamlPaths, o => o.Including(x => x.FullName)); 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] [Test, AutoMockData]
public void No_recyclarr_yml_when_not_exists( public void No_recyclarr_yml_when_not_exists(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
@ -86,7 +64,7 @@ public class ConfigurationFinderTest
var testFile = paths.ConfigsDirectory.File("test.yml"); var testFile = paths.ConfigsDirectory.File("test.yml");
fs.AddEmptyFile(testFile); fs.AddEmptyFile(testFile);
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>()); var result = sut.GetConfigFiles();
result.Should().ContainSingle(x => x.FullName == testFile.FullName); result.Should().ContainSingle(x => x.FullName == testFile.FullName);
} }
@ -100,7 +78,7 @@ public class ConfigurationFinderTest
var configFile = paths.AppDataDirectory.File("recyclarr.yml"); var configFile = paths.AppDataDirectory.File("recyclarr.yml");
fs.AddEmptyFile(configFile); fs.AddEmptyFile(configFile);
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>()); var result = sut.GetConfigFiles();
result.Should().ContainSingle(x => x.FullName == configFile.FullName); result.Should().ContainSingle(x => x.FullName == configFile.FullName);
} }
@ -111,7 +89,7 @@ public class ConfigurationFinderTest
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths, [Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut) ConfigurationFinder sut)
{ {
var act = () => sut.GetConfigFiles(Array.Empty<IFileInfo>()); var act = () => sut.GetConfigFiles();
act.Should().Throw<NoConfigurationFilesException>(); act.Should().Throw<NoConfigurationFilesException>();
} }

Loading…
Cancel
Save