diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99f91490..e29ccc49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/.idea/.idea.Recyclarr/.idea/runConfigurations/delete_custom_formats.xml b/src/.idea/.idea.Recyclarr/.idea/runConfigurations/delete_custom_formats.xml
new file mode 100644
index 00000000..2fff3253
--- /dev/null
+++ b/src/.idea/.idea.Recyclarr/.idea/runConfigurations/delete_custom_formats.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Recyclarr.Cli/Console/CliSetup.cs b/src/Recyclarr.Cli/Console/CliSetup.cs
index b7dca9c0..8798a691 100644
--- a/src/Recyclarr.Cli/Console/CliSetup.cs
+++ b/src/Recyclarr.Cli/Console/CliSetup.cs
@@ -28,5 +28,11 @@ public static class CliSetup
config.AddCommand("create");
config.AddCommand("list");
});
+
+ cli.AddBranch("delete", delete =>
+ {
+ delete.SetDescription("Delete operations for remote services (e.g. Radarr, Sonarr)");
+ delete.AddCommand("custom-formats");
+ });
}
}
diff --git a/src/Recyclarr.Cli/Console/Commands/DeleteCustomFormatsCommand.cs b/src/Recyclarr.Cli/Console/Commands/DeleteCustomFormatsCommand.cs
index bfb9b4bc..6f2a5628 100644
--- a/src/Recyclarr.Cli/Console/Commands/DeleteCustomFormatsCommand.cs
+++ b/src/Recyclarr.Cli/Console/Commands/DeleteCustomFormatsCommand.cs
@@ -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
{
-
+ 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, "")]
+ [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();
+ public IReadOnlyCollection 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 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;
+ }
}
diff --git a/src/Recyclarr.Cli/Console/Settings/IDeleteCustomFormatSettings.cs b/src/Recyclarr.Cli/Console/Settings/IDeleteCustomFormatSettings.cs
new file mode 100644
index 00000000..66546044
--- /dev/null
+++ b/src/Recyclarr.Cli/Console/Settings/IDeleteCustomFormatSettings.cs
@@ -0,0 +1,10 @@
+namespace Recyclarr.Cli.Console.Settings;
+
+public interface IDeleteCustomFormatSettings
+{
+ public string InstanceName { get; }
+ public IReadOnlyCollection CustomFormatNames { get; }
+ public bool All { get; }
+ public bool Force { get; }
+ bool Preview { get; }
+}
diff --git a/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/CustomFormatService.cs b/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/CustomFormatService.cs
index 24624be6..c2c71be9 100644
--- a/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/CustomFormatService.cs
+++ b/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/CustomFormatService.cs
@@ -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);
}
}
diff --git a/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/ICustomFormatService.cs b/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/ICustomFormatService.cs
index 50513128..84d616f7 100644
--- a/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/ICustomFormatService.cs
+++ b/src/Recyclarr.Cli/Pipelines/CustomFormat/Api/ICustomFormatService.cs
@@ -8,5 +8,9 @@ public interface ICustomFormatService
Task> GetCustomFormats(IServiceConfiguration config);
Task 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);
}
diff --git a/src/Recyclarr.Cli/Processors/ConsoleExceptionHandler.cs b/src/Recyclarr.Cli/Processors/ConsoleExceptionHandler.cs
new file mode 100644
index 00000000..39451eba
--- /dev/null
+++ b/src/Recyclarr.Cli/Processors/ConsoleExceptionHandler.cs
@@ -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> GetValidationErrorsAsync(FlurlHttpException e)
+ {
+ var response = await e.GetResponseJsonAsync>();
+ if (response is null)
+ {
+ return Array.Empty();
+ }
+
+ return response
+ .Select(x => (string) x.errorMessage)
+ .NotNull(x => !string.IsNullOrEmpty(x))
+ .ToList();
+ }
+}
diff --git a/src/Recyclarr.Cli/Processors/Delete/DeleteCustomFormatsProcessor.cs b/src/Recyclarr.Cli/Processors/Delete/DeleteCustomFormatsProcessor.cs
new file mode 100644
index 00000000..f7929afc
--- /dev/null
+++ b/src/Recyclarr.Cli/Processors/Delete/DeleteCustomFormatsProcessor.cs
@@ -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 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> ObtainCustomFormats(IServiceConfiguration config)
+ {
+ IList cfs = new List();
+
+ await _console.Status().StartAsync("Obtaining custom formats...", async _ =>
+ {
+ cfs = await _api.GetCustomFormats(config);
+ });
+
+ return cfs;
+ }
+
+ private IList ProcessManuallySpecifiedFormats(
+ IDeleteCustomFormatSettings settings,
+ IList cfs)
+ {
+ ILookup 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 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();
+ }
+}
diff --git a/src/Recyclarr.Cli/Processors/Delete/IDeleteCustomFormatsProcessor.cs b/src/Recyclarr.Cli/Processors/Delete/IDeleteCustomFormatsProcessor.cs
new file mode 100644
index 00000000..8016e587
--- /dev/null
+++ b/src/Recyclarr.Cli/Processors/Delete/IDeleteCustomFormatsProcessor.cs
@@ -0,0 +1,8 @@
+using Recyclarr.Cli.Console.Settings;
+
+namespace Recyclarr.Cli.Processors.Delete;
+
+public interface IDeleteCustomFormatsProcessor
+{
+ Task Process(IDeleteCustomFormatSettings settings);
+}
diff --git a/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs b/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs
deleted file mode 100644
index 042b5ac3..00000000
--- a/src/Recyclarr.Cli/Processors/ProcessorExtensions.cs
+++ /dev/null
@@ -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 GetConfigsBasedOnSettings(
- this IEnumerable 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 GetSplitInstances(this IEnumerable configs)
- {
- return configs
- .GroupBy(x => x.BaseUrl)
- .Where(x => x.Count() > 1)
- .SelectMany(x => x.Select(y => y.InstanceName));
- }
-
- public static IEnumerable GetInvalidInstanceNames(
- this ISyncSettings settings,
- IEnumerable configs)
- {
- if (settings.Instances is null)
- {
- return Array.Empty();
- }
-
- var configInstances = configs.Select(x => x.InstanceName).ToList();
- return settings.Instances
- .Where(x => !configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase));
- }
-}
diff --git a/src/Recyclarr.Cli/Processors/ServiceProcessorsAutofacModule.cs b/src/Recyclarr.Cli/Processors/ServiceProcessorsAutofacModule.cs
index de9f4373..19b210c8 100644
--- a/src/Recyclarr.Cli/Processors/ServiceProcessorsAutofacModule.cs
+++ b/src/Recyclarr.Cli/Processors/ServiceProcessorsAutofacModule.cs
@@ -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();
+
// Sync
builder.RegisterType().As();
builder.RegisterType();
@@ -20,6 +23,9 @@ public class ServiceProcessorsAutofacModule : Module
builder.RegisterType().As();
builder.RegisterType();
+ // Delete
+ builder.RegisterType().As();
+
builder.RegisterTypes(
typeof(TemplateConfigCreator),
typeof(LocalConfigCreator))
diff --git a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs
index 026525df..5aa7dd30 100644
--- a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs
+++ b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs
@@ -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 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 LoadAndFilterConfigs(
- IEnumerable 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 ProcessService(ISyncSettings settings, IEnumerable 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> GetValidationErrorsAsync(FlurlHttpException e)
- {
- var response = await e.GetResponseJsonAsync>();
- if (response is null)
- {
- return Array.Empty();
- }
-
- return response
- .Select(x => (string) x.errorMessage)
- .NotNull(x => !string.IsNullOrEmpty(x))
- .ToList();
- }
-
private void PrintProcessingHeader(SupportedServices serviceType, IServiceConfiguration config)
{
var instanceName = config.InstanceName;
diff --git a/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs b/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs
index 8c236313..cda2044b 100644
--- a/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs
+++ b/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs
@@ -24,6 +24,7 @@ public class ConfigAutofacModule : Module
builder.RegisterType().As().SingleInstance();
builder.RegisterType().As();
+ builder.RegisterType().As();
builder.RegisterType().As();
builder.RegisterType().As();
builder.RegisterType().As();
diff --git a/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs b/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs
index 4c8b14a8..01dbb463 100644
--- a/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs
+++ b/src/Recyclarr.TrashLib/Config/ConfigExtensions.cs
@@ -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 GetConfigsBasedOnSettings(
+ this IEnumerable 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 GetSplitInstances(this IEnumerable configs)
+ {
+ return configs
+ .GroupBy(x => x.BaseUrl)
+ .Where(x => x.Count() > 1)
+ .SelectMany(x => x.Select(y => y.InstanceName));
+ }
+
+ public static IEnumerable GetInvalidInstanceNames(
+ this IEnumerable configs,
+ ConfigFilterCriteria criteria)
+ {
+ if (criteria.Instances is null || !criteria.Instances.Any())
+ {
+ return Array.Empty();
+ }
+
+ var configInstances = configs.Select(x => x.InstanceName).ToList();
+ return criteria.Instances
+ .Where(x => !configInstances.Contains(x, StringComparer.InvariantCultureIgnoreCase));
+ }
}
diff --git a/src/Recyclarr.TrashLib/Config/ConfigFilterCriteria.cs b/src/Recyclarr.TrashLib/Config/ConfigFilterCriteria.cs
new file mode 100644
index 00000000..bd6475fc
--- /dev/null
+++ b/src/Recyclarr.TrashLib/Config/ConfigFilterCriteria.cs
@@ -0,0 +1,8 @@
+namespace Recyclarr.TrashLib.Config;
+
+public record ConfigFilterCriteria
+{
+ public IReadOnlyCollection? ManualConfigFiles { get; init; }
+ public SupportedServices? Service { get; init; }
+ public IReadOnlyCollection? Instances { get; init; }
+}
diff --git a/src/Recyclarr.TrashLib/Config/ConfigurationRegistry.cs b/src/Recyclarr.TrashLib/Config/ConfigurationRegistry.cs
new file mode 100644
index 00000000..8a24aac0
--- /dev/null
+++ b/src/Recyclarr.TrashLib/Config/ConfigurationRegistry.cs
@@ -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 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 PrepareManualConfigs(IEnumerable 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 LoadAndFilterConfigs(
+ IEnumerable 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);
+ }
+}
diff --git a/src/Recyclarr.TrashLib/Config/IConfigurationRegistry.cs b/src/Recyclarr.TrashLib/Config/IConfigurationRegistry.cs
new file mode 100644
index 00000000..283b9223
--- /dev/null
+++ b/src/Recyclarr.TrashLib/Config/IConfigurationRegistry.cs
@@ -0,0 +1,8 @@
+using Recyclarr.TrashLib.Config.Services;
+
+namespace Recyclarr.TrashLib.Config;
+
+public interface IConfigurationRegistry
+{
+ IReadOnlyCollection FindAndLoadConfigs(ConfigFilterCriteria? filterCriteria = null);
+}
diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationFinder.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationFinder.cs
index 61ecd659..dd6d4855 100644
--- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationFinder.cs
+++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationFinder.cs
@@ -34,14 +34,9 @@ public class ConfigurationFinder : IConfigurationFinder
return configs;
}
- public IReadOnlyCollection GetConfigFiles(IReadOnlyCollection? configs = null)
+ public IReadOnlyCollection GetConfigFiles()
{
- if (configs is not null && configs.Any())
- {
- return configs;
- }
-
- configs = FindDefaultConfigFiles();
+ var configs = FindDefaultConfigFiles();
if (configs.Count == 0)
{
throw new NoConfigurationFilesException();
diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ErrorHandling/InvalidConfigurationFilesException.cs b/src/Recyclarr.TrashLib/Config/Parsing/ErrorHandling/InvalidConfigurationFilesException.cs
new file mode 100644
index 00000000..375ee455
--- /dev/null
+++ b/src/Recyclarr.TrashLib/Config/Parsing/ErrorHandling/InvalidConfigurationFilesException.cs
@@ -0,0 +1,13 @@
+using System.IO.Abstractions;
+
+namespace Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
+
+public class InvalidConfigurationFilesException : Exception
+{
+ public IReadOnlyCollection InvalidFiles { get; }
+
+ public InvalidConfigurationFilesException(IReadOnlyCollection invalidFiles)
+ {
+ InvalidFiles = invalidFiles;
+ }
+}
diff --git a/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationFinder.cs b/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationFinder.cs
index 51c69bfb..9bcf49d6 100644
--- a/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationFinder.cs
+++ b/src/Recyclarr.TrashLib/Config/Parsing/IConfigurationFinder.cs
@@ -4,5 +4,5 @@ namespace Recyclarr.TrashLib.Config.Parsing;
public interface IConfigurationFinder
{
- IReadOnlyCollection GetConfigFiles(IReadOnlyCollection? configs = null);
+ IReadOnlyCollection GetConfigFiles();
}
diff --git a/src/Recyclarr.TrashLib/ExceptionTypes/CommandException.cs b/src/Recyclarr.TrashLib/ExceptionTypes/CommandException.cs
new file mode 100644
index 00000000..17ecf58e
--- /dev/null
+++ b/src/Recyclarr.TrashLib/ExceptionTypes/CommandException.cs
@@ -0,0 +1,9 @@
+namespace Recyclarr.TrashLib.ExceptionTypes;
+
+public class CommandException : Exception
+{
+ public CommandException(string? message)
+ : base(message)
+ {
+ }
+}
diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs
index f2f92794..7f30461e 100644
--- a/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs
+++ b/src/tests/Recyclarr.Cli.Tests/Processors/ProcessorExtensionsTest.cs
@@ -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();
- 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();
- 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();
- 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();
- settings.Instances.Returns(Array.Empty());
-
- var result = configs.GetConfigsBasedOnSettings(settings);
+ var result = configs.GetConfigsBasedOnSettings(new ConfigFilterCriteria
+ {
+ Instances = Array.Empty()
+ });
result.Select(x => x.InstanceName).Should().BeEquivalentTo("radarr1", "sonarr1");
}
diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/ConfigurationRegistryTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/ConfigurationRegistryTest.cs
new file mode 100644
index 00000000..1f051100
--- /dev/null
+++ b/src/tests/Recyclarr.TrashLib.Tests/Config/ConfigurationRegistryTest.cs
@@ -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();
+
+ 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();
+
+ var act = () => sut.FindAndLoadConfigs(new ConfigFilterCriteria
+ {
+ ManualConfigFiles = new[] {"manual.yml"}
+ });
+
+ act.Should().ThrowExactly();
+ }
+
+ [Test]
+ public void Throw_on_invalid_instances()
+ {
+ var sut = Resolve();
+
+ 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()
+ .Which.InstanceNames.Should().BeEquivalentTo("instance2");
+ }
+
+ [Test]
+ public void Throw_on_split_instances()
+ {
+ var sut = Resolve();
+
+ 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()
+ .Which.InstanceNames.Should().BeEquivalentTo("instance1", "instance2");
+ }
+}
diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationFinderTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationFinderTest.cs
index 66b15691..47c803ab 100644
--- a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationFinderTest.cs
+++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/ConfigurationFinderTest.cs
@@ -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());
+ 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());
+ 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());
+ 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());
+ var act = () => sut.GetConfigFiles();
act.Should().Throw();
}