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,