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(); }