From 243d0760876c37e63ac3d99f0677ce114396522a Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 15 May 2022 18:36:21 -0500 Subject: [PATCH] refactor: New command structure The goal is to separate initialization logic from command business logic. Some initialization requires modifying the environment before we instantiate many objects needed for implementing command behavior. If those objects get instantiated, they will most likely already start using files/directories/environment on the system and we can't modify those while they're in use. --- .../Command/Helpers/CliTypeActivatorTest.cs | 3 +- .../SonarrServiceTest.cs} | 47 ++++--- .../Services/ConfigurationLoaderTest.cs | 2 +- .../Command/Helpers/IServiceInitialization.cs | 6 + .../Command/Helpers/ServiceInitialization.cs | 75 +++++++++++ src/Recyclarr/Command/IRadarrCommand.cs | 5 + src/Recyclarr/Command/IServiceCommand.cs | 3 +- src/Recyclarr/Command/ISonarrCommand.cs | 7 ++ src/Recyclarr/Command/RadarrCommand.cs | 68 ++-------- src/Recyclarr/Command/ServiceCommand.cs | 117 +++++------------- .../Command/Services/RadarrService.cs | 48 +++++++ src/Recyclarr/Command/Services/ServiceBase.cs | 55 ++++++++ .../Command/Services/SonarrService.cs | 74 +++++++++++ src/Recyclarr/Command/SonarrCommand.cs | 95 +++----------- src/Recyclarr/CompositionRoot.cs | 7 +- src/Recyclarr/Program.cs | 53 ++++---- .../Sonarr/ReleaseProfileListerTest.cs | 1 - 17 files changed, 391 insertions(+), 275 deletions(-) rename src/Recyclarr.Tests/Command/{SonarrCommandTest.cs => Services/SonarrServiceTest.cs} (56%) create mode 100644 src/Recyclarr/Command/Helpers/IServiceInitialization.cs create mode 100644 src/Recyclarr/Command/Helpers/ServiceInitialization.cs create mode 100644 src/Recyclarr/Command/IRadarrCommand.cs create mode 100644 src/Recyclarr/Command/ISonarrCommand.cs create mode 100644 src/Recyclarr/Command/Services/RadarrService.cs create mode 100644 src/Recyclarr/Command/Services/ServiceBase.cs create mode 100644 src/Recyclarr/Command/Services/SonarrService.cs diff --git a/src/Recyclarr.Tests/Command/Helpers/CliTypeActivatorTest.cs b/src/Recyclarr.Tests/Command/Helpers/CliTypeActivatorTest.cs index 46f21c6b..b6e7a13e 100644 --- a/src/Recyclarr.Tests/Command/Helpers/CliTypeActivatorTest.cs +++ b/src/Recyclarr.Tests/Command/Helpers/CliTypeActivatorTest.cs @@ -23,8 +23,9 @@ public class CliTypeActivatorTest { public bool Preview => false; public bool Debug => false; - public ICollection? Config => null; + public ICollection Config => new List(); public string CacheStoragePath => ""; + public string Name => ""; } [Test] diff --git a/src/Recyclarr.Tests/Command/SonarrCommandTest.cs b/src/Recyclarr.Tests/Command/Services/SonarrServiceTest.cs similarity index 56% rename from src/Recyclarr.Tests/Command/SonarrCommandTest.cs rename to src/Recyclarr.Tests/Command/Services/SonarrServiceTest.cs index 755ab1b7..6dbcc0c8 100644 --- a/src/Recyclarr.Tests/Command/SonarrCommandTest.cs +++ b/src/Recyclarr.Tests/Command/Services/SonarrServiceTest.cs @@ -1,41 +1,45 @@ using AutoFixture.NUnit3; using CliFx.Exceptions; -using CliFx.Infrastructure; using FluentAssertions; using NSubstitute; using NUnit.Framework; -using TestLibrary.AutoFixture; using Recyclarr.Command; +using Recyclarr.Command.Services; +using TestLibrary.AutoFixture; using TrashLib.Sonarr; -namespace Recyclarr.Tests.Command; +namespace Recyclarr.Tests.Command.Services; [TestFixture] [Parallelizable(ParallelScope.All)] -public class SonarrCommandTest +public class SonarrServiceTest { [Test, AutoMockData] public async Task List_terms_without_value_fails( - IConsole console, - SonarrCommand sut) + [Frozen] ISonarrCommand cmd, + SonarrService sut) { + cmd.ListReleaseProfiles.Returns(false); + // When `--list-terms` is specified on the command line without a value, it gets a `null` value assigned. - sut.ListTerms = null; + cmd.ListTerms.Returns((string?) null); - var act = () => sut.ExecuteAsync(console).AsTask(); + var act = () => sut.Execute(cmd); await act.Should().ThrowAsync(); } [Test, AutoMockData] public async Task List_terms_with_empty_value_fails( - IConsole console, - SonarrCommand sut) + [Frozen] ISonarrCommand cmd, + SonarrService sut) { + cmd.ListReleaseProfiles.Returns(false); + // If the user specifies a blank string as the value, it should still fail. - sut.ListTerms = ""; + cmd.ListTerms.Returns(""); - var act = () => sut.ExecuteAsync(console).AsTask(); + var act = () => sut.Execute(cmd); await act.Should().ThrowAsync(); } @@ -43,12 +47,14 @@ public class SonarrCommandTest [Test, AutoMockData] public async Task List_terms_uses_specified_trash_id( [Frozen] IReleaseProfileLister lister, - IConsole console, - SonarrCommand sut) + [Frozen] ISonarrCommand cmd, + SonarrService sut) { - sut.ListTerms = "some_id"; + cmd.ListReleaseProfiles.Returns(false); + + cmd.ListTerms.Returns("some_id"); - await sut.ExecuteAsync(console); + await sut.Execute(cmd); lister.Received().ListTerms("some_id"); } @@ -56,12 +62,13 @@ public class SonarrCommandTest [Test, AutoMockData] public async Task List_release_profiles_is_invoked( [Frozen] IReleaseProfileLister lister, - IConsole console, - SonarrCommand sut) + [Frozen] ISonarrCommand cmd, + SonarrService sut) { - sut.ListReleaseProfiles = true; + cmd.ListReleaseProfiles.Returns(true); + cmd.ListTerms.Returns((string?) null); - await sut.ExecuteAsync(console); + await sut.Execute(cmd); lister.Received().ListReleaseProfiles(); } diff --git a/src/Recyclarr.Tests/Config/Services/ConfigurationLoaderTest.cs b/src/Recyclarr.Tests/Config/Services/ConfigurationLoaderTest.cs index 3e58657a..a37c05ae 100644 --- a/src/Recyclarr.Tests/Config/Services/ConfigurationLoaderTest.cs +++ b/src/Recyclarr.Tests/Config/Services/ConfigurationLoaderTest.cs @@ -11,10 +11,10 @@ using FluentValidation.Results; using JetBrains.Annotations; using NSubstitute; using NUnit.Framework; +using Recyclarr.Config; using TestLibrary; using TestLibrary.AutoFixture; using TestLibrary.NSubstitute; -using Recyclarr.Config; using TrashLib.Config; using TrashLib.Config.Services; using TrashLib.Sonarr.Config; diff --git a/src/Recyclarr/Command/Helpers/IServiceInitialization.cs b/src/Recyclarr/Command/Helpers/IServiceInitialization.cs new file mode 100644 index 00000000..368bdf71 --- /dev/null +++ b/src/Recyclarr/Command/Helpers/IServiceInitialization.cs @@ -0,0 +1,6 @@ +namespace Recyclarr.Command.Helpers; + +public interface IServiceInitialization +{ + void Initialize(IServiceCommand cmd); +} diff --git a/src/Recyclarr/Command/Helpers/ServiceInitialization.cs b/src/Recyclarr/Command/Helpers/ServiceInitialization.cs new file mode 100644 index 00000000..76fc4272 --- /dev/null +++ b/src/Recyclarr/Command/Helpers/ServiceInitialization.cs @@ -0,0 +1,75 @@ +using Common.Networking; +using Flurl.Http; +using Flurl.Http.Configuration; +using Newtonsoft.Json; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using TrashLib.Config.Settings; +using TrashLib.Extensions; +using TrashLib.Repo; + +namespace Recyclarr.Command.Helpers; + +internal class ServiceInitialization : IServiceInitialization +{ + private readonly ILogger _log; + private readonly LoggingLevelSwitch _loggingLevelSwitch; + private readonly ISettingsPersister _settingsPersister; + private readonly ISettingsProvider _settingsProvider; + private readonly IRepoUpdater _repoUpdater; + + public ServiceInitialization( + ILogger log, + LoggingLevelSwitch loggingLevelSwitch, + ISettingsPersister settingsPersister, + ISettingsProvider settingsProvider, + IRepoUpdater repoUpdater) + { + _log = log; + _loggingLevelSwitch = loggingLevelSwitch; + _settingsPersister = settingsPersister; + _settingsProvider = settingsProvider; + _repoUpdater = repoUpdater; + } + + public void Initialize(IServiceCommand cmd) + { + // Must happen first because everything can use the logger. + _loggingLevelSwitch.MinimumLevel = cmd.Debug ? LogEventLevel.Debug : LogEventLevel.Information; + + // Has to happen right after logging because stuff below may use settings. + _settingsPersister.Load(); + + SetupHttp(); + _repoUpdater.UpdateRepo(); + } + + private void SetupHttp() + { + FlurlHttp.Configure(settings => + { + var jsonSettings = new JsonSerializerSettings + { + // This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future + // version, this needs to fail to indicate that a software change is required. Otherwise, we lose + // state between when we request settings, and re-apply them again with a few properties modified. + MissingMemberHandling = MissingMemberHandling.Error, + + // This makes sure that null properties, such as maxSize and preferredSize in Radarr + // Quality Definitions, do not get written out to JSON request bodies. + NullValueHandling = NullValueHandling.Ignore + }; + + settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings); + FlurlLogging.SetupLogging(settings, _log); + + if (!_settingsProvider.Settings.EnableSslCertificateValidation) + { + _log.Warning( + "Security Risk: Certificate validation is being DISABLED because setting `enable_ssl_certificate_validation` is set to `false`"); + settings.HttpClientFactory = new UntrustedCertClientFactory(); + } + }); + } +} diff --git a/src/Recyclarr/Command/IRadarrCommand.cs b/src/Recyclarr/Command/IRadarrCommand.cs new file mode 100644 index 00000000..03d4b9e2 --- /dev/null +++ b/src/Recyclarr/Command/IRadarrCommand.cs @@ -0,0 +1,5 @@ +namespace Recyclarr.Command; + +public interface IRadarrCommand : IServiceCommand +{ +} diff --git a/src/Recyclarr/Command/IServiceCommand.cs b/src/Recyclarr/Command/IServiceCommand.cs index 42aff555..f4cbd875 100644 --- a/src/Recyclarr/Command/IServiceCommand.cs +++ b/src/Recyclarr/Command/IServiceCommand.cs @@ -4,6 +4,7 @@ public interface IServiceCommand { bool Preview { get; } bool Debug { get; } - ICollection? Config { get; } + ICollection Config { get; } string CacheStoragePath { get; } + string Name { get; } } diff --git a/src/Recyclarr/Command/ISonarrCommand.cs b/src/Recyclarr/Command/ISonarrCommand.cs new file mode 100644 index 00000000..5f484dd4 --- /dev/null +++ b/src/Recyclarr/Command/ISonarrCommand.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Command; + +public interface ISonarrCommand : IServiceCommand +{ + bool ListReleaseProfiles { get; } + string? ListTerms { get; } +} diff --git a/src/Recyclarr/Command/RadarrCommand.cs b/src/Recyclarr/Command/RadarrCommand.cs index aad0ca2b..9d73308e 100644 --- a/src/Recyclarr/Command/RadarrCommand.cs +++ b/src/Recyclarr/Command/RadarrCommand.cs @@ -1,71 +1,29 @@ using CliFx.Attributes; -using Flurl.Http; using JetBrains.Annotations; -using Recyclarr.Config; -using Serilog; -using Serilog.Core; -using TrashLib.Config.Settings; -using TrashLib.Extensions; -using TrashLib.Radarr.Config; -using TrashLib.Radarr.CustomFormat; -using TrashLib.Radarr.QualityDefinition; -using TrashLib.Repo; +using Recyclarr.Command.Services; +using Recyclarr.Migration; namespace Recyclarr.Command; [Command("radarr", Description = "Perform operations on a Radarr instance")] [UsedImplicitly] -public class RadarrCommand : ServiceCommand +internal class RadarrCommand : ServiceCommand, IRadarrCommand { - private readonly IConfigurationLoader _configLoader; - private readonly Func _customFormatUpdaterFactory; - private readonly ILogger _log; - private readonly Func _qualityUpdaterFactory; - - public RadarrCommand( - ILogger log, - LoggingLevelSwitch loggingLevelSwitch, - ILogJanitor logJanitor, - ISettingsPersister settingsPersister, - ISettingsProvider settingsProvider, - IRepoUpdater repoUpdater, - IConfigurationLoader configLoader, - Func qualityUpdaterFactory, - Func customFormatUpdaterFactory) - : base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider, repoUpdater) - { - _log = log; - _configLoader = configLoader; - _qualityUpdaterFactory = qualityUpdaterFactory; - _customFormatUpdaterFactory = customFormatUpdaterFactory; - } + private readonly Lazy _service; public override string CacheStoragePath { get; } = Path.Combine(AppPaths.AppDataPath, "cache", "radarr"); - protected override async Task Process() - { - try - { - foreach (var config in _configLoader.LoadMany(Config, "radarr")) - { - _log.Information("Processing server {Url}", config.BaseUrl); + public override string Name => "Radarr"; - if (config.QualityDefinition != null) - { - await _qualityUpdaterFactory().Process(Preview, config); - } + public RadarrCommand(IMigrationExecutor migration, Lazy service) + : base(migration) + { + _service = service; + } - if (config.CustomFormats.Count > 0) - { - await _customFormatUpdaterFactory().Process(Preview, config); - } - } - } - catch (FlurlHttpException e) - { - _log.Error("HTTP error while communicating with Radarr: {Msg}", e.SanitizedExceptionMessage()); - ExitDueToFailure(); - } + protected override async Task Process() + { + await _service.Value.Execute(this); } } diff --git a/src/Recyclarr/Command/ServiceCommand.cs b/src/Recyclarr/Command/ServiceCommand.cs index 39251bad..0fb150f5 100644 --- a/src/Recyclarr/Command/ServiceCommand.cs +++ b/src/Recyclarr/Command/ServiceCommand.cs @@ -1,30 +1,16 @@ +using System.Text; using CliFx; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Infrastructure; -using Common.Networking; -using Flurl.Http; -using Flurl.Http.Configuration; using JetBrains.Annotations; -using Newtonsoft.Json; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using TrashLib.Config.Settings; -using TrashLib.Extensions; -using TrashLib.Repo; -using YamlDotNet.Core; +using Recyclarr.Migration; namespace Recyclarr.Command; public abstract class ServiceCommand : ICommand, IServiceCommand { - private readonly ILogger _log; - private readonly LoggingLevelSwitch _loggingLevelSwitch; - private readonly ILogJanitor _logJanitor; - private readonly ISettingsPersister _settingsPersister; - private readonly ISettingsProvider _settingsProvider; - private readonly IRepoUpdater _repoUpdater; + private readonly IMigrationExecutor _migration; [CommandOption("preview", 'p', Description = "Only display the processed markdown results without making any API calls.")] @@ -41,93 +27,52 @@ public abstract class ServiceCommand : ICommand, IServiceCommand new List {AppPaths.DefaultConfigPath}; public abstract string CacheStoragePath { get; } + public abstract string Name { get; } - protected ServiceCommand( - ILogger log, - LoggingLevelSwitch loggingLevelSwitch, - ILogJanitor logJanitor, - ISettingsPersister settingsPersister, - ISettingsProvider settingsProvider, - IRepoUpdater repoUpdater) + protected ServiceCommand(IMigrationExecutor migration) { - _loggingLevelSwitch = loggingLevelSwitch; - _logJanitor = logJanitor; - _settingsPersister = settingsPersister; - _settingsProvider = settingsProvider; - _repoUpdater = repoUpdater; - _log = log; + _migration = migration; } public async ValueTask ExecuteAsync(IConsole console) { - // Must happen first because everything can use the logger. - _loggingLevelSwitch.MinimumLevel = Debug ? LogEventLevel.Debug : LogEventLevel.Information; + // Stuff that needs to happen pre-service-initialization goes here - // Has to happen right after logging because stuff below may use settings. - _settingsPersister.Load(); + // Migrations are performed before we process command line arguments because we cannot instantiate any service + // objects via the DI container before migration logic is performed. This is due to the fact that migration + // steps may alter important files and directories which those services may depend on. + PerformMigrations(); - SetupHttp(); - _repoUpdater.UpdateRepo(); + // Initialize command services and execute business logic (system environment changes should be done by this + // point) + await Process(); + } + private void PerformMigrations() + { try { - await Process(); + _migration.PerformAllMigrationSteps(); } - catch (YamlException e) + catch (MigrationException e) { - var inner = e.InnerException; - if (inner == null) + var msg = new StringBuilder(); + msg.AppendLine("Fatal exception during migration step. Details are below.\n"); + msg.AppendLine($"Step That Failed: {e.OperationDescription}"); + msg.AppendLine($"Failure Reason: {e.OriginalException.Message}"); + + if (e.Remediation.Any()) { - throw; + msg.AppendLine("\nPossible remediation steps:"); + foreach (var remedy in e.Remediation) + { + msg.AppendLine($" - {remedy}"); + } } - _log.Error("Found Unrecognized YAML Property: {ErrorMsg}", inner.Message); - _log.Error("Please remove the property quoted in the above message from your YAML file"); - throw new CommandException("Exiting due to invalid configuration"); - } - catch (Exception e) when (e is not CommandException) - { - _log.Error(e, "Unrecoverable Exception"); - ExitDueToFailure(); + throw new CommandException(msg.ToString()); } - finally - { - _logJanitor.DeleteOldestLogFiles(20); - } - } - - private void SetupHttp() - { - FlurlHttp.Configure(settings => - { - var jsonSettings = new JsonSerializerSettings - { - // This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future - // version, this needs to fail to indicate that a software change is required. Otherwise, we lose - // state between when we request settings, and re-apply them again with a few properties modified. - MissingMemberHandling = MissingMemberHandling.Error, - - // This makes sure that null properties, such as maxSize and preferredSize in Radarr - // Quality Definitions, do not get written out to JSON request bodies. - NullValueHandling = NullValueHandling.Ignore - }; - - settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings); - FlurlLogging.SetupLogging(settings, _log); - - if (!_settingsProvider.Settings.EnableSslCertificateValidation) - { - _log.Warning( - "Security Risk: Certificate validation is being DISABLED because setting `enable_ssl_certificate_validation` is set to `false`"); - settings.HttpClientFactory = new UntrustedCertClientFactory(); - } - }); } protected abstract Task Process(); - - protected static void ExitDueToFailure() - { - throw new CommandException("Exiting due to previous exception"); - } } diff --git a/src/Recyclarr/Command/Services/RadarrService.cs b/src/Recyclarr/Command/Services/RadarrService.cs new file mode 100644 index 00000000..c9e189c6 --- /dev/null +++ b/src/Recyclarr/Command/Services/RadarrService.cs @@ -0,0 +1,48 @@ +using Recyclarr.Command.Helpers; +using Recyclarr.Config; +using Serilog; +using TrashLib.Radarr.Config; +using TrashLib.Radarr.CustomFormat; +using TrashLib.Radarr.QualityDefinition; + +namespace Recyclarr.Command.Services; + +public class RadarrService : ServiceBase +{ + private readonly IConfigurationLoader _configLoader; + private readonly Func _customFormatUpdaterFactory; + private readonly ILogger _log; + private readonly Func _qualityUpdaterFactory; + + public RadarrService( + ILogger log, + IServiceInitialization serviceInitialization, + IConfigurationLoader configLoader, + Func qualityUpdaterFactory, + Func customFormatUpdaterFactory) + : base(log, serviceInitialization) + { + _log = log; + _configLoader = configLoader; + _qualityUpdaterFactory = qualityUpdaterFactory; + _customFormatUpdaterFactory = customFormatUpdaterFactory; + } + + protected override async Task Process(IRadarrCommand cmd) + { + foreach (var config in _configLoader.LoadMany(cmd.Config, "radarr")) + { + _log.Information("Processing server {Url}", config.BaseUrl); + + if (config.QualityDefinition != null) + { + await _qualityUpdaterFactory().Process(cmd.Preview, config); + } + + if (config.CustomFormats.Count > 0) + { + await _customFormatUpdaterFactory().Process(cmd.Preview, config); + } + } + } +} diff --git a/src/Recyclarr/Command/Services/ServiceBase.cs b/src/Recyclarr/Command/Services/ServiceBase.cs new file mode 100644 index 00000000..1e8451fe --- /dev/null +++ b/src/Recyclarr/Command/Services/ServiceBase.cs @@ -0,0 +1,55 @@ +using CliFx.Exceptions; +using Flurl.Http; +using Recyclarr.Command.Helpers; +using Serilog; +using TrashLib.Extensions; +using YamlDotNet.Core; + +namespace Recyclarr.Command.Services; + +/// +/// Mainly intended to handle common exception recovery logic between service command classes. +/// +public abstract class ServiceBase where T : IServiceCommand +{ + private readonly ILogger _log; + private readonly IServiceInitialization _serviceInitialization; + + protected ServiceBase(ILogger log, IServiceInitialization serviceInitialization) + { + _log = log; + _serviceInitialization = serviceInitialization; + } + + public async Task Execute(T cmd) + { + try + { + _serviceInitialization.Initialize(cmd); + await Process(cmd); + } + catch (YamlException e) + { + var message = e.InnerException is not null ? e.InnerException.Message : e.Message; + _log.Error("Found Unrecognized YAML Property: {ErrorMsg}", message); + _log.Error("Please remove the property quoted in the above message from your YAML file"); + throw new CommandException("Exiting due to invalid configuration"); + } + catch (FlurlHttpException e) + { + _log.Error("HTTP error while communicating with {ServiceName}: {Msg}", cmd.Name, + e.SanitizedExceptionMessage()); + ExitDueToFailure(); + } + catch (Exception e) when (e is not CommandException) + { + _log.Error(e, "Unrecoverable Exception"); + ExitDueToFailure(); + } + } + + protected abstract Task Process(T cmd); + + private static void ExitDueToFailure() + => throw new CommandException("Exiting due to previous exception"); +} diff --git a/src/Recyclarr/Command/Services/SonarrService.cs b/src/Recyclarr/Command/Services/SonarrService.cs new file mode 100644 index 00000000..75b94802 --- /dev/null +++ b/src/Recyclarr/Command/Services/SonarrService.cs @@ -0,0 +1,74 @@ +using CliFx.Exceptions; +using Recyclarr.Command.Helpers; +using Recyclarr.Config; +using Serilog; +using TrashLib.Sonarr; +using TrashLib.Sonarr.Config; +using TrashLib.Sonarr.QualityDefinition; +using TrashLib.Sonarr.ReleaseProfile; + +namespace Recyclarr.Command.Services; + +public class SonarrService : ServiceBase +{ + private readonly ILogger _log; + private readonly IConfigurationLoader _configLoader; + private readonly Func _profileUpdaterFactory; + private readonly Func _qualityUpdaterFactory; + private readonly IReleaseProfileLister _lister; + + public SonarrService( + ILogger log, + IServiceInitialization serviceInitialization, + IConfigurationLoader configLoader, + Func profileUpdaterFactory, + Func qualityUpdaterFactory, + IReleaseProfileLister lister) + : base(log, serviceInitialization) + { + _log = log; + _configLoader = configLoader; + _profileUpdaterFactory = profileUpdaterFactory; + _qualityUpdaterFactory = qualityUpdaterFactory; + _lister = lister; + } + + protected override async Task Process(ISonarrCommand cmd) + { + if (cmd.ListReleaseProfiles) + { + _lister.ListReleaseProfiles(); + return; + } + + if (cmd.ListTerms != "empty") + { + if (!string.IsNullOrEmpty(cmd.ListTerms)) + { + _lister.ListTerms(cmd.ListTerms); + } + else + { + throw new CommandException( + "The --list-terms option was specified without a Release Profile Trash ID specified"); + } + + return; + } + + foreach (var config in _configLoader.LoadMany(cmd.Config, "sonarr")) + { + _log.Information("Processing server {Url}", config.BaseUrl); + + if (config.ReleaseProfiles.Count > 0) + { + await _profileUpdaterFactory().Process(cmd.Preview, config); + } + + if (config.QualityDefinition.HasValue) + { + await _qualityUpdaterFactory().Process(cmd.Preview, config); + } + } + } +} diff --git a/src/Recyclarr/Command/SonarrCommand.cs b/src/Recyclarr/Command/SonarrCommand.cs index 9a2d0c1d..494db8ec 100644 --- a/src/Recyclarr/Command/SonarrCommand.cs +++ b/src/Recyclarr/Command/SonarrCommand.cs @@ -1,32 +1,19 @@ using CliFx.Attributes; -using CliFx.Exceptions; -using Flurl.Http; using JetBrains.Annotations; -using Recyclarr.Config; -using Serilog; -using Serilog.Core; -using TrashLib.Config.Settings; -using TrashLib.Repo; -using TrashLib.Sonarr; -using TrashLib.Sonarr.Config; -using TrashLib.Sonarr.QualityDefinition; -using TrashLib.Sonarr.ReleaseProfile; +using Recyclarr.Command.Services; +using Recyclarr.Migration; namespace Recyclarr.Command; [Command("sonarr", Description = "Perform operations on a Sonarr instance")] [UsedImplicitly] -public class SonarrCommand : ServiceCommand +internal class SonarrCommand : ServiceCommand, ISonarrCommand { - private readonly IConfigurationLoader _configLoader; - private readonly ILogger _log; - private readonly Func _profileUpdaterFactory; - private readonly Func _qualityUpdaterFactory; - private readonly IReleaseProfileLister _lister; + private readonly Lazy _service; [CommandOption("list-release-profiles", Description = "List available release profiles from the guide in YAML format.")] - public bool ListReleaseProfiles { get; [UsedImplicitly] set; } = false; + public bool ListReleaseProfiles { get; [UsedImplicitly] set; } // The default value is "empty" because I need to know when the user specifies the option but no value with it. // Discussed here: https://github.com/Tyrrrz/CliFx/discussions/128#discussioncomment-2647015 @@ -35,73 +22,19 @@ public class SonarrCommand : ServiceCommand "Note that not every release profile has terms that may be filtered.")] public string? ListTerms { get; [UsedImplicitly] set; } = "empty"; - public SonarrCommand( - ILogger log, - LoggingLevelSwitch loggingLevelSwitch, - ILogJanitor logJanitor, - ISettingsPersister settingsPersister, - ISettingsProvider settingsProvider, - IRepoUpdater repoUpdater, - IConfigurationLoader configLoader, - Func profileUpdaterFactory, - Func qualityUpdaterFactory, - IReleaseProfileLister lister) - : base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider, repoUpdater) - { - _log = log; - _configLoader = configLoader; - _profileUpdaterFactory = profileUpdaterFactory; - _qualityUpdaterFactory = qualityUpdaterFactory; - _lister = lister; - } - public override string CacheStoragePath { get; } = Path.Combine(AppPaths.AppDataPath, "cache", "sonarr"); - protected override async Task Process() - { - try - { - if (ListReleaseProfiles) - { - _lister.ListReleaseProfiles(); - return; - } - - if (ListTerms != "empty") - { - if (!string.IsNullOrEmpty(ListTerms)) - { - _lister.ListTerms(ListTerms); - } - else - { - throw new CommandException( - "The --list-terms option was specified without a Release Profile Trash ID specified"); - } + public override string Name => "Sonarr"; - return; - } - - foreach (var config in _configLoader.LoadMany(Config, "sonarr")) - { - _log.Information("Processing server {Url}", config.BaseUrl); - - if (config.ReleaseProfiles.Count > 0) - { - await _profileUpdaterFactory().Process(Preview, config); - } + public SonarrCommand(IMigrationExecutor migration, Lazy service) + : base(migration) + { + _service = service; + } - if (config.QualityDefinition.HasValue) - { - await _qualityUpdaterFactory().Process(Preview, config); - } - } - } - catch (FlurlHttpException e) - { - _log.Error(e, "HTTP error while communicating with Sonarr"); - ExitDueToFailure(); - } + protected override async Task Process() + { + await _service.Value.Execute(this); } } diff --git a/src/Recyclarr/CompositionRoot.cs b/src/Recyclarr/CompositionRoot.cs index 2fdaf55a..a0adc61c 100644 --- a/src/Recyclarr/CompositionRoot.cs +++ b/src/Recyclarr/CompositionRoot.cs @@ -7,6 +7,7 @@ using CliFx; using CliFx.Infrastructure; using Common; using Recyclarr.Command.Helpers; +using Recyclarr.Command.Services; using Recyclarr.Config; using Recyclarr.Migration; using Serilog; @@ -60,9 +61,13 @@ public static class CompositionRoot private static void CommandRegistrations(ContainerBuilder builder) { + builder.RegisterType(); + builder.RegisterType(); + builder.RegisterType().As(); + // Register all types deriving from CliFx's ICommand. These are all of our supported subcommands. builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) - .Where(t => t.IsAssignableTo(typeof(ICommand))); + .AssignableTo(); // Used to access the chosen command class. This is assigned from CliTypeActivator // diff --git a/src/Recyclarr/Program.cs b/src/Recyclarr/Program.cs index 3418692f..ba7fb58d 100644 --- a/src/Recyclarr/Program.cs +++ b/src/Recyclarr/Program.cs @@ -1,10 +1,11 @@ using System.Diagnostics; using System.Text; using Autofac; +using Autofac.Core; +using Autofac.Core.Activators.Reflection; using CliFx; using CliFx.Infrastructure; using Recyclarr.Command.Helpers; -using Recyclarr.Migration; namespace Recyclarr; @@ -20,39 +21,35 @@ internal static class Program var console = _container.Resolve(); - try - { - var migration = _container.Resolve(); - migration.PerformAllMigrationSteps(); - } - catch (MigrationException e) - { - var msg = new StringBuilder(); - msg.AppendLine("Fatal exception during migration step. Details are below.\n"); - msg.AppendLine($"Step That Failed: {e.OperationDescription}"); - msg.AppendLine($"Failure Reason: {e.OriginalException.Message}"); - - if (e.Remediation.Any()) - { - msg.AppendLine("\nPossible remediation steps:"); - foreach (var remedy in e.Remediation) - { - msg.AppendLine($" - {remedy}"); - } - } - - await console.Error.WriteAsync(msg); - return 1; - } - - return await new CliApplicationBuilder() - .AddCommandsFromThisAssembly() + var status = await new CliApplicationBuilder() + .AddCommands(GetRegisteredCommandTypes()) .SetExecutableName(ExecutableName) .SetVersion(BuildVersion()) .UseTypeActivator(type => CliTypeActivator.ResolveType(_container, type)) .UseConsole(console) .Build() .RunAsync(); + + // Log cleanup happens here instead of ServiceBase or other objects because we want it to run only once before + // application exit, not per-service. + var logJanitor = _container.Resolve(); + logJanitor.DeleteOldestLogFiles(20); + + return status; + } + + private static IEnumerable GetRegisteredCommandTypes() + { + if (_container is null) + { + throw new NullReferenceException("DI Container was null during migration process"); + } + + return _container.ComponentRegistry.Registrations + .SelectMany(x => x.Services) + .OfType() + .Select(x => x.ServiceType) + .Where(x => x.IsAssignableTo()); } private static string BuildVersion() diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfileListerTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfileListerTest.cs index af42dd77..0fd1f740 100644 --- a/src/TrashLib.Tests/Sonarr/ReleaseProfileListerTest.cs +++ b/src/TrashLib.Tests/Sonarr/ReleaseProfileListerTest.cs @@ -82,7 +82,6 @@ public class ReleaseProfileListerTest console.ReadOutputString().Should().ContainAll(expectedOutput.SelectMany(x => x)); } - [Test, AutoMockData] public void Release_profile_trash_id_is_used_to_look_up_data( [Frozen] ISonarrGuideService guide,