Merge branch 'dotnet8' into master

Relates to #211
spectre-console-remove-di-hacks
Robert Dailey 6 months ago
commit 40e08a1099

@ -1593,3 +1593,6 @@ ij_javascript_array_initializer_right_brace_on_new_line = false
[src/tests/**.cs]
dotnet_diagnostic.ca1707.severity = none
# CA1861: Avoid constant arrays as arguments
dotnet_diagnostic.CA1861.severity = none

@ -14,8 +14,9 @@ on:
- '**.cs'
env:
BASE_REF: ${{ github.ref == 'refs/heads/master' && github.event.before ||
(github.event.base_ref || github.event.pull_request.base.ref || 'master') }}
baseRef: ${{ github.ref == 'refs/heads/master' && github.event.before ||
(github.event.base_ref || github.event.pull_request.base.ref || 'master') }}
dotnetVersion: 8.0.x
jobs:
inspect:
@ -30,7 +31,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
dotnet-version: ${{ env.dotnetVersion }}
- name: Restore
run: dotnet restore src
@ -55,7 +56,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
dotnet-version: ${{ env.dotnetVersion }}
- name: Install Resharper Tools
run: dotnet tool install -g JetBrains.ReSharper.GlobalTools
@ -64,7 +65,7 @@ jobs:
run: dotnet build src
- name: Run Code Cleanup
run: ../ci/code_cleanup.sh "${{ env.BASE_REF }}"
run: ../ci/code_cleanup.sh "${{ env.baseRef }}"
working-directory: src
- name: Check Diff

@ -14,7 +14,7 @@ on:
type: boolean
env:
dotnetVersion: "7.0.x"
dotnetVersion: 8.0.x
jobs:
build:

@ -13,7 +13,7 @@ on:
- "src/**"
env:
dotnetVersion: "7.0.x"
dotnetVersion: 8.0.x
jobs:
sonarcloud:

@ -6,11 +6,11 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/Recyclarr.Cli/bin/Debug/net7.0/recyclarr",
"program": "${workspaceFolder}/src/Recyclarr.Cli/bin/Debug/net8.0/recyclarr",
"args": [
"radarr"
],
"cwd": "${workspaceFolder}/src/Recyclarr.Cli/bin/Debug/net7.0/",
"cwd": "${workspaceFolder}/src/Recyclarr.Cli/bin/Debug/net8.0/",
"stopAtEntry": false,
"console": "internalConsole"
}

@ -24,7 +24,7 @@ that everyone should follow.
The following tools are required:
- .NET SDK 7.0 and tooling (e.g. `dotnet`)
- .NET SDK 8.0 and tooling (e.g. `dotnet`)
- Powershell v5.1 or greater
- Docker CLI (Docker Desktop on Windows)

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/runtime:7.0-alpine as base
FROM mcr.microsoft.com/dotnet/runtime:8.0-alpine as base
FROM base AS base-arm
ENV RUNTIME=linux-musl-arm

@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningLevel>9999</WarningLevel>
<ImplicitUsings>enable</ImplicitUsings>

@ -13,27 +13,26 @@
<PackageVersion Include="Flurl" Version="3.0.7" />
<PackageVersion Include="Flurl.Http" Version="4.0.0-pre5" />
<PackageVersion Include="GitVersion.MsBuild" Version="5.12.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2023.2.0" />
<PackageVersion Include="JorgeSerrano.Json.JsonSnakeCaseNamingPolicy" Version="0.9.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageVersion Include="MudBlazor" Version="6.11.0" />
<PackageVersion Include="ReactiveUI.Blazor" Version="19.5.1" />
<PackageVersion Include="Serilog" Version="3.0.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="7.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="3.4.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="4.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="Spectre.Console" Version="0.47.0" />
<PackageVersion Include="Spectre.Console.Analyzer" Version="0.47.0" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.47.0" />
<PackageVersion Include="SuperLinq" Version="5.2.0" />
<PackageVersion Include="SuperLinq" Version="5.3.0" />
<PackageVersion Include="System.Data.HashFunction.FNV" Version="2.0.0" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Reactive" Version="6.0.0" />
<PackageVersion Include="System.Text.Json" Version="7.0.3" />
<PackageVersion Include="System.Text.Json" Version="8.0.0" />
<PackageVersion Include="SystemTextJson.JsonDiffPatch" Version="1.3.1" />
<PackageVersion Include="TestableIO.System.IO.Abstractions" Version="19.2.69" />
<PackageVersion Include="TestableIO.System.IO.Abstractions" Version="19.2.87" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Extensions" Version="2.0.5" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.69" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.87" />
<PackageVersion Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<!-- Unit Test Packages -->
@ -47,16 +46,16 @@
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="FluentAssertions.Analyzers" Version="0.26.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.3.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="NSubstitute" Version="5.1.0" />
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.16" />
<PackageVersion Include="NUnit" Version="3.13.3" />
<PackageVersion Include="NUnit" Version="3.14.0" />
<PackageVersion Include="NUnit.Analyzers" Version="3.9.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageVersion Include="Serilog.Sinks.Observable" Version="2.0.2" />
<PackageVersion Include="Serilog.Sinks.NUnit" Version="1.0.3" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.47.0" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="19.2.69" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="19.2.87" />
</ItemGroup>
<!-- Following found during vulerabilities Code Scan -->
<ItemGroup>

@ -1,17 +1,3 @@
using System.Runtime.Serialization;
namespace Recyclarr.Cli.Cache;
[Serializable]
public class CacheException : Exception
{
public CacheException(string? message)
: base(message)
{
}
protected CacheException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
public class CacheException(string? message) : Exception(message);

@ -1,12 +1,7 @@
namespace Recyclarr.Cli.Cache;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CacheObjectNameAttribute : Attribute
public sealed class CacheObjectNameAttribute(string name) : Attribute
{
public CacheObjectNameAttribute(string name)
{
Name = name;
}
public string Name { get; }
public string Name { get; } = name;
}

@ -3,23 +3,14 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Cache;
public class CachePersister : ICachePersister
public class CachePersister(ILogger log, IServiceCache serviceCache) : ICachePersister
{
private readonly IServiceCache _cache;
private readonly ILogger _log;
public CachePersister(ILogger log, IServiceCache cache)
{
_log = log;
_cache = cache;
}
public CustomFormatCache Load(IServiceConfiguration config)
{
var cache = _cache.Load<CustomFormatCache>(config);
var cache = serviceCache.Load<CustomFormatCache>(config);
if (cache == null)
{
_log.Debug("Custom format cache does not exist; proceeding without it");
log.Debug("Custom format cache does not exist; proceeding without it");
return new CustomFormatCache();
}
@ -27,7 +18,7 @@ public class CachePersister : ICachePersister
// incompatibility that we do not support.
if (cache.Version != CustomFormatCache.LatestVersion)
{
_log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
cache.Version, CustomFormatCache.LatestVersion);
throw new CacheException("Version mismatch");
}
@ -37,8 +28,8 @@ public class CachePersister : ICachePersister
public void Save(IServiceConfiguration config, CustomFormatCache cache)
{
_log.Debug("Saving Cache with {Mappings}", JsonSerializer.Serialize(cache.TrashIdMappings));
log.Debug("Saving Cache with {Mappings}", JsonSerializer.Serialize(cache.TrashIdMappings));
_cache.Save(cache, config);
serviceCache.Save(cache, config);
}
}

@ -9,26 +9,17 @@ using Recyclarr.Json;
namespace Recyclarr.Cli.Cache;
public partial class ServiceCache : IServiceCache
public partial class ServiceCache(ICacheStoragePath storagePath, ILogger log) : IServiceCache
{
private readonly ICacheStoragePath _storagePath;
private readonly JsonSerializerOptions _jsonSettings;
private readonly ILogger _log;
public ServiceCache(ICacheStoragePath storagePath, ILogger log)
{
_storagePath = storagePath;
_log = log;
_jsonSettings = GlobalJsonSerializerSettings.Recyclarr;
}
private readonly JsonSerializerOptions _jsonSettings = GlobalJsonSerializerSettings.Recyclarr;
public T? Load<T>(IServiceConfiguration config) where T : class
{
var path = PathFromAttribute<T>(config);
_log.Debug("Loading cache from path: {Path}", path.FullName);
log.Debug("Loading cache from path: {Path}", path.FullName);
if (!path.Exists)
{
_log.Debug("Cache path does not exist");
log.Debug("Cache path does not exist");
return null;
}
@ -41,7 +32,7 @@ public partial class ServiceCache : IServiceCache
}
catch (JsonException e)
{
_log.Error("Failed to read cache data, will proceed without cache. Reason: {Msg}", e.Message);
log.Error("Failed to read cache data, will proceed without cache. Reason: {Msg}", e.Message);
}
return null;
@ -50,7 +41,7 @@ public partial class ServiceCache : IServiceCache
public void Save<T>(T obj, IServiceConfiguration config) where T : class
{
var path = PathFromAttribute<T>(config);
_log.Debug("Saving cache to path: {Path}", path.FullName);
log.Debug("Saving cache to path: {Path}", path.FullName);
path.CreateParentDirectory();
using var stream = path.Create();
@ -76,7 +67,7 @@ public partial class ServiceCache : IServiceCache
throw new ArgumentException($"Object name '{objectName}' has unacceptable characters");
}
return _storagePath.CalculatePath(config, objectName);
return storagePath.CalculatePath(config, objectName);
}
[GeneratedRegex(@"^[\w-]+$", RegexOptions.None, 1000)]

@ -1,9 +1,3 @@
namespace Recyclarr.Cli.Console;
public class CommandException : Exception
{
public CommandException(string? message)
: base(message)
{
}
}
public class CommandException(string? message) : Exception(message);

@ -10,12 +10,9 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("Create a starter configuration file.")]
public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
public class ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ConfigCreateCommand.CliSettings>
{
private readonly IConfigCreationProcessor _processor;
private readonly IMultiRepoUpdater _repoUpdater;
private readonly ILogger _log;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
[SuppressMessage("Performance", "CA1819:Properties should not return arrays",
@ -41,23 +38,16 @@ public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
public bool Force { get; init; }
}
public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor, IMultiRepoUpdater repoUpdater)
{
_processor = processor;
_repoUpdater = repoUpdater;
_log = log;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
try
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process(settings);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
processor.Process(settings);
}
catch (FileExistsException e)
{
_log.Error(
log.Error(
"The file {ConfigFile} already exists. Please choose another path or " +
"delete/move the existing file and run this command again", e.AttemptedPath);

@ -10,35 +10,23 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List local configuration files.")]
public class ConfigListLocalCommand : AsyncCommand<ConfigListLocalCommand.CliSettings>
public class ConfigListLocalCommand(ILogger log, ConfigListLocalProcessor processor, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ConfigListLocalCommand.CliSettings>
{
private readonly ILogger _log;
private readonly ConfigListLocalProcessor _processor;
private readonly IMultiRepoUpdater _repoUpdater;
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
{
}
public ConfigListLocalCommand(ILogger log, ConfigListLocalProcessor processor, IMultiRepoUpdater repoUpdater)
{
_log = log;
_processor = processor;
_repoUpdater = repoUpdater;
}
public class CliSettings : BaseCommandSettings;
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
try
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process();
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
processor.Process();
return 0;
}
catch (NoConfigurationFilesException)
{
_log.Error("No configuration files found");
log.Error("No configuration files found");
}
return 1;

@ -10,12 +10,12 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List local configuration files.")]
public class ConfigListTemplatesCommand : AsyncCommand<ConfigListTemplatesCommand.CliSettings>
public class ConfigListTemplatesCommand(
ILogger log,
ConfigListTemplateProcessor processor,
IMultiRepoUpdater repoUpdater)
: AsyncCommand<ConfigListTemplatesCommand.CliSettings>
{
private readonly ILogger _log;
private readonly ConfigListTemplateProcessor _processor;
private readonly IMultiRepoUpdater _repoUpdater;
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings, IConfigListTemplatesSettings
{
@ -26,24 +26,17 @@ public class ConfigListTemplatesCommand : AsyncCommand<ConfigListTemplatesComman
public bool Includes { get; init; }
}
public ConfigListTemplatesCommand(ILogger log, ConfigListTemplateProcessor processor, IMultiRepoUpdater repoUpdater)
{
_log = log;
_processor = processor;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
try
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process(settings);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
processor.Process(settings);
return 0;
}
catch (NoConfigurationFilesException)
{
_log.Error("No configuration files found");
log.Error("No configuration files found");
}
return 1;

@ -11,11 +11,11 @@ namespace Recyclarr.Cli.Console.Commands;
[Description("Delete things from services like Radarr & Sonarr")]
[UsedImplicitly]
public class DeleteCustomFormatsCommand : AsyncCommand<DeleteCustomFormatsCommand.CliSettings>
public class DeleteCustomFormatsCommand(
IDeleteCustomFormatsProcessor processor,
ConsoleExceptionHandler exceptionHandler)
: AsyncCommand<DeleteCustomFormatsCommand.CliSettings>
{
private readonly IDeleteCustomFormatsProcessor _processor;
private readonly ConsoleExceptionHandler _exceptionHandler;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
[SuppressMessage("Performance", "CA1819:Properties should not return arrays",
@ -44,24 +44,16 @@ public class DeleteCustomFormatsCommand : AsyncCommand<DeleteCustomFormatsComman
public bool Preview { get; init; } = false;
}
public DeleteCustomFormatsCommand(
IDeleteCustomFormatsProcessor processor,
ConsoleExceptionHandler exceptionHandler)
{
_processor = processor;
_exceptionHandler = exceptionHandler;
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
try
{
await _processor.Process(settings);
await processor.Process(settings);
}
catch (Exception e)
{
if (!await _exceptionHandler.HandleException(e))
if (!await exceptionHandler.HandleException(e))
{
// This means we didn't handle the exception; rethrow it.
throw;

@ -14,11 +14,9 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List custom formats in the guide for a particular service.")]
public class ListCustomFormatsCommand : AsyncCommand<ListCustomFormatsCommand.CliSettings>
public class ListCustomFormatsCommand(CustomFormatDataLister lister, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ListCustomFormatsCommand.CliSettings>
{
private readonly CustomFormatDataLister _lister;
private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings, IListCustomFormatSettings
@ -37,16 +35,10 @@ public class ListCustomFormatsCommand : AsyncCommand<ListCustomFormatsCommand.Cl
public bool Raw { get; init; } = false;
}
public ListCustomFormatsCommand(CustomFormatDataLister lister, IMultiRepoUpdater repoUpdater)
{
_lister = lister;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken, settings.Raw);
_lister.List(settings);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken, settings.Raw);
lister.List(settings);
return 0;
}
}

@ -11,11 +11,9 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List media naming formats in the guide for a particular service.")]
public class ListMediaNamingCommand : AsyncCommand<ListMediaNamingCommand.CliSettings>
public class ListMediaNamingCommand(MediaNamingDataLister lister, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ListMediaNamingCommand.CliSettings>
{
private readonly MediaNamingDataLister _lister;
private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
@ -26,16 +24,10 @@ public class ListMediaNamingCommand : AsyncCommand<ListMediaNamingCommand.CliSet
public SupportedServices Service { get; init; }
}
public ListMediaNamingCommand(MediaNamingDataLister lister, IMultiRepoUpdater repoUpdater)
{
_lister = lister;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_lister.ListNaming(settings.Service);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
lister.ListNaming(settings.Service);
return 0;
}
}

@ -12,11 +12,9 @@ namespace Recyclarr.Cli.Console.Commands;
#pragma warning disable CS8765
[UsedImplicitly]
[Description("List quality definitions in the guide for a particular service.")]
public class ListQualitiesCommand : AsyncCommand<ListQualitiesCommand.CliSettings>
public class ListQualitiesCommand(QualitySizeDataLister lister, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ListQualitiesCommand.CliSettings>
{
private readonly QualitySizeDataLister _lister;
private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
@ -27,16 +25,10 @@ public class ListQualitiesCommand : AsyncCommand<ListQualitiesCommand.CliSetting
public SupportedServices Service { get; init; }
}
public ListQualitiesCommand(QualitySizeDataLister lister, IMultiRepoUpdater repoUpdater)
{
_lister = lister;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_lister.ListQualities(settings.Service);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
lister.ListQualities(settings.Service);
return 0;
}
}

@ -11,12 +11,9 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List Sonarr release profiles in the guide for a particular service.")]
public class ListReleaseProfilesCommand : AsyncCommand<ListReleaseProfilesCommand.CliSettings>
public class ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ListReleaseProfilesCommand.CliSettings>
{
private readonly ILogger _log;
private readonly ReleaseProfileDataLister _lister;
private readonly IMultiRepoUpdater _repoUpdater;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
@ -29,32 +26,25 @@ public class ListReleaseProfilesCommand : AsyncCommand<ListReleaseProfilesComman
public string? ListTerms { get; init; }
}
public ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, IMultiRepoUpdater repoUpdater)
{
_log = log;
_lister = lister;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
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!);
lister.ListTerms(settings.ListTerms!);
}
else
{
_lister.ListReleaseProfiles();
lister.ListReleaseProfiles();
}
}
catch (ArgumentException e)
{
_log.Error(e, "Error");
log.Error(e, "Error");
return 1;
}

@ -12,31 +12,20 @@ namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("Perform migration steps that may be needed between versions")]
public class MigrateCommand : Command<MigrateCommand.CliSettings>
public class MigrateCommand(
IAnsiConsole console,
IMigrationExecutor migration) : 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 class CliSettings : ServiceCommandSettings;
public override int Execute(CommandContext context, CliSettings settings)
{
try
{
_migration.PerformAllMigrationSteps(settings.Debug);
_console.WriteLine("All migration steps completed");
migration.PerformAllMigrationSteps(settings.Debug);
console.WriteLine("All migration steps completed");
}
catch (MigrationException e)
{
@ -46,7 +35,7 @@ public class MigrateCommand : Command<MigrateCommand.CliSettings>
msg.AppendLine($"Failure Reason: {e.OriginalException.Message}");
// ReSharper disable once InvertIf
if (e.Remediation.Any())
if (e.Remediation.Count != 0)
{
msg.AppendLine("\nPossible remediation steps:");
foreach (var remedy in e.Remediation)
@ -55,12 +44,12 @@ public class MigrateCommand : Command<MigrateCommand.CliSettings>
}
}
_console.Write(msg.ToString());
console.Write(msg.ToString());
return 1;
}
catch (RequiredMigrationException ex)
{
_console.WriteLine($"ERROR: {ex.Message}");
console.WriteLine($"ERROR: {ex.Message}");
return 1;
}

@ -13,12 +13,9 @@ namespace Recyclarr.Cli.Console.Commands;
[Description("Sync the guide to services")]
[UsedImplicitly]
public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
public class SyncCommand(IMigrationExecutor migration, IMultiRepoUpdater repoUpdater, ISyncProcessor syncProcessor)
: AsyncCommand<SyncCommand.CliSettings>
{
private readonly IMigrationExecutor _migration;
private readonly IMultiRepoUpdater _repoUpdater;
private readonly ISyncProcessor _syncProcessor;
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
[SuppressMessage("Performance", "CA1819:Properties should not return arrays",
@ -48,21 +45,14 @@ public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
public IReadOnlyCollection<string> Instances => InstancesOption;
}
public SyncCommand(IMigrationExecutor migration, IMultiRepoUpdater repoUpdater, ISyncProcessor syncProcessor)
{
_migration = migration;
_repoUpdater = repoUpdater;
_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();
migration.CheckNeededMigrations();
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
return (int) await _syncProcessor.ProcessConfigs(settings);
return (int) await syncProcessor.ProcessConfigs(settings);
}
}

@ -3,36 +3,28 @@ using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Helpers;
internal class AutofacTypeRegistrar : ITypeRegistrar
internal class AutofacTypeRegistrar(ContainerBuilder builder, Action<ILifetimeScope> assignScope)
: 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();
builder.RegisterType(implementation).As(service).SingleInstance();
}
public void RegisterInstance(Type service, object implementation)
{
_builder.RegisterInstance(implementation).As(service);
builder.RegisterInstance(implementation).As(service);
}
public void RegisterLazy(Type service, Func<object> factory)
{
_builder.Register(_ => factory()).As(service).SingleInstance();
builder.Register(_ => factory()).As(service).SingleInstance();
}
public ITypeResolver Build()
{
var container = _builder.Build();
_assignScope(container);
var container = builder.Build();
assignScope(container);
return new AutofacTypeResolver(container);
}
}

@ -3,17 +3,10 @@ using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Helpers;
internal class AutofacTypeResolver : ITypeResolver
internal class AutofacTypeResolver(ILifetimeScope scope) : 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;
return type is not null ? scope.Resolve(type) : null;
}
}

@ -7,16 +7,9 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Console.Helpers;
public class CacheStoragePath : ICacheStoragePath
public class CacheStoragePath(IAppPaths paths) : ICacheStoragePath
{
private readonly IAppPaths _paths;
private readonly IFNV1a _hash;
public CacheStoragePath(IAppPaths paths)
{
_paths = paths;
_hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(64));
}
private readonly IFNV1a _hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(64));
private string BuildUniqueServiceDir(IServiceConfiguration config)
{
@ -26,7 +19,7 @@ public class CacheStoragePath : ICacheStoragePath
private IFileInfo CalculatePathInternal(IServiceConfiguration config, string cacheObjectName, string serviceDir)
{
return _paths.CacheDirectory
return paths.CacheDirectory
.SubDirectory(config.ServiceType.ToString().ToLower(CultureInfo.CurrentCulture))
.SubDirectory(serviceDir)
.File(cacheObjectName + ".json");

@ -2,26 +2,17 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Console.Setup;
public class AppPathSetupTask : IBaseCommandSetupTask
public class AppPathSetupTask(ILogger log, IAppPaths paths) : IBaseCommandSetupTask
{
private readonly ILogger _log;
private readonly IAppPaths _paths;
public AppPathSetupTask(ILogger log, IAppPaths paths)
{
_log = log;
_paths = paths;
}
public void OnStart()
{
_log.Debug("App Data Dir: {AppData}", _paths.AppDataDirectory);
log.Debug("App Data Dir: {AppData}", paths.AppDataDirectory);
// Initialize other directories used throughout the application
// Do not initialize the repo directory here; the GitRepositoryFactory handles that later.
_paths.CacheDirectory.Create();
_paths.LogDirectory.Create();
_paths.ConfigsDirectory.Create();
paths.CacheDirectory.Create();
paths.LogDirectory.Create();
paths.ConfigsDirectory.Create();
}
public void OnFinish()

@ -10,21 +10,14 @@ using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Setup;
public class CliInterceptor : ICommandInterceptor
public class CliInterceptor(LoggingLevelSwitch loggingLevelSwitch, AppDataPathProvider appDataPathProvider)
: ICommandInterceptor
{
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly AppDataPathProvider _appDataPathProvider;
private readonly Subject<Unit> _interceptedSubject = new();
private readonly ConsoleAppCancellationTokenSource _ct = 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)
@ -46,14 +39,14 @@ public class CliInterceptor : ICommandInterceptor
{
HandleBaseCommand(cmd);
_appDataPathProvider.AppDataPath = cmd.AppData;
appDataPathProvider.AppDataPath = cmd.AppData;
}
private void HandleBaseCommand(BaseCommandSettings cmd)
{
cmd.CancellationToken = _ct.Token;
_loggingLevelSwitch.MinimumLevel = cmd.Debug switch
loggingLevelSwitch.MinimumLevel = cmd.Debug switch
{
true => LogEventLevel.Debug,
_ => LogEventLevel.Information

@ -3,19 +3,9 @@ using Recyclarr.Settings;
namespace Recyclarr.Cli.Console.Setup;
public class JanitorCleanupTask : IBaseCommandSetupTask
public class JanitorCleanupTask(ILogJanitor janitor, ILogger log, ISettingsProvider settingsProvider)
: IBaseCommandSetupTask
{
private readonly ILogJanitor _janitor;
private readonly ILogger _log;
private readonly ISettingsProvider _settingsProvider;
public JanitorCleanupTask(ILogJanitor janitor, ILogger log, ISettingsProvider settingsProvider)
{
_janitor = janitor;
_log = log;
_settingsProvider = settingsProvider;
}
public void OnStart()
{
// No work to do for this event
@ -23,8 +13,8 @@ public class JanitorCleanupTask : IBaseCommandSetupTask
public void OnFinish()
{
var maxFiles = _settingsProvider.Settings.LogJanitor.MaxFiles;
_log.Debug("Cleaning up logs using max files of {MaxFiles}", maxFiles);
_janitor.DeleteOldestLogFiles(maxFiles);
var maxFiles = settingsProvider.Settings.LogJanitor.MaxFiles;
log.Debug("Cleaning up logs using max files of {MaxFiles}", maxFiles);
janitor.DeleteOldestLogFiles(maxFiles);
}
}

@ -2,18 +2,11 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Logging;
public class LogJanitor : ILogJanitor
public class LogJanitor(IAppPaths paths) : ILogJanitor
{
private readonly IAppPaths _paths;
public LogJanitor(IAppPaths paths)
{
_paths = paths;
}
public void DeleteOldestLogFiles(int numberOfNewestToKeep)
{
foreach (var file in _paths.LogDirectory.GetFiles()
foreach (var file in paths.LogDirectory.GetFiles()
.OrderByDescending(f => f.Name)
.Skip(numberOfNewestToKeep))
{

@ -8,17 +8,8 @@ using Serilog.Templates.Themes;
namespace Recyclarr.Cli.Logging;
public class LoggerFactory
public class LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
{
private readonly IAppPaths _paths;
private readonly LoggingLevelSwitch _levelSwitch;
public LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
{
_paths = paths;
_levelSwitch = levelSwitch;
}
private static string GetBaseTemplateString()
{
var scope = LogProperty.Scope;
@ -45,12 +36,12 @@ public class LoggerFactory
public ILogger Create()
{
var logFilePrefix = $"recyclarr_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}";
var logDir = _paths.LogDirectory;
var logDir = paths.LogDirectory;
return new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Verbose)
.Enrich.With<ExceptionMessageEnricher>()
.WriteTo.Console(GetConsoleTemplate(), levelSwitch: _levelSwitch)
.WriteTo.Console(GetConsoleTemplate(), levelSwitch: levelSwitch)
.WriteTo.Logger(c => c
.MinimumLevel.Debug()
.WriteTo.File(GetFileTemplate(), LogFilePath("debug")))

@ -1,18 +1,12 @@
namespace Recyclarr.Cli.Migration;
public class MigrationException : Exception
public class MigrationException(
Exception originalException,
string operationDescription,
IReadOnlyCollection<string> remediation)
: Exception
{
public MigrationException(
Exception originalException,
string operationDescription,
IReadOnlyCollection<string> remediation)
{
OperationDescription = operationDescription;
OriginalException = originalException;
Remediation = remediation;
}
public Exception OriginalException { get; }
public string OperationDescription { get; }
public IReadOnlyCollection<string> Remediation { get; }
public Exception OriginalException { get; } = originalException;
public string OperationDescription { get; } = operationDescription;
public IReadOnlyCollection<string> Remediation { get; } = remediation;
}

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

@ -7,15 +7,8 @@ using Spectre.Console;
namespace Recyclarr.Cli.Migration.Steps;
[UsedImplicitly]
public class DeleteRepoDirMigrationStep : IMigrationStep
public class DeleteRepoDirMigrationStep(IAppPaths paths) : IMigrationStep
{
private readonly IAppPaths _paths;
public DeleteRepoDirMigrationStep(IAppPaths paths)
{
_paths = paths;
}
public int Order => 1;
public string Description => "Delete old repo directory";
public IReadOnlyCollection<string> Remediation => new[]
@ -25,7 +18,7 @@ public class DeleteRepoDirMigrationStep : IMigrationStep
};
public bool Required => false;
private IDirectoryInfo RepoDir => _paths.AppDataDirectory.SubDir("repo");
private IDirectoryInfo RepoDir => paths.AppDataDirectory.SubDir("repo");
public bool CheckIfNeeded()
{

@ -5,17 +5,8 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.CustomFormat;
public class CustomFormatDataLister
public class CustomFormatDataLister(IAnsiConsole console, ICustomFormatGuideService guide)
{
private readonly IAnsiConsole _console;
private readonly ICustomFormatGuideService _guide;
public CustomFormatDataLister(IAnsiConsole console, ICustomFormatGuideService guide)
{
_console = console;
_guide = guide;
}
public void List(IListCustomFormatSettings settings)
{
switch (settings)
@ -34,21 +25,21 @@ public class CustomFormatDataLister
{
if (!raw)
{
_console.WriteLine(
console.WriteLine(
"\nThe following score sets are available. Use these with the `score_set` property in any " +
"quality profile defined under the top-level `quality_profiles` list.");
_console.WriteLine();
console.WriteLine();
}
var scoreSets = _guide.GetCustomFormatData(serviceType)
var scoreSets = guide.GetCustomFormatData(serviceType)
.SelectMany(x => x.TrashScores.Keys)
.Distinct(StringComparer.InvariantCultureIgnoreCase)
.Order(StringComparer.InvariantCultureIgnoreCase);
foreach (var set in scoreSets)
{
_console.WriteLine(raw ? set : $" - {set}");
console.WriteLine(raw ? set : $" - {set}");
}
}
@ -56,12 +47,12 @@ public class CustomFormatDataLister
{
if (!raw)
{
_console.WriteLine();
_console.WriteLine("List of Custom Formats in the TRaSH Guides:");
_console.WriteLine();
console.WriteLine();
console.WriteLine("List of Custom Formats in the TRaSH Guides:");
console.WriteLine();
}
var categories = _guide.GetCustomFormatData(serviceType)
var categories = guide.GetCustomFormatData(serviceType)
.OrderBy(x => x.Name)
.ToLookup(x => x.Category)
.OrderBy(x => x.Key);
@ -70,19 +61,19 @@ public class CustomFormatDataLister
{
var title = cat.Key is not null ? $"{cat.Key}" : "[No Category]";
_console.WriteLine($" # {title}");
console.WriteLine($" # {title}");
foreach (var cf in cat)
{
_console.WriteLine($" - {cf.TrashId} # {cf.Name}");
console.WriteLine($" - {cf.TrashId} # {cf.Name}");
}
_console.WriteLine();
console.WriteLine();
}
if (!raw)
{
_console.WriteLine(
console.WriteLine(
"The above Custom Formats are in YAML format and ready to be copied & pasted " +
"under the `trash_ids:` property.");
}

@ -27,49 +27,39 @@ public record CustomFormatTransactionData
public Collection<CustomFormatData> UnchangedCustomFormats { get; } = new();
}
public class CustomFormatSyncPipeline : ISyncPipeline
public class CustomFormatSyncPipeline(
ILogger log,
ICachePersister cachePersister,
ICustomFormatPipelinePhases phases)
: ISyncPipeline
{
private readonly ILogger _log;
private readonly ICachePersister _cachePersister;
private readonly ICustomFormatPipelinePhases _phases;
public CustomFormatSyncPipeline(
ILogger log,
ICachePersister cachePersister,
ICustomFormatPipelinePhases phases)
{
_log = log;
_cachePersister = cachePersister;
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
var cache = _cachePersister.Load(config);
var cache = cachePersister.Load(config);
var guideCfs = _phases.ConfigPhase.Execute(config);
var guideCfs = phases.ConfigPhase.Execute(config);
if (guideCfs.IsEmpty())
{
_log.Debug("No custom formats to process");
log.Debug("No custom formats to process");
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var serviceData = await phases.ApiFetchPhase.Execute(config);
cache = cache.RemoveStale(serviceData);
var transactions = _phases.TransactionPhase.Execute(config, guideCfs, serviceData, cache);
var transactions = phases.TransactionPhase.Execute(config, guideCfs, serviceData, cache);
_phases.PreviewPhase.Execute(transactions);
phases.PreviewPhase.Execute(transactions);
if (settings.Preview)
{
return;
}
await _phases.ApiPersistencePhase.Execute(config, transactions);
await phases.ApiPersistencePhase.Execute(config, transactions);
_cachePersister.Save(config, cache.Update(transactions) with
cachePersister.Save(config, cache.Update(transactions) with
{
InstanceName = config.InstanceName
});

@ -4,18 +4,11 @@ using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatApiFetchPhase
public class CustomFormatApiFetchPhase(ICustomFormatApiService api)
{
private readonly ICustomFormatApiService _api;
public CustomFormatApiFetchPhase(ICustomFormatApiService api)
{
_api = api;
}
public async Task<IReadOnlyCollection<CustomFormatData>> Execute(IServiceConfiguration config)
{
var result = await _api.GetCustomFormats(config);
var result = await api.GetCustomFormats(config);
return result.AsReadOnly();
}
}

@ -3,20 +3,13 @@ using Recyclarr.ServarrApi.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatApiPersistencePhase
public class CustomFormatApiPersistencePhase(ICustomFormatApiService api)
{
private readonly ICustomFormatApiService _api;
public CustomFormatApiPersistencePhase(ICustomFormatApiService api)
{
_api = api;
}
public async Task Execute(IServiceConfiguration config, CustomFormatTransactionData transactions)
{
foreach (var cf in transactions.NewCustomFormats)
{
var response = await _api.CreateCustomFormat(config, cf);
var response = await api.CreateCustomFormat(config, cf);
if (response is not null)
{
cf.Id = response.Id;
@ -25,12 +18,12 @@ public class CustomFormatApiPersistencePhase
foreach (var dto in transactions.UpdatedCustomFormats)
{
await _api.UpdateCustomFormat(config, dto);
await api.UpdateCustomFormat(config, dto);
}
foreach (var map in transactions.DeletedCustomFormats)
{
await _api.DeleteCustomFormat(config, map.CustomFormatId);
await api.DeleteCustomFormat(config, map.CustomFormatId);
}
}
}

@ -5,19 +5,8 @@ using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatConfigPhase
public class CustomFormatConfigPhase(ILogger log, ICustomFormatGuideService guide, ProcessedCustomFormatCache cache)
{
private readonly ILogger _log;
private readonly ICustomFormatGuideService _guide;
private readonly ProcessedCustomFormatCache _cache;
public CustomFormatConfigPhase(ILogger log, ICustomFormatGuideService guide, ProcessedCustomFormatCache cache)
{
_log = log;
_guide = guide;
_cache = cache;
}
public IReadOnlyCollection<CustomFormatData> Execute(IServiceConfiguration config)
{
// Match custom formats in the YAML config to those in the guide, by Trash ID
@ -30,7 +19,7 @@ public class CustomFormatConfigPhase
var processedCfs = config.CustomFormats
.SelectMany(x => x.TrashIds)
.Distinct(StringComparer.InvariantCultureIgnoreCase)
.GroupJoin(_guide.GetCustomFormatData(config.ServiceType),
.GroupJoin(guide.GetCustomFormatData(config.ServiceType),
x => x,
x => x.TrashId,
(id, cf) => (Id: id, CustomFormats: cf))
@ -39,11 +28,11 @@ public class CustomFormatConfigPhase
var invalidCfs = processedCfs[false].Select(x => x.Id).ToList();
if (invalidCfs.IsNotEmpty())
{
_log.Warning("These Custom Formats do not exist in the guide and will be skipped: {Cfs}", invalidCfs);
log.Warning("These Custom Formats do not exist in the guide and will be skipped: {Cfs}", invalidCfs);
}
var validCfs = processedCfs[true].SelectMany(x => x.CustomFormats).ToList();
_cache.AddCustomFormats(validCfs);
cache.AddCustomFormats(validCfs);
return validCfs;
}
}

@ -1,19 +1,12 @@
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatPreviewPhase
public class CustomFormatPreviewPhase(ILogger log)
{
private readonly ILogger _log;
public CustomFormatPreviewPhase(ILogger log)
{
_log = log;
}
public void Execute(CustomFormatTransactionData transactions)
{
foreach (var (guideCf, conflictingId) in transactions.ConflictingCustomFormats)
{
_log.Warning(
log.Warning(
"Custom Format with name {Name} (Trash ID: {TrashId}) will be skipped because another " +
"CF already exists with that name (ID: {ConflictId}). To fix the conflict, delete or " +
"rename the CF with the mentioned name",
@ -23,30 +16,30 @@ public class CustomFormatPreviewPhase
var created = transactions.NewCustomFormats;
if (created.Count > 0)
{
_log.Information("Created {Count} New Custom Formats", created.Count);
log.Information("Created {Count} New Custom Formats", created.Count);
foreach (var cf in created)
{
_log.Debug("> Created: {TrashId} ({Name})", cf.TrashId, cf.Name);
log.Debug("> Created: {TrashId} ({Name})", cf.TrashId, cf.Name);
}
}
var updated = transactions.UpdatedCustomFormats;
if (updated.Count > 0)
{
_log.Information("Updated {Count} Existing Custom Formats", updated.Count);
log.Information("Updated {Count} Existing Custom Formats", updated.Count);
foreach (var cf in updated)
{
_log.Debug("> Updated: {TrashId} ({Name})", cf.TrashId, cf.Name);
log.Debug("> Updated: {TrashId} ({Name})", cf.TrashId, cf.Name);
}
}
var skipped = transactions.UnchangedCustomFormats;
if (skipped.Count > 0)
{
_log.Information("Skipped {Count} Custom Formats that did not change", skipped.Count);
_log.Debug("Custom Formats Skipped: {CustomFormats}",
log.Information("Skipped {Count} Custom Formats that did not change", skipped.Count);
log.Debug("Custom Formats Skipped: {CustomFormats}",
skipped.ToDictionary(k => k.TrashId, v => v.Name));
// Do not print skipped CFs to console; they are too verbose
@ -55,22 +48,22 @@ public class CustomFormatPreviewPhase
var deleted = transactions.DeletedCustomFormats;
if (deleted.Count > 0)
{
_log.Information("Deleted {Count} Custom Formats", deleted.Count);
log.Information("Deleted {Count} Custom Formats", deleted.Count);
foreach (var mapping in deleted)
{
_log.Debug("> Deleted: {TrashId} ({CustomFormatName})", mapping.TrashId, mapping.CustomFormatName);
log.Debug("> Deleted: {TrashId} ({CustomFormatName})", mapping.TrashId, mapping.CustomFormatName);
}
}
var totalCount = created.Count + updated.Count + deleted.Count;
if (totalCount > 0)
{
_log.Information("Total of {Count} custom formats were synced", totalCount);
log.Information("Total of {Count} custom formats were synced", totalCount);
}
else
{
_log.Information("All custom formats are already up to date!");
log.Information("All custom formats are already up to date!");
}
}
}

@ -7,15 +7,8 @@ using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatTransactionPhase
public class CustomFormatTransactionPhase(ILogger log)
{
private readonly ILogger _log;
public CustomFormatTransactionPhase(ILogger log)
{
_log = log;
}
[SuppressMessage("Performance", "CA1822:Mark members as static")]
public CustomFormatTransactionData Execute(
IServiceConfiguration config,
@ -27,7 +20,7 @@ public class CustomFormatTransactionPhase
foreach (var guideCf in guideCfs)
{
_log.Debug("Process transaction for guide CF {TrashId} ({Name})", guideCf.TrashId, guideCf.Name);
log.Debug("Process transaction for guide CF {TrashId} ({Name})", guideCf.TrashId, guideCf.Name);
guideCf.Id = cache.FindId(guideCf) ?? 0;
@ -76,7 +69,7 @@ public class CustomFormatTransactionPhase
// - Use the ID from the service, not the cache, and do an update
if (guideCf.Id != serviceCf.Id)
{
_log.Debug(
log.Debug(
"Format IDs for CF {Name} did not match which indicates a manually-created CF is " +
"replaced, or that the cache is out of sync with the service ({GuideId} != {ServiceId})",
serviceCf.Name, guideCf.Id, serviceCf.Id);

@ -2,33 +2,23 @@ using System.Diagnostics.CodeAnalysis;
using Recyclarr.Common;
using Recyclarr.TrashGuide.MediaNaming;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Cli.Pipelines.MediaNaming;
public class MediaNamingDataLister
public class MediaNamingDataLister(
IAnsiConsole console,
IMediaNamingGuideService guide)
{
private readonly IAnsiConsole _console;
private readonly IMediaNamingGuideService _guide;
public MediaNamingDataLister(
IAnsiConsole console,
IMediaNamingGuideService guide)
{
_console = console;
_guide = guide;
}
public void ListNaming(SupportedServices serviceType)
{
switch (serviceType)
{
case SupportedServices.Radarr:
ListRadarrNaming(_guide.GetRadarrNamingData());
ListRadarrNaming(guide.GetRadarrNamingData());
break;
case SupportedServices.Sonarr:
ListSonarrNaming(_guide.GetSonarrNamingData());
ListSonarrNaming(guide.GetSonarrNamingData());
break;
default:
@ -38,31 +28,31 @@ public class MediaNamingDataLister
private void ListRadarrNaming(RadarrMediaNamingData guideData)
{
_console.MarkupLine("Media Naming Formats [red](Preview)[/]");
console.MarkupLine("Media Naming Formats [red](Preview)[/]");
_console.WriteLine();
_console.Write(DictionaryToTableRadarr("Movie Folder Format", guideData.Folder));
_console.WriteLine();
_console.Write(DictionaryToTableRadarr("Standard Movie Format", guideData.File));
console.WriteLine();
console.Write(DictionaryToTableRadarr("Movie Folder Format", guideData.Folder));
console.WriteLine();
console.Write(DictionaryToTableRadarr("Standard Movie Format", guideData.File));
}
private void ListSonarrNaming(SonarrMediaNamingData guideData)
{
_console.MarkupLine("Media Naming Formats [red](Preview)[/]");
_console.WriteLine();
_console.Write(DictionaryToTableSonarr("Season Folder Format", guideData.Season));
_console.WriteLine();
_console.Write(DictionaryToTableSonarr("Series Folder Format", guideData.Series));
_console.WriteLine();
_console.Write(DictionaryToTableSonarr("Standard Episode Format", guideData.Episodes.Standard));
_console.WriteLine();
_console.Write(DictionaryToTableSonarr("Daily Episode Format", guideData.Episodes.Daily));
_console.WriteLine();
_console.Write(DictionaryToTableSonarr("Anime Episode Format", guideData.Episodes.Anime));
console.MarkupLine("Media Naming Formats [red](Preview)[/]");
console.WriteLine();
console.Write(DictionaryToTableSonarr("Season Folder Format", guideData.Season));
console.WriteLine();
console.Write(DictionaryToTableSonarr("Series Folder Format", guideData.Series));
console.WriteLine();
console.Write(DictionaryToTableSonarr("Standard Episode Format", guideData.Episodes.Standard));
console.WriteLine();
console.Write(DictionaryToTableSonarr("Daily Episode Format", guideData.Episodes.Daily));
console.WriteLine();
console.Write(DictionaryToTableSonarr("Anime Episode Format", guideData.Episodes.Anime));
}
private static IRenderable DictionaryToTableRadarr(string title, IReadOnlyDictionary<string, string> formats)
private static Rows DictionaryToTableRadarr(string title, IReadOnlyDictionary<string, string> formats)
{
var table = new Table()
.AddColumns("Key", "Format");
@ -82,7 +72,7 @@ public class MediaNamingDataLister
return new Rows(Markup.FromInterpolated($"[orange3]{title}[/]"), table);
}
private static IRenderable DictionaryToTableSonarr(string title, IReadOnlyDictionary<string, string> formats)
private static Rows DictionaryToTableSonarr(string title, IReadOnlyDictionary<string, string> formats)
{
var table = new Table()
.AddColumns("Key", "Sonarr Version", "Format");

@ -14,34 +14,27 @@ public interface IMediaNamingPipelinePhases
MediaNamingApiPersistencePhase ApiPersistencePhase { get; }
}
public class MediaNamingSyncPipeline : ISyncPipeline
public class MediaNamingSyncPipeline(IMediaNamingPipelinePhases phases) : ISyncPipeline
{
private readonly IMediaNamingPipelinePhases _phases;
public MediaNamingSyncPipeline(IMediaNamingPipelinePhases phases)
{
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
var processedNaming = await _phases.ConfigPhase.Execute(config);
if (_phases.Logger.LogConfigPhaseAndExitIfNeeded(processedNaming))
var processedNaming = await phases.ConfigPhase.Execute(config);
if (phases.Logger.LogConfigPhaseAndExitIfNeeded(processedNaming))
{
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(serviceData, processedNaming);
var transactions = phases.TransactionPhase.Execute(serviceData, processedNaming);
if (settings.Preview)
{
_phases.PreviewPhase.Execute(transactions);
phases.PreviewPhase.Execute(transactions);
return;
}
await _phases.ApiPersistencePhase.Execute(config, transactions);
_phases.Logger.LogPersistenceResults(serviceData, transactions);
await phases.ApiPersistencePhase.Execute(config, transactions);
phases.Logger.LogPersistenceResults(serviceData, transactions);
}
}

@ -3,17 +3,10 @@ using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingApiFetchPhase
public class MediaNamingApiFetchPhase(IMediaNamingApiService api)
{
private readonly IMediaNamingApiService _api;
public MediaNamingApiFetchPhase(IMediaNamingApiService api)
{
_api = api;
}
public async Task<MediaNamingDto> Execute(IServiceConfiguration config)
{
return await _api.GetNaming(config);
return await api.GetNaming(config);
}
}

@ -3,17 +3,10 @@ using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingApiPersistencePhase
public class MediaNamingApiPersistencePhase(IMediaNamingApiService api)
{
private readonly IMediaNamingApiService _api;
public MediaNamingApiPersistencePhase(IMediaNamingApiService api)
{
_api = api;
}
public async Task Execute(IServiceConfiguration config, MediaNamingDto serviceDto)
{
await _api.UpdateNaming(config, serviceDto);
await api.UpdateNaming(config, serviceDto);
}
}

@ -13,18 +13,10 @@ public record ProcessedNamingConfig
public IReadOnlyCollection<InvalidNamingConfig> InvalidNaming { get; init; } = new List<InvalidNamingConfig>();
}
public class MediaNamingConfigPhase
public class MediaNamingConfigPhase(IMediaNamingGuideService guide, ISonarrCapabilityFetcher sonarrCapabilities)
{
private readonly IMediaNamingGuideService _guide;
private readonly ISonarrCapabilityFetcher _sonarrCapabilities;
private List<InvalidNamingConfig> _errors = new();
public MediaNamingConfigPhase(IMediaNamingGuideService guide, ISonarrCapabilityFetcher sonarrCapabilities)
{
_guide = guide;
_sonarrCapabilities = sonarrCapabilities;
}
public async Task<ProcessedNamingConfig> Execute(IServiceConfiguration config)
{
_errors = new List<InvalidNamingConfig>();
@ -39,9 +31,9 @@ public class MediaNamingConfigPhase
return new ProcessedNamingConfig {Dto = dto, InvalidNaming = _errors};
}
private MediaNamingDto ProcessRadarrNaming(RadarrConfiguration config)
private RadarrMediaNamingDto ProcessRadarrNaming(RadarrConfiguration config)
{
var guideData = _guide.GetRadarrNamingData();
var guideData = guide.GetRadarrNamingData();
var configData = config.MediaNaming;
return new RadarrMediaNamingDto
@ -54,9 +46,9 @@ public class MediaNamingConfigPhase
private async Task<MediaNamingDto> ProcessSonarrNaming(SonarrConfiguration config)
{
var guideData = _guide.GetSonarrNamingData();
var guideData = guide.GetSonarrNamingData();
var configData = config.MediaNaming;
var capabilities = await _sonarrCapabilities.GetCapabilities(config);
var capabilities = await sonarrCapabilities.GetCapabilities(config);
var keySuffix = capabilities.SupportsCustomFormats ? ":4" : ":3";
return new SonarrMediaNamingDto

@ -2,23 +2,16 @@ using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingPhaseLogger
public class MediaNamingPhaseLogger(ILogger log)
{
private readonly ILogger _log;
public MediaNamingPhaseLogger(ILogger log)
{
_log = log;
}
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(ProcessedNamingConfig config)
{
if (config.InvalidNaming.Any())
if (config.InvalidNaming.Count != 0)
{
foreach (var (topic, invalidValue) in config.InvalidNaming)
{
_log.Error("An invalid media naming format is specified for {Topic}: {Value}", topic, invalidValue);
log.Error("An invalid media naming format is specified for {Topic}: {Value}", topic, invalidValue);
}
return true;
@ -31,9 +24,9 @@ public class MediaNamingPhaseLogger
_ => throw new ArgumentException("Unsupported configuration type in LogConfigPhase method")
};
if (!differences.Any())
if (differences.Count == 0)
{
_log.Debug("No media naming changes to process");
log.Debug("No media naming changes to process");
return true;
}
@ -49,14 +42,14 @@ public class MediaNamingPhaseLogger
_ => throw new ArgumentException("Unsupported configuration type in LogPersistenceResults method")
};
if (differences.Any())
if (differences.Count != 0)
{
_log.Information("Media naming has been updated");
_log.Debug("Naming differences: {Diff}", differences);
log.Information("Media naming has been updated");
log.Debug("Naming differences: {Diff}", differences);
}
else
{
_log.Information("Media naming is up to date!");
log.Information("Media naming is up to date!");
}
}
}

@ -3,16 +3,10 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingPreviewPhase
public class MediaNamingPreviewPhase(IAnsiConsole console)
{
private readonly IAnsiConsole _console;
private Table? _table;
public MediaNamingPreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(MediaNamingDto serviceDto)
{
_table = new Table()
@ -33,8 +27,8 @@ public class MediaNamingPreviewPhase
throw new ArgumentException("Config type not supported in media naming preview");
}
_console.WriteLine();
_console.Write(_table);
console.WriteLine();
console.Write(_table);
}
private void AddRow(string field, object? value)

@ -5,19 +5,12 @@ namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record QualityProfileServiceData(IReadOnlyList<QualityProfileDto> Profiles, QualityProfileDto Schema);
public class QualityProfileApiFetchPhase
public class QualityProfileApiFetchPhase(IQualityProfileApiService api)
{
private readonly IQualityProfileApiService _api;
public QualityProfileApiFetchPhase(IQualityProfileApiService api)
{
_api = api;
}
public async Task<QualityProfileServiceData> Execute(IServiceConfiguration config)
{
var profiles = await _api.GetQualityProfiles(config);
var schema = await _api.GetSchema(config);
var profiles = await api.GetQualityProfiles(config);
var schema = await api.GetSchema(config);
return new QualityProfileServiceData(profiles.AsReadOnly(), schema);
}
}

@ -3,33 +3,22 @@ using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileApiPersistencePhase
public class QualityProfileApiPersistencePhase(
ILogger log,
IQualityProfileApiService api,
QualityProfileStatCalculator statCalculator)
{
private readonly ILogger _log;
private readonly IQualityProfileApiService _api;
private readonly QualityProfileStatCalculator _statCalculator;
public QualityProfileApiPersistencePhase(
ILogger log,
IQualityProfileApiService api,
QualityProfileStatCalculator statCalculator)
{
_log = log;
_api = api;
_statCalculator = statCalculator;
}
public async Task Execute(IServiceConfiguration config, QualityProfileTransactionData transactions)
{
var profilesWithStats = transactions.UpdatedProfiles
.Select(x => _statCalculator.Calculate(x))
.Select(x => statCalculator.Calculate(x))
.ToLookup(x => x.HasChanges);
// Profiles without changes (false) get logged
var unchangedProfiles = profilesWithStats[false].ToList();
if (unchangedProfiles.Any())
if (unchangedProfiles.Count != 0)
{
_log.Debug("These profiles have no changes and will not be persisted: {Profiles}",
log.Debug("These profiles have no changes and will not be persisted: {Profiles}",
unchangedProfiles.Select(x => x.Profile.ProfileName));
}
@ -42,11 +31,11 @@ public class QualityProfileApiPersistencePhase
switch (profile.UpdateReason)
{
case QualityProfileUpdateReason.New:
await _api.CreateQualityProfile(config, dto);
await api.CreateQualityProfile(config, dto);
break;
case QualityProfileUpdateReason.Changed:
await _api.UpdateQualityProfile(config, dto);
await api.UpdateQualityProfile(config, dto);
break;
default:
@ -66,7 +55,7 @@ public class QualityProfileApiPersistencePhase
if (createdProfiles.Count > 0)
{
_log.Information("Created {Count} Profiles: {Names}", createdProfiles.Count, createdProfiles);
log.Information("Created {Count} Profiles: {Names}", createdProfiles.Count, createdProfiles);
}
var updatedProfiles = changedProfiles
@ -76,7 +65,7 @@ public class QualityProfileApiPersistencePhase
if (updatedProfiles.Count > 0)
{
_log.Information("Updated {Count} Profiles: {Names}", updatedProfiles.Count, updatedProfiles);
log.Information("Updated {Count} Profiles: {Names}", updatedProfiles.Count, updatedProfiles);
}
if (changedProfiles.Count != 0)
@ -85,14 +74,14 @@ public class QualityProfileApiPersistencePhase
var numQuality = changedProfiles.Count(x => x.QualitiesChanged);
var numScores = changedProfiles.Count(x => x.ScoresChanged);
_log.Information(
log.Information(
"A total of {NumProfiles} profiles were synced. {NumQuality} contain quality changes and " +
"{NumScores} contain updated scores",
numProfiles, numQuality, numScores);
}
else
{
_log.Information("All quality profiles are up to date!");
log.Information("All quality profiles are up to date!");
}
}
}

@ -15,17 +15,8 @@ public record ProcessedQualityProfileData
public IList<CustomFormatData> ScorelessCfs { get; } = new List<CustomFormatData>();
}
public class QualityProfileConfigPhase
public class QualityProfileConfigPhase(ILogger log, ProcessedCustomFormatCache cache)
{
private readonly ILogger _log;
private readonly ProcessedCustomFormatCache _cache;
public QualityProfileConfigPhase(ILogger log, ProcessedCustomFormatCache cache)
{
_log = log;
_cache = cache;
}
public IReadOnlyCollection<ProcessedQualityProfileData> Execute(IServiceConfiguration config)
{
// 1. For each group of CFs that has a quality profile specified
@ -35,7 +26,7 @@ public class QualityProfileConfigPhase
.SelectMany(x => x.QualityProfiles
.Select(y => (Profile: y, x.TrashIds)))
.SelectMany(x => x.TrashIds
.Select(_cache.LookupByTrashId)
.Select(cache.LookupByTrashId)
.NotNull()
.Select(y => (x.Profile, Cf: y)));
@ -47,7 +38,7 @@ public class QualityProfileConfigPhase
{
if (!allProfiles.TryGetValue(profile.Name, out var profileCfs))
{
_log.Debug("Implicitly adding quality profile config for {ProfileName}", profile.Name);
log.Debug("Implicitly adding quality profile config for {ProfileName}", profile.Name);
// If the user did not specify a quality profile in their config, we still create the QP object
// for consistency (at the very least for the name).
@ -76,14 +67,14 @@ public class QualityProfileConfigPhase
.Select(x => (x.Name, x.TrashId))
.ToList();
if (!scoreless.Any())
if (scoreless.Count == 0)
{
return;
}
foreach (var (name, trashId) in scoreless)
{
_log.Debug("CF has no score in the guide or config YAML: {Name} ({TrashId})", name, trashId);
log.Debug("CF has no score in the guide or config YAML: {Name} ({TrashId})", name, trashId);
}
}
@ -106,14 +97,14 @@ public class QualityProfileConfigPhase
{
if (existingScore.Score != scoreToUse)
{
_log.Warning(
log.Warning(
"Custom format {Name} ({TrashId}) is duplicated in quality profile {ProfileName} with a score " +
"of {NewScore}, which is different from the original score of {OriginalScore}",
cf.Name, cf.TrashId, scoreConfig.Name, scoreToUse, existingScore.Score);
}
else
{
_log.Debug("Skipping duplicate score for {Name} ({TrashId})", cf.Name, cf.TrashId);
log.Debug("Skipping duplicate score for {Name} ({TrashId})", cf.Name, cf.TrashId);
}
return;
@ -139,7 +130,7 @@ public class QualityProfileConfigPhase
return scoreFromSet;
}
_log.Debug("CF {CfName} has no Score Set with name '{ScoreSetName}'", cf.Name, profile.ScoreSet);
log.Debug("CF {CfName} has no Score Set with name '{ScoreSetName}'", cf.Name, profile.ScoreSet);
}
return cf.DefaultScore;

@ -4,29 +4,22 @@ using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
[UsedImplicitly]
public class QualityProfileNoticePhase
public class QualityProfileNoticePhase(ILogger log)
{
private readonly ILogger _log;
public QualityProfileNoticePhase(ILogger log)
{
_log = log;
}
public void Execute(QualityProfileTransactionData transactions)
{
if (transactions.NonExistentProfiles.Count > 0)
{
_log.Warning(
log.Warning(
"The following quality profile names have no definition in the top-level `quality_profiles` " +
"list *and* do not exist in the remote service. Either create them manually in the service *or* add " +
"them to the top-level `quality_profiles` section so that Recyclarr can create the profiles for you");
_log.Warning("{QualityProfileNames}", transactions.NonExistentProfiles);
log.Warning("{QualityProfileNames}", transactions.NonExistentProfiles);
}
if (transactions.InvalidProfiles.Count > 0)
{
_log.Warning(
log.Warning(
"The following validation errors occurred for one or more quality profiles. " +
"These profiles will *not* be synced");
@ -34,33 +27,33 @@ public class QualityProfileNoticePhase
foreach (var (profile, errors) in transactions.InvalidProfiles)
{
numErrors += errors.LogValidationErrors(_log, $"Profile '{profile.ProfileName}'");
numErrors += errors.LogValidationErrors(log, $"Profile '{profile.ProfileName}'");
}
if (numErrors > 0)
{
_log.Error("Profile validation failed with {Count} errors", numErrors);
log.Error("Profile validation failed with {Count} errors", numErrors);
}
}
var invalidQualityNames = transactions.UpdatedProfiles
.Select(x => (x.ProfileName, x.UpdatedQualities.InvalidQualityNames))
.Where(x => x.InvalidQualityNames.Any())
.Where(x => x.InvalidQualityNames.Count != 0)
.ToList();
foreach (var (profileName, invalidNames) in invalidQualityNames)
{
_log.Warning("Quality profile '{ProfileName}' references invalid quality names: {InvalidNames}",
log.Warning("Quality profile '{ProfileName}' references invalid quality names: {InvalidNames}",
profileName, invalidNames);
}
var invalidCfExceptNames = transactions.UpdatedProfiles
.Where(x => x.InvalidExceptCfNames.Any())
.Where(x => x.InvalidExceptCfNames.Count != 0)
.Select(x => (x.ProfileName, x.InvalidExceptCfNames));
foreach (var (profileName, invalidNames) in invalidCfExceptNames)
{
_log.Warning(
log.Warning(
"`except` under `reset_unmatched_scores` in quality profile '{ProfileName}' has invalid " +
"CF names: {CfNames}", profileName, invalidNames);
}

@ -4,15 +4,8 @@ using Spectre.Console.Rendering;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfilePreviewPhase
public class QualityProfilePreviewPhase(IAnsiConsole console)
{
private readonly IAnsiConsole _console;
public QualityProfilePreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(QualityProfileTransactionData transactions)
{
var tree = new Tree("Quality Profile Changes [red](Preview)[/]");
@ -26,7 +19,7 @@ public class QualityProfilePreviewPhase
new Markup("[b]Profile Updates[/]"),
SetupProfileTable(profile)));
if (profile.ProfileConfig.Profile.Qualities.Any())
if (profile.ProfileConfig.Profile.Qualities.Count != 0)
{
profileTree.AddNode(SetupQualityItemTable(profile));
}
@ -38,9 +31,9 @@ public class QualityProfilePreviewPhase
tree.AddNode(profileTree);
}
_console.WriteLine();
_console.Write(tree);
_console.WriteLine();
console.WriteLine();
console.Write(tree);
console.WriteLine();
}
private static Table SetupProfileTable(UpdatedQualityProfile profile)
@ -75,7 +68,7 @@ public class QualityProfilePreviewPhase
static string Null<T>(T? val) => val is null ? "<unset>" : val.ToString() ?? "<invalid>";
}
private static IRenderable SetupQualityItemTable(UpdatedQualityProfile profile)
private static Rows SetupQualityItemTable(UpdatedQualityProfile profile)
{
static IRenderable BuildName(ProfileItemDto item)
{
@ -128,7 +121,7 @@ public class QualityProfilePreviewPhase
.Where(x => x.Reason != FormatScoreUpdateReason.NoChange && x.Dto.Score != x.NewScore)
.ToList();
if (!updatedScores.Any())
if (updatedScores.Count == 0)
{
return new Markup("[hotpink]No score changes[/]");
}

@ -14,18 +14,11 @@ public record ProfileWithStats
public bool HasChanges => ProfileChanged || ScoresChanged || QualitiesChanged;
}
public class QualityProfileStatCalculator
public class QualityProfileStatCalculator(ILogger log)
{
private readonly ILogger _log;
public QualityProfileStatCalculator(ILogger log)
{
_log = log;
}
public ProfileWithStats Calculate(UpdatedQualityProfile profile)
{
_log.Debug("Updates for profile {ProfileName}", profile.ProfileName);
log.Debug("Updates for profile {ProfileName}", profile.ProfileName);
var stats = new ProfileWithStats {Profile = profile};
var oldDto = profile.ProfileDto;
@ -49,7 +42,7 @@ public class QualityProfileStatCalculator
void Log<T>(string msg, T oldValue, T newValue)
{
_log.Debug("{Msg}: {Old} -> {New}", msg, oldValue, newValue);
log.Debug("{Msg}: {Old} -> {New}", msg, oldValue, newValue);
stats.ProfileChanged |= !EqualityComparer<T>.Default.Equals(oldValue, newValue);
}
}
@ -75,11 +68,11 @@ public class QualityProfileStatCalculator
return;
}
_log.Debug("> Scores updated for quality profile: {ProfileName}", profileDto.Name);
log.Debug("> Scores updated for quality profile: {ProfileName}", profileDto.Name);
foreach (var (dto, newScore, reason) in scores)
{
_log.Debug(" - {Format} ({Id}): {OldScore} -> {NewScore} ({Reason})",
log.Debug(" - {Format} ({Id}): {OldScore} -> {NewScore} ({Reason})",
dto.Name, dto.Format, dto.Score, newScore, reason);
}

@ -105,7 +105,7 @@ public class QualityProfileTransactionPhase
QualityProfileDto profileDto)
{
var except = resetConfig.Except;
if (!except.Any())
if (except.Count == 0)
{
return Array.Empty<string>();
}

@ -14,37 +14,28 @@ public interface IQualityProfilePipelinePhases
QualityProfileNoticePhase NoticePhase { get; }
}
public class QualityProfileSyncPipeline : ISyncPipeline
public class QualityProfileSyncPipeline(ILogger log, IQualityProfilePipelinePhases phases) : ISyncPipeline
{
private readonly ILogger _log;
private readonly IQualityProfilePipelinePhases _phases;
public QualityProfileSyncPipeline(ILogger log, IQualityProfilePipelinePhases phases)
{
_log = log;
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
var guideData = _phases.ConfigPhase.Execute(config);
if (!guideData.Any())
var guideData = phases.ConfigPhase.Execute(config);
if (guideData.Count == 0)
{
_log.Debug("No quality profiles to process");
log.Debug("No quality profiles to process");
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(guideData, serviceData);
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = phases.TransactionPhase.Execute(guideData, serviceData);
_phases.NoticePhase.Execute(transactions);
phases.NoticePhase.Execute(transactions);
if (settings.Preview)
{
_phases.PreviewPhase.Value.Execute(transactions);
phases.PreviewPhase.Value.Execute(transactions);
return;
}
await _phases.ApiPersistencePhase.Execute(config, transactions);
await phases.ApiPersistencePhase.Execute(config, transactions);
}
}

@ -3,17 +3,10 @@ using Recyclarr.ServarrApi.QualityDefinition;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeApiFetchPhase
public class QualitySizeApiFetchPhase(IQualityDefinitionApiService api)
{
private readonly IQualityDefinitionApiService _api;
public QualitySizeApiFetchPhase(IQualityDefinitionApiService api)
{
_api = api;
}
public async Task<IList<ServiceQualityDefinitionItem>> Execute(IServiceConfiguration config)
{
return await _api.GetQualityDefinition(config);
return await api.GetQualityDefinition(config);
}
}

@ -3,20 +3,11 @@ using Recyclarr.ServarrApi.QualityDefinition;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeApiPersistencePhase
public class QualitySizeApiPersistencePhase(ILogger log, IQualityDefinitionApiService api)
{
private readonly ILogger _log;
private readonly IQualityDefinitionApiService _api;
public QualitySizeApiPersistencePhase(ILogger log, IQualityDefinitionApiService api)
{
_log = log;
_api = api;
}
public async Task Execute(IServiceConfiguration config, IList<ServiceQualityDefinitionItem> serverQuality)
{
await _api.UpdateQualityDefinition(config, serverQuality);
_log.Information("Number of updated qualities: {Count}", serverQuality.Count);
await api.UpdateQualityDefinition(config, serverQuality);
log.Information("Number of updated qualities: {Count}", serverQuality.Count);
}
}

@ -4,37 +4,28 @@ using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeGuidePhase
public class QualitySizeGuidePhase(ILogger log, IQualitySizeGuideService guide)
{
private readonly ILogger _log;
private readonly IQualitySizeGuideService _guide;
public QualitySizeGuidePhase(ILogger log, IQualitySizeGuideService guide)
{
_log = log;
_guide = guide;
}
public QualitySizeData? Execute(IServiceConfiguration config)
{
var qualityDef = config.QualityDefinition;
if (qualityDef is null)
{
_log.Debug("{Instance} has no quality definition", config.InstanceName);
log.Debug("{Instance} has no quality definition", config.InstanceName);
return null;
}
var qualityDefinitions = _guide.GetQualitySizeData(config.ServiceType);
var qualityDefinitions = guide.GetQualitySizeData(config.ServiceType);
var selectedQuality = qualityDefinitions
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityDef.Type));
if (selectedQuality == null)
{
_log.Error("The specified quality definition type does not exist: {Type}", qualityDef.Type);
log.Error("The specified quality definition type does not exist: {Type}", qualityDef.Type);
return null;
}
_log.Information("Processing Quality Definition: {QualityDefinition}", qualityDef.Type);
log.Information("Processing Quality Definition: {QualityDefinition}", qualityDef.Type);
AdjustPreferredRatio(qualityDef, selectedQuality);
return selectedQuality;
}
@ -46,13 +37,13 @@ public class QualitySizeGuidePhase
return;
}
_log.Information("Using an explicit preferred ratio which will override values from the guide");
log.Information("Using an explicit preferred ratio which will override values from the guide");
// Fix an out of range ratio and warn the user
if (config.PreferredRatio is < 0 or > 1)
{
var clampedRatio = Math.Clamp(config.PreferredRatio.Value, 0, 1);
_log.Warning("Your `preferred_ratio` of {CurrentRatio} is out of range. " +
log.Warning("Your `preferred_ratio` of {CurrentRatio} is out of range. " +
"It must be a decimal between 0.0 and 1.0. It has been clamped to {ClampedRatio}",
config.PreferredRatio, clampedRatio);

@ -3,15 +3,8 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizePreviewPhase
public class QualitySizePreviewPhase(IAnsiConsole console)
{
private readonly IAnsiConsole _console;
public QualitySizePreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(QualitySizeData selectedQuality)
{
var table = new Table();
@ -28,7 +21,7 @@ public class QualitySizePreviewPhase
table.AddRow(quality, q.AnnotatedMin, q.AnnotatedMax, q.AnnotatedPreferred);
}
_console.WriteLine();
_console.Write(table);
console.WriteLine();
console.Write(table);
}
}

@ -4,15 +4,8 @@ using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeTransactionPhase
public class QualitySizeTransactionPhase(ILogger log)
{
private readonly ILogger _log;
public QualitySizeTransactionPhase(ILogger log)
{
_log = log;
}
public Collection<ServiceQualityDefinitionItem> Execute(
IEnumerable<QualitySizeItem> guideQuality,
IList<ServiceQualityDefinitionItem> serverQuality)
@ -23,7 +16,7 @@ public class QualitySizeTransactionPhase
var serverEntry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Quality);
if (serverEntry == null)
{
_log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
continue;
}
@ -38,7 +31,7 @@ public class QualitySizeTransactionPhase
serverEntry.PreferredSize = qualityData.PreferredForApi;
newQuality.Add(serverEntry);
_log.Debug("Setting Quality " +
log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}] [Preferred: {Preferred}]",
serverEntry.Quality?.Name, serverEntry.Quality?.Source, serverEntry.MinSize, serverEntry.MaxSize,
serverEntry.PreferredSize);

@ -4,28 +4,19 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.QualitySize;
public class QualitySizeDataLister
public class QualitySizeDataLister(
IAnsiConsole console,
IQualitySizeGuideService guide)
{
private readonly IAnsiConsole _console;
private readonly IQualitySizeGuideService _guide;
public QualitySizeDataLister(
IAnsiConsole console,
IQualitySizeGuideService guide)
{
_console = console;
_guide = guide;
}
public void ListQualities(SupportedServices serviceType)
{
_console.WriteLine("\nList of Quality Definition types in the TRaSH Guides:\n");
console.WriteLine("\nList of Quality Definition types in the TRaSH Guides:\n");
_guide.GetQualitySizeData(serviceType)
guide.GetQualitySizeData(serviceType)
.Select(x => x.Type)
.ForEach(x => _console.WriteLine($" - {x}"));
.ForEach(x => console.WriteLine($" - {x}"));
_console.WriteLine(
console.WriteLine(
"\nThe above quality definition types can be used with the `quality_definition:` property in your " +
"recyclarr.yml file.");
}

@ -13,34 +13,25 @@ public interface IQualitySizePipelinePhases
QualitySizeApiPersistencePhase ApiPersistencePhase { get; }
}
public class QualitySizeSyncPipeline : ISyncPipeline
public class QualitySizeSyncPipeline(ILogger log, IQualitySizePipelinePhases phases) : ISyncPipeline
{
private readonly ILogger _log;
private readonly IQualitySizePipelinePhases _phases;
public QualitySizeSyncPipeline(ILogger log, IQualitySizePipelinePhases phases)
{
_log = log;
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
var selectedQuality = _phases.GuidePhase.Execute(config);
var selectedQuality = phases.GuidePhase.Execute(config);
if (selectedQuality is null)
{
_log.Debug("No quality definition to process");
log.Debug("No quality definition to process");
return;
}
if (settings.Preview)
{
_phases.PreviewPhase.Value.Execute(selectedQuality);
phases.PreviewPhase.Value.Execute(selectedQuality);
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(selectedQuality.Qualities, serviceData);
await _phases.ApiPersistencePhase.Execute(config, transactions);
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = phases.TransactionPhase.Execute(selectedQuality.Qualities, serviceData);
await phases.ApiPersistencePhase.Execute(config, transactions);
}
}

@ -3,16 +3,9 @@ using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class IncludeExcludeFilter : IReleaseProfileFilter
public class IncludeExcludeFilter(ILogger log) : IReleaseProfileFilter
{
private readonly ILogger _log;
private readonly ReleaseProfileDataFilterer _filterer;
public IncludeExcludeFilter(ILogger log)
{
_log = log;
_filterer = new ReleaseProfileDataFilterer(log);
}
private readonly ReleaseProfileDataFilterer _filterer = new(log);
public ReleaseProfileData Transform(ReleaseProfileData profile, ReleaseProfileConfig config)
{
@ -21,7 +14,7 @@ public class IncludeExcludeFilter : IReleaseProfileFilter
return profile;
}
_log.Debug("This profile will be filtered");
log.Debug("This profile will be filtered");
var newProfile = _filterer.FilterProfile(profile, config.Filter);
return newProfile ?? profile;
}

@ -4,16 +4,9 @@ using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class ReleaseProfileDataFilterer
public class ReleaseProfileDataFilterer(ILogger log)
{
private readonly ILogger _log;
private readonly ReleaseProfileDataValidationFilterer _validator;
public ReleaseProfileDataFilterer(ILogger log)
{
_log = log;
_validator = new ReleaseProfileDataValidationFilterer(log);
}
private readonly ReleaseProfileDataValidationFilterer _validator = new(log);
public ReadOnlyCollection<TermData> ExcludeTerms(
IEnumerable<TermData> terms,
@ -63,9 +56,9 @@ public class ReleaseProfileDataFilterer
ReleaseProfileData selectedProfile,
SonarrProfileFilterConfig profileFilter)
{
if (profileFilter.Include.Any())
if (profileFilter.Include.Count != 0)
{
_log.Debug("Using inclusion filter");
log.Debug("Using inclusion filter");
return selectedProfile with
{
Required = IncludeTerms(selectedProfile.Required, profileFilter.Include),
@ -74,9 +67,9 @@ public class ReleaseProfileDataFilterer
};
}
if (profileFilter.Exclude.Any())
if (profileFilter.Exclude.Count != 0)
{
_log.Debug("Using exclusion filter");
log.Debug("Using exclusion filter");
return selectedProfile with
{
Required = ExcludeTerms(selectedProfile.Required, profileFilter.Exclude),
@ -85,7 +78,7 @@ public class ReleaseProfileDataFilterer
};
}
_log.Debug("Filter property present but is empty");
log.Debug("Filter property present but is empty");
return null;
}
}

@ -3,17 +3,11 @@ using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class ReleaseProfileFilterPipeline : IReleaseProfileFilterPipeline
public class ReleaseProfileFilterPipeline(IOrderedEnumerable<IReleaseProfileFilter> filters)
: IReleaseProfileFilterPipeline
{
private readonly IOrderedEnumerable<IReleaseProfileFilter> _filters;
public ReleaseProfileFilterPipeline(IOrderedEnumerable<IReleaseProfileFilter> filters)
{
_filters = filters;
}
public ReleaseProfileData Process(ReleaseProfileData profile, ReleaseProfileConfig config)
{
return _filters.Aggregate(profile, (current, filter) => filter.Transform(current, config));
return filters.Aggregate(profile, (current, filter) => filter.Transform(current, config));
}
}

@ -3,15 +3,8 @@ using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class StrictNegativeScoresFilter : IReleaseProfileFilter
public class StrictNegativeScoresFilter(ILogger log) : IReleaseProfileFilter
{
private readonly ILogger _log;
public StrictNegativeScoresFilter(ILogger log)
{
_log = log;
}
public ReleaseProfileData Transform(ReleaseProfileData profile, ReleaseProfileConfig config)
{
if (!config.StrictNegativeScores)
@ -19,7 +12,7 @@ public class StrictNegativeScoresFilter : IReleaseProfileFilter
return profile;
}
_log.Debug("Negative scores will be strictly ignored");
log.Debug("Negative scores will be strictly ignored");
var splitPreferred = profile.Preferred.ToLookup(x => x.Score < 0);
return profile with

@ -3,18 +3,10 @@ using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileApiFetchPhase
public class ReleaseProfileApiFetchPhase(IReleaseProfileApiService rpService)
{
private readonly IReleaseProfileApiService _rpService;
public ReleaseProfileApiFetchPhase(
IReleaseProfileApiService rpService)
{
_rpService = rpService;
}
public async Task<IList<SonarrReleaseProfile>> Execute(IServiceConfiguration config)
{
return await _rpService.GetReleaseProfiles(config);
return await rpService.GetReleaseProfiles(config);
}
}

@ -4,35 +4,26 @@ using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileApiPersistencePhase
public class ReleaseProfileApiPersistencePhase(ILogger log, IReleaseProfileApiService api)
{
private readonly ILogger _log;
private readonly IReleaseProfileApiService _api;
public ReleaseProfileApiPersistencePhase(ILogger log, IReleaseProfileApiService api)
{
_log = log;
_api = api;
}
public async Task Execute(IServiceConfiguration config, ReleaseProfileTransactionData transactions)
{
foreach (var profile in transactions.UpdatedProfiles)
{
_log.Information("Update existing profile: {ProfileName}", profile.Name);
await _api.UpdateReleaseProfile(config, profile);
log.Information("Update existing profile: {ProfileName}", profile.Name);
await api.UpdateReleaseProfile(config, profile);
}
foreach (var profile in transactions.CreatedProfiles)
{
_log.Information("Create new profile: {ProfileName}", profile.Name);
await _api.CreateReleaseProfile(config, profile);
log.Information("Create new profile: {ProfileName}", profile.Name);
await api.CreateReleaseProfile(config, profile);
}
foreach (var profile in transactions.DeletedProfiles)
{
_log.Information("Deleting old release profile: {ProfileName}", profile.Name);
await _api.DeleteReleaseProfile(config, profile.Id);
log.Information("Deleting old release profile: {ProfileName}", profile.Name);
await api.DeleteReleaseProfile(config, profile.Id);
}
}
}

@ -10,31 +10,20 @@ public record ProcessedReleaseProfileData(
IReadOnlyCollection<string> Tags
);
public class ReleaseProfileConfigPhase
public class ReleaseProfileConfigPhase(
ILogger log,
IReleaseProfileGuideService guide,
IReleaseProfileFilterPipeline filters)
{
private readonly ILogger _log;
private readonly IReleaseProfileGuideService _guide;
private readonly IReleaseProfileFilterPipeline _filters;
public ReleaseProfileConfigPhase(
ILogger log,
IReleaseProfileGuideService guide,
IReleaseProfileFilterPipeline filters)
{
_log = log;
_guide = guide;
_filters = filters;
}
public IReadOnlyList<ProcessedReleaseProfileData>? Execute(SonarrConfiguration config)
{
if (config.ReleaseProfiles.IsEmpty())
{
_log.Debug("{Instance} has no release profiles", config.InstanceName);
log.Debug("{Instance} has no release profiles", config.InstanceName);
return null;
}
var profilesFromGuide = _guide.GetReleaseProfileData();
var profilesFromGuide = guide.GetReleaseProfileData();
var filteredProfiles = new List<ProcessedReleaseProfileData>();
var configProfiles = config.ReleaseProfiles.SelectMany(x => x.TrashIds.Select(y => (TrashId: y, Config: x)));
@ -44,14 +33,14 @@ public class ReleaseProfileConfigPhase
var selectedProfile = profilesFromGuide.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId));
if (selectedProfile is null)
{
_log.Warning("A release profile with Trash ID {TrashId} does not exist", trashId);
log.Warning("A release profile with Trash ID {TrashId} does not exist", trashId);
continue;
}
_log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name,
log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name,
selectedProfile.TrashId);
selectedProfile = _filters.Process(selectedProfile, configProfile);
selectedProfile = filters.Process(selectedProfile, configProfile);
filteredProfiles.Add(new ProcessedReleaseProfileData(selectedProfile, configProfile.Tags));
}

@ -4,15 +4,8 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfilePreviewPhase
public class ReleaseProfilePreviewPhase(IAnsiConsole console)
{
private readonly IAnsiConsole _console;
public ReleaseProfilePreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(ReleaseProfileTransactionData profiles)
{
var tree = new Tree("Release Profiles [red](Preview)[/]");
@ -20,8 +13,8 @@ public class ReleaseProfilePreviewPhase
PrintCategoryOfChanges("Created Profiles", tree, profiles.CreatedProfiles);
PrintCategoryOfChanges("Updated Profiles", tree, profiles.UpdatedProfiles);
_console.WriteLine();
_console.Write(tree);
console.WriteLine();
console.Write(tree);
}
private void PrintCategoryOfChanges(string nodeTitle, Tree tree, IEnumerable<SonarrReleaseProfile> profiles)
@ -44,7 +37,7 @@ public class ReleaseProfilePreviewPhase
PrintTerms(rpNode, "Must Not Contain", profile.Ignored);
PrintPreferredTerms(rpNode, "Preferred", profile.Preferred);
_console.WriteLine("");
console.WriteLine("");
}
private static void PrintTerms(TreeNode tree, string title, IReadOnlyCollection<string> terms)

@ -5,15 +5,8 @@ using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileTransactionPhase
public class ReleaseProfileTransactionPhase(ServiceTagCache tagCache)
{
private readonly ServiceTagCache _tagCache;
public ReleaseProfileTransactionPhase(ServiceTagCache tagCache)
{
_tagCache = tagCache;
}
public ReleaseProfileTransactionData Execute(
IReadOnlyList<ProcessedReleaseProfileData> configProfiles,
IList<SonarrReleaseProfile> serviceData)
@ -43,7 +36,7 @@ public class ReleaseProfileTransactionPhase
return new ReleaseProfileTransactionData(updated, created, deleted);
}
private static IReadOnlyList<SonarrReleaseProfile> DeleteOldManagedProfiles(
private static List<SonarrReleaseProfile> DeleteOldManagedProfiles(
IList<SonarrReleaseProfile> serviceData,
IReadOnlyList<ProcessedReleaseProfileData> configProfiles)
{
@ -67,7 +60,7 @@ public class ReleaseProfileTransactionPhase
profileToUpdate.Required = profile.Profile.Required.Select(x => x.Term).ToList();
profileToUpdate.IncludePreferredWhenRenaming = profile.Profile.IncludePreferredWhenRenaming;
profileToUpdate.Tags = profile.Tags
.Select(x => _tagCache.GetTagIdByName(x))
.Select(tagCache.GetTagIdByName)
.NotNull()
.ToList();
}

@ -5,28 +5,19 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile;
public class ReleaseProfileDataLister
public class ReleaseProfileDataLister(IAnsiConsole console, IReleaseProfileGuideService guide)
{
private readonly IAnsiConsole _console;
private readonly IReleaseProfileGuideService _guide;
public ReleaseProfileDataLister(IAnsiConsole console, IReleaseProfileGuideService guide)
{
_console = console;
_guide = guide;
}
public void ListReleaseProfiles()
{
_console.WriteLine("\nList of Release Profiles in the TRaSH Guides:\n");
console.WriteLine("\nList of Release Profiles in the TRaSH Guides:\n");
var profilesFromGuide = _guide.GetReleaseProfileData();
var profilesFromGuide = guide.GetReleaseProfileData();
foreach (var profile in profilesFromGuide)
{
_console.WriteLine($" - {profile.TrashId} # {profile.Name}");
console.WriteLine($" - {profile.TrashId} # {profile.Name}");
}
_console.WriteLine(
console.WriteLine(
"\nThe above Release Profiles are in YAML format and ready to be copied & pasted under the `trash_ids:` property.");
}
@ -45,7 +36,7 @@ public class ReleaseProfileDataLister
public void ListTerms(string releaseProfileId)
{
var profile = _guide.GetReleaseProfileData()
var profile = guide.GetReleaseProfileData()
.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(releaseProfileId));
if (profile is null)
@ -60,37 +51,37 @@ public class ReleaseProfileDataLister
"(terms must have Trash IDs assigned in order to be filtered)");
}
_console.WriteLine();
_console.WriteLine($"List of Terms for the '{profile.Name}' Release Profile that may be filtered:\n");
console.WriteLine();
console.WriteLine($"List of Terms for the '{profile.Name}' Release Profile that may be filtered:\n");
PrintTerms(profile.Required, "Required");
PrintTerms(profile.Ignored, "Ignored");
PrintTerms(profile.Preferred.SelectMany(x => x.Terms), "Preferred");
_console.WriteLine(
console.WriteLine(
"The above Term Filters are in YAML format and ready to be copied & pasted under the `include:` or `exclude:` filter properties.");
}
private void PrintTerms(IEnumerable<TermData> terms, string category)
{
var filteredTerms = terms.Where(x => x.TrashId.Any()).ToList();
if (!filteredTerms.Any())
var filteredTerms = terms.Where(x => x.TrashId.Length != 0).ToList();
if (filteredTerms.Count == 0)
{
return;
}
_console.WriteLine($"{category} Terms:\n");
console.WriteLine($"{category} Terms:\n");
foreach (var term in filteredTerms)
{
var line = new StringBuilder($" - {term.TrashId}");
if (term.Name.Any())
if (term.Name.Length != 0)
{
line.Append($" # {term.Name}");
}
_console.WriteLine(line.ToString());
console.WriteLine(line.ToString());
}
_console.WriteLine();
console.WriteLine();
}
}

@ -13,42 +13,33 @@ public interface IReleaseProfilePipelinePhases
ReleaseProfileApiPersistencePhase ApiPersistencePhase { get; }
}
public class ReleaseProfileSyncPipeline : ISyncPipeline
public class ReleaseProfileSyncPipeline(ILogger log, IReleaseProfilePipelinePhases phases) : ISyncPipeline
{
private readonly ILogger _log;
private readonly IReleaseProfilePipelinePhases _phases;
public ReleaseProfileSyncPipeline(ILogger log, IReleaseProfilePipelinePhases phases)
{
_log = log;
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
if (config is not SonarrConfiguration sonarrConfig)
{
_log.Debug("Skipping release profile pipeline because {Instance} is not a Sonarr config",
log.Debug("Skipping release profile pipeline because {Instance} is not a Sonarr config",
config.InstanceName);
return;
}
var profiles = _phases.ConfigPhase.Execute(sonarrConfig);
var profiles = phases.ConfigPhase.Execute(sonarrConfig);
if (profiles is null)
{
_log.Debug("No release profiles to process");
log.Debug("No release profiles to process");
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(profiles, serviceData);
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = phases.TransactionPhase.Execute(profiles, serviceData);
if (settings.Preview)
{
_phases.PreviewPhase.Value.Execute(transactions);
phases.PreviewPhase.Value.Execute(transactions);
return;
}
await _phases.ApiPersistencePhase.Execute(config, transactions);
await phases.ApiPersistencePhase.Execute(config, transactions);
}
}

@ -3,22 +3,13 @@ using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagApiFetchPhase
public class TagApiFetchPhase(ISonarrTagApiService api, ServiceTagCache cache)
{
private readonly ISonarrTagApiService _api;
private readonly ServiceTagCache _cache;
public TagApiFetchPhase(ISonarrTagApiService api, ServiceTagCache cache)
{
_api = api;
_cache = cache;
}
public async Task<IList<SonarrTag>> Execute(IServiceConfiguration config)
{
var tags = await _api.GetTags(config);
_cache.Clear();
_cache.AddTags(tags);
var tags = await api.GetTags(config);
cache.Clear();
cache.AddTags(tags);
return tags;
}
}

@ -3,31 +3,20 @@ using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagApiPersistencePhase
public class TagApiPersistencePhase(
ILogger log,
ServiceTagCache cache,
ISonarrTagApiService api)
{
private readonly ILogger _log;
private readonly ServiceTagCache _cache;
private readonly ISonarrTagApiService _api;
public TagApiPersistencePhase(
ILogger log,
ServiceTagCache cache,
ISonarrTagApiService api)
{
_log = log;
_cache = cache;
_api = api;
}
public async Task Execute(IServiceConfiguration config, IEnumerable<string> tagsToCreate)
{
var createdTags = new List<SonarrTag>();
foreach (var tag in tagsToCreate)
{
_log.Debug("Creating Tag: {Tag}", tag);
createdTags.Add(await _api.CreateTag(config, tag));
log.Debug("Creating Tag: {Tag}", tag);
createdTags.Add(await api.CreateTag(config, tag));
}
_cache.AddTags(createdTags);
cache.AddTags(createdTags);
}
}

@ -3,22 +3,15 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagPreviewPhase
public class TagPreviewPhase(IAnsiConsole console)
{
private readonly IAnsiConsole _console;
public TagPreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(IReadOnlyList<string> tagsToCreate)
{
if (tagsToCreate.IsNullOrEmpty())
{
_console.WriteLine();
_console.MarkupLine("[green]No tags to create[/]");
_console.WriteLine();
console.WriteLine();
console.MarkupLine("[green]No tags to create[/]");
console.WriteLine();
return;
}
@ -30,6 +23,6 @@ public class TagPreviewPhase
table.AddRow(tag);
}
_console.Write(table);
console.Write(table);
}
}

@ -13,43 +13,34 @@ public interface ITagPipelinePhases
TagApiPersistencePhase ApiPersistencePhase { get; }
}
public class TagSyncPipeline : ISyncPipeline
public class TagSyncPipeline(
ILogger log,
ITagPipelinePhases phases) : ISyncPipeline
{
private readonly ILogger _log;
private readonly ITagPipelinePhases _phases;
public TagSyncPipeline(
ILogger log,
ITagPipelinePhases phases)
{
_log = log;
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
if (config is not SonarrConfiguration sonarrConfig)
{
_log.Debug("Skipping tag pipeline because {Instance} is not a Sonarr config", config.InstanceName);
log.Debug("Skipping tag pipeline because {Instance} is not a Sonarr config", config.InstanceName);
return;
}
var tags = _phases.ConfigPhase.Execute(sonarrConfig);
var tags = phases.ConfigPhase.Execute(sonarrConfig);
if (tags is null)
{
_log.Debug("No tags to process");
log.Debug("No tags to process");
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(tags, serviceData);
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = phases.TransactionPhase.Execute(tags, serviceData);
if (settings.Preview)
{
_phases.PreviewPhase.Value.Execute(transactions.AsReadOnly());
phases.PreviewPhase.Value.Execute(transactions.AsReadOnly());
return;
}
await _phases.ApiPersistencePhase.Execute(config, transactions);
await phases.ApiPersistencePhase.Execute(config, transactions);
}
}

@ -2,18 +2,11 @@ using Recyclarr.Cli.Console.Settings;
namespace Recyclarr.Cli.Processors.Config;
public class ConfigCreationProcessor : IConfigCreationProcessor
public class ConfigCreationProcessor(IOrderedEnumerable<IConfigCreator> creators) : IConfigCreationProcessor
{
private readonly IOrderedEnumerable<IConfigCreator> _creators;
public ConfigCreationProcessor(IOrderedEnumerable<IConfigCreator> creators)
{
_creators = creators;
}
public void Process(ICreateConfigSettings settings)
{
var creator = _creators.FirstOrDefault(x => x.CanHandle(settings));
var creator = creators.FirstOrDefault(x => x.CanHandle(settings));
if (creator is null)
{
throw new FatalException("Unable to determine which config creation logic to use");

@ -9,38 +9,25 @@ using Spectre.Console.Rendering;
namespace Recyclarr.Cli.Processors.Config;
public class ConfigListLocalProcessor
public class ConfigListLocalProcessor(
IAnsiConsole console,
IConfigurationFinder configFinder,
IConfigurationLoader configLoader,
IAppPaths paths)
{
private readonly IAnsiConsole _console;
private readonly IConfigurationFinder _configFinder;
private readonly IConfigurationLoader _configLoader;
private readonly IAppPaths _paths;
public ConfigListLocalProcessor(
IAnsiConsole console,
IConfigurationFinder configFinder,
IConfigurationLoader configLoader,
IAppPaths paths)
{
_console = console;
_configFinder = configFinder;
_configLoader = configLoader;
_paths = paths;
}
public void Process()
{
var tree = new Tree(_paths.AppDataDirectory.ToString()!);
var tree = new Tree(paths.AppDataDirectory.ToString()!);
foreach (var configPath in _configFinder.GetConfigFiles())
foreach (var configPath in configFinder.GetConfigFiles())
{
var configs = _configLoader.Load(configPath);
var configs = configLoader.Load(configPath);
var rows = new List<IRenderable>();
BuildInstanceTree(rows, configs, SupportedServices.Radarr);
BuildInstanceTree(rows, configs, SupportedServices.Sonarr);
if (!rows.Any())
if (rows.Count == 0)
{
rows.Add(new Markup("([red]Empty[/])"));
}
@ -54,24 +41,24 @@ public class ConfigListLocalProcessor
tree.AddNode(configTree);
}
_console.WriteLine();
_console.Write(tree);
console.WriteLine();
console.Write(tree);
}
private string MakeRelative(IFileInfo path)
{
var configPath = new Uri(path.FullName, UriKind.Absolute);
var configDir = new Uri(_paths.ConfigsDirectory.FullName, UriKind.Absolute);
var configDir = new Uri(paths.ConfigsDirectory.FullName, UriKind.Absolute);
return configDir.MakeRelativeUri(configPath).ToString();
}
private static void BuildInstanceTree(
ICollection<IRenderable> rows,
List<IRenderable> rows,
IReadOnlyCollection<IServiceConfiguration> registry,
SupportedServices service)
{
var configs = registry.GetConfigsOfType(service).ToList();
if (!configs.Any())
if (configs.Count == 0)
{
return;
}

@ -5,26 +5,17 @@ using Spectre.Console;
namespace Recyclarr.Cli.Processors.Config;
public class ConfigListTemplateProcessor
public class ConfigListTemplateProcessor(IAnsiConsole console, IConfigTemplateGuideService guideService)
{
private readonly IAnsiConsole _console;
private readonly IConfigTemplateGuideService _guideService;
public ConfigListTemplateProcessor(IAnsiConsole console, IConfigTemplateGuideService guideService)
{
_console = console;
_guideService = guideService;
}
public void Process(IConfigListTemplatesSettings settings)
{
if (settings.Includes)
{
ListData(_guideService.GetIncludeData());
ListData(guideService.GetIncludeData());
return;
}
ListData(_guideService.GetTemplateData());
ListData(guideService.GetTemplateData());
}
private void ListData(IReadOnlyCollection<TemplatePath> data)
@ -42,10 +33,10 @@ public class ConfigListTemplateProcessor
table.AddRow(s, r);
}
_console.Write(table);
console.Write(table);
}
private static IEnumerable<Markup> RenderTemplates(
private static List<Markup> RenderTemplates(
Table table,
IEnumerable<TemplatePath> templatePaths,
SupportedServices service)

@ -11,26 +11,14 @@ namespace Recyclarr.Cli.Processors.Config;
/// opportunity to use this
/// with the GUI.
/// </remarks>
public class ConfigManipulator : IConfigManipulator
public class ConfigManipulator(
IAnsiConsole console,
ConfigParser configParser,
ConfigSaver configSaver,
ConfigValidationExecutor validator)
: IConfigManipulator
{
private readonly IAnsiConsole _console;
private readonly ConfigParser _configParser;
private readonly ConfigSaver _configSaver;
private readonly ConfigValidationExecutor _validator;
public ConfigManipulator(
IAnsiConsole console,
ConfigParser configParser,
ConfigSaver configSaver,
ConfigValidationExecutor validator)
{
_console = console;
_configParser = configParser;
_configSaver = configSaver;
_validator = validator;
}
private static IReadOnlyDictionary<string, TConfig> InvokeCallbackForEach<TConfig>(
private static Dictionary<string, TConfig> InvokeCallbackForEach<TConfig>(
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback,
IReadOnlyDictionary<string, TConfig>? configs)
where TConfig : ServiceConfigYaml
@ -60,7 +48,7 @@ public class ConfigManipulator : IConfigManipulator
// - Run validation & report issues
// - Consistently reformat the output file (when it is saved again)
// - Ignore stuff for diffing purposes, such as comments.
var config = _configParser.Load<RootConfigYaml>(source);
var config = configParser.Load<RootConfigYaml>(source);
if (config is null)
{
// Do not log here, since ConfigParser already has substantial logging
@ -73,13 +61,13 @@ public class ConfigManipulator : IConfigManipulator
Sonarr = InvokeCallbackForEach(editCallback, config.Sonarr)
};
if (!_validator.Validate(config, YamlValidatorRuleSets.RootConfig))
if (!validator.Validate(config, YamlValidatorRuleSets.RootConfig))
{
_console.WriteLine(
console.WriteLine(
"The configuration file will still be created, despite the previous validation errors. " +
"You must open the file and correct the above issues before running a sync command.");
}
_configSaver.Save(config, destinationFile);
configSaver.Save(config, destinationFile);
}
}

@ -1,11 +1,6 @@
namespace Recyclarr.Cli.Processors.Config;
public class FileExistsException : Exception
public class FileExistsException(string attemptedPath) : Exception
{
public FileExistsException(string attemptedPath)
{
AttemptedPath = attemptedPath;
}
public string AttemptedPath { get; }
public string AttemptedPath { get; } = attemptedPath;
}

@ -6,21 +6,9 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Processors.Config;
public class LocalConfigCreator : IConfigCreator
public class LocalConfigCreator(ILogger log, IAppPaths paths, IFileSystem fs, IResourceDataReader resources)
: IConfigCreator
{
private readonly ILogger _log;
private readonly IAppPaths _paths;
private readonly IFileSystem _fs;
private readonly IResourceDataReader _resources;
public LocalConfigCreator(ILogger log, IAppPaths paths, IFileSystem fs, IResourceDataReader resources)
{
_log = log;
_paths = paths;
_fs = fs;
_resources = resources;
}
public bool CanHandle(ICreateConfigSettings settings)
{
return true;
@ -29,8 +17,8 @@ public class LocalConfigCreator : IConfigCreator
public void Create(ICreateConfigSettings settings)
{
var configFile = settings.Path is null
? _paths.AppDataDirectory.File("recyclarr.yml")
: _fs.FileInfo.New(settings.Path);
? paths.AppDataDirectory.File("recyclarr.yml")
: fs.FileInfo.New(settings.Path);
if (configFile.Exists)
{
@ -40,9 +28,9 @@ public class LocalConfigCreator : IConfigCreator
configFile.CreateParentDirectory();
using var stream = configFile.CreateText();
var ymlData = _resources.ReadData("config-template.yml");
var ymlData = resources.ReadData("config-template.yml");
stream.Write(ymlData);
_log.Information("Created configuration at: {Path}", configFile.FullName);
log.Information("Created configuration at: {Path}", configFile.FullName);
}
}

@ -6,33 +6,22 @@ using Recyclarr.TrashGuide;
namespace Recyclarr.Cli.Processors.Config;
public class TemplateConfigCreator : IConfigCreator
public class TemplateConfigCreator(
ILogger log,
IConfigTemplateGuideService templates,
IAppPaths paths)
: IConfigCreator
{
private readonly ILogger _log;
private readonly IConfigTemplateGuideService _templates;
private readonly IAppPaths _paths;
public TemplateConfigCreator(
ILogger log,
IConfigTemplateGuideService templates,
IAppPaths paths)
{
_log = log;
_templates = templates;
_paths = paths;
}
public bool CanHandle(ICreateConfigSettings settings)
{
return settings.Templates.Any();
return settings.Templates.Count != 0;
}
public void Create(ICreateConfigSettings settings)
{
_log.Debug("Creating config from templates: {Templates}", settings.Templates);
log.Debug("Creating config from templates: {Templates}", settings.Templates);
var matchingTemplateData = _templates.GetTemplateData()
var matchingTemplateData = templates.GetTemplateData()
.IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase)
.Select(x => x.TemplateFile);
@ -44,7 +33,7 @@ public class TemplateConfigCreator : IConfigCreator
}
catch (FileExistsException e)
{
_log.Error("Template configuration file could not be saved: {Reason}", e.AttemptedPath);
log.Error("Template configuration file could not be saved: {Reason}", e.AttemptedPath);
}
catch (FileLoadException)
{
@ -53,7 +42,7 @@ public class TemplateConfigCreator : IConfigCreator
}
catch (Exception e)
{
_log.Error(e, "Unable to save configuration template file");
log.Error(e, "Unable to save configuration template file");
throw;
}
}
@ -61,7 +50,7 @@ public class TemplateConfigCreator : IConfigCreator
private void CopyTemplate(IFileInfo templateFile, ICreateConfigSettings settings)
{
var destinationFile = _paths.ConfigsDirectory.File(templateFile.Name);
var destinationFile = paths.ConfigsDirectory.File(templateFile.Name);
if (destinationFile.Exists && !settings.Force)
{
@ -74,11 +63,11 @@ public class TemplateConfigCreator : IConfigCreator
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (destinationFile.Exists)
{
_log.Information("Replacing existing file: {Path}", destinationFile);
log.Information("Replacing existing file: {Path}", destinationFile);
}
else
{
_log.Information("Created configuration file: {Path}", destinationFile);
log.Information("Created configuration file: {Path}", destinationFile);
}
}
}

@ -11,28 +11,14 @@ using Spectre.Console;
namespace Recyclarr.Cli.Processors.Delete;
public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
public class DeleteCustomFormatsProcessor(
ILogger log,
IAnsiConsole console,
ICustomFormatApiService api,
IConfigurationRegistry configRegistry,
ISonarrCapabilityFetcher sonarCapabilities)
: IDeleteCustomFormatsProcessor
{
private readonly ILogger _log;
private readonly IAnsiConsole _console;
private readonly ICustomFormatApiService _api;
private readonly IConfigurationRegistry _configRegistry;
private readonly ISonarrCapabilityFetcher _sonarCapabilities;
public DeleteCustomFormatsProcessor(
ILogger log,
IAnsiConsole console,
ICustomFormatApiService api,
IConfigurationRegistry configRegistry,
ISonarrCapabilityFetcher sonarCapabilities)
{
_log = log;
_console = console;
_api = api;
_configRegistry = configRegistry;
_sonarCapabilities = sonarCapabilities;
}
public async Task Process(IDeleteCustomFormatSettings settings)
{
var config = GetTargetConfig(settings);
@ -43,7 +29,7 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (!settings.All)
{
if (!settings.CustomFormatNames.Any())
if (settings.CustomFormatNames.Count == 0)
{
throw new CommandException("Custom format names must be specified if the `--all` option is not used.");
}
@ -53,7 +39,7 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (!cfs.Any())
{
_console.MarkupLine("[yellow]Done[/]: No custom formats found or specified to delete.");
console.MarkupLine("[yellow]Done[/]: No custom formats found or specified to delete.");
return;
}
@ -61,14 +47,14 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (settings.Preview)
{
_console.MarkupLine("This is a preview! [u]No actual deletions will be performed.[/]");
console.MarkupLine("This is a preview! [u]No actual deletions will be performed.[/]");
return;
}
if (!settings.Force &&
!_console.Confirm("\nAre you sure you want to [bold red]permanently delete[/] the above custom formats?"))
!console.Confirm("\nAre you sure you want to [bold red]permanently delete[/] the above custom formats?"))
{
_console.WriteLine("Aborted!");
console.WriteLine("Aborted!");
return;
}
@ -79,7 +65,7 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{
if (config is SonarrConfiguration)
{
var capabilities = await _sonarCapabilities.GetCapabilities(config);
var capabilities = await sonarCapabilities.GetCapabilities(config);
if (!capabilities.SupportsCustomFormats)
{
throw new ServiceIncompatibilityException("Custom formats are not supported in Sonarr v3");
@ -90,7 +76,7 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
private async Task DeleteCustomFormats(ICollection<CustomFormatData> cfs, IServiceConfiguration config)
{
await _console.Progress().StartAsync(async ctx =>
await console.Progress().StartAsync(async ctx =>
{
var task = ctx.AddTask("Deleting Custom Formats").MaxValue(cfs.Count);
@ -99,13 +85,13 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{
try
{
await _api.DeleteCustomFormat(config, cf.Id, token);
_log.Debug("Deleted {Name}", cf.Name);
await api.DeleteCustomFormat(config, cf.Id, token);
log.Debug("Deleted {Name}", cf.Name);
}
catch (Exception e)
{
_log.Debug(e, "Failed to delete CF");
_console.WriteLine($"Failed to delete CF: {cf.Name}");
log.Debug(e, "Failed to delete CF");
console.WriteLine($"Failed to delete CF: {cf.Name}");
}
task.Increment(1);
@ -117,9 +103,9 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{
IList<CustomFormatData> cfs = new List<CustomFormatData>();
await _console.Status().StartAsync("Obtaining custom formats...", async _ =>
await console.Status().StartAsync("Obtaining custom formats...", async _ =>
{
cfs = await _api.GetCustomFormats(config);
cfs = await api.GetCustomFormats(config);
});
return cfs;
@ -141,10 +127,10 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (result[false].Any())
{
var cfNames = result[false].Select(x => x.Name).ToList();
_log.Debug("Unmatched CFs: {Names}", cfNames);
log.Debug("Unmatched CFs: {Names}", cfNames);
foreach (var name in cfNames)
{
_console.MarkupLineInterpolated($"[yellow]Warning[/]: Unmatched CF Name: [teal]{name}[/]");
console.MarkupLineInterpolated($"[yellow]Warning[/]: Unmatched CF Name: [teal]{name}[/]");
}
}
@ -156,8 +142,8 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
[SuppressMessage("ReSharper", "CoVariantArrayConversion")]
private void PrintPreview(ICollection<CustomFormatData> cfs)
{
_console.MarkupLine("The following custom formats will be [bold red]DELETED[/]:");
_console.WriteLine();
console.MarkupLine("The following custom formats will be [bold red]DELETED[/]:");
console.WriteLine();
var cfNames = cfs
.Select(x => x.Name)
@ -174,13 +160,13 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
.ToArray());
}
_console.Write(grid);
_console.WriteLine();
console.Write(grid);
console.WriteLine();
}
private IServiceConfiguration GetTargetConfig(IDeleteCustomFormatSettings settings)
{
var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
var configs = configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
{
Instances = new[] {settings.InstanceName}
});

@ -8,66 +8,57 @@ using Recyclarr.VersionControl;
namespace Recyclarr.Cli.Processors.ErrorHandling;
public class ConsoleExceptionHandler
public class ConsoleExceptionHandler(ILogger log, IFlurlHttpExceptionHandler httpExceptionHandler)
{
private readonly ILogger _log;
private readonly IFlurlHttpExceptionHandler _httpExceptionHandler;
public ConsoleExceptionHandler(ILogger log, IFlurlHttpExceptionHandler httpExceptionHandler)
{
_log = log;
_httpExceptionHandler = httpExceptionHandler;
}
public async Task<bool> HandleException(Exception sourceException)
{
switch (sourceException)
{
case GitCmdException e:
_log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
e.ExitCode, e.Error);
break;
case FlurlHttpException e:
_log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
await _httpExceptionHandler.ProcessServiceErrorMessages(new ServiceErrorMessageExtractor(e));
log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
await httpExceptionHandler.ProcessServiceErrorMessages(new ServiceErrorMessageExtractor(e));
break;
case NoConfigurationFilesException:
_log.Error("No configuration files found");
log.Error("No configuration files found");
break;
case InvalidInstancesException e:
_log.Error("The following instances do not exist: {Names}", e.InstanceNames);
log.Error("The following instances do not exist: {Names}", e.InstanceNames);
break;
case DuplicateInstancesException e:
_log.Error("The following instance names are duplicated: {Names}", e.InstanceNames);
_log.Error("Instance names are unique and may not be reused");
log.Error("The following instance names are duplicated: {Names}", e.InstanceNames);
log.Error("Instance names are unique and may not be reused");
break;
case SplitInstancesException e:
_log.Error("The following configs share the same `base_url`, which isn't allowed: {Instances}",
log.Error("The following configs share the same `base_url`, which isn't allowed: {Instances}",
e.InstanceNames);
_log.Error(
log.Error(
"Consolidate the config files manually to fix. " +
"See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance");
break;
case InvalidConfigurationFilesException e:
_log.Error("Manually-specified configuration files do not exist: {Files}", e.InvalidFiles);
log.Error("Manually-specified configuration files do not exist: {Files}", e.InvalidFiles);
break;
case PostProcessingException e:
_log.Error("Configuration post-processing failed: {Message}", e.Message);
log.Error("Configuration post-processing failed: {Message}", e.Message);
break;
case ServiceIncompatibilityException e:
_log.Error(e.Message);
log.Error(e.Message);
break;
case CommandException e:
_log.Error(e.Message);
log.Error(e.Message);
break;
default:

@ -6,18 +6,10 @@ using Recyclarr.Json;
namespace Recyclarr.Cli.Processors.ErrorHandling;
public sealed class ErrorResponseParser
public sealed class ErrorResponseParser(ILogger log, string responseBody)
{
private readonly ILogger _log;
private readonly Func<Stream> _streamFactory;
private readonly JsonSerializerOptions _jsonSettings;
public ErrorResponseParser(ILogger log, string responseBody)
{
_log = log;
_streamFactory = () => new MemoryStream(Encoding.UTF8.GetBytes(responseBody));
_jsonSettings = GlobalJsonSerializerSettings.Services;
}
private readonly Func<Stream> _streamFactory = () => new MemoryStream(Encoding.UTF8.GetBytes(responseBody));
private readonly JsonSerializerOptions _jsonSettings = GlobalJsonSerializerSettings.Services;
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public bool DeserializeList(Func<IEnumerable<JsonElement>, IEnumerable<string>> expr)
@ -34,7 +26,7 @@ public sealed class ErrorResponseParser
var parsed = expr(value);
foreach (var s in parsed)
{
_log.Error("Error message from remote service: {Message:l}", s);
log.Error("Error message from remote service: {Message:l}", s);
}
return true;
@ -57,7 +49,7 @@ public sealed class ErrorResponseParser
return false;
}
_log.Error("Error message from remote service: {Message:l}", value);
log.Error("Error message from remote service: {Message:l}", value);
return true;
}
catch
@ -81,11 +73,11 @@ public sealed class ErrorResponseParser
return false;
}
_log.Error("Error message from remote service: {Message:l}", value.Title);
log.Error("Error message from remote service: {Message:l}", value.Title);
foreach (var (topic, msg) in value.Errors.SelectMany(x => x.Value.Select(y => (x.Key, Msg: y))))
{
_log.Error("{Topic:l}: {Message:l}", topic, msg);
log.Error("{Topic:l}: {Message:l}", topic, msg);
}
return true;

@ -3,15 +3,8 @@ using Recyclarr.Common.Extensions;
namespace Recyclarr.Cli.Processors.ErrorHandling;
public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
public class FlurlHttpExceptionHandler(ILogger log) : IFlurlHttpExceptionHandler
{
private readonly ILogger _log;
public FlurlHttpExceptionHandler(ILogger log)
{
_log = log;
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public async Task ProcessServiceErrorMessages(IServiceErrorMessageExtractor extractor)
{
@ -20,7 +13,7 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
switch (statusCode)
{
case 401:
_log.Error("Reason: Recyclarr is unauthorized to talk to the service. Is your `api_key` correct?");
log.Error("Reason: Recyclarr is unauthorized to talk to the service. Is your `api_key` correct?");
break;
default:
@ -31,7 +24,7 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
private void ProcessBody(string responseBody)
{
var parser = new ErrorResponseParser(_log, responseBody);
var parser = new ErrorResponseParser(log, responseBody);
// Try to parse validation errors
if (parser.DeserializeList(s => s
@ -54,6 +47,6 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
}
// Last resort
_log.Error("Reason: Unable to determine. Please report this as a bug and attach your `verbose.log` file.");
log.Error("Reason: Unable to determine. Please report this as a bug and attach your `verbose.log` file.");
}
}

@ -2,22 +2,15 @@ using Flurl.Http;
namespace Recyclarr.Cli.Processors.ErrorHandling;
public class ServiceErrorMessageExtractor : IServiceErrorMessageExtractor
public class ServiceErrorMessageExtractor(FlurlHttpException e) : IServiceErrorMessageExtractor
{
private readonly FlurlHttpException _e;
public ServiceErrorMessageExtractor(FlurlHttpException e)
{
_e = e;
}
public async Task<string> GetErrorMessage()
{
return await _e.GetResponseStringAsync();
return await e.GetResponseStringAsync();
}
public int? GetHttpStatusCode()
{
return _e.StatusCode;
return e.StatusCode;
}
}

@ -1,19 +1,3 @@
using System.Runtime.Serialization;
using JetBrains.Annotations;
namespace Recyclarr.Cli.Processors;
[Serializable]
public class FatalException : Exception
{
public FatalException(string? message, Exception? innerException = null)
: base(message, innerException)
{
}
[UsedImplicitly]
protected FatalException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
public class FatalException(string? message, Exception? innerException = null) : Exception(message, innerException);

@ -4,32 +4,21 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Processors.Sync;
public class SyncPipelineExecutor
public class SyncPipelineExecutor(
ILogger log,
IOrderedEnumerable<ISyncPipeline> pipelines,
IEnumerable<IPipelineCache> caches)
{
private readonly ILogger _log;
private readonly IOrderedEnumerable<ISyncPipeline> _pipelines;
private readonly IEnumerable<IPipelineCache> _caches;
public SyncPipelineExecutor(
ILogger log,
IOrderedEnumerable<ISyncPipeline> pipelines,
IEnumerable<IPipelineCache> caches)
{
_log = log;
_pipelines = pipelines;
_caches = caches;
}
public async Task Process(ISyncSettings settings, IServiceConfiguration config)
{
foreach (var cache in _caches)
foreach (var cache in caches)
{
cache.Clear();
}
foreach (var pipeline in _pipelines)
foreach (var pipeline in pipelines)
{
_log.Debug("Executing Pipeline: {Pipeline}", pipeline.GetType().Name);
log.Debug("Executing Pipeline: {Pipeline}", pipeline.GetType().Name);
await pipeline.Execute(settings, config);
}
}

@ -10,37 +10,21 @@ using Spectre.Console;
namespace Recyclarr.Cli.Processors.Sync;
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public class SyncProcessor : ISyncProcessor
public class SyncProcessor(
IAnsiConsole console,
ILogger log,
IConfigurationRegistry configRegistry,
SyncPipelineExecutor pipelines,
ServiceAgnosticCapabilityEnforcer capabilityEnforcer,
ConsoleExceptionHandler exceptionHandler)
: ISyncProcessor
{
private readonly IAnsiConsole _console;
private readonly ILogger _log;
private readonly IConfigurationRegistry _configRegistry;
private readonly SyncPipelineExecutor _pipelines;
private readonly ServiceAgnosticCapabilityEnforcer _capabilityEnforcer;
private readonly ConsoleExceptionHandler _exceptionHandler;
public SyncProcessor(
IAnsiConsole console,
ILogger log,
IConfigurationRegistry configRegistry,
SyncPipelineExecutor pipelines,
ServiceAgnosticCapabilityEnforcer capabilityEnforcer,
ConsoleExceptionHandler exceptionHandler)
{
_console = console;
_log = log;
_configRegistry = configRegistry;
_pipelines = pipelines;
_capabilityEnforcer = capabilityEnforcer;
_exceptionHandler = exceptionHandler;
}
public async Task<ExitStatus> ProcessConfigs(ISyncSettings settings)
{
bool failureDetected;
try
{
var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
var configs = configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
{
ManualConfigFiles = settings.Configs,
Instances = settings.Instances,
@ -51,7 +35,7 @@ public class SyncProcessor : ISyncProcessor
}
catch (Exception e)
{
if (!await _exceptionHandler.HandleException(e))
if (!await exceptionHandler.HandleException(e))
{
// This means we didn't handle the exception; rethrow it.
throw;
@ -72,13 +56,13 @@ public class SyncProcessor : ISyncProcessor
try
{
PrintProcessingHeader(config.ServiceType, config);
await _capabilityEnforcer.Check(config);
await _pipelines.Process(settings, config);
_log.Information("Completed at {Date}", DateTime.Now);
await capabilityEnforcer.Check(config);
await pipelines.Process(settings, config);
log.Information("Completed at {Date}", DateTime.Now);
}
catch (Exception e)
{
if (!await _exceptionHandler.HandleException(e))
if (!await exceptionHandler.HandleException(e))
{
// This means we didn't handle the exception; rethrow it.
throw;
@ -95,7 +79,7 @@ public class SyncProcessor : ISyncProcessor
{
var instanceName = config.InstanceName;
_console.WriteLine(
console.WriteLine(
$"""
===========================================
@ -104,6 +88,6 @@ public class SyncProcessor : ISyncProcessor
""");
_log.Debug("Processing {Server} server {Name}", serviceType, instanceName);
log.Debug("Processing {Server} server {Name}", serviceType, instanceName);
}
}

@ -1,12 +1,7 @@
namespace Recyclarr.Common;
public class ConflictingYamlFilesException : Exception
public class ConflictingYamlFilesException(IEnumerable<string> supportedFiles) : Exception(BuildMessage(supportedFiles))
{
public ConflictingYamlFilesException(IEnumerable<string> supportedFiles)
: base(BuildMessage(supportedFiles))
{
}
private static string BuildMessage(IEnumerable<string> supportedFiles)
{
return

@ -7,29 +7,18 @@ public static class CollectionExtensions
// From: https://stackoverflow.com/a/34362585/157971
public static IReadOnlyCollection<T> AsReadOnly<T>(this ICollection<T> source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
ArgumentNullException.ThrowIfNull(source);
return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source);
}
// From: https://stackoverflow.com/a/34362585/157971
private sealed class ReadOnlyCollectionAdapter<T> : IReadOnlyCollection<T>
private sealed class ReadOnlyCollectionAdapter<T>(ICollection<T> source) : IReadOnlyCollection<T>
{
private readonly ICollection<T> _source;
public ReadOnlyCollectionAdapter(ICollection<T> source)
{
_source = source;
}
public int Count => _source.Count;
public int Count => source.Count;
public IEnumerator<T> GetEnumerator()
{
return _source.GetEnumerator();
return source.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
@ -82,7 +71,7 @@ public static class CollectionExtensions
public static IList<T>? ToListOrNull<T>(this IEnumerable<T> source)
{
var list = source.ToList();
return list.Any() ? list : null;
return list.Count != 0 ? list : null;
}
public static IEnumerable<T> Flatten<T>(this IEnumerable<T> items, Func<T, IEnumerable<T>> flattenWhich)

@ -29,7 +29,7 @@ public class RuntimeValidationService : IRuntimeValidationService
throw new ValidationException($"No validator is available for type: {instance.GetType().FullName}");
}
IValidatorSelector validatorSelector = ruleSets.Any()
IValidatorSelector validatorSelector = ruleSets.Length != 0
? new RulesetValidatorSelector(ruleSets)
: new DefaultValidatorSelector();

@ -3,17 +3,9 @@ using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.Common;
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
public sealed class GenericEqualityComparer<T> : IEqualityComparer<T>
public sealed class GenericEqualityComparer<T>(Func<T, T, bool> equalsPredicate, Func<T, int> hashPredicate)
: IEqualityComparer<T>
{
private readonly Func<T, T, bool> _equalsPredicate;
private readonly Func<T, int> _hashPredicate;
public GenericEqualityComparer(Func<T, T, bool> equalsPredicate, Func<T, int> hashPredicate)
{
_equalsPredicate = equalsPredicate;
_hashPredicate = hashPredicate;
}
public bool Equals(T? x, T? y)
{
if (ReferenceEquals(x, y))
@ -36,11 +28,11 @@ public sealed class GenericEqualityComparer<T> : IEqualityComparer<T>
return false;
}
return _equalsPredicate(x, y);
return equalsPredicate(x, y);
}
public int GetHashCode(T obj)
{
return _hashPredicate(obj);
return hashPredicate(obj);
}
}

@ -3,24 +3,16 @@ using System.Text;
namespace Recyclarr.Common;
public class ResourceDataReader : IResourceDataReader
public class ResourceDataReader(Assembly assembly, string subdirectory = "") : IResourceDataReader
{
private readonly Assembly _assembly;
private readonly string? _namespace;
private readonly string _subdirectory;
public ResourceDataReader(Assembly assembly, string subdirectory = "")
{
_subdirectory = subdirectory;
_assembly = assembly;
}
public ResourceDataReader(Type typeWithNamespaceToUse, string subdirectory = "")
: this(Assembly.GetAssembly(typeWithNamespaceToUse)
?? throw new ArgumentException("Cannot get assembly from type", nameof(typeWithNamespaceToUse)),
subdirectory)
{
_subdirectory = subdirectory;
_namespace = typeWithNamespaceToUse.Namespace;
_assembly = Assembly.GetAssembly(typeWithNamespaceToUse)
?? throw new ArgumentException("Cannot get assembly from type", nameof(typeWithNamespaceToUse));
}
public string ReadData(string filename)
@ -39,9 +31,9 @@ public class ResourceDataReader : IResourceDataReader
nameBuilder.Append($"{_namespace}.");
}
if (!string.IsNullOrEmpty(_subdirectory))
if (!string.IsNullOrEmpty(subdirectory))
{
nameBuilder.Append($"{_subdirectory}.");
nameBuilder.Append($"{subdirectory}.");
}
nameBuilder.Append(filename);
@ -50,7 +42,7 @@ public class ResourceDataReader : IResourceDataReader
private string FindResourcePath(string resourcePath)
{
var foundResource = Array.Find(_assembly.GetManifestResourceNames(), x => x.EndsWith(resourcePath));
var foundResource = Array.Find(assembly.GetManifestResourceNames(), x => x.EndsWith(resourcePath));
if (foundResource is null)
{
throw new ArgumentException($"Embedded resource not found: {resourcePath}");
@ -61,7 +53,7 @@ public class ResourceDataReader : IResourceDataReader
private string GetResourceData(string resourcePath)
{
using var stream = _assembly.GetManifestResourceStream(resourcePath);
using var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream is null)
{
throw new ArgumentException($"Unable to open embedded resource: {resourcePath}");

@ -1,16 +1,10 @@
namespace Recyclarr.Common;
public class ScopedState<T>
public class ScopedState<T>(T defaultValue = default!)
{
private readonly T _defaultValue;
private readonly Stack<Node> _scopeStack = new();
public ScopedState(T defaultValue = default!)
{
_defaultValue = defaultValue;
}
public T Value => _scopeStack.Count > 0 ? _scopeStack.Peek().Value : _defaultValue;
public T Value => _scopeStack.Count > 0 ? _scopeStack.Peek().Value : defaultValue;
public int? ActiveScope => _scopeStack.Count > 0 ? _scopeStack.Peek().Scope : null;
@ -44,15 +38,9 @@ public class ScopedState<T>
return prevCount != StackSize;
}
private sealed class Node
private sealed class Node(T value, int scope)
{
public Node(T value, int scope)
{
Value = value;
Scope = scope;
}
public T Value { get; set; }
public int Scope { get; }
public T Value { get; set; } = value;
public int Scope { get; } = scope;
}
}

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

Loading…
Cancel
Save