Merge branch 'cli-revamp' into master

pull/201/head
Robert Dailey 1 year ago
commit cd6636d44c

@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- New `list` subcommand for listing information from the guide.
- New `sync` command for syncing all services, specific service types, and/or specific instances.
- New `config` subcommand for performing configuration-specific operations.
### Changed
- The CLI has been completely redesigned to be more consistent and structured (#142).
### Deprecated
- The `create-config` subcommand is deprecated and replaced by `config create`.
- The `sonarr` subcommand is deprecated and replaced by `sync sonarr`.
- The `radarr` subcommand is deprecated and replaced by `sync radarr`.
## [4.1.3] - 2023-01-07
### Changed

@ -4,12 +4,4 @@ echo "-------------------------------------------------------------"
echo " Executing Tasks: $(date)"
echo "-------------------------------------------------------------"
echo
echo ">>> Sonarr <<<"
echo
recyclarr sonarr
echo
echo ">>> Radarr <<<"
echo
recyclarr radarr
recyclarr sync

@ -4,7 +4,7 @@ set -e
config=/config/recyclarr.yml
if [[ "$RECYCLARR_CREATE_CONFIG" = true && ! -f "$config" ]]; then
echo "Creating default recyclarr.yml file..."
recyclarr create-config
recyclarr config create
fi
# If the script has any arguments, invoke the CLI instead

@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="create-config (custom)" type="DotNetProject" factoryName=".NET Project">
<configuration default="false" name="config create -p custom" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="create-config --path ./custom-config.yml" />
<option name="PROGRAM_PARAMETERS" value="config create -p ./custom-config.yml" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />

@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="create-config (default)" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr/bin/Debug/net7.0/recyclarr.exe" />
<configuration default="false" name="create-config" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="create-config" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr/bin/Debug/net7.0" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="list custom-formats radarr" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="list custom-formats radarr" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="list custom-formats sonarr" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="list custom-formats sonarr" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="list qualities radarr" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="list qualities radarr" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="list qualities sonarr" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="list qualities sonarr" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="list release-profiles --terms" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="list release-profiles --terms EBC725268D687D588A20CBC5F97E538B" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="list release-profiles" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="list release-profiles" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="migrate" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="migrate" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="radarr --list-custom-formats" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr/bin/Debug/net7.0/recyclarr.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="radarr --list-custom-formats" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr/bin/Debug/net7.0" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="recyclarr" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sonarr" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr/bin/Debug/net7.0/recyclarr.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="sonarr" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr/bin/Debug/net7.0" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sync -c custom" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="sync -c custom-config.yml" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sync" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="sync" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sync -i -i" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="sync -i movies -i v4" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -9,6 +9,7 @@
<GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
<!--<DisableGitVersionTask>true</DisableGitVersionTask>-->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<NoWarn>CA1308</NoWarn>
</PropertyGroup>
<!--
@ -35,6 +36,7 @@
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" PrivateAssets="All" />
<PackageReference Include="GitVersion.MsBuild" PrivateAssets="All" />
<PackageReference Include="Spectre.Console.Analyzer" PrivateAssets="All" />
</ItemGroup>
<!-- TEST ONLY PROPERTIES -->
@ -60,6 +62,7 @@
<PackageReference Include="NUnit3TestAdapter" PrivateAssets="All" />
<PackageReference Include="Serilog.Sinks.NUnit" PrivateAssets="All" />
<PackageReference Include="Serilog.Sinks.TestCorrelator" PrivateAssets="All" />
<PackageReference Include="Spectre.Console.Testing" PrivateAssets="All" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Extensions" PrivateAssets="All" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" PrivateAssets="All" />
</ItemGroup>

@ -7,7 +7,7 @@
<PackageVersion Include="Autofac.Extras.Ordering" Version="4.0.0" />
<PackageVersion Include="AutofacSerilogIntegration" Version="5.0.0" />
<PackageVersion Include="AutoMapper" Version="12.0.0" />
<PackageVersion Include="CliFx" Version="2.3.1" />
<PackageVersion Include="AutoMapper.Contrib.Autofac.DependencyInjection" Version="7.1.0" />
<PackageVersion Include="CliWrap" Version="3.6.0" />
<PackageVersion Include="FluentValidation" Version="11.4.0" />
<PackageVersion Include="Flurl" Version="3.0.7" />
@ -24,6 +24,9 @@
<PackageVersion Include="Serilog.Expressions" Version="3.4.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="Spectre.Console" Version="0.45.1-preview.0.49" />
<PackageVersion Include="Spectre.Console.Analyzer" Version="0.45.1-preview.0.49" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.45.1-preview.0.49" />
<PackageVersion Include="System.Data.HashFunction.FNV" Version="2.0.0" />
<PackageVersion Include="System.Reactive" Version="5.0.0" />
<PackageVersion Include="TestableIO.System.IO.Abstractions" Version="19.1.5" />
@ -49,6 +52,7 @@
<PackageVersion Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageVersion Include="Serilog.Sinks.NUnit" Version="1.0.3" />
<PackageVersion Include="Serilog.Sinks.TestCorrelator" Version="3.2.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.45.1-preview.0.49" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="19.1.5" />
</ItemGroup>
<!-- Following found during vulerabilities Code Scan -->

@ -4,10 +4,8 @@ using System.IO.Abstractions.TestingHelpers;
using System.Reactive.Linq;
using Autofac;
using Autofac.Features.ResolveAnything;
using CliFx.Infrastructure;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Cli.Command;
using Recyclarr.Common.TestLibrary;
using Recyclarr.TestLibrary;
using Recyclarr.TrashLib;
@ -17,6 +15,8 @@ using Recyclarr.TrashLib.Services.System;
using Recyclarr.TrashLib.Startup;
using Serilog;
using Serilog.Events;
using Spectre.Console;
using Spectre.Console.Testing;
namespace Recyclarr.Cli.TestLibrary;
@ -28,14 +28,18 @@ public abstract class IntegrationFixture : IDisposable
Paths = new AppPaths(Fs.CurrentDirectory().SubDirectory("test").SubDirectory("recyclarr"));
Logger = CreateLogger();
Container = CompositionRoot.Setup(builder =>
SetupMetadataJson();
_container = new Lazy<IContainer>(() =>
{
var builder = new ContainerBuilder();
CompositionRoot.Setup(builder);
builder.RegisterInstance(Fs).As<IFileSystem>();
builder.RegisterInstance(Paths).As<IAppPaths>();
builder.RegisterInstance(Console).As<IConsole>();
builder.RegisterInstance(Logger).As<ILogger>().SingleInstance();
builder.RegisterInstance(Console).As<IAnsiConsole>();
builder.RegisterInstance(Logger).As<ILogger>();
builder.RegisterMockFor<IServiceCommand>();
builder.RegisterMockFor<IGitRepository>();
builder.RegisterMockFor<IGitRepositoryFactory>();
builder.RegisterMockFor<IServiceConfiguration>();
@ -48,9 +52,9 @@ public abstract class IntegrationFixture : IDisposable
RegisterExtraTypes(builder);
builder.RegisterSource<AnyConcreteTypeNotAlreadyRegisteredSource>();
});
SetupMetadataJson();
return builder.Build();
});
}
// ReSharper disable once VirtualMemberNeverOverridden.Global
@ -64,6 +68,7 @@ public abstract class IntegrationFixture : IDisposable
return new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Debug)
.WriteTo.TestCorrelator()
.WriteTo.Console()
.CreateLogger();
}
@ -75,9 +80,11 @@ public abstract class IntegrationFixture : IDisposable
// ReSharper disable MemberCanBePrivate.Global
private readonly Lazy<IContainer> _container;
protected ILifetimeScope Container => _container.Value;
protected MockFileSystem Fs { get; } = new();
protected FakeInMemoryConsole Console { get; } = new();
protected ILifetimeScope Container { get; }
protected TestConsole Console { get; } = new();
protected IAppPaths Paths { get; }
protected ILogger Logger { get; }
@ -96,8 +103,10 @@ public abstract class IntegrationFixture : IDisposable
return;
}
Container.Dispose();
Console.Dispose();
if (_container.IsValueCreated)
{
_container.Value.Dispose();
}
}
public void Dispose()

@ -0,0 +1,17 @@
using AutoMapper;
using NUnit.Framework;
using Recyclarr.Cli.TestLibrary;
namespace Recyclarr.Cli.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class AutoMapperConfigurationTest : IntegrationFixture
{
[Test]
public void Automapper_config_is_valid()
{
var mapper = Resolve<MapperConfiguration>();
mapper.AssertConfigurationIsValid();
}
}

@ -2,7 +2,7 @@ using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Cli.Command.Setup;
using Recyclarr.Cli.Console.Setup;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Settings;

@ -1,44 +0,0 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Cli.Command;
using Recyclarr.Cli.TestLibrary;
// ReSharper disable MethodHasAsyncOverload
namespace Recyclarr.Cli.Tests.Command;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CreateConfigCommandTest : IntegrationFixture
{
[Test]
public async Task Config_file_created_when_using_default_path()
{
var sut = new CreateConfigCommand();
await sut.Process(Container);
var file = Fs.GetFile(Paths.ConfigPath.FullName);
file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty();
}
[Test]
public async Task Config_file_created_when_using_user_specified_path()
{
var sut = new CreateConfigCommand();
var ymlPath = Fs.CurrentDirectory()
.SubDirectory("user")
.SubDirectory("specified")
.File("file.yml").FullName;
sut.AppDataDirectory = ymlPath;
await sut.Process(Container);
var file = Fs.GetFile(ymlPath);
file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty();
}
}

@ -1,79 +0,0 @@
using System.IO.Abstractions;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Cli.Command;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.Common.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Repo;
namespace Recyclarr.Cli.Tests.Command;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrCommandTest : IntegrationFixture
{
[Test, AutoMockData]
public async Task List_terms_without_value_fails(
IConsole console,
SonarrCommand sut)
{
sut.ListReleaseProfiles = false;
// When `--list-terms` is specified on the command line without a value, it gets a `null` value assigned.
sut.ListTerms = null;
var act = async () => await sut.Process(Container);
await act.Should().ThrowAsync<CommandException>();
}
[Test, AutoMockData]
public async Task List_terms_with_empty_value_fails(
IConsole console,
SonarrCommand sut)
{
sut.ListReleaseProfiles = false;
// If the user specifies a blank string as the value, it should still fail.
sut.ListTerms = "";
var act = async () => await sut.Process(Container);
await act.Should().ThrowAsync<CommandException>();
}
[Test]
public async Task List_terms_uses_specified_trash_id()
{
var repoPaths = Resolve<IRepoPathsFactory>().Create();
var cfDir = repoPaths.SonarrReleaseProfilePaths.First();
Fs.AddFileFromResource(cfDir.File("optionals.json"), "optionals.json");
var sut = new SonarrCommand
{
ListReleaseProfiles = false,
ListTerms = "76e060895c5b8a765c310933da0a5357"
};
await sut.Process(Container);
Console.ReadOutputString().Should().Contain("List of Terms");
}
[Test]
public async Task List_release_profiles_is_invoked()
{
var sut = new SonarrCommand
{
ListReleaseProfiles = true,
ListTerms = null
};
await sut.Process(Container);
Console.ReadOutputString().Should().Contain("List of Release Profiles");
}
}

@ -1,7 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Autofac;
using FluentAssertions;
using FluentValidation;
using NUnit.Framework;
using Recyclarr.Cli.Config;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.TestLibrary;
namespace Recyclarr.Cli.Tests.Config;
@ -10,13 +14,34 @@ namespace Recyclarr.Cli.Tests.Config;
[Parallelizable(ParallelScope.All)]
public class ConfigValidationExecutorTest : IntegrationFixture
{
[SuppressMessage("Design", "CA1812", Justification = "Instantiated via reflection in unit test")]
private sealed class TestValidator : AbstractValidator<ServiceConfiguration>
{
public bool ShouldSucceed { get; set; }
public TestValidator()
{
RuleFor(x => x).Must(_ => ShouldSucceed);
}
}
protected override void RegisterExtraTypes(ContainerBuilder builder)
{
builder.RegisterType<TestValidator>()
.AsSelf()
.As<IValidator<ServiceConfiguration>>()
.SingleInstance();
}
[Test]
public void Invalid_returns_false()
public void Return_false_on_validation_failure()
{
var validator = Resolve<TestValidator>();
validator.ShouldSucceed = false;
var sut = Resolve<ConfigValidationExecutor>();
var config = new TestConfig {ApiKey = ""}; // Use bad data
var result = sut.Validate(config);
var result = sut.Validate(new TestConfig());
result.Should().BeFalse();
}
@ -24,10 +49,12 @@ public class ConfigValidationExecutorTest : IntegrationFixture
[Test]
public void Valid_returns_true()
{
var validator = Resolve<TestValidator>();
validator.ShouldSucceed = true;
var sut = Resolve<ConfigValidationExecutor>();
var config = new TestConfig {ApiKey = "good", BaseUrl = "good"}; // Use good data
var result = sut.Validate(config);
var result = sut.Validate(new TestConfig());
result.Should().BeTrue();
}

@ -2,12 +2,12 @@ using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using CliFx.Exceptions;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Cli.Config;
using Recyclarr.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Startup;
namespace Recyclarr.Cli.Tests.Config;
@ -16,13 +16,13 @@ namespace Recyclarr.Cli.Tests.Config;
[Parallelizable(ParallelScope.All)]
public class ConfigurationFinderTest
{
private static string[] GetYamlPaths(IAppPaths paths)
private static IFileInfo[] GetYamlPaths(IAppPaths paths)
{
return new[]
{
paths.ConfigPath.FullName,
paths.ConfigsDirectory.File("b.yml").FullName,
paths.ConfigsDirectory.File("c.yml").FullName
paths.ConfigPath,
paths.ConfigsDirectory.File("b.yml"),
paths.ConfigsDirectory.File("c.yml")
};
}
@ -36,12 +36,12 @@ public class ConfigurationFinderTest
foreach (var path in yamlPaths)
{
fs.AddFile(path, new MockFileData(""));
fs.AddFile(path.FullName, new MockFileData(""));
}
var result = sut.GetConfigFiles(null);
result.Should().BeEquivalentTo(yamlPaths);
result.Should().BeEquivalentTo(yamlPaths, o => o.Including(x => x.FullName));
}
[Test, AutoMockData]
@ -54,12 +54,12 @@ public class ConfigurationFinderTest
foreach (var path in yamlPaths)
{
fs.AddFile(path, new MockFileData(""));
fs.AddEmptyFile(path);
}
var result = sut.GetConfigFiles(new List<string>());
var result = sut.GetConfigFiles(new List<IFileInfo>());
result.Should().BeEquivalentTo(yamlPaths);
result.Should().BeEquivalentTo(yamlPaths, o => o.Including(x => x.FullName));
}
[Test, AutoMockData]
@ -72,31 +72,15 @@ public class ConfigurationFinderTest
foreach (var path in yamlPaths)
{
fs.AddFile(path, new MockFileData(""));
fs.AddFile(path.FullName, new MockFileData(""));
}
var manualConfig = fs.CurrentDirectory().File("manual-config.yml");
fs.AddFile(manualConfig.FullName, new MockFileData(""));
fs.AddEmptyFile(manualConfig);
var result = sut.GetConfigFiles(new[] {manualConfig.FullName});
var result = sut.GetConfigFiles(new[] {manualConfig});
result.Should().BeEquivalentTo(manualConfig.FullName);
}
[Test, AutoMockData]
public void Non_existent_files_are_skipped(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut)
{
var yamlPaths = GetYamlPaths(paths);
fs.AddFile(yamlPaths[0], new MockFileData(""));
fs.AddFile(yamlPaths[1], new MockFileData(""));
var result = sut.GetConfigFiles(yamlPaths);
result.Should().BeEquivalentTo(yamlPaths.Take(2));
result.Should().ContainSingle(x => x.FullName == manualConfig.FullName);
}
[Test, AutoMockData]
@ -105,12 +89,12 @@ public class ConfigurationFinderTest
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut)
{
var testFile = paths.ConfigsDirectory.File("test.yml").FullName;
fs.AddFile(testFile, new MockFileData(""));
var testFile = paths.ConfigsDirectory.File("test.yml");
fs.AddEmptyFile(testFile);
var result = sut.GetConfigFiles(Array.Empty<string>());
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>());
result.Should().BeEquivalentTo(testFile);
result.Should().ContainSingle(x => x.FullName == testFile.FullName);
}
[Test, AutoMockData]
@ -119,11 +103,11 @@ public class ConfigurationFinderTest
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut)
{
fs.AddFile(paths.ConfigPath.FullName, new MockFileData(""));
fs.AddEmptyFile(paths.ConfigPath);
var result = sut.GetConfigFiles(Array.Empty<string>());
var result = sut.GetConfigFiles(Array.Empty<IFileInfo>());
result.Should().BeEquivalentTo(paths.ConfigPath.FullName);
result.Should().ContainSingle(x => x.FullName == paths.ConfigPath.FullName);
}
[Test, AutoMockData]
@ -132,8 +116,8 @@ public class ConfigurationFinderTest
[Frozen(Matching.ImplementedInterfaces)] AppPaths paths,
ConfigurationFinder sut)
{
var act = () => sut.GetConfigFiles(Array.Empty<string>());
var act = () => sut.GetConfigFiles(Array.Empty<IFileInfo>());
act.Should().Throw<CommandException>();
act.Should().Throw<NoConfigurationFilesException>();
}
}

@ -6,13 +6,16 @@ using Autofac;
using FluentAssertions;
using FluentValidation;
using NUnit.Framework;
using Recyclarr.Cli.Config;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Secrets;
using Recyclarr.TrashLib.Config.Yaml;
using Recyclarr.TrashLib.Services.Radarr.Config;
using Recyclarr.TrashLib.Services.Sonarr.Config;
using Recyclarr.TrashLib.TestLibrary;
using Serilog.Sinks.TestCorrelator;
@ -39,80 +42,96 @@ public class ConfigurationLoaderTest : IntegrationFixture
[Test]
public void Load_many_iterations_of_config()
{
static string MockYaml(params object[] args)
static string MockYaml(string sectionName, params object[] args)
{
var str = new StringBuilder("sonarr:");
const string templateYaml = "\n - base_url: {0}\n api_key: abc";
str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p)));
var str = new StringBuilder($"{sectionName}:");
const string templateYaml = @"
instance{1}:
base_url: {0}
api_key: abc";
var counter = 0;
str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p, counter++)));
return str.ToString();
}
var baseDir = Fs.CurrentDirectory();
var fileData = new (string, string)[]
var fileData = new[]
{
(baseDir.File("config1.yml").FullName, MockYaml(1, 2)),
(baseDir.File("config2.yml").FullName, MockYaml(3)),
(baseDir.File("config3.yml").FullName, "bad yaml")
(baseDir.File("config1.yml"), MockYaml("sonarr", 1, 2)),
(baseDir.File("config2.yml"), MockYaml("sonarr", 3)),
(baseDir.File("config3.yml"), "bad yaml"),
(baseDir.File("config4.yml"), MockYaml("radarr", 4))
};
foreach (var (file, data) in fileData)
{
Fs.AddFile(file, new MockFileData(data));
Fs.AddFile(file.FullName, new MockFileData(data));
}
var expected = new List<SonarrConfiguration>
var expectedSonarr = new[]
{
new {ApiKey = "abc", BaseUrl = "1"},
new {ApiKey = "abc", BaseUrl = "2"},
new {ApiKey = "abc", BaseUrl = "3"}
};
var expectedRadarr = new[]
{
new() {ApiKey = "abc", BaseUrl = "1"},
new() {ApiKey = "abc", BaseUrl = "2"},
new() {ApiKey = "abc", BaseUrl = "3"}
new {ApiKey = "abc", BaseUrl = "4"}
};
var loader = Resolve<IConfigurationLoader<SonarrConfiguration>>();
var actual = loader.LoadMany(fileData.Select(x => x.Item1), "sonarr").ToList();
var loader = Resolve<IConfigurationLoader>();
var actual = loader.LoadMany(fileData.Select(x => x.Item1));
actual.Should().BeEquivalentTo(expected, o => o.Excluding(x => x.LineNumber));
actual.Get<SonarrConfiguration>(SupportedServices.Sonarr)
.Should().BeEquivalentTo(expectedSonarr);
actual.Get<RadarrConfiguration>(SupportedServices.Radarr)
.Should().BeEquivalentTo(expectedRadarr);
}
[Test]
public void Parse_using_stream()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
var configLoader = Resolve<ConfigurationLoader>();
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
configs.Should().BeEquivalentTo(new List<SonarrConfiguration>
{
new()
configs.Get<SonarrConfiguration>(SupportedServices.Sonarr)
.Should().BeEquivalentTo(new List<SonarrConfiguration>
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
Name = "name",
ReleaseProfiles = new List<ReleaseProfileConfig>
new()
{
new()
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
InstanceName = "name",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
TrashIds = new[] {"123"},
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
{
TrashIds = new[] {"456"},
StrictNegativeScores = false,
Tags = new List<string>
new()
{
TrashIds = new[] {"123"},
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
{
"tv",
"series"
TrashIds = new[] {"456"},
StrictNegativeScores = false,
Tags = new List<string>
{
"tv",
"series"
}
}
}
}
}
}, o => o.Excluding(x => x.LineNumber));
}, o => o.Excluding(x => x.LineNumber));
}
[Test]
public void Test_secret_loading()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
@ -135,7 +154,7 @@ secret_rp: 1234567
{
new()
{
Name = "instance1",
InstanceName = "instance1",
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "https://radarr:7878",
ReleaseProfiles = new List<ReleaseProfileConfig>
@ -149,14 +168,15 @@ secret_rp: 1234567
};
var parsedSecret = configLoader.LoadFromStream(new StringReader(testYml), "sonarr");
parsedSecret.Should().BeEquivalentTo(expected, o => o.Excluding(x => x.LineNumber));
parsedSecret.Get<SonarrConfiguration>(SupportedServices.Sonarr)
.Should().BeEquivalentTo(expected, o => o.Excluding(x => x.LineNumber));
}
[Test]
public void Throw_when_referencing_invalid_secret()
{
using var logContext = TestCorrelator.CreateContext();
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
@ -179,7 +199,7 @@ sonarr:
[Test]
public void Throw_when_referencing_secret_without_secrets_file()
{
var configLoader = Resolve<ConfigurationLoader<TestConfig>>();
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
@ -197,7 +217,7 @@ sonarr:
[Test]
public void Throw_when_secret_value_is_not_scalar()
{
var configLoader = Resolve<ConfigurationLoader<TestConfig>>();
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
@ -213,7 +233,7 @@ sonarr:
[Test]
public void Throw_when_expected_value_is_not_scalar()
{
var configLoader = Resolve<ConfigurationLoader<SonarrConfiguration>>();
var configLoader = Resolve<ConfigurationLoader>();
const string testYml = @"
sonarr:
@ -231,7 +251,7 @@ sonarr:
}
[Test, AutoMockData]
public void Throw_when_yaml_file_only_has_comment(ConfigurationLoader<TestConfig> sut)
public void Throw_when_yaml_file_only_has_comment(ConfigurationLoader sut)
{
const string testYml = "# YAML with nothing but this comment";
@ -241,7 +261,7 @@ sonarr:
}
[Test, AutoMockData]
public void Throw_when_yaml_file_is_empty(ConfigurationLoader<TestConfig> sut)
public void Throw_when_yaml_file_is_empty(ConfigurationLoader sut)
{
const string testYml = "";
@ -251,7 +271,7 @@ sonarr:
}
[Test, AutoMockData]
public void Do_not_throw_when_file_not_empty_but_has_no_desired_sections(ConfigurationLoader<TestConfig> sut)
public void Do_not_throw_when_file_not_empty_but_has_no_desired_sections(ConfigurationLoader sut)
{
const string testYml = @"
not_wanted:

@ -2,11 +2,11 @@ using Autofac;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Cli.Command.Helpers;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Tests.Command.Helpers;
namespace Recyclarr.Cli.Tests.Console.Helpers;
[TestFixture]
[Parallelizable(ParallelScope.All)]
@ -17,7 +17,7 @@ public class CacheStoragePathTest : IntegrationFixture
{
var config = Substitute.ForPartsOf<ServiceConfiguration>();
config.BaseUrl = "something";
config.Name = null;
config.InstanceName = null;
using var scope = Container.BeginLifetimeScope(builder =>
{
@ -35,7 +35,7 @@ public class CacheStoragePathTest : IntegrationFixture
{
var config = Substitute.ForPartsOf<ServiceConfiguration>();
config.BaseUrl = "something";
config.Name = "thename";
config.InstanceName = "thename";
using var scope = Container.BeginLifetimeScope(builder =>
{

@ -1,27 +1,21 @@
using Autofac;
using CliFx.Infrastructure;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.Cli.Migration;
using Recyclarr.Cli.Migration.Steps;
using Recyclarr.TrashLib.Startup;
using Recyclarr.Cli.TestLibrary;
using Spectre.Console.Testing;
namespace Recyclarr.Cli.Tests.Migration;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MigrationExecutorTest
public class MigrationExecutorTest : IntegrationFixture
{
[Test]
public void Migration_steps_are_in_expected_order()
{
var container = CompositionRoot.Setup(builder =>
{
builder.RegisterInstance(Substitute.For<IAppPaths>());
});
var steps = container.Resolve<IEnumerable<IMigrationStep>>();
var steps = Resolve<IEnumerable<IMigrationStep>>();
var orderedSteps = steps.OrderBy(x => x.Order).Select(x => x.GetType()).ToList();
orderedSteps.Should().BeEquivalentTo(
new[]
@ -35,7 +29,7 @@ public class MigrationExecutorTest
[Test]
public void Step_not_executed_if_check_returns_false()
{
using var console = new FakeInMemoryConsole();
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
@ -50,7 +44,7 @@ public class MigrationExecutorTest
[Test]
public void Step_executed_if_check_returns_true()
{
using var console = new FakeInMemoryConsole();
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
@ -65,7 +59,7 @@ public class MigrationExecutorTest
[Test]
public void Steps_executed_in_ascending_order()
{
using var console = new FakeInMemoryConsole();
using var console = new TestConsole();
var steps = new[]
{
@ -93,7 +87,7 @@ public class MigrationExecutorTest
[Test]
public void Exception_converted_to_migration_exception()
{
using var console = new FakeInMemoryConsole();
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
@ -108,7 +102,7 @@ public class MigrationExecutorTest
[Test]
public void Migration_exceptions_are_not_converted()
{
using var console = new FakeInMemoryConsole();
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
var exception = new MigrationException(new Exception(), "a", new[] {"b"});

@ -36,8 +36,8 @@ public class MigrateTrashUpdaterAppDataDirTest
[Frozen(Matching.ImplementedInterfaces)] TestAppPaths paths,
MigrateTrashUpdaterAppDataDir sut)
{
fs.AddFileNoData(sut.OldPath.File("recyclarr.yml"));
fs.AddFileNoData(sut.NewPath.File("recyclarr.yml"));
fs.AddEmptyFile(sut.OldPath.File("recyclarr.yml"));
fs.AddEmptyFile(sut.NewPath.File("recyclarr.yml"));
var act = () => sut.Execute(null);
@ -52,12 +52,12 @@ public class MigrateTrashUpdaterAppDataDirTest
{
// Add file instead of directory since the migration step only operates on files
var baseDir = sut.OldPath;
fs.AddFileNoData(baseDir.File("settings.yml"));
fs.AddFileNoData(baseDir.File("recyclarr.yml"));
fs.AddFileNoData(baseDir.File("this-gets-ignored.yml"));
fs.AddEmptyFile(baseDir.File("settings.yml"));
fs.AddEmptyFile(baseDir.File("recyclarr.yml"));
fs.AddEmptyFile(baseDir.File("this-gets-ignored.yml"));
fs.AddDirectory(baseDir.SubDirectory("repo"));
fs.AddDirectory(baseDir.SubDirectory("cache"));
fs.AddFileNoData(baseDir.File("cache/sonarr/test.txt"));
fs.AddEmptyFile(baseDir.File("cache/sonarr/test.txt"));
sut.Execute(null);

@ -1,92 +0,0 @@
using Autofac;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Flurl.Http;
using JetBrains.Annotations;
using MoreLinq.Extensions;
using Recyclarr.Cli.Command.Setup;
using Recyclarr.Cli.Logging;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Repo.VersionControl;
using Recyclarr.TrashLib.Startup;
using Serilog;
using Serilog.Events;
namespace Recyclarr.Cli.Command;
public abstract class BaseCommand : ICommand
{
// Not explicitly defined as a Command here because for legacy reasons, different subcommands expose this in
// different ways to the user.
public abstract string? AppDataDirectory { get; set; }
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
// ReSharper disable once MemberCanBeProtected.Global
public bool Debug { get; [UsedImplicitly] set; } = false;
protected ILogger Logger { get; private set; } = Log.Logger;
protected virtual void RegisterServices(ContainerBuilder builder)
{
}
public virtual async ValueTask ExecuteAsync(IConsole console)
{
// Must happen first because everything can use the logger.
var logLevel = Debug ? LogEventLevel.Debug : LogEventLevel.Information;
await using var container = CompositionRoot.Setup(builder =>
{
builder.RegisterInstance(console).As<IConsole>().ExternallyOwned();
builder.Register(c => c.Resolve<DefaultAppDataSetup>().CreateAppPaths(AppDataDirectory))
.As<IAppPaths>()
.SingleInstance();
builder.Register(c => c.Resolve<LoggerFactory>().Create(logLevel))
.As<ILogger>()
.SingleInstance();
RegisterServices(builder);
});
Logger = container.Resolve<ILogger>();
var tasks = container.Resolve<IOrderedEnumerable<IBaseCommandSetupTask>>().ToArray();
tasks.ForEach(x => x.OnStart());
try
{
await Process(container);
}
catch (Exception e)
{
switch (e)
{
case GitCmdException e2:
Logger.Error(e2, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
e2.ExitCode, e2.Error);
break;
case FlurlHttpException e2:
Logger.Error("HTTP error: {Message}", e2.SanitizedExceptionMessage());
break;
default:
Logger.Error("{Message}", e.Message);
break;
}
Logger.Debug("Exception Stacktrace: {Stacktrace}", e.StackTrace);
throw new CommandException("Exiting due to exception");
}
finally
{
tasks.Reverse().ForEach(x => x.OnFinish());
}
}
public abstract Task Process(ILifetimeScope container);
}

@ -1,47 +0,0 @@
using System.IO.Abstractions;
using Autofac;
using CliFx.Attributes;
using CliFx.Exceptions;
using JetBrains.Annotations;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Startup;
using Serilog;
namespace Recyclarr.Cli.Command;
[Command("create-config", Description = "Create a starter YAML configuration file")]
[UsedImplicitly]
public class CreateConfigCommand : BaseCommand
{
[CommandOption("path", 'p', Description =
"Path where the new YAML file should be created. Must include the filename (e.g. path/to/config.yml). " +
"File must not already exist. If not specified, uses the default path of `recyclarr.yml` in the app data " +
"directory")]
public override string? AppDataDirectory { get; set; }
public override async Task Process(ILifetimeScope container)
{
var fs = container.Resolve<IFileSystem>();
var paths = container.Resolve<IAppPaths>();
var log = container.Resolve<ILogger>();
var reader = new ResourceDataReader(typeof(Program));
var ymlData = reader.ReadData("config-template.yml");
var configFile = AppDataDirectory is not null
? fs.FileInfo.New(AppDataDirectory)
: paths.ConfigPath;
if (configFile.Exists)
{
throw new CommandException(
$"The file {configFile} already exists. Please choose another path or delete/move the existing " +
"file and run this command again.");
}
configFile.CreateParentDirectory();
await using var stream = configFile.CreateText();
await stream.WriteAsync(ymlData);
log.Information("Created configuration at: {Path}", configFile);
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Cli.Command;
public interface IServiceCommand
{
string Name { get; }
}

@ -1,49 +0,0 @@
using System.Text;
using Autofac;
using CliFx.Attributes;
using CliFx.Exceptions;
using JetBrains.Annotations;
using Recyclarr.Cli.Migration;
namespace Recyclarr.Cli.Command;
[Command("migrate", Description = "Perform any migration steps that may be needed between versions")]
[UsedImplicitly]
public class MigrateCommand : BaseCommand
{
[CommandOption("app-data", Description =
"Explicitly specify the location of the recyclarr application data directory. " +
"Mainly for usage in Docker; not recommended for normal use.")]
public override string? AppDataDirectory { get; set; }
public override Task Process(ILifetimeScope container)
{
var migration = container.Resolve<IMigrationExecutor>();
try
{
migration.PerformAllMigrationSteps(Debug);
}
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}");
// ReSharper disable once InvertIf
if (e.Remediation.Any())
{
msg.AppendLine("\nPossible remediation steps:");
foreach (var remedy in e.Remediation)
{
msg.AppendLine($" - {remedy}");
}
}
throw new CommandException(msg.ToString());
}
return Task.CompletedTask;
}
}

@ -1,99 +0,0 @@
using Autofac;
using CliFx.Attributes;
using CliFx.Infrastructure;
using JetBrains.Annotations;
using Recyclarr.Cli.Config;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Services.CustomFormat;
using Recyclarr.TrashLib.Services.QualitySize;
using Recyclarr.TrashLib.Services.Radarr;
using Recyclarr.TrashLib.Services.Radarr.Config;
using Serilog;
namespace Recyclarr.Cli.Command;
[Command("radarr", Description = "Perform operations on a Radarr instance")]
[UsedImplicitly]
internal class RadarrCommand : ServiceCommand
{
// ReSharper disable MemberCanBePrivate.Global
[CommandOption("list-custom-formats", Description =
"List available custom formats from the guide in YAML format.")]
public bool ListCustomFormats { get; [UsedImplicitly] set; }
[CommandOption("list-qualities", Description =
"List available quality definition types from the guide.")]
public bool ListQualities { get; [UsedImplicitly] set; }
// ReSharper restore MemberCanBePrivate.Global
public override string Name => "Radarr";
public override async Task Process(ILifetimeScope container)
{
await base.Process(container);
var lister = container.Resolve<IRadarrGuideDataLister>();
var log = container.Resolve<ILogger>();
var guideService = container.Resolve<IRadarrGuideService>();
var console = container.Resolve<IConsole>();
if (ListCustomFormats)
{
lister.ListCustomFormats();
return;
}
if (ListQualities)
{
lister.ListQualities();
return;
}
var configFinder = container.Resolve<IConfigurationFinder>();
var configLoader = container.Resolve<IConfigurationLoader<RadarrConfiguration>>();
foreach (var config in configLoader.LoadMany(configFinder.GetConfigFiles(Config), "radarr"))
{
await using var scope = container.BeginLifetimeScope(builder =>
{
builder.RegisterInstance(config).As<IServiceConfiguration>();
});
var serverName = Name;
var instanceName = config.Name ?? FlurlLogging.SanitizeUrl(config.BaseUrl);
await console.Output.WriteLineAsync($@"
===========================================
Processing {serverName} Server: [{instanceName}]
===========================================
");
log.Debug("Processing {Server} server {Name}", serverName, instanceName);
var validator = scope.Resolve<ConfigValidationExecutor>();
if (!validator.Validate(config))
{
log.Error("Due to validation failure, this instance will be skipped");
continue;
}
// ReSharper disable InvertIf
if (config.QualityDefinition != null)
{
var updater = scope.Resolve<IQualitySizeUpdater>();
await updater.Process(Preview, config.QualityDefinition, guideService);
}
if (config.CustomFormats.Count > 0)
{
var updater = scope.Resolve<ICustomFormatUpdater>();
await updater.Process(Preview, config.CustomFormats, guideService);
}
// ReSharper restore InvertIf
}
}
}

@ -1,46 +0,0 @@
using Autofac;
using CliFx.Attributes;
using JetBrains.Annotations;
using Recyclarr.Cli.Migration;
using Recyclarr.TrashLib.Repo;
namespace Recyclarr.Cli.Command;
public abstract class ServiceCommand : BaseCommand, IServiceCommand
{
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
// ReSharper disable once MemberCanBeProtected.Global
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("config", 'c', Description =
"One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `recyclarr.yml` in the same directory as the executable.")]
// ReSharper disable once MemberCanBeProtected.Global
public IReadOnlyCollection<string> Config { get; [UsedImplicitly] set; } = new List<string>();
[CommandOption("app-data", Description =
"Explicitly specify the location of the recyclarr application data directory. " +
"Mainly for usage in Docker; not recommended for normal use.")]
public override string? AppDataDirectory { get; [UsedImplicitly] set; }
public abstract string Name { get; }
protected override void RegisterServices(ContainerBuilder builder)
{
builder.RegisterInstance(this).As<IServiceCommand>();
}
public override async Task Process(ILifetimeScope container)
{
var repoUpdater = container.Resolve<IRepoUpdater>();
var migration = container.Resolve<IMigrationExecutor>();
Logger.Debug("Recyclarr Version: {Version}", GitVersionInformation.InformationalVersion);
// Will throw if migration is required, otherwise just a warning is issued.
migration.CheckNeededMigrations();
await repoUpdater.UpdateRepo();
}
}

@ -1,140 +0,0 @@
using Autofac;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using JetBrains.Annotations;
using Recyclarr.Cli.Config;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Services.CustomFormat;
using Recyclarr.TrashLib.Services.QualitySize;
using Recyclarr.TrashLib.Services.Sonarr;
using Recyclarr.TrashLib.Services.Sonarr.Config;
using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile;
using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile.Guide;
using Serilog;
namespace Recyclarr.Cli.Command;
[Command("sonarr", Description = "Perform operations on a Sonarr instance")]
[UsedImplicitly]
public class SonarrCommand : ServiceCommand
{
// ReSharper disable MemberCanBePrivate.Global
[CommandOption("list-release-profiles", Description =
"List available release profiles from the guide in YAML format.")]
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
[CommandOption("list-terms", Description =
"For the given Release Profile Trash ID, list terms in it that can be filtered in YAML format. " +
"Note that not every release profile has terms that may be filtered.")]
public string? ListTerms { get; [UsedImplicitly] set; } = "empty";
[CommandOption("list-qualities", Description =
"List available quality definition types from the guide.")]
public bool ListQualities { get; [UsedImplicitly] set; }
[CommandOption("list-custom-formats", Description =
"List available custom formats from the guide in YAML format.")]
public bool ListCustomFormats { get; [UsedImplicitly] set; }
// ReSharper restore MemberCanBePrivate.Global
public override string Name => "Sonarr";
public override async Task Process(ILifetimeScope container)
{
await base.Process(container);
var lister = container.Resolve<ISonarrGuideDataLister>();
var log = container.Resolve<ILogger>();
var guideService = container.Resolve<ISonarrGuideService>();
var console = container.Resolve<IConsole>();
if (ListReleaseProfiles)
{
lister.ListReleaseProfiles();
return;
}
if (ListQualities)
{
lister.ListQualities();
return;
}
if (ListCustomFormats)
{
lister.ListCustomFormats();
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");
}
return;
}
var configFinder = container.Resolve<IConfigurationFinder>();
var configLoader = container.Resolve<IConfigurationLoader<SonarrConfiguration>>();
foreach (var config in configLoader.LoadMany(configFinder.GetConfigFiles(Config), "sonarr"))
{
await using var scope = container.BeginLifetimeScope(builder =>
{
builder.RegisterInstance(config).As<IServiceConfiguration>();
});
var serverName = Name;
var instanceName = config.Name ?? FlurlLogging.SanitizeUrl(config.BaseUrl);
await console.Output.WriteLineAsync($@"
===========================================
Processing {serverName} Server: [{instanceName}]
===========================================
");
log.Debug("Processing {Server} server {Name}", serverName, instanceName);
var validator = scope.Resolve<ConfigValidationExecutor>();
if (!validator.Validate(config))
{
log.Error("Due to validation failure, this instance will be skipped");
continue;
}
// ReSharper disable InvertIf
if (config.ReleaseProfiles.Count > 0)
{
var updater = scope.Resolve<IReleaseProfileUpdater>();
await updater.Process(Preview, config);
}
if (config.QualityDefinition != null)
{
var updater = scope.Resolve<IQualitySizeUpdater>();
await updater.Process(Preview, config.QualityDefinition, guideService);
}
if (config.CustomFormats.Count > 0)
{
var updater = scope.Resolve<ICustomFormatUpdater>();
await updater.Process(Preview, config.CustomFormats, guideService);
}
// ReSharper restore InvertIf
}
}
}

@ -1,41 +1,34 @@
using System.IO.Abstractions;
using System.Reflection;
using Autofac;
using Autofac.Core.Activators.Reflection;
using Autofac.Extras.Ordering;
using CliFx;
using Recyclarr.Cli.Command.Helpers;
using Recyclarr.Cli.Command.Setup;
using Recyclarr.Cli.Config;
using AutoMapper.Contrib.Autofac.DependencyInjection;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.Console.Setup;
using Recyclarr.Cli.Logging;
using Recyclarr.Cli.Migration;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Cache;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Repo;
using Recyclarr.TrashLib.Repo.VersionControl;
using Recyclarr.TrashLib.Services.Common;
using Recyclarr.TrashLib.Services.CustomFormat;
using Recyclarr.TrashLib.Services.Processors;
using Recyclarr.TrashLib.Services.Radarr;
using Recyclarr.TrashLib.Services.Sonarr;
using Recyclarr.TrashLib.Services.System;
using Recyclarr.TrashLib.Startup;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.ObjectFactories;
using Serilog;
using Spectre.Console.Cli;
namespace Recyclarr.Cli;
public static class CompositionRoot
{
public static ILifetimeScope Setup(Action<ContainerBuilder>? extraRegistrations = null)
{
return Setup(new ContainerBuilder(), extraRegistrations);
}
private static ILifetimeScope Setup(ContainerBuilder builder, Action<ContainerBuilder>? extraRegistrations = null)
public static void Setup(ContainerBuilder builder)
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(x => x.FullName?.StartsWithIgnoreCase("Recyclarr") ?? false)
@ -52,6 +45,9 @@ public static class CompositionRoot
builder.RegisterModule<CustomFormatAutofacModule>();
builder.RegisterModule<GuideServicesAutofacModule>();
builder.RegisterModule<SystemServiceAutofacModule>();
builder.RegisterModule(new ConfigAutofacModule(assemblies));
builder.RegisterModule<ServiceProcessorsAutofacModule>();
builder.RegisterModule(new CommonAutofacModule(Assembly.GetExecutingAssembly()));
// Needed for Autofac.Extras.Ordering
builder.RegisterSource<OrderedRegistrationSource>();
@ -59,44 +55,34 @@ public static class CompositionRoot
builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.RegisterType<ServiceRequestBuilder>().As<IServiceRequestBuilder>();
builder.RegisterType<ProgressBar>();
ConfigurationRegistrations(builder, assemblies);
CommandRegistrations(builder);
builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
builder.RegisterAutoMapper(false, assemblies);
builder.RegisterType<FlurlClientFactory>().As<IFlurlClientFactory>().SingleInstance();
extraRegistrations?.Invoke(builder);
return builder.Build();
}
private static void RegisterLogger(ContainerBuilder builder)
{
builder.RegisterType<LogJanitor>().As<ILogJanitor>();
builder.RegisterType<LoggerFactory>();
builder.Register(c => c.Resolve<LoggerFactory>().Create()).As<ILogger>().SingleInstance();
}
private static void RegisterAppPaths(ContainerBuilder builder)
{
builder.RegisterModule<CommonAutofacModule>();
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.RegisterType<DefaultAppDataSetup>();
}
private static void ConfigurationRegistrations(ContainerBuilder builder, Assembly[] assemblies)
{
builder.RegisterModule(new ConfigAutofacModule(assemblies));
builder.RegisterType<DefaultObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();
builder.RegisterType<ConfigValidationExecutor>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>))
.WithProperty(new AutowiringParameter())
.As(typeof(IConfigurationLoader<>));
builder.Register(c =>
{
var appData = c.Resolve<AppDataPathProvider>();
var dataSetup = c.Resolve<DefaultAppDataSetup>();
return dataSetup.CreateAppPaths(appData.AppDataPath);
})
.As<IAppPaths>()
.SingleInstance();
}
private static void CommandRegistrations(ContainerBuilder builder)
@ -107,8 +93,7 @@ public static class CompositionRoot
.As<IBaseCommandSetupTask>()
.OrderByRegistration();
// Register all types deriving from CliFx's ICommand. These are all of our supported subcommands.
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.AssignableTo<ICommand>();
.AssignableTo<CommandSettings>();
}
}

@ -1,62 +0,0 @@
using System.IO.Abstractions;
using CliFx.Exceptions;
using Recyclarr.TrashLib.Startup;
using Serilog;
namespace Recyclarr.Cli.Config;
public class ConfigurationFinder : IConfigurationFinder
{
private readonly IAppPaths _paths;
private readonly IFileSystem _fs;
private readonly ILogger _log;
public ConfigurationFinder(IAppPaths paths, IFileSystem fs, ILogger log)
{
_paths = paths;
_fs = fs;
_log = log;
}
private IReadOnlyCollection<string> FindDefaultConfigFiles()
{
var configs = new List<string>();
if (_paths.ConfigsDirectory.Exists)
{
configs.AddRange(_paths.ConfigsDirectory.EnumerateFiles("*.yml").Select(x => x.FullName));
}
if (_paths.ConfigPath.Exists)
{
configs.Add(_paths.ConfigPath.FullName);
}
return configs;
}
public IReadOnlyCollection<string> GetConfigFiles(IReadOnlyCollection<string>? configs)
{
if (configs is null || !configs.Any())
{
configs = FindDefaultConfigFiles();
}
else
{
var split = configs.ToLookup(x => _fs.File.Exists(x));
foreach (var nonExistentConfig in split[false])
{
_log.Warning("Configuration file does not exist {File}", nonExistentConfig);
}
configs = split[true].ToList();
}
if (configs.Count == 0)
{
throw new CommandException("No configuration YAML files found");
}
return configs;
}
}

@ -1,161 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Cli.Logging;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Config.Yaml;
using Serilog;
using Serilog.Context;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace Recyclarr.Cli.Config;
public class ConfigurationLoader<T> : IConfigurationLoader<T>
where T : ServiceConfiguration
{
private readonly ILogger _log;
private readonly IDeserializer _deserializer;
private readonly IFileSystem _fs;
public ConfigurationLoader(
ILogger log,
IFileSystem fs,
IYamlSerializerFactory yamlFactory)
{
_log = log;
_fs = fs;
_deserializer = yamlFactory.CreateDeserializer();
}
public ICollection<T> LoadMany(IEnumerable<string> configFiles, string configSection)
{
return configFiles.SelectMany(file => Load(file, configSection)).ToList();
}
public ICollection<T> Load(string file, string configSection)
{
_log.Debug("Loading config file: {File}", file);
using var logScope = LogContext.PushProperty(LogProperty.Scope, _fs.Path.GetFileName(file));
try
{
using var stream = _fs.File.OpenText(file);
return LoadFromStream(stream, configSection);
}
catch (EmptyYamlException)
{
_log.Warning("Configuration file yielded no usable configuration (is it empty?)");
return Array.Empty<T>();
}
catch (YamlException e)
{
var line = e.Start.Line;
switch (e.InnerException)
{
case InvalidCastException:
_log.Error("Incompatible value assigned/used at line {Line}: {Msg}", line,
e.InnerException.Message);
break;
default:
_log.Error("Exception at line {Line}: {Msg}", line, e.InnerException?.Message ?? e.Message);
break;
}
}
_log.Error("Due to previous exception, this file will be skipped: {File}", file);
return Array.Empty<T>();
}
public ICollection<T> LoadFromStream(TextReader stream, string requestedSection)
{
_log.Debug("Loading config section: {Section}", requestedSection);
var parser = new Parser(stream);
parser.Consume<StreamStart>();
if (parser.Current is StreamEnd)
{
_log.Debug("Skipping this config due to StreamEnd");
throw new EmptyYamlException();
}
parser.Consume<DocumentStart>();
if (parser.Current is DocumentEnd)
{
_log.Debug("Skipping this config due to DocumentEnd");
throw new EmptyYamlException();
}
return ParseAllSections(parser, requestedSection);
}
private ICollection<T> ParseAllSections(Parser parser, string requestedSection)
{
var configs = new List<T>();
parser.Consume<MappingStart>();
while (parser.TryConsume<Scalar>(out var section))
{
if (section.Value == requestedSection)
{
configs.AddRange(ParseSingleSection(parser));
}
else
{
_log.Debug("Skipping non-matching config section {Section} at line {Line}",
section.Value, section.Start.Line);
parser.SkipThisAndNestedEvents();
}
}
// If any config names are null, that means user specified array-style (deprecated) instances.
if (configs.Any(x => x.Name is null))
{
_log.Warning(
"Found array-style list of instances instead of named-style. " +
"Array-style lists of Sonarr/Radarr instances are deprecated");
}
return configs;
}
private ICollection<T> ParseSingleSection(Parser parser)
{
var configs = new List<T>();
switch (parser.Current)
{
case MappingStart:
ParseAndAdd<MappingStart, MappingEnd>(parser, configs);
break;
case SequenceStart:
ParseAndAdd<SequenceStart, SequenceEnd>(parser, configs);
break;
}
return configs;
}
private void ParseAndAdd<TStart, TEnd>(Parser parser, ICollection<T> configs)
where TStart : ParsingEvent
where TEnd : ParsingEvent
{
parser.Consume<TStart>();
while (!parser.TryConsume<TEnd>(out _))
{
var lineNumber = parser.Current?.Start.Line;
string? instanceName = null;
if (parser.TryConsume<Scalar>(out var key))
{
instanceName = key.Value;
}
var newConfig = _deserializer.Deserialize<T>(parser);
newConfig.Name = instanceName;
newConfig.LineNumber = lineNumber ?? 0;
configs.Add(newConfig);
}
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Cli.Config;
public interface IConfigurationFinder
{
IReadOnlyCollection<string> GetConfigFiles(IReadOnlyCollection<string>? configs);
}

@ -1,11 +0,0 @@
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Config;
public interface IConfigurationLoader<T>
where T : IServiceConfiguration
{
ICollection<T> LoadMany(IEnumerable<string> configFiles, string configSection);
ICollection<T> Load(string file, string configSection);
ICollection<T> LoadFromStream(TextReader stream, string requestedSection);
}

@ -0,0 +1,37 @@
using Recyclarr.Cli.Console.Commands;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console;
public static class CliSetup
{
public static void Commands(IConfigurator cli)
{
cli.AddCommand<SyncCommand>("sync")
.WithExample("sync", "radarr", "--instance", "movies")
.WithExample("sync", "-i", "instance1", "-i", "instance2")
.WithExample("sync", "sonarr", "--preview");
cli.AddCommand<MigrateCommand>("migrate");
cli.AddBranch("list", list =>
{
list.SetDescription("List information from the guide");
list.AddCommand<ListCustomFormatsCommand>("custom-formats");
list.AddCommand<ListReleaseProfilesCommand>("release-profiles");
list.AddCommand<ListQualitiesCommand>("qualities");
});
cli.AddBranch("config", config =>
{
config.SetDescription("Operations for configuration files");
config.AddCommand<ConfigCreateCommand>("create");
});
// LEGACY / DEPRECATED SUBCOMMANDS
cli.AddCommand<RadarrCommand>("radarr");
cli.AddCommand<SonarrCommand>("sonarr");
cli.AddCommand<ConfigCreateCommand>("create-config")
.WithDescription("OBSOLETE: Use `config create` instead");
}
}

@ -0,0 +1,11 @@
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console;
public static class CommandConfiguratorExtensions
{
public static ICommandConfigurator WithExample(this ICommandConfigurator cli, params string[] args)
{
return cli.WithExample(args);
}
}

@ -0,0 +1,57 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Services.Processors;
using Serilog;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("Create a starter configuration file.")]
public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
{
private readonly IConfigCreationProcessor _processor;
private readonly ILogger _log;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
{
[CommandOption("-p|--path")]
[Description("Path to where the configuration file should be created.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string? Path { get; init; }
}
public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor)
{
_processor = processor;
_log = log;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
if (context.Name == "create-config")
{
_log.Warning("The `create-config` subcommand is DEPRECATED -- Use `config create` instead!");
}
try
{
await _processor.Process(settings.Path);
}
catch (FileExistsException e)
{
_log.Error(
"The file {ConfigFile} already exists. Please choose another path or " +
"delete/move the existing file and run this command again", e.AttemptedPath);
return 1;
}
return 0;
}
}

@ -0,0 +1,46 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Autofac.Features.Indexed;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Services.Common;
using Spectre.Console.Cli;
#pragma warning disable CS8765
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List custom formats in the guide for a particular service.")]
internal class ListCustomFormatsCommand : Command<ListCustomFormatsCommand.CliSettings>
{
private readonly IGuideDataLister _lister;
private readonly IIndex<SupportedServices, IGuideService> _guideService;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
{
[CommandArgument(0, "<service>")]
[EnumDescription<SupportedServices>("The service to obtain information about.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public required SupportedServices Service { get; init; }
}
public ListCustomFormatsCommand(
IGuideDataLister lister,
IIndex<SupportedServices, IGuideService> guideService)
{
_lister = lister;
_guideService = guideService;
}
public override int Execute(CommandContext context, CliSettings settings)
{
var guideService = _guideService[settings.Service];
_lister.ListCustomFormats(guideService.GetCustomFormatData());
return 0;
}
}

@ -0,0 +1,45 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Autofac.Features.Indexed;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Services.Common;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands;
#pragma warning disable CS8765
[UsedImplicitly]
[Description("List quality definitions in the guide for a particular service.")]
internal class ListQualitiesCommand : Command<ListQualitiesCommand.CliSettings>
{
private readonly IGuideDataLister _lister;
private readonly IIndex<SupportedServices, IGuideService> _guideService;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
{
[CommandArgument(0, "<service>")]
[EnumDescription<SupportedServices>("The service to obtain information about.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public required SupportedServices Service { get; init; }
}
public ListQualitiesCommand(
IGuideDataLister lister,
IIndex<SupportedServices, IGuideService> guideService)
{
_lister = lister;
_guideService = guideService;
}
public override int Execute(CommandContext context, CliSettings settings)
{
var guideService = _guideService[settings.Service];
_lister.ListQualities(guideService.GetQualities());
return 0;
}
}

@ -0,0 +1,62 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.TrashLib.Services.Sonarr;
using Serilog;
using Spectre.Console.Cli;
#pragma warning disable CS8765
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List Sonarr release profiles in the guide for a particular service.")]
internal class ListReleaseProfilesCommand : Command<ListReleaseProfilesCommand.CliSettings>
{
private readonly ILogger _log;
private readonly ISonarrGuideDataLister _lister;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
{
[CommandOption("--terms")]
[Description(
"For the given Release Profile Trash ID, list terms in it that can be filtered in YAML format. " +
"Note that not every release profile has terms that may be filtered.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string? ListTerms { get; init; }
}
public ListReleaseProfilesCommand(
ILogger log,
ISonarrGuideDataLister lister)
{
_log = log;
_lister = lister;
}
public override int Execute(CommandContext context, CliSettings settings)
{
try
{
if (settings.ListTerms is not null)
{
// Ignore nullability of ListTerms since the Settings.Validate() method will check for null/empty.
_lister.ListTerms(settings.ListTerms!);
}
else
{
_lister.ListReleaseProfiles();
}
}
catch (ArgumentException e)
{
_log.Error(e, "Error");
return 1;
}
return 0;
}
}

@ -0,0 +1,69 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Migration;
using Spectre.Console;
using Spectre.Console.Cli;
#pragma warning disable CS8765
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("Perform migration steps that may be needed between versions")]
public class MigrateCommand : Command<MigrateCommand.CliSettings>
{
private readonly IMigrationExecutor _migration;
private readonly IAnsiConsole _console;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : ServiceCommandSettings
{
}
public MigrateCommand(
IAnsiConsole console,
IMigrationExecutor migration)
{
_console = console;
_migration = migration;
}
public override int Execute(CommandContext context, CliSettings settings)
{
try
{
_migration.PerformAllMigrationSteps(settings.Debug);
}
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}");
// ReSharper disable once InvertIf
if (e.Remediation.Any())
{
msg.AppendLine("\nPossible remediation steps:");
foreach (var remedy in e.Remediation)
{
msg.AppendLine($" - {remedy}");
}
}
_console.Write(msg.ToString());
return 1;
}
catch (RequiredMigrationException ex)
{
_console.WriteLine($"ERROR: {ex.Message}");
return 1;
}
return 0;
}
}

@ -0,0 +1,90 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.Migration;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Services.Processors;
using Recyclarr.TrashLib.Services.Radarr;
using Serilog;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("OBSOLETE: Use `sync radarr` instead")]
internal class RadarrCommand : AsyncCommand<RadarrCommand.CliSettings>
{
private readonly ILogger _log;
private readonly IRadarrGuideDataLister _lister;
private readonly IMigrationExecutor _migration;
private readonly ISyncProcessor _syncProcessor;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : ServiceCommandSettings, ISyncSettings
{
public SupportedServices? Service => SupportedServices.Radarr;
public IReadOnlyCollection<string> Instances { get; } = Array.Empty<string>();
[CommandOption("-p|--preview")]
[Description("Only display the processed markdown results without making any API calls.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool Preview { get; init; }
[CommandOption("-c|--config")]
[Description(
"One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `recyclarr.yml` in the same directory as the executable.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
[TypeConverter(typeof(FileInfoConverter))]
public IFileInfo[] ConfigsOption { get; init; } = Array.Empty<IFileInfo>();
public IReadOnlyCollection<IFileInfo> Configs => ConfigsOption;
[CommandOption("--list-custom-formats")]
[Description("List available custom formats from the guide in YAML format.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool ListCustomFormats { get; init; }
[CommandOption("--list-qualities")]
[Description("List available quality definition types from the guide.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool ListQualities { get; init; }
}
public RadarrCommand(
ILogger log,
IRadarrGuideDataLister lister,
IMigrationExecutor migration,
ISyncProcessor syncProcessor)
{
_log = log;
_lister = lister;
_migration = migration;
_syncProcessor = syncProcessor;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
_log.Warning("The `radarr` subcommand is DEPRECATED -- Use `sync` instead!");
if (settings.ListCustomFormats)
{
_lister.ListCustomFormats();
return 0;
}
if (settings.ListQualities)
{
_lister.ListQualities();
return 0;
}
// Will throw if migration is required, otherwise just a warning is issued.
_migration.CheckNeededMigrations();
return (int) await _syncProcessor.ProcessConfigs(settings);
}
}

@ -0,0 +1,13 @@
using System.ComponentModel;
using JetBrains.Annotations;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands.Shared;
public class BaseCommandSettings : CommandSettings
{
[CommandOption("-d|--debug")]
[Description("Show debug logs in console output.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool Debug { get; init; }
}

@ -0,0 +1,13 @@
using System.ComponentModel;
using JetBrains.Annotations;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands.Shared;
public class ServiceCommandSettings : BaseCommandSettings
{
[CommandOption("--app-data")]
[Description("Custom path to the application data directory")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string? AppData { get; init; }
}

@ -0,0 +1,129 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.Migration;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Services.Processors;
using Recyclarr.TrashLib.Services.Sonarr;
using Serilog;
using Spectre.Console;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("OBSOLETE: Use `sync sonarr` instead")]
internal class SonarrCommand : AsyncCommand<SonarrCommand.CliSettings>
{
private readonly ILogger _log;
private readonly ISonarrGuideDataLister _lister;
private readonly IMigrationExecutor _migration;
private readonly ISyncProcessor _syncProcessor;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : ServiceCommandSettings, ISyncSettings
{
public SupportedServices? Service => SupportedServices.Sonarr;
public IReadOnlyCollection<string> Instances { get; } = Array.Empty<string>();
[CommandOption("-p|--preview")]
[Description("Only display the processed markdown results without making any API calls.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool Preview { get; init; }
[CommandOption("-c|--config")]
[Description(
"One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `recyclarr.yml` in the same directory as the executable.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
[TypeConverter(typeof(FileInfoConverter))]
public IFileInfo[] ConfigsOption { get; init; } = Array.Empty<IFileInfo>();
public IReadOnlyCollection<IFileInfo> Configs => ConfigsOption;
[CommandOption("--list-custom-formats")]
[Description("List available custom formats from the guide in YAML format.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool ListCustomFormats { get; init; }
[CommandOption("--list-qualities")]
[Description("List available quality definition types from the guide.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool ListQualities { get; init; }
[CommandOption("--list-release-profiles")]
[Description("List available release profiles from the guide in YAML format.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool ListReleaseProfiles { get; init; }
// 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
[CommandOption("--list-terms")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
[Description(
"For the given Release Profile Trash ID, list terms in it that can be filtered in YAML format. " +
"Note that not every release profile has terms that may be filtered.")]
public string? ListTerms { get; init; } = "empty";
public override ValidationResult Validate()
{
if (string.IsNullOrEmpty(ListTerms))
{
return ValidationResult.Error(
"The --list-terms option was specified without a Release Profile Trash ID specified");
}
return base.Validate();
}
}
public SonarrCommand(
ILogger log,
ISonarrGuideDataLister lister,
IMigrationExecutor migration,
ISyncProcessor syncProcessor)
{
_log = log;
_lister = lister;
_migration = migration;
_syncProcessor = syncProcessor;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
_log.Warning("The `sonarr` subcommand is DEPRECATED -- Use `sync` instead!");
if (settings.ListCustomFormats)
{
_lister.ListCustomFormats();
return 0;
}
if (settings.ListQualities)
{
_lister.ListQualities();
return 0;
}
if (settings.ListReleaseProfiles)
{
_lister.ListReleaseProfiles();
return 0;
}
if (settings.ListTerms != "empty")
{
// Ignore nullability of ListTerms since the Settings.Validate() method will check for null/empty.
_lister.ListTerms(settings.ListTerms!);
return 0;
}
// Will throw if migration is required, otherwise just a warning is issued.
_migration.CheckNeededMigrations();
return (int) await _syncProcessor.ProcessConfigs(settings);
}
}

@ -0,0 +1,67 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.Migration;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Services.Processors;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Commands;
[Description("Sync the guide to services")]
[UsedImplicitly]
public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
{
private readonly IMigrationExecutor _migration;
private readonly ISyncProcessor _syncProcessor;
[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, ISyncSettings
{
[CommandArgument(0, "[service]")]
[EnumDescription<SupportedServices>("The service to sync. If not specified, all services are synced.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public SupportedServices? Service { get; init; }
[CommandOption("-c|--config")]
[Description("One or more YAML configuration files to load & use.")]
[TypeConverter(typeof(FileInfoConverter))]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public IFileInfo[] ConfigsOption { get; init; } = Array.Empty<IFileInfo>();
public IReadOnlyCollection<IFileInfo> Configs => ConfigsOption;
[CommandOption("-p|--preview")]
[Description("Perform a dry run: preview the results without syncing.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool Preview { get; init; }
[CommandOption("-i|--instance")]
[Description("One or more instance names to sync. If not specified, all instances will be synced.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string[] InstancesOption { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Instances => InstancesOption;
}
public SyncCommand(
IMigrationExecutor migration,
ISyncProcessor syncProcessor)
{
_migration = migration;
_syncProcessor = syncProcessor;
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
// Will throw if migration is required, otherwise just a warning is issued.
_migration.CheckNeededMigrations();
return (int) await _syncProcessor.ProcessConfigs(settings);
}
}

@ -0,0 +1,6 @@
namespace Recyclarr.Cli.Console.Helpers;
public class AppDataPathProvider
{
public string? AppDataPath { get; set; }
}

@ -0,0 +1,38 @@
using Autofac;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Helpers;
internal class AutofacTypeRegistrar : ITypeRegistrar
{
private readonly ContainerBuilder _builder;
private readonly Action<ILifetimeScope> _assignScope;
public AutofacTypeRegistrar(ContainerBuilder builder, Action<ILifetimeScope> assignScope)
{
_builder = builder;
_assignScope = assignScope;
}
public void Register(Type service, Type implementation)
{
_builder.RegisterType(implementation).As(service).SingleInstance();
}
public void RegisterInstance(Type service, object implementation)
{
_builder.RegisterInstance(implementation).As(service);
}
public void RegisterLazy(Type service, Func<object> factory)
{
_builder.Register(_ => factory()).As(service).SingleInstance();
}
public ITypeResolver Build()
{
var container = _builder.Build();
_assignScope(container);
return new AutofacTypeResolver(container);
}
}

@ -0,0 +1,19 @@
using Autofac;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Helpers;
internal class AutofacTypeResolver : ITypeResolver
{
private readonly ILifetimeScope _scope;
public AutofacTypeResolver(ILifetimeScope scope)
{
_scope = scope;
}
public object? Resolve(Type? type)
{
return type is not null ? _scope.Resolve(type) : null;
}
}

@ -6,34 +6,31 @@ using Recyclarr.TrashLib.Cache;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Startup;
namespace Recyclarr.Cli.Command.Helpers;
namespace Recyclarr.Cli.Console.Helpers;
public class CacheStoragePath : ICacheStoragePath
{
private readonly IAppPaths _paths;
private readonly IServiceCommand _serviceCommand;
private readonly IServiceConfiguration _config;
private readonly IFNV1a _hash;
public CacheStoragePath(
IAppPaths paths,
IServiceCommand serviceCommand,
IServiceConfiguration config)
{
_paths = paths;
_serviceCommand = serviceCommand;
_config = config;
_hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(32));
}
private string BuildUniqueServiceDir(string? serviceName)
private string BuildUniqueServiceDir()
{
// In the future, once array-style configurations are removed, the service name will no longer be optional
// and the below condition can be removed and the logic simplified.
var dirName = new StringBuilder();
if (serviceName is not null)
if (_config.InstanceName is not null)
{
dirName.Append($"{serviceName}_");
dirName.Append($"{_config.InstanceName}_");
}
var guid = _hash.ComputeHash(Encoding.ASCII.GetBytes(_config.BaseUrl)).AsHexString();
@ -44,8 +41,8 @@ public class CacheStoragePath : ICacheStoragePath
public IFileInfo CalculatePath(string cacheObjectName)
{
return _paths.CacheDirectory
.SubDirectory(_serviceCommand.Name.ToLower(CultureInfo.CurrentCulture))
.SubDirectory(BuildUniqueServiceDir(_config.Name))
.SubDirectory(_config.ServiceName.ToLower(CultureInfo.CurrentCulture))
.SubDirectory(BuildUniqueServiceDir())
.File(cacheObjectName + ".json");
}
}

@ -0,0 +1,21 @@
using System.ComponentModel;
using System.Text;
namespace Recyclarr.Cli.Console.Helpers;
[AttributeUsage(AttributeTargets.Property)]
public sealed class EnumDescriptionAttribute<TEnum> : DescriptionAttribute
where TEnum : Enum
{
public override string Description { get; }
public EnumDescriptionAttribute(string description)
{
var enumNames = Enum.GetNames(typeof(TEnum))
.Select(x => x.ToLowerInvariant());
var str = new StringBuilder(description.Trim());
str.Append($" (Valid Values: {string.Join(", ", enumNames)})");
Description = str.ToString();
}
}

@ -0,0 +1,32 @@
using System.ComponentModel;
using System.Globalization;
using System.IO.Abstractions;
namespace Recyclarr.Cli.Console.Helpers;
internal class FileInfoConverter : TypeConverter
{
private readonly IFileSystem _fs;
public FileInfoConverter(IFileSystem fs)
{
_fs = fs;
}
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
{
// ReSharper disable once InvertIf
if (value is string path)
{
var info = _fs.FileInfo.New(path);
if (!info.Exists)
{
throw new FileNotFoundException("The file does not exist", path);
}
return info;
}
return null;
}
}

@ -1,7 +1,7 @@
using Recyclarr.TrashLib.Startup;
using Serilog;
namespace Recyclarr.Cli.Command.Setup;
namespace Recyclarr.Cli.Console.Setup;
public class AppPathSetupTask : IBaseCommandSetupTask
{

@ -0,0 +1,58 @@
using System.Reactive;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Recyclarr.Cli.Console.Commands.Shared;
using Recyclarr.Cli.Console.Helpers;
using Serilog.Core;
using Serilog.Events;
using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Setup;
public class CliInterceptor : ICommandInterceptor
{
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly AppDataPathProvider _appDataPathProvider;
private readonly Subject<Unit> _interceptedSubject = new();
public IObservable<Unit> OnIntercepted => _interceptedSubject.AsObservable();
public CliInterceptor(LoggingLevelSwitch loggingLevelSwitch, AppDataPathProvider appDataPathProvider)
{
_loggingLevelSwitch = loggingLevelSwitch;
_appDataPathProvider = appDataPathProvider;
}
public void Intercept(CommandContext context, CommandSettings settings)
{
switch (settings)
{
case ServiceCommandSettings cmd:
HandleServiceCommand(cmd);
break;
case BaseCommandSettings cmd:
HandleBaseCommand(cmd);
break;
}
_interceptedSubject.OnNext(Unit.Default);
_interceptedSubject.OnCompleted();
}
private void HandleServiceCommand(ServiceCommandSettings cmd)
{
HandleBaseCommand(cmd);
_appDataPathProvider.AppDataPath = cmd.AppData;
}
private void HandleBaseCommand(BaseCommandSettings cmd)
{
_loggingLevelSwitch.MinimumLevel = cmd.Debug switch
{
true => LogEventLevel.Debug,
_ => LogEventLevel.Information
};
}
}

@ -1,4 +1,4 @@
namespace Recyclarr.Cli.Command.Setup;
namespace Recyclarr.Cli.Console.Setup;
public interface IBaseCommandSetupTask
{

@ -2,7 +2,7 @@ using Recyclarr.Cli.Logging;
using Recyclarr.TrashLib.Config.Settings;
using Serilog;
namespace Recyclarr.Cli.Command.Setup;
namespace Recyclarr.Cli.Console.Setup;
public class JanitorCleanupTask : IBaseCommandSetupTask
{

@ -1,6 +1,9 @@
using System.IO.Abstractions;
using Recyclarr.Common.Serilog;
using Recyclarr.TrashLib;
using Recyclarr.TrashLib.Startup;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
@ -10,10 +13,12 @@ namespace Recyclarr.Cli.Logging;
public class LoggerFactory
{
private readonly IAppPaths _paths;
private readonly LoggingLevelSwitch _levelSwitch;
public LoggerFactory(IAppPaths paths)
public LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
{
_paths = paths;
_levelSwitch = levelSwitch;
}
private static string GetBaseTemplateString()
@ -22,29 +27,33 @@ public class LoggerFactory
return
$"{{#if {scope} is not null}}{{{scope}}}: {{#end}}" +
"{@m}\n" +
"{@x}";
"{@m}";
}
private static ExpressionTemplate GetConsoleTemplate()
{
var template = "[{@l:u3}] " + GetBaseTemplateString();
var template = "[{@l:u3}] " + GetBaseTemplateString() +
"{#if ExceptionMessage is not null}: {ExceptionMessage}{#end}" +
"\n";
return new ExpressionTemplate(template, theme: TemplateTheme.Code);
}
private static ExpressionTemplate GetFileTemplate()
{
var template = "[{@t:HH:mm:ss} {@l:u3}] " + GetBaseTemplateString();
var template = "[{@t:HH:mm:ss} {@l:u3}] " + GetBaseTemplateString() + "\n{@x}";
return new ExpressionTemplate(template);
}
public ILogger Create(LogEventLevel level)
public ILogger Create()
{
var logPath = _paths.LogDirectory.File($"trash_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
return new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Debug)
.WriteTo.Console(GetConsoleTemplate(), level)
.Enrich.With<ExceptionMessageEnricher>()
.WriteTo.Console(GetConsoleTemplate(), levelSwitch: _levelSwitch)
.WriteTo.File(GetFileTemplate(), logPath.FullName)
.Enrich.FromLogContext()
.CreateLogger();

@ -1,4 +1,4 @@
using CliFx.Infrastructure;
using Spectre.Console;
namespace Recyclarr.Cli.Migration;
@ -9,5 +9,5 @@ public interface IMigrationStep
IReadOnlyCollection<string> Remediation { get; }
bool Required { get; }
bool CheckIfNeeded();
void Execute(IConsole? console);
void Execute(IAnsiConsole? console);
}

@ -1,14 +1,13 @@
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Spectre.Console;
namespace Recyclarr.Cli.Migration;
public class MigrationExecutor : IMigrationExecutor
{
private readonly IConsole _console;
private readonly IAnsiConsole _console;
private readonly List<IMigrationStep> _migrationSteps;
public MigrationExecutor(IEnumerable<IMigrationStep> migrationSteps, IConsole console)
public MigrationExecutor(IEnumerable<IMigrationStep> migrationSteps, IAnsiConsole console)
{
_console = console;
_migrationSteps = migrationSteps.OrderBy(x => x.Order).ToList();
@ -16,7 +15,7 @@ public class MigrationExecutor : IMigrationExecutor
public void PerformAllMigrationSteps(bool withDiagnostics)
{
_console.Output.WriteLine("Performing migration steps...");
_console.WriteLine("Performing migration steps...");
// ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator
foreach (var step in _migrationSteps)
@ -38,7 +37,7 @@ public class MigrationExecutor : IMigrationExecutor
throw new MigrationException(e, step.Description, step.Remediation);
}
_console.Output.WriteLine($"Migrate: {step.Description}");
_console.WriteLine($"Migrate: {step.Description}");
}
}
@ -55,16 +54,16 @@ public class MigrationExecutor : IMigrationExecutor
foreach (var step in neededMigrationSteps)
{
var requiredText = step.Required ? "Required" : "Not Required";
_console.Output.WriteLine($"Migration Needed ({requiredText}): {step.Description}");
_console.WriteLine($"Migration Needed ({requiredText}): {step.Description}");
wereAnyRequired |= step.Required;
}
_console.Output.WriteLine(
_console.WriteLine(
"\nRun the `migrate` subcommand to perform the above migration steps automatically\n");
if (wereAnyRequired)
{
throw new CommandException("Some migrations above are REQUIRED. Application will now exit.");
throw new RequiredMigrationException();
}
}
}

@ -0,0 +1,10 @@
namespace Recyclarr.Cli.Migration;
public class RequiredMigrationException : Exception
{
public RequiredMigrationException()
: base("Some REQUIRED migrations did not pass")
{
throw new NotImplementedException();
}
}

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using CliFx.Infrastructure;
using JetBrains.Annotations;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Startup;
using Spectre.Console;
namespace Recyclarr.Cli.Migration.Steps;
@ -52,7 +52,7 @@ public class MigrateTrashUpdaterAppDataDir : IMigrationStep
return OldPath.Exists;
}
public void Execute(IConsole? console)
public void Execute(IAnsiConsole? console)
{
MoveDirectory("cache", console);
MoveFile("recyclarr.yml");
@ -64,7 +64,7 @@ public class MigrateTrashUpdaterAppDataDir : IMigrationStep
}
}
private void MoveDirectory(string directory, IConsole? console)
private void MoveDirectory(string directory, IAnsiConsole? console)
{
var oldPath = OldPath.SubDirectory(directory);
if (oldPath.Exists)

@ -1,6 +1,6 @@
using System.IO.Abstractions;
using CliFx.Infrastructure;
using JetBrains.Annotations;
using Spectre.Console;
namespace Recyclarr.Cli.Migration.Steps;
@ -36,7 +36,7 @@ public class MigrateTrashYml : IMigrationStep
public bool CheckIfNeeded() => _fileSystem.File.Exists(_oldConfigPath);
public void Execute(IConsole? console)
public void Execute(IAnsiConsole? console)
{
_fileSystem.File.Move(_oldConfigPath, _newConfigPath);
}

@ -1,41 +1,97 @@
using System.Diagnostics;
using System.Text;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Autofac;
using CliFx;
using MoreLinq;
using Recyclarr.Cli.Console;
using Recyclarr.Cli.Console.Helpers;
using Recyclarr.Cli.Console.Setup;
using Serilog;
using Serilog.Core;
using Spectre.Console;
using Spectre.Console.Cli;
namespace Recyclarr.Cli;
internal static class Program
internal static partial class Program
{
private static string ExecutableName => Process.GetCurrentProcess().ProcessName;
private static ILifetimeScope? _scope;
private static IBaseCommandSetupTask[] _tasks = Array.Empty<IBaseCommandSetupTask>();
private static ILogger? _log;
public static async Task<int> Main()
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public static int Main(string[] args)
{
var status = await new CliApplicationBuilder()
.AddCommands(GetAllCommandTypes())
.SetExecutableName(ExecutableName)
.SetVersion(BuildVersion())
.Build()
.RunAsync();
return status;
}
var builder = new ContainerBuilder();
CompositionRoot.Setup(builder);
private static IEnumerable<Type> GetAllCommandTypes()
{
return typeof(Program).Assembly.GetTypes()
.Where(x => x.IsAssignableTo<ICommand>() && !x.IsAbstract);
var logLevelSwitch = new LoggingLevelSwitch();
builder.RegisterInstance(logLevelSwitch);
var appDataPathProvider = new AppDataPathProvider();
builder.RegisterInstance(appDataPathProvider);
var app = new CommandApp(new AutofacTypeRegistrar(builder, s => _scope = s));
app.Configure(config =>
{
#if DEBUG
config.PropagateExceptions();
config.ValidateExamples();
#endif
config.Settings.PropagateExceptions = true;
config.SetApplicationName("recyclarr");
// config.SetApplicationVersion("v1.2.3");
var interceptor = new CliInterceptor(logLevelSwitch, appDataPathProvider);
interceptor.OnIntercepted.Subscribe(_ => OnAppInitialized());
config.SetInterceptor(interceptor);
CliSetup.Commands(config);
});
var result = 1;
try
{
result = app.Run(args);
}
catch (CommandRuntimeException ex)
{
var msg = CommandMessageRegex().Replace(ex.Message, "[gold1]$0[/]");
AnsiConsole.Markup($"[red]Error:[/] [white]{msg}[/]");
_log?.Debug(ex, "Command Exception");
}
catch (Exception ex)
{
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
_log?.Debug(ex, "Non-recoverable Exception");
}
finally
{
OnAppCleanup();
}
return result;
}
private static string BuildVersion()
private static void OnAppInitialized()
{
var builder = new StringBuilder($"v{GitVersionInformation.MajorMinorPatch}");
var metadata = GitVersionInformation.FullBuildMetaData;
if (!string.IsNullOrEmpty(metadata))
if (_scope is null)
{
builder.Append($" ({metadata})");
throw new InvalidProgramException("Composition root is not initialized");
}
return builder.ToString();
_log = _scope.Resolve<ILogger>();
_log.Debug("Recyclarr Version: {Version}", GitVersionInformation.InformationalVersion);
_tasks = _scope.Resolve<IOrderedEnumerable<IBaseCommandSetupTask>>().ToArray();
_tasks.ForEach(x => x.OnStart());
}
private static void OnAppCleanup()
{
_tasks.Reverse().ForEach(x => x.OnFinish());
}
[GeneratedRegex("'.*?'")]
private static partial Regex CommandMessageRegex();
}

@ -8,11 +8,12 @@
<PackageReference Include="Autofac" />
<PackageReference Include="Autofac.Extras.AggregateService" />
<PackageReference Include="Autofac.Extras.Ordering" />
<PackageReference Include="CliFx" />
<PackageReference Include="AutoMapper.Contrib.Autofac.DependencyInjection" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Expressions" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Spectre.Console.Cli" />
<PackageReference Include="TestableIO.System.IO.Abstractions" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="YamlDotNet" />

@ -26,7 +26,7 @@ sonarr:
type: series
# Release profiles from the guide to sync to Sonarr v3 (Sonarr v4 does not use this!)
# Use `recyclarr sonarr --list-release-profiles` for values you can put here.
# Use `recyclarr list release-profiles` for values you can put here.
# https://trash-guides.info/Sonarr/Sonarr-Release-Profile-RegEx/
release_profiles:
# Series
@ -56,7 +56,7 @@ radarr:
custom_formats:
# A list of custom formats to sync to Radarr.
# Use `recyclarr radarr --list-custom-formats` for values you can put here.
# Use `recyclarr list custom-formats radarr` for values you can put here.
# https://trash-guides.info/Radarr/Radarr-collection-of-custom-formats/
- trash_ids:
- ed38b889b31be83fda192888e2286d83 # BR-DISK

@ -0,0 +1,21 @@
using Autofac;
namespace Recyclarr.Common.Autofac;
public sealed class LifetimeScopedValue<T> : IDisposable
{
private readonly ILifetimeScope _scope;
public LifetimeScopedValue(ILifetimeScope scope, T value)
{
_scope = scope;
Value = value;
}
public T Value { get; }
public void Dispose()
{
_scope.Dispose();
}
}

@ -1,12 +1,26 @@
using System.Reflection;
using Autofac;
using Recyclarr.Common.FluentValidation;
using Module = Autofac.Module;
namespace Recyclarr.Common;
public class CommonAutofacModule : Module
{
private readonly Assembly _rootAssembly;
public CommonAutofacModule(Assembly rootAssembly)
{
_rootAssembly = rootAssembly;
}
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<DefaultEnvironment>().As<IEnvironment>();
builder.RegisterType<FileUtilities>().As<IFileUtilities>();
builder.RegisterType<ValidatorFactory>();
builder.Register(_ => new ResourceDataReader(_rootAssembly))
.As<IResourceDataReader>();
}
}

@ -0,0 +1,12 @@
using Autofac;
namespace Recyclarr.Common.Extensions;
public static class AutofacExtensions
{
public static object ResolveGeneric(this ILifetimeScope scope, Type genericType, params Type[] genericArgs)
{
var type = genericType.MakeGenericType(genericArgs);
return scope.Resolve(type);
}
}

@ -38,4 +38,24 @@ public static class CollectionExtensions
{
return observable.Where(x => x is not null).Select(x => x!);
}
public static bool Empty<T>(this ICollection<T>? collection)
{
return collection is null or {Count: 0};
}
public static bool Empty<T>(this IReadOnlyCollection<T>? collection)
{
return collection is null or {Count: 0};
}
public static bool NotEmpty<T>(this ICollection<T>? collection)
{
return collection is {Count: > 0};
}
public static bool NotEmpty<T>(this IReadOnlyCollection<T>? collection)
{
return collection is {Count: > 0};
}
}

@ -1,6 +1,6 @@
using System.IO.Abstractions;
using System.Text.RegularExpressions;
using CliFx.Infrastructure;
using Spectre.Console;
namespace Recyclarr.Common.Extensions;
@ -22,7 +22,7 @@ public static class FileSystemExtensions
}
public static void MergeDirectory(this IFileSystem fs, IDirectoryInfo targetDir, IDirectoryInfo destDir,
IConsole? console = null)
IAnsiConsole? console = null)
{
var directories = targetDir
.EnumerateDirectories("*", SearchOption.AllDirectories)
@ -31,14 +31,14 @@ public static class FileSystemExtensions
foreach (var dir in directories)
{
console?.Output.WriteLine($" - Attributes: {dir.Attributes}");
console?.WriteLine($" - Attributes: {dir.Attributes}");
// Is it a symbolic link?
if ((dir.Attributes & FileAttributes.ReparsePoint) != 0)
{
var newPath = RelocatePath(dir.FullName, targetDir.FullName, destDir.FullName);
fs.CreateParentDirectory(newPath);
console?.Output.WriteLine($" - Symlink: {dir.FullName} :: TO :: {newPath}");
console?.WriteLine($" - Symlink: {dir.FullName} :: TO :: {newPath}");
dir.MoveTo(newPath);
continue;
}
@ -48,12 +48,12 @@ public static class FileSystemExtensions
{
var newPath = RelocatePath(file.FullName, targetDir.FullName, destDir.FullName);
fs.CreateParentDirectory(newPath);
console?.Output.WriteLine($" - Moving: {file.FullName} :: TO :: {newPath}");
console?.WriteLine($" - Moving: {file.FullName} :: TO :: {newPath}");
file.MoveTo(newPath);
}
// Delete the directory now that it is empty.
console?.Output.WriteLine($" - Deleting: {dir.FullName}");
console?.WriteLine($" - Deleting: {dir.FullName}");
dir.Delete();
}
}

@ -0,0 +1,8 @@
using FluentValidation;
namespace Recyclarr.Common.FluentValidation;
public interface IValidatorFactory
{
IValidator GetValidator(Type typeToValidate);
}

@ -0,0 +1,20 @@
using Autofac;
using FluentValidation;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Common.FluentValidation;
public class ValidatorFactory : IValidatorFactory
{
private readonly ILifetimeScope _scope;
public ValidatorFactory(ILifetimeScope scope)
{
_scope = scope;
}
public IValidator GetValidator(Type typeToValidate)
{
return (IValidator) _scope.ResolveGeneric(typeof(IValidator<>), typeToValidate);
}
}

@ -0,0 +1,6 @@
namespace Recyclarr.Common;
public interface IResourceDataReader
{
string ReadData(string filename);
}

@ -1,65 +0,0 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Text;
using CliFx.Infrastructure;
namespace Recyclarr.Common;
/// <summary>
/// An ASCII progress bar
/// </summary>
public sealed class ProgressBar //: IProgress<double>
{
private readonly IConsole _console;
private readonly TimeSpan _animationInterval = TimeSpan.FromSeconds(1.0 / 8);
private const string Animation = @"|/-\";
private int _animationIndex;
private readonly Subject<float> _reportProgress = new();
public IObserver<float> ReportProgress => _reportProgress;
public string Description { get; set; } = "";
public ProgressBar(IConsole console)
{
_console = console;
// A progress bar is only for temporary display in a console window.
// If the console output is redirected to a file, draw nothing.
// Otherwise, we'll end up with a lot of garbage in the target file.
if (!_console.IsOutputRedirected)
{
_reportProgress.Sample(_animationInterval)
.Select(CalculateText)
.StartWith(string.Empty)
.Buffer(2, 1) // sliding window: take previous and current
.Subscribe(x => UpdateText(x[0].Length, x[1]));
}
}
private string CalculateText(float progress)
{
const int blockCount = 10;
var progressBlockCount = (int) (progress * blockCount);
var percent = (int) (progress * 100);
var progressBlocks = new string('#', progressBlockCount);
var progressBlocksUnfilled = new string('-', blockCount - progressBlockCount);
var currentAnimationFrame = Animation[_animationIndex++ % Animation.Length];
return $"[{progressBlocks}{progressBlocksUnfilled}] {percent,3}% {currentAnimationFrame} {Description}";
}
private void UpdateText(int previousTextLength, string text)
{
var outputBuilder = new StringBuilder();
outputBuilder.Append('\r');
outputBuilder.Append(text);
// If the previous string was longer, "erase" the old characters with spaces.
var lengthDifference = previousTextLength - text.Length;
if (lengthDifference > 0)
{
outputBuilder.Append(' ', lengthDifference);
}
_console.Output.Write(outputBuilder);
}
}

@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Autofac" />
<PackageReference Include="CliFx" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="System.Reactive" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="TestableIO.System.IO.Abstractions" />

@ -3,7 +3,7 @@ using System.Text;
namespace Recyclarr.Common;
public class ResourceDataReader
public class ResourceDataReader : IResourceDataReader
{
private readonly Assembly? _assembly;
private readonly string? _namespace;

@ -0,0 +1,18 @@
using Serilog.Core;
using Serilog.Events;
namespace Recyclarr.Common.Serilog;
public class ExceptionMessageEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var msg = logEvent.Exception?.Message;
if (string.IsNullOrEmpty(msg))
{
return;
}
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ExceptionMessage", msg));
}
}

@ -1,4 +1,5 @@
using System.IO.Abstractions;
using System.Reflection;
using Autofac;
using AutofacSerilogIntegration;
using Recyclarr.Common;
@ -12,7 +13,7 @@ public static class CompositionRoot
{
builder.RegisterLogger();
builder.RegisterModule<CommonAutofacModule>();
builder.RegisterModule(new CommonAutofacModule(Assembly.GetExecutingAssembly()));
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.RegisterType<DefaultAppDataSetup>();

@ -7,6 +7,8 @@ namespace Recyclarr.TestLibrary.AutoFixture;
public class MockFileSystemSpecimenBuilder : ICustomization
{
private static int _mockPathCounter;
public void Customize(IFixture fixture)
{
var fs = new MockFileSystem();
@ -14,13 +16,15 @@ public class MockFileSystemSpecimenBuilder : ICustomization
fixture.Customize<IFileInfo>(x => x.FromFactory(() =>
{
var name = $"MockFile-{fixture.Create<string>()}";
var name = $"MockFile-{_mockPathCounter}";
Interlocked.Increment(ref _mockPathCounter);
return fs.CurrentDirectory().File(name);
}));
fixture.Customize<IDirectoryInfo>(x => x.FromFactory(() =>
{
var name = $"MockDirectory-{fixture.Create<string>()}";
var name = $"MockDirectory-{_mockPathCounter}";
Interlocked.Increment(ref _mockPathCounter);
return fs.CurrentDirectory().SubDirectory(name);
}));
}

@ -5,23 +5,28 @@ namespace Recyclarr.TestLibrary;
public static class MockFileSystemExtensions
{
public static void AddFileNoData(this MockFileSystem fs, string path)
public static void AddEmptyFile(this MockFileSystem fs, string path)
{
fs.AddFile(FileUtils.NormalizePath(path), new MockFileData(""));
fs.AddFile(path, new MockFileData(""));
}
public static void AddFileNoData(this MockFileSystem fs, IFileInfo path)
public static void AddEmptyFile(this MockFileSystem fs, IFileInfo path)
{
fs.AddFile(path.FullName, new MockFileData(""));
fs.AddEmptyFile(path.FullName);
}
public static void AddDirectory2(this MockFileSystem fs, string path)
public static void AddDirectory(this MockFileSystem fs, IDirectoryInfo path)
{
fs.AddDirectory(FileUtils.NormalizePath(path));
fs.AddDirectory(path.FullName);
}
public static void AddDirectory(this MockFileSystem fs, IDirectoryInfo path)
public static void AddFile(this MockFileSystem fs, IFileInfo path, MockFileData data)
{
fs.AddDirectory(path.FullName);
fs.AddFile(path.FullName, data);
}
public static MockFileData GetFile(this MockFileSystem fs, IFileInfo path)
{
return fs.GetFile(path.FullName);
}
}

@ -6,8 +6,5 @@ namespace Recyclarr.TrashLib.TestLibrary;
[UsedImplicitly]
public class TestConfig : ServiceConfiguration
{
public TestConfig()
{
Name = "Test";
}
public override string ServiceName => "Test";
}

@ -17,6 +17,8 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
{
ApiKey = "valid",
BaseUrl = "valid",
InstanceName = "valid",
LineNumber = 1,
CustomFormats = new List<CustomFormatConfig>
{
new()
@ -210,4 +212,32 @@ public class ServiceConfigurationValidatorTest : IntegrationFixture
result.ShouldHaveValidationErrorFor(FirstCf +
$"{nameof(CustomFormatConfig.QualityProfiles)}[0].{nameof(QualityProfileScoreConfig.Name)}");
}
[Test]
public void Validation_failure_when_instance_name_empty()
{
var config = new TestConfig
{
InstanceName = ""
};
var validator = Resolve<ServiceConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.InstanceName);
}
[Test]
public void Validation_failure_when_line_number_equals_zero()
{
var config = new TestConfig
{
LineNumber = 0
};
var validator = Resolve<ServiceConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.LineNumber);
}
}

@ -1,144 +0,0 @@
using FluentValidation.TestHelper;
using NUnit.Framework;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Services.Sonarr;
using Recyclarr.TrashLib.Services.Sonarr.Config;
namespace Recyclarr.TrashLib.Tests.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationValidatorTest
{
[Test]
public void Sonarr_v4_succeeds()
{
var config = new SonarrConfiguration
{
ApiKey = "valid",
BaseUrl = "valid",
CustomFormats = new List<CustomFormatConfig>
{
new()
{
TrashIds = new List<string> {"valid"},
QualityProfiles = new List<QualityProfileScoreConfig>
{
new()
{
Name = "valid"
}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var capabilities = new SonarrCapabilities
{
SupportsCustomFormats = true,
SupportsNamedReleaseProfiles = true
};
var validator = new SonarrConfigurationValidator(capabilities);
var result = validator.TestValidate(config);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Sonarr_v3_succeeds()
{
var config = new SonarrConfiguration
{
ApiKey = "valid",
BaseUrl = "valid",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = new List<string> {"valid"},
Filter = new SonarrProfileFilterConfig {Include = new[] {"valid"}},
Tags = new[] {"valid"}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = "valid"
}
};
var capabilities = new SonarrCapabilities
{
SupportsCustomFormats = false,
SupportsNamedReleaseProfiles = true
};
var validator = new SonarrConfigurationValidator(capabilities);
var result = validator.TestValidate(config);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Sonarr_v4_failures()
{
var config = new SonarrConfiguration
{
ReleaseProfiles = new List<ReleaseProfileConfig> {new()}
};
var capabilities = new SonarrCapabilities {SupportsCustomFormats = true};
var validator = new SonarrConfigurationValidator(capabilities);
var result = validator.TestValidate(config);
// Release profiles not allowed in v4
result.ShouldHaveValidationErrorFor(x => x.ReleaseProfiles);
}
[Test]
public void Sonarr_v3_failures()
{
var config = new SonarrConfiguration
{
CustomFormats = new List<CustomFormatConfig> {new()},
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = Array.Empty<string>(),
Filter = new SonarrProfileFilterConfig
{
Include = new[] {"include"},
Exclude = new[] {"exclude"}
}
}
}
};
var capabilities = new SonarrCapabilities
{
SupportsCustomFormats = false,
SupportsNamedReleaseProfiles = false
};
var validator = new SonarrConfigurationValidator(capabilities);
var result = validator.TestValidate(config);
// Custom formats not allowed in v3
result.ShouldHaveValidationErrorFor(x => x.CustomFormats);
// Due to named release profiles not being supported (minimum version requirement not met)
result.ShouldHaveValidationErrorFor(x => x);
var releaseProfiles = $"{nameof(config.ReleaseProfiles)}[0].";
// Release profile trash IDs cannot be empty
result.ShouldHaveValidationErrorFor(releaseProfiles + nameof(ReleaseProfileConfig.TrashIds));
// Cannot use include + exclude filters together
result.ShouldHaveValidationErrorFor(releaseProfiles +
$"{nameof(ReleaseProfileConfig.Filter)}.{nameof(SonarrProfileFilterConfig.Include)}");
}
}

@ -1,10 +1,10 @@
using AutoFixture.NUnit3;
using CliFx.Infrastructure;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Services.Common;
using Recyclarr.TrashLib.TestLibrary;
using Spectre.Console.Testing;
namespace Recyclarr.TrashLib.Tests.CustomFormat;
@ -14,7 +14,7 @@ public class GuideDataListerTest
{
[Test, AutoMockData]
public void Custom_formats_appear_in_console_output(
[Frozen(Matching.ImplementedInterfaces)] FakeInMemoryConsole console,
[Frozen(Matching.ImplementedInterfaces)] TestConsole console,
GuideDataLister sut)
{
var testData = new[]
@ -25,7 +25,7 @@ public class GuideDataListerTest
sut.ListCustomFormats(testData);
console.ReadOutputString().Should().ContainAll(
console.Output.Should().ContainAll(
testData.SelectMany(x => new[] {x.Name, x.TrashId}));
}
}

@ -50,7 +50,7 @@ public class GuideProcessorTest
public async Task Guide_processor_behaves_as_expected_with_normal_guide_data()
{
var ctx = new Context();
var guideService = Substitute.For<IRadarrGuideService>();
var guideService = Substitute.For<RadarrGuideService>();
var guideProcessor = new GuideProcessor(new TestGuideProcessorSteps());
// simulate guide data

@ -0,0 +1,58 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using FluentAssertions;
using NUnit.Framework;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TestLibrary;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Services.Processors;
namespace Recyclarr.TrashLib.Tests.Services.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigCreationProcessorTest : IntegrationFixture
{
[Test]
public async Task Config_file_created_when_using_default_path()
{
var sut = Resolve<ConfigCreationProcessor>();
await sut.Process(null);
var file = Fs.GetFile(Paths.ConfigPath);
file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty();
}
[Test]
public async Task Config_file_created_when_using_user_specified_path()
{
var sut = Resolve<ConfigCreationProcessor>();
var ymlPath = Fs.CurrentDirectory()
.SubDirectory("user")
.SubDirectory("specified")
.File("file.yml");
await sut.Process(ymlPath.FullName);
var file = Fs.GetFile(ymlPath);
file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty();
}
[Test]
public async Task Should_throw_if_file_already_exists()
{
var sut = Resolve<ConfigCreationProcessor>();
var yml = Fs.CurrentDirectory().File("file.yml");
Fs.AddEmptyFile(yml);
yml.Refresh(); // Required since file was created after IFileInfo was constructed
var act = () => sut.Process(yml.FullName);
await act.Should().ThrowAsync<FileExistsException>();
}
}

@ -9,8 +9,8 @@ using NUnit.Framework;
using Recyclarr.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Repo;
using Recyclarr.TrashLib.Services.Sonarr;
using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile;
using Recyclarr.TrashLib.Services.Sonarr.ReleaseProfile.Guide;
namespace Recyclarr.TrashLib.Tests.Sonarr.ReleaseProfile.Guide;

@ -0,0 +1,95 @@
using AutoFixture.NUnit3;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Services.Sonarr.Capabilities;
using Recyclarr.TrashLib.Services.Sonarr.Config;
namespace Recyclarr.TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrCapabilityEnforcerTest
{
[Test, AutoMockData]
public void Fail_when_capabilities_not_obtained(
[Frozen] ISonarrCapabilityChecker checker,
SonarrCapabilityEnforcer sut)
{
var config = new SonarrConfiguration();
checker.GetCapabilities().Returns((SonarrCapabilities?) null);
var act = () => sut.Check(config);
act.Should().Throw<ServiceIncompatibilityException>().WithMessage("*obtained*");
}
[Test, AutoMockData]
public void Minimum_version_not_met(
[Frozen] ISonarrCapabilityChecker checker,
SonarrCapabilityEnforcer sut)
{
var config = new SonarrConfiguration();
checker.GetCapabilities().Returns(new SonarrCapabilities(new Version())
{
SupportsNamedReleaseProfiles = false
});
var act = () => sut.Check(config);
act.Should().Throw<ServiceIncompatibilityException>().WithMessage("*minimum*");
}
[Test, AutoMockData]
public void Release_profiles_not_allowed_in_v4(
[Frozen] ISonarrCapabilityChecker checker,
SonarrCapabilityEnforcer sut)
{
var config = new SonarrConfiguration
{
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
}
};
checker.GetCapabilities().Returns(new SonarrCapabilities(new Version())
{
SupportsNamedReleaseProfiles = true,
SupportsCustomFormats = true
});
var act = () => sut.Check(config);
act.Should().Throw<ServiceIncompatibilityException>().WithMessage("*v3*");
}
[Test, AutoMockData]
public void Custom_formats_not_allowed_in_v3(
[Frozen] ISonarrCapabilityChecker checker,
SonarrCapabilityEnforcer sut)
{
var config = new SonarrConfiguration
{
CustomFormats = new List<CustomFormatConfig>
{
new()
}
};
checker.GetCapabilities().Returns(new SonarrCapabilities(new Version())
{
SupportsNamedReleaseProfiles = true,
SupportsCustomFormats = false
});
var act = () => sut.Check(config);
act.Should().Throw<ServiceIncompatibilityException>().WithMessage("*v4*");
}
}

@ -1,58 +1,44 @@
using AutoMapper;
using Autofac;
using FluentAssertions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using NSubstitute;
using NUnit.Framework;
using Recyclarr.TrashLib.Services.Sonarr;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TestLibrary;
using Recyclarr.TrashLib.Services.Sonarr.Api;
using Recyclarr.TrashLib.Services.Sonarr.Api.Objects;
using Recyclarr.TrashLib.Startup;
using Serilog;
using Recyclarr.TrashLib.Services.Sonarr.Capabilities;
namespace Recyclarr.TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrCompatibilityTest
public class SonarrCompatibilityTest : IntegrationFixture
{
private sealed class TestContext : IDisposable
private static JObject SerializeJson<T>(T obj)
{
private readonly JsonSerializerSettings _jsonSettings;
public TestContext()
JsonSerializerSettings jsonSettings = new()
{
_jsonSettings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
Mapper = AutoMapperConfig.Setup();
}
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
public IMapper Mapper { get; }
public void Dispose()
{
}
return JObject.Parse(JsonConvert.SerializeObject(obj, jsonSettings));
}
public string SerializeJson<T>(T obj)
{
return JsonConvert.SerializeObject(obj, _jsonSettings);
}
protected override void RegisterExtraTypes(ContainerBuilder builder)
{
builder.RegisterMockFor<ISonarrCapabilityChecker>();
}
[Test]
public void Receive_v1_to_v2()
{
using var ctx = new TestContext();
static SonarrCapabilities Compat() => new();
var sut = Resolve<SonarrReleaseProfileCompatibilityHandler>();
var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), Compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV1)));
var result = sut.CompatibleReleaseProfileForReceiving(SerializeJson(dataV1));
result.Should().BeEquivalentTo(new SonarrReleaseProfile
{
@ -63,13 +49,10 @@ public class SonarrCompatibilityTest
[Test]
public void Receive_v2_to_v2()
{
using var ctx = new TestContext();
static SonarrCapabilities Compat() => new();
var sut = Resolve<SonarrReleaseProfileCompatibilityHandler>();
var dataV2 = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), Compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV2)));
var result = sut.CompatibleReleaseProfileForReceiving(SerializeJson(dataV2));
result.Should().BeEquivalentTo(dataV2);
}
@ -77,12 +60,14 @@ public class SonarrCompatibilityTest
[Test]
public void Send_v2_to_v1()
{
using var ctx = new TestContext();
static SonarrCapabilities Compat() => new() {ArraysNeededForReleaseProfileRequiredAndIgnored = false};
var capabilityChecker = Resolve<ISonarrCapabilityChecker>();
capabilityChecker.GetCapabilities().Returns(new SonarrCapabilities(new Version())
{
ArraysNeededForReleaseProfileRequiredAndIgnored = false
});
var sut = Resolve<SonarrReleaseProfileCompatibilityHandler>();
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), Compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForSending(data);
@ -92,12 +77,14 @@ public class SonarrCompatibilityTest
[Test]
public void Send_v2_to_v2()
{
using var ctx = new TestContext();
static SonarrCapabilities Compat() => new() {ArraysNeededForReleaseProfileRequiredAndIgnored = true};
var capabilityChecker = Resolve<ISonarrCapabilityChecker>();
capabilityChecker.GetCapabilities().Returns(new SonarrCapabilities(new Version())
{
ArraysNeededForReleaseProfileRequiredAndIgnored = true
});
var sut = Resolve<SonarrReleaseProfileCompatibilityHandler>();
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(Substitute.For<ILogger>(), Compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForSending(data);

@ -0,0 +1,70 @@
using FluentValidation.TestHelper;
using NUnit.Framework;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.Services.Sonarr.Config;
namespace Recyclarr.TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationValidatorTest : IntegrationFixture
{
[Test]
public void No_validation_failure_for_service_name()
{
var config = new SonarrConfiguration();
var validator = Resolve<SonarrConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldNotHaveValidationErrorFor(x => x.ServiceName);
}
[Test]
public void Validation_failure_when_rps_and_cfs_used_together()
{
var config = new SonarrConfiguration
{
ReleaseProfiles = new[] {new ReleaseProfileConfig()},
CustomFormats = new[] {new CustomFormatConfig()}
};
var validator = Resolve<SonarrConfigurationValidator>();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x.ReleaseProfiles);
}
[Test]
public void Sonarr_release_profile_failures()
{
var config = new SonarrConfiguration
{
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = Array.Empty<string>(),
Filter = new SonarrProfileFilterConfig
{
Include = new[] {"include"},
Exclude = new[] {"exclude"}
}
}
}
};
var validator = new SonarrConfigurationValidator();
var result = validator.TestValidate(config);
var releaseProfiles = $"{nameof(config.ReleaseProfiles)}[0].";
// Release profile trash IDs cannot be empty
result.ShouldHaveValidationErrorFor(releaseProfiles + nameof(ReleaseProfileConfig.TrashIds));
// Cannot use include + exclude filters together
result.ShouldHaveValidationErrorFor(releaseProfiles +
$"{nameof(ReleaseProfileConfig.Filter)}.{nameof(SonarrProfileFilterConfig.Include)}");
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save