Merge branch 'dotnet8' into master

Relates to #211
pull/231/head
Robert Dailey 6 months ago
commit 40e08a1099

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

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

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

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

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

@ -24,7 +24,7 @@ that everyone should follow.
The following tools are required: 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 - Powershell v5.1 or greater
- Docker CLI (Docker Desktop on Windows) - 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 FROM base AS base-arm
ENV RUNTIME=linux-musl-arm ENV RUNTIME=linux-musl-arm

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

@ -13,27 +13,26 @@
<PackageVersion Include="Flurl" Version="3.0.7" /> <PackageVersion Include="Flurl" Version="3.0.7" />
<PackageVersion Include="Flurl.Http" Version="4.0.0-pre5" /> <PackageVersion Include="Flurl.Http" Version="4.0.0-pre5" />
<PackageVersion Include="GitVersion.MsBuild" Version="5.12.0" /> <PackageVersion Include="GitVersion.MsBuild" Version="5.12.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2023.2.0" /> <PackageVersion Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageVersion Include="JorgeSerrano.Json.JsonSnakeCaseNamingPolicy" Version="0.9.0" />
<PackageVersion Include="MudBlazor" Version="6.11.0" /> <PackageVersion Include="MudBlazor" Version="6.11.0" />
<PackageVersion Include="ReactiveUI.Blazor" Version="19.5.1" /> <PackageVersion Include="ReactiveUI.Blazor" Version="19.5.1" />
<PackageVersion Include="Serilog" Version="3.0.1" /> <PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="7.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="3.4.1" /> <PackageVersion Include="Serilog.Expressions" Version="4.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.File" 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" Version="0.47.0" />
<PackageVersion Include="Spectre.Console.Analyzer" Version="0.47.0" /> <PackageVersion Include="Spectre.Console.Analyzer" Version="0.47.0" />
<PackageVersion Include="Spectre.Console.Cli" 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.Data.HashFunction.FNV" Version="2.0.0" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" /> <PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Reactive" Version="6.0.0" /> <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="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.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" /> <PackageVersion Include="YamlDotNet" Version="13.7.1" />
</ItemGroup> </ItemGroup>
<!-- Unit Test Packages --> <!-- Unit Test Packages -->
@ -47,16 +46,16 @@
<PackageVersion Include="FluentAssertions" Version="6.12.0" /> <PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="FluentAssertions.Analyzers" Version="0.26.0" /> <PackageVersion Include="FluentAssertions.Analyzers" Version="0.26.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.3.3" /> <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" Version="5.1.0" />
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.16" /> <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="NUnit.Analyzers" Version="3.9.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageVersion Include="Serilog.Sinks.Observable" Version="2.0.2" /> <PackageVersion Include="Serilog.Sinks.Observable" Version="2.0.2" />
<PackageVersion Include="Serilog.Sinks.NUnit" Version="1.0.3" /> <PackageVersion Include="Serilog.Sinks.NUnit" Version="1.0.3" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.47.0" /> <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> </ItemGroup>
<!-- Following found during vulerabilities Code Scan --> <!-- Following found during vulerabilities Code Scan -->
<ItemGroup> <ItemGroup>

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

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

@ -3,23 +3,14 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Cache; 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) public CustomFormatCache Load(IServiceConfiguration config)
{ {
var cache = _cache.Load<CustomFormatCache>(config); var cache = serviceCache.Load<CustomFormatCache>(config);
if (cache == null) 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(); return new CustomFormatCache();
} }
@ -27,7 +18,7 @@ public class CachePersister : ICachePersister
// incompatibility that we do not support. // incompatibility that we do not support.
if (cache.Version != CustomFormatCache.LatestVersion) 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); cache.Version, CustomFormatCache.LatestVersion);
throw new CacheException("Version mismatch"); throw new CacheException("Version mismatch");
} }
@ -37,8 +28,8 @@ public class CachePersister : ICachePersister
public void Save(IServiceConfiguration config, CustomFormatCache cache) 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; 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 = GlobalJsonSerializerSettings.Recyclarr;
private readonly JsonSerializerOptions _jsonSettings;
private readonly ILogger _log;
public ServiceCache(ICacheStoragePath storagePath, ILogger log)
{
_storagePath = storagePath;
_log = log;
_jsonSettings = GlobalJsonSerializerSettings.Recyclarr;
}
public T? Load<T>(IServiceConfiguration config) where T : class public T? Load<T>(IServiceConfiguration config) where T : class
{ {
var path = PathFromAttribute<T>(config); 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) if (!path.Exists)
{ {
_log.Debug("Cache path does not exist"); log.Debug("Cache path does not exist");
return null; return null;
} }
@ -41,7 +32,7 @@ public partial class ServiceCache : IServiceCache
} }
catch (JsonException e) 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; return null;
@ -50,7 +41,7 @@ public partial class ServiceCache : IServiceCache
public void Save<T>(T obj, IServiceConfiguration config) where T : class public void Save<T>(T obj, IServiceConfiguration config) where T : class
{ {
var path = PathFromAttribute<T>(config); 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(); path.CreateParentDirectory();
using var stream = path.Create(); using var stream = path.Create();
@ -76,7 +67,7 @@ public partial class ServiceCache : IServiceCache
throw new ArgumentException($"Object name '{objectName}' has unacceptable characters"); throw new ArgumentException($"Object name '{objectName}' has unacceptable characters");
} }
return _storagePath.CalculatePath(config, objectName); return storagePath.CalculatePath(config, objectName);
} }
[GeneratedRegex(@"^[\w-]+$", RegexOptions.None, 1000)] [GeneratedRegex(@"^[\w-]+$", RegexOptions.None, 1000)]

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

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

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

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

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

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

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

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

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

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

@ -13,12 +13,9 @@ namespace Recyclarr.Cli.Console.Commands;
[Description("Sync the guide to services")] [Description("Sync the guide to services")]
[UsedImplicitly] [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] [UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")] [SuppressMessage("Design", "CA1034:Nested types should not be visible")]
[SuppressMessage("Performance", "CA1819:Properties should not return arrays", [SuppressMessage("Performance", "CA1819:Properties should not return arrays",
@ -48,21 +45,14 @@ public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
public IReadOnlyCollection<string> Instances => InstancesOption; 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")] [SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings) public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{ {
// Will throw if migration is required, otherwise just a warning is issued. // 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; 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) 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) 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) public void RegisterLazy(Type service, Func<object> factory)
{ {
_builder.Register(_ => factory()).As(service).SingleInstance(); builder.Register(_ => factory()).As(service).SingleInstance();
} }
public ITypeResolver Build() public ITypeResolver Build()
{ {
var container = _builder.Build(); var container = builder.Build();
_assignScope(container); assignScope(container);
return new AutofacTypeResolver(container); return new AutofacTypeResolver(container);
} }
} }

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

@ -2,26 +2,17 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Console.Setup; 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() 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 // Initialize other directories used throughout the application
// Do not initialize the repo directory here; the GitRepositoryFactory handles that later. // Do not initialize the repo directory here; the GitRepositoryFactory handles that later.
_paths.CacheDirectory.Create(); paths.CacheDirectory.Create();
_paths.LogDirectory.Create(); paths.LogDirectory.Create();
_paths.ConfigsDirectory.Create(); paths.ConfigsDirectory.Create();
} }
public void OnFinish() public void OnFinish()

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

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

@ -2,18 +2,11 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Logging; 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) public void DeleteOldestLogFiles(int numberOfNewestToKeep)
{ {
foreach (var file in _paths.LogDirectory.GetFiles() foreach (var file in paths.LogDirectory.GetFiles()
.OrderByDescending(f => f.Name) .OrderByDescending(f => f.Name)
.Skip(numberOfNewestToKeep)) .Skip(numberOfNewestToKeep))
{ {

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

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

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

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

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

@ -27,49 +27,39 @@ public record CustomFormatTransactionData
public Collection<CustomFormatData> UnchangedCustomFormats { get; } = new(); 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) 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()) if (guideCfs.IsEmpty())
{ {
_log.Debug("No custom formats to process"); log.Debug("No custom formats to process");
return; return;
} }
var serviceData = await _phases.ApiFetchPhase.Execute(config); var serviceData = await phases.ApiFetchPhase.Execute(config);
cache = cache.RemoveStale(serviceData); 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) if (settings.Preview)
{ {
return; 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 InstanceName = config.InstanceName
}); });

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

@ -3,20 +3,13 @@ using Recyclarr.ServarrApi.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases; 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) public async Task Execute(IServiceConfiguration config, CustomFormatTransactionData transactions)
{ {
foreach (var cf in transactions.NewCustomFormats) 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) if (response is not null)
{ {
cf.Id = response.Id; cf.Id = response.Id;
@ -25,12 +18,12 @@ public class CustomFormatApiPersistencePhase
foreach (var dto in transactions.UpdatedCustomFormats) foreach (var dto in transactions.UpdatedCustomFormats)
{ {
await _api.UpdateCustomFormat(config, dto); await api.UpdateCustomFormat(config, dto);
} }
foreach (var map in transactions.DeletedCustomFormats) 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; 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) public IReadOnlyCollection<CustomFormatData> Execute(IServiceConfiguration config)
{ {
// Match custom formats in the YAML config to those in the guide, by Trash ID // 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 var processedCfs = config.CustomFormats
.SelectMany(x => x.TrashIds) .SelectMany(x => x.TrashIds)
.Distinct(StringComparer.InvariantCultureIgnoreCase) .Distinct(StringComparer.InvariantCultureIgnoreCase)
.GroupJoin(_guide.GetCustomFormatData(config.ServiceType), .GroupJoin(guide.GetCustomFormatData(config.ServiceType),
x => x, x => x,
x => x.TrashId, x => x.TrashId,
(id, cf) => (Id: id, CustomFormats: cf)) (id, cf) => (Id: id, CustomFormats: cf))
@ -39,11 +28,11 @@ public class CustomFormatConfigPhase
var invalidCfs = processedCfs[false].Select(x => x.Id).ToList(); var invalidCfs = processedCfs[false].Select(x => x.Id).ToList();
if (invalidCfs.IsNotEmpty()) 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(); var validCfs = processedCfs[true].SelectMany(x => x.CustomFormats).ToList();
_cache.AddCustomFormats(validCfs); cache.AddCustomFormats(validCfs);
return validCfs; return validCfs;
} }
} }

@ -1,19 +1,12 @@
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases; 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) public void Execute(CustomFormatTransactionData transactions)
{ {
foreach (var (guideCf, conflictingId) in transactions.ConflictingCustomFormats) foreach (var (guideCf, conflictingId) in transactions.ConflictingCustomFormats)
{ {
_log.Warning( log.Warning(
"Custom Format with name {Name} (Trash ID: {TrashId}) will be skipped because another " + "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 " + "CF already exists with that name (ID: {ConflictId}). To fix the conflict, delete or " +
"rename the CF with the mentioned name", "rename the CF with the mentioned name",
@ -23,30 +16,30 @@ public class CustomFormatPreviewPhase
var created = transactions.NewCustomFormats; var created = transactions.NewCustomFormats;
if (created.Count > 0) 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) 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; var updated = transactions.UpdatedCustomFormats;
if (updated.Count > 0) 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) 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; var skipped = transactions.UnchangedCustomFormats;
if (skipped.Count > 0) if (skipped.Count > 0)
{ {
_log.Information("Skipped {Count} Custom Formats that did not change", skipped.Count); log.Information("Skipped {Count} Custom Formats that did not change", skipped.Count);
_log.Debug("Custom Formats Skipped: {CustomFormats}", log.Debug("Custom Formats Skipped: {CustomFormats}",
skipped.ToDictionary(k => k.TrashId, v => v.Name)); skipped.ToDictionary(k => k.TrashId, v => v.Name));
// Do not print skipped CFs to console; they are too verbose // Do not print skipped CFs to console; they are too verbose
@ -55,22 +48,22 @@ public class CustomFormatPreviewPhase
var deleted = transactions.DeletedCustomFormats; var deleted = transactions.DeletedCustomFormats;
if (deleted.Count > 0) 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) 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; var totalCount = created.Count + updated.Count + deleted.Count;
if (totalCount > 0) if (totalCount > 0)
{ {
_log.Information("Total of {Count} custom formats were synced", totalCount); log.Information("Total of {Count} custom formats were synced", totalCount);
} }
else 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; 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")] [SuppressMessage("Performance", "CA1822:Mark members as static")]
public CustomFormatTransactionData Execute( public CustomFormatTransactionData Execute(
IServiceConfiguration config, IServiceConfiguration config,
@ -27,7 +20,7 @@ public class CustomFormatTransactionPhase
foreach (var guideCf in guideCfs) 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; 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 // - Use the ID from the service, not the cache, and do an update
if (guideCf.Id != serviceCf.Id) if (guideCf.Id != serviceCf.Id)
{ {
_log.Debug( log.Debug(
"Format IDs for CF {Name} did not match which indicates a manually-created CF is " + "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})", "replaced, or that the cache is out of sync with the service ({GuideId} != {ServiceId})",
serviceCf.Name, guideCf.Id, serviceCf.Id); serviceCf.Name, guideCf.Id, serviceCf.Id);

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

@ -14,34 +14,27 @@ public interface IMediaNamingPipelinePhases
MediaNamingApiPersistencePhase ApiPersistencePhase { get; } 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) public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{ {
var processedNaming = await _phases.ConfigPhase.Execute(config); var processedNaming = await phases.ConfigPhase.Execute(config);
if (_phases.Logger.LogConfigPhaseAndExitIfNeeded(processedNaming)) if (phases.Logger.LogConfigPhaseAndExitIfNeeded(processedNaming))
{ {
return; 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) if (settings.Preview)
{ {
_phases.PreviewPhase.Execute(transactions); phases.PreviewPhase.Execute(transactions);
return; return;
} }
await _phases.ApiPersistencePhase.Execute(config, transactions); await phases.ApiPersistencePhase.Execute(config, transactions);
_phases.Logger.LogPersistenceResults(serviceData, transactions); phases.Logger.LogPersistenceResults(serviceData, transactions);
} }
} }

@ -3,17 +3,10 @@ using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases; 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) 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; 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) 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 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(); private List<InvalidNamingConfig> _errors = new();
public MediaNamingConfigPhase(IMediaNamingGuideService guide, ISonarrCapabilityFetcher sonarrCapabilities)
{
_guide = guide;
_sonarrCapabilities = sonarrCapabilities;
}
public async Task<ProcessedNamingConfig> Execute(IServiceConfiguration config) public async Task<ProcessedNamingConfig> Execute(IServiceConfiguration config)
{ {
_errors = new List<InvalidNamingConfig>(); _errors = new List<InvalidNamingConfig>();
@ -39,9 +31,9 @@ public class MediaNamingConfigPhase
return new ProcessedNamingConfig {Dto = dto, InvalidNaming = _errors}; 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; var configData = config.MediaNaming;
return new RadarrMediaNamingDto return new RadarrMediaNamingDto
@ -54,9 +46,9 @@ public class MediaNamingConfigPhase
private async Task<MediaNamingDto> ProcessSonarrNaming(SonarrConfiguration config) private async Task<MediaNamingDto> ProcessSonarrNaming(SonarrConfiguration config)
{ {
var guideData = _guide.GetSonarrNamingData(); var guideData = guide.GetSonarrNamingData();
var configData = config.MediaNaming; var configData = config.MediaNaming;
var capabilities = await _sonarrCapabilities.GetCapabilities(config); var capabilities = await sonarrCapabilities.GetCapabilities(config);
var keySuffix = capabilities.SupportsCustomFormats ? ":4" : ":3"; var keySuffix = capabilities.SupportsCustomFormats ? ":4" : ":3";
return new SonarrMediaNamingDto return new SonarrMediaNamingDto

@ -2,23 +2,16 @@ using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases; 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. // Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(ProcessedNamingConfig config) public bool LogConfigPhaseAndExitIfNeeded(ProcessedNamingConfig config)
{ {
if (config.InvalidNaming.Any()) if (config.InvalidNaming.Count != 0)
{ {
foreach (var (topic, invalidValue) in config.InvalidNaming) 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; return true;
@ -31,9 +24,9 @@ public class MediaNamingPhaseLogger
_ => throw new ArgumentException("Unsupported configuration type in LogConfigPhase method") _ => 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; return true;
} }
@ -49,14 +42,14 @@ public class MediaNamingPhaseLogger
_ => throw new ArgumentException("Unsupported configuration type in LogPersistenceResults method") _ => throw new ArgumentException("Unsupported configuration type in LogPersistenceResults method")
}; };
if (differences.Any()) if (differences.Count != 0)
{ {
_log.Information("Media naming has been updated"); log.Information("Media naming has been updated");
_log.Debug("Naming differences: {Diff}", differences); log.Debug("Naming differences: {Diff}", differences);
} }
else 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; namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingPreviewPhase public class MediaNamingPreviewPhase(IAnsiConsole console)
{ {
private readonly IAnsiConsole _console;
private Table? _table; private Table? _table;
public MediaNamingPreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(MediaNamingDto serviceDto) public void Execute(MediaNamingDto serviceDto)
{ {
_table = new Table() _table = new Table()
@ -33,8 +27,8 @@ public class MediaNamingPreviewPhase
throw new ArgumentException("Config type not supported in media naming preview"); throw new ArgumentException("Config type not supported in media naming preview");
} }
_console.WriteLine(); console.WriteLine();
_console.Write(_table); console.Write(_table);
} }
private void AddRow(string field, object? value) 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 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) public async Task<QualityProfileServiceData> Execute(IServiceConfiguration config)
{ {
var profiles = await _api.GetQualityProfiles(config); var profiles = await api.GetQualityProfiles(config);
var schema = await _api.GetSchema(config); var schema = await api.GetSchema(config);
return new QualityProfileServiceData(profiles.AsReadOnly(), schema); return new QualityProfileServiceData(profiles.AsReadOnly(), schema);
} }
} }

@ -3,33 +3,22 @@ using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases; 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) public async Task Execute(IServiceConfiguration config, QualityProfileTransactionData transactions)
{ {
var profilesWithStats = transactions.UpdatedProfiles var profilesWithStats = transactions.UpdatedProfiles
.Select(x => _statCalculator.Calculate(x)) .Select(x => statCalculator.Calculate(x))
.ToLookup(x => x.HasChanges); .ToLookup(x => x.HasChanges);
// Profiles without changes (false) get logged // Profiles without changes (false) get logged
var unchangedProfiles = profilesWithStats[false].ToList(); 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)); unchangedProfiles.Select(x => x.Profile.ProfileName));
} }
@ -42,11 +31,11 @@ public class QualityProfileApiPersistencePhase
switch (profile.UpdateReason) switch (profile.UpdateReason)
{ {
case QualityProfileUpdateReason.New: case QualityProfileUpdateReason.New:
await _api.CreateQualityProfile(config, dto); await api.CreateQualityProfile(config, dto);
break; break;
case QualityProfileUpdateReason.Changed: case QualityProfileUpdateReason.Changed:
await _api.UpdateQualityProfile(config, dto); await api.UpdateQualityProfile(config, dto);
break; break;
default: default:
@ -66,7 +55,7 @@ public class QualityProfileApiPersistencePhase
if (createdProfiles.Count > 0) 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 var updatedProfiles = changedProfiles
@ -76,7 +65,7 @@ public class QualityProfileApiPersistencePhase
if (updatedProfiles.Count > 0) 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) if (changedProfiles.Count != 0)
@ -85,14 +74,14 @@ public class QualityProfileApiPersistencePhase
var numQuality = changedProfiles.Count(x => x.QualitiesChanged); var numQuality = changedProfiles.Count(x => x.QualitiesChanged);
var numScores = changedProfiles.Count(x => x.ScoresChanged); var numScores = changedProfiles.Count(x => x.ScoresChanged);
_log.Information( log.Information(
"A total of {NumProfiles} profiles were synced. {NumQuality} contain quality changes and " + "A total of {NumProfiles} profiles were synced. {NumQuality} contain quality changes and " +
"{NumScores} contain updated scores", "{NumScores} contain updated scores",
numProfiles, numQuality, numScores); numProfiles, numQuality, numScores);
} }
else 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 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) public IReadOnlyCollection<ProcessedQualityProfileData> Execute(IServiceConfiguration config)
{ {
// 1. For each group of CFs that has a quality profile specified // 1. For each group of CFs that has a quality profile specified
@ -35,7 +26,7 @@ public class QualityProfileConfigPhase
.SelectMany(x => x.QualityProfiles .SelectMany(x => x.QualityProfiles
.Select(y => (Profile: y, x.TrashIds))) .Select(y => (Profile: y, x.TrashIds)))
.SelectMany(x => x.TrashIds .SelectMany(x => x.TrashIds
.Select(_cache.LookupByTrashId) .Select(cache.LookupByTrashId)
.NotNull() .NotNull()
.Select(y => (x.Profile, Cf: y))); .Select(y => (x.Profile, Cf: y)));
@ -47,7 +38,7 @@ public class QualityProfileConfigPhase
{ {
if (!allProfiles.TryGetValue(profile.Name, out var profileCfs)) 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 // 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). // for consistency (at the very least for the name).
@ -76,14 +67,14 @@ public class QualityProfileConfigPhase
.Select(x => (x.Name, x.TrashId)) .Select(x => (x.Name, x.TrashId))
.ToList(); .ToList();
if (!scoreless.Any()) if (scoreless.Count == 0)
{ {
return; return;
} }
foreach (var (name, trashId) in scoreless) 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) if (existingScore.Score != scoreToUse)
{ {
_log.Warning( log.Warning(
"Custom format {Name} ({TrashId}) is duplicated in quality profile {ProfileName} with a score " + "Custom format {Name} ({TrashId}) is duplicated in quality profile {ProfileName} with a score " +
"of {NewScore}, which is different from the original score of {OriginalScore}", "of {NewScore}, which is different from the original score of {OriginalScore}",
cf.Name, cf.TrashId, scoreConfig.Name, scoreToUse, existingScore.Score); cf.Name, cf.TrashId, scoreConfig.Name, scoreToUse, existingScore.Score);
} }
else 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; return;
@ -139,7 +130,7 @@ public class QualityProfileConfigPhase
return scoreFromSet; 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; return cf.DefaultScore;

@ -4,29 +4,22 @@ using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases; namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
[UsedImplicitly] [UsedImplicitly]
public class QualityProfileNoticePhase public class QualityProfileNoticePhase(ILogger log)
{ {
private readonly ILogger _log;
public QualityProfileNoticePhase(ILogger log)
{
_log = log;
}
public void Execute(QualityProfileTransactionData transactions) public void Execute(QualityProfileTransactionData transactions)
{ {
if (transactions.NonExistentProfiles.Count > 0) if (transactions.NonExistentProfiles.Count > 0)
{ {
_log.Warning( log.Warning(
"The following quality profile names have no definition in the top-level `quality_profiles` " + "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 " + "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"); "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) if (transactions.InvalidProfiles.Count > 0)
{ {
_log.Warning( log.Warning(
"The following validation errors occurred for one or more quality profiles. " + "The following validation errors occurred for one or more quality profiles. " +
"These profiles will *not* be synced"); "These profiles will *not* be synced");
@ -34,33 +27,33 @@ public class QualityProfileNoticePhase
foreach (var (profile, errors) in transactions.InvalidProfiles) foreach (var (profile, errors) in transactions.InvalidProfiles)
{ {
numErrors += errors.LogValidationErrors(_log, $"Profile '{profile.ProfileName}'"); numErrors += errors.LogValidationErrors(log, $"Profile '{profile.ProfileName}'");
} }
if (numErrors > 0) 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 var invalidQualityNames = transactions.UpdatedProfiles
.Select(x => (x.ProfileName, x.UpdatedQualities.InvalidQualityNames)) .Select(x => (x.ProfileName, x.UpdatedQualities.InvalidQualityNames))
.Where(x => x.InvalidQualityNames.Any()) .Where(x => x.InvalidQualityNames.Count != 0)
.ToList(); .ToList();
foreach (var (profileName, invalidNames) in invalidQualityNames) 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); profileName, invalidNames);
} }
var invalidCfExceptNames = transactions.UpdatedProfiles var invalidCfExceptNames = transactions.UpdatedProfiles
.Where(x => x.InvalidExceptCfNames.Any()) .Where(x => x.InvalidExceptCfNames.Count != 0)
.Select(x => (x.ProfileName, x.InvalidExceptCfNames)); .Select(x => (x.ProfileName, x.InvalidExceptCfNames));
foreach (var (profileName, invalidNames) in invalidCfExceptNames) foreach (var (profileName, invalidNames) in invalidCfExceptNames)
{ {
_log.Warning( log.Warning(
"`except` under `reset_unmatched_scores` in quality profile '{ProfileName}' has invalid " + "`except` under `reset_unmatched_scores` in quality profile '{ProfileName}' has invalid " +
"CF names: {CfNames}", profileName, invalidNames); "CF names: {CfNames}", profileName, invalidNames);
} }

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

@ -14,18 +14,11 @@ public record ProfileWithStats
public bool HasChanges => ProfileChanged || ScoresChanged || QualitiesChanged; 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) 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 stats = new ProfileWithStats {Profile = profile};
var oldDto = profile.ProfileDto; var oldDto = profile.ProfileDto;
@ -49,7 +42,7 @@ public class QualityProfileStatCalculator
void Log<T>(string msg, T oldValue, T newValue) 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); stats.ProfileChanged |= !EqualityComparer<T>.Default.Equals(oldValue, newValue);
} }
} }
@ -75,11 +68,11 @@ public class QualityProfileStatCalculator
return; 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) 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); dto.Name, dto.Format, dto.Score, newScore, reason);
} }

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

@ -14,37 +14,28 @@ public interface IQualityProfilePipelinePhases
QualityProfileNoticePhase NoticePhase { get; } 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) public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{ {
var guideData = _phases.ConfigPhase.Execute(config); var guideData = phases.ConfigPhase.Execute(config);
if (!guideData.Any()) if (guideData.Count == 0)
{ {
_log.Debug("No quality profiles to process"); log.Debug("No quality profiles to process");
return; return;
} }
var serviceData = await _phases.ApiFetchPhase.Execute(config); var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(guideData, serviceData); var transactions = phases.TransactionPhase.Execute(guideData, serviceData);
_phases.NoticePhase.Execute(transactions); phases.NoticePhase.Execute(transactions);
if (settings.Preview) if (settings.Preview)
{ {
_phases.PreviewPhase.Value.Execute(transactions); phases.PreviewPhase.Value.Execute(transactions);
return; 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; 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) 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; 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) public async Task Execute(IServiceConfiguration config, IList<ServiceQualityDefinitionItem> serverQuality)
{ {
await _api.UpdateQualityDefinition(config, serverQuality); await api.UpdateQualityDefinition(config, serverQuality);
_log.Information("Number of updated qualities: {Count}", serverQuality.Count); log.Information("Number of updated qualities: {Count}", serverQuality.Count);
} }
} }

@ -4,37 +4,28 @@ using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases; 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) public QualitySizeData? Execute(IServiceConfiguration config)
{ {
var qualityDef = config.QualityDefinition; var qualityDef = config.QualityDefinition;
if (qualityDef is null) if (qualityDef is null)
{ {
_log.Debug("{Instance} has no quality definition", config.InstanceName); log.Debug("{Instance} has no quality definition", config.InstanceName);
return null; return null;
} }
var qualityDefinitions = _guide.GetQualitySizeData(config.ServiceType); var qualityDefinitions = guide.GetQualitySizeData(config.ServiceType);
var selectedQuality = qualityDefinitions var selectedQuality = qualityDefinitions
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityDef.Type)); .FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityDef.Type));
if (selectedQuality == null) 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; return null;
} }
_log.Information("Processing Quality Definition: {QualityDefinition}", qualityDef.Type); log.Information("Processing Quality Definition: {QualityDefinition}", qualityDef.Type);
AdjustPreferredRatio(qualityDef, selectedQuality); AdjustPreferredRatio(qualityDef, selectedQuality);
return selectedQuality; return selectedQuality;
} }
@ -46,13 +37,13 @@ public class QualitySizeGuidePhase
return; 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 // Fix an out of range ratio and warn the user
if (config.PreferredRatio is < 0 or > 1) if (config.PreferredRatio is < 0 or > 1)
{ {
var clampedRatio = Math.Clamp(config.PreferredRatio.Value, 0, 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}", "It must be a decimal between 0.0 and 1.0. It has been clamped to {ClampedRatio}",
config.PreferredRatio, clampedRatio); config.PreferredRatio, clampedRatio);

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

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

@ -4,28 +4,19 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.QualitySize; 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) 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) .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 " + "\nThe above quality definition types can be used with the `quality_definition:` property in your " +
"recyclarr.yml file."); "recyclarr.yml file.");
} }

@ -13,34 +13,25 @@ public interface IQualitySizePipelinePhases
QualitySizeApiPersistencePhase ApiPersistencePhase { get; } 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) public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{ {
var selectedQuality = _phases.GuidePhase.Execute(config); var selectedQuality = phases.GuidePhase.Execute(config);
if (selectedQuality is null) if (selectedQuality is null)
{ {
_log.Debug("No quality definition to process"); log.Debug("No quality definition to process");
return; return;
} }
if (settings.Preview) if (settings.Preview)
{ {
_phases.PreviewPhase.Value.Execute(selectedQuality); phases.PreviewPhase.Value.Execute(selectedQuality);
return; return;
} }
var serviceData = await _phases.ApiFetchPhase.Execute(config); var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(selectedQuality.Qualities, serviceData); var transactions = phases.TransactionPhase.Execute(selectedQuality.Qualities, serviceData);
await _phases.ApiPersistencePhase.Execute(config, transactions); await phases.ApiPersistencePhase.Execute(config, transactions);
} }
} }

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

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

@ -3,17 +3,11 @@ using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters; 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) 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; 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) public ReleaseProfileData Transform(ReleaseProfileData profile, ReleaseProfileConfig config)
{ {
if (!config.StrictNegativeScores) if (!config.StrictNegativeScores)
@ -19,7 +12,7 @@ public class StrictNegativeScoresFilter : IReleaseProfileFilter
return profile; 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); var splitPreferred = profile.Preferred.ToLookup(x => x.Score < 0);
return profile with return profile with

@ -3,18 +3,10 @@ using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases; 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) 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; 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) public async Task Execute(IServiceConfiguration config, ReleaseProfileTransactionData transactions)
{ {
foreach (var profile in transactions.UpdatedProfiles) foreach (var profile in transactions.UpdatedProfiles)
{ {
_log.Information("Update existing profile: {ProfileName}", profile.Name); log.Information("Update existing profile: {ProfileName}", profile.Name);
await _api.UpdateReleaseProfile(config, profile); await api.UpdateReleaseProfile(config, profile);
} }
foreach (var profile in transactions.CreatedProfiles) foreach (var profile in transactions.CreatedProfiles)
{ {
_log.Information("Create new profile: {ProfileName}", profile.Name); log.Information("Create new profile: {ProfileName}", profile.Name);
await _api.CreateReleaseProfile(config, profile); await api.CreateReleaseProfile(config, profile);
} }
foreach (var profile in transactions.DeletedProfiles) foreach (var profile in transactions.DeletedProfiles)
{ {
_log.Information("Deleting old release profile: {ProfileName}", profile.Name); log.Information("Deleting old release profile: {ProfileName}", profile.Name);
await _api.DeleteReleaseProfile(config, profile.Id); await api.DeleteReleaseProfile(config, profile.Id);
} }
} }
} }

@ -10,31 +10,20 @@ public record ProcessedReleaseProfileData(
IReadOnlyCollection<string> Tags 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) public IReadOnlyList<ProcessedReleaseProfileData>? Execute(SonarrConfiguration config)
{ {
if (config.ReleaseProfiles.IsEmpty()) if (config.ReleaseProfiles.IsEmpty())
{ {
_log.Debug("{Instance} has no release profiles", config.InstanceName); log.Debug("{Instance} has no release profiles", config.InstanceName);
return null; return null;
} }
var profilesFromGuide = _guide.GetReleaseProfileData(); var profilesFromGuide = guide.GetReleaseProfileData();
var filteredProfiles = new List<ProcessedReleaseProfileData>(); var filteredProfiles = new List<ProcessedReleaseProfileData>();
var configProfiles = config.ReleaseProfiles.SelectMany(x => x.TrashIds.Select(y => (TrashId: y, Config: x))); 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)); var selectedProfile = profilesFromGuide.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId));
if (selectedProfile is null) 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; continue;
} }
_log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name, log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name,
selectedProfile.TrashId); selectedProfile.TrashId);
selectedProfile = _filters.Process(selectedProfile, configProfile); selectedProfile = filters.Process(selectedProfile, configProfile);
filteredProfiles.Add(new ProcessedReleaseProfileData(selectedProfile, configProfile.Tags)); filteredProfiles.Add(new ProcessedReleaseProfileData(selectedProfile, configProfile.Tags));
} }

@ -4,15 +4,8 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases; 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) public void Execute(ReleaseProfileTransactionData profiles)
{ {
var tree = new Tree("Release Profiles [red](Preview)[/]"); var tree = new Tree("Release Profiles [red](Preview)[/]");
@ -20,8 +13,8 @@ public class ReleaseProfilePreviewPhase
PrintCategoryOfChanges("Created Profiles", tree, profiles.CreatedProfiles); PrintCategoryOfChanges("Created Profiles", tree, profiles.CreatedProfiles);
PrintCategoryOfChanges("Updated Profiles", tree, profiles.UpdatedProfiles); PrintCategoryOfChanges("Updated Profiles", tree, profiles.UpdatedProfiles);
_console.WriteLine(); console.WriteLine();
_console.Write(tree); console.Write(tree);
} }
private void PrintCategoryOfChanges(string nodeTitle, Tree tree, IEnumerable<SonarrReleaseProfile> profiles) private void PrintCategoryOfChanges(string nodeTitle, Tree tree, IEnumerable<SonarrReleaseProfile> profiles)
@ -44,7 +37,7 @@ public class ReleaseProfilePreviewPhase
PrintTerms(rpNode, "Must Not Contain", profile.Ignored); PrintTerms(rpNode, "Must Not Contain", profile.Ignored);
PrintPreferredTerms(rpNode, "Preferred", profile.Preferred); PrintPreferredTerms(rpNode, "Preferred", profile.Preferred);
_console.WriteLine(""); console.WriteLine("");
} }
private static void PrintTerms(TreeNode tree, string title, IReadOnlyCollection<string> terms) 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; 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( public ReleaseProfileTransactionData Execute(
IReadOnlyList<ProcessedReleaseProfileData> configProfiles, IReadOnlyList<ProcessedReleaseProfileData> configProfiles,
IList<SonarrReleaseProfile> serviceData) IList<SonarrReleaseProfile> serviceData)
@ -43,7 +36,7 @@ public class ReleaseProfileTransactionPhase
return new ReleaseProfileTransactionData(updated, created, deleted); return new ReleaseProfileTransactionData(updated, created, deleted);
} }
private static IReadOnlyList<SonarrReleaseProfile> DeleteOldManagedProfiles( private static List<SonarrReleaseProfile> DeleteOldManagedProfiles(
IList<SonarrReleaseProfile> serviceData, IList<SonarrReleaseProfile> serviceData,
IReadOnlyList<ProcessedReleaseProfileData> configProfiles) IReadOnlyList<ProcessedReleaseProfileData> configProfiles)
{ {
@ -67,7 +60,7 @@ public class ReleaseProfileTransactionPhase
profileToUpdate.Required = profile.Profile.Required.Select(x => x.Term).ToList(); profileToUpdate.Required = profile.Profile.Required.Select(x => x.Term).ToList();
profileToUpdate.IncludePreferredWhenRenaming = profile.Profile.IncludePreferredWhenRenaming; profileToUpdate.IncludePreferredWhenRenaming = profile.Profile.IncludePreferredWhenRenaming;
profileToUpdate.Tags = profile.Tags profileToUpdate.Tags = profile.Tags
.Select(x => _tagCache.GetTagIdByName(x)) .Select(tagCache.GetTagIdByName)
.NotNull() .NotNull()
.ToList(); .ToList();
} }

@ -5,28 +5,19 @@ using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile; 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() 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) 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."); "\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) public void ListTerms(string releaseProfileId)
{ {
var profile = _guide.GetReleaseProfileData() var profile = guide.GetReleaseProfileData()
.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(releaseProfileId)); .FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(releaseProfileId));
if (profile is null) if (profile is null)
@ -60,37 +51,37 @@ public class ReleaseProfileDataLister
"(terms must have Trash IDs assigned in order to be filtered)"); "(terms must have Trash IDs assigned in order to be filtered)");
} }
_console.WriteLine(); console.WriteLine();
_console.WriteLine($"List of Terms for the '{profile.Name}' Release Profile that may be filtered:\n"); console.WriteLine($"List of Terms for the '{profile.Name}' Release Profile that may be filtered:\n");
PrintTerms(profile.Required, "Required"); PrintTerms(profile.Required, "Required");
PrintTerms(profile.Ignored, "Ignored"); PrintTerms(profile.Ignored, "Ignored");
PrintTerms(profile.Preferred.SelectMany(x => x.Terms), "Preferred"); 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."); "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) private void PrintTerms(IEnumerable<TermData> terms, string category)
{ {
var filteredTerms = terms.Where(x => x.TrashId.Any()).ToList(); var filteredTerms = terms.Where(x => x.TrashId.Length != 0).ToList();
if (!filteredTerms.Any()) if (filteredTerms.Count == 0)
{ {
return; return;
} }
_console.WriteLine($"{category} Terms:\n"); console.WriteLine($"{category} Terms:\n");
foreach (var term in filteredTerms) foreach (var term in filteredTerms)
{ {
var line = new StringBuilder($" - {term.TrashId}"); var line = new StringBuilder($" - {term.TrashId}");
if (term.Name.Any()) if (term.Name.Length != 0)
{ {
line.Append($" # {term.Name}"); 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; } 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) public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{ {
if (config is not SonarrConfiguration sonarrConfig) 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); config.InstanceName);
return; return;
} }
var profiles = _phases.ConfigPhase.Execute(sonarrConfig); var profiles = phases.ConfigPhase.Execute(sonarrConfig);
if (profiles is null) if (profiles is null)
{ {
_log.Debug("No release profiles to process"); log.Debug("No release profiles to process");
return; return;
} }
var serviceData = await _phases.ApiFetchPhase.Execute(config); var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(profiles, serviceData); var transactions = phases.TransactionPhase.Execute(profiles, serviceData);
if (settings.Preview) if (settings.Preview)
{ {
_phases.PreviewPhase.Value.Execute(transactions); phases.PreviewPhase.Value.Execute(transactions);
return; 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; 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) public async Task<IList<SonarrTag>> Execute(IServiceConfiguration config)
{ {
var tags = await _api.GetTags(config); var tags = await api.GetTags(config);
_cache.Clear(); cache.Clear();
_cache.AddTags(tags); cache.AddTags(tags);
return tags; return tags;
} }
} }

@ -3,31 +3,20 @@ using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases; 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) public async Task Execute(IServiceConfiguration config, IEnumerable<string> tagsToCreate)
{ {
var createdTags = new List<SonarrTag>(); var createdTags = new List<SonarrTag>();
foreach (var tag in tagsToCreate) foreach (var tag in tagsToCreate)
{ {
_log.Debug("Creating Tag: {Tag}", tag); log.Debug("Creating Tag: {Tag}", tag);
createdTags.Add(await _api.CreateTag(config, 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; 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) public void Execute(IReadOnlyList<string> tagsToCreate)
{ {
if (tagsToCreate.IsNullOrEmpty()) if (tagsToCreate.IsNullOrEmpty())
{ {
_console.WriteLine(); console.WriteLine();
_console.MarkupLine("[green]No tags to create[/]"); console.MarkupLine("[green]No tags to create[/]");
_console.WriteLine(); console.WriteLine();
return; return;
} }
@ -30,6 +23,6 @@ public class TagPreviewPhase
table.AddRow(tag); table.AddRow(tag);
} }
_console.Write(table); console.Write(table);
} }
} }

@ -13,43 +13,34 @@ public interface ITagPipelinePhases
TagApiPersistencePhase ApiPersistencePhase { get; } 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) public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{ {
if (config is not SonarrConfiguration sonarrConfig) 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; return;
} }
var tags = _phases.ConfigPhase.Execute(sonarrConfig); var tags = phases.ConfigPhase.Execute(sonarrConfig);
if (tags is null) if (tags is null)
{ {
_log.Debug("No tags to process"); log.Debug("No tags to process");
return; return;
} }
var serviceData = await _phases.ApiFetchPhase.Execute(config); var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(tags, serviceData); var transactions = phases.TransactionPhase.Execute(tags, serviceData);
if (settings.Preview) if (settings.Preview)
{ {
_phases.PreviewPhase.Value.Execute(transactions.AsReadOnly()); phases.PreviewPhase.Value.Execute(transactions.AsReadOnly());
return; 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; 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) 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) if (creator is null)
{ {
throw new FatalException("Unable to determine which config creation logic to use"); 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; 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() 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>(); var rows = new List<IRenderable>();
BuildInstanceTree(rows, configs, SupportedServices.Radarr); BuildInstanceTree(rows, configs, SupportedServices.Radarr);
BuildInstanceTree(rows, configs, SupportedServices.Sonarr); BuildInstanceTree(rows, configs, SupportedServices.Sonarr);
if (!rows.Any()) if (rows.Count == 0)
{ {
rows.Add(new Markup("([red]Empty[/])")); rows.Add(new Markup("([red]Empty[/])"));
} }
@ -54,24 +41,24 @@ public class ConfigListLocalProcessor
tree.AddNode(configTree); tree.AddNode(configTree);
} }
_console.WriteLine(); console.WriteLine();
_console.Write(tree); console.Write(tree);
} }
private string MakeRelative(IFileInfo path) private string MakeRelative(IFileInfo path)
{ {
var configPath = new Uri(path.FullName, UriKind.Absolute); 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(); return configDir.MakeRelativeUri(configPath).ToString();
} }
private static void BuildInstanceTree( private static void BuildInstanceTree(
ICollection<IRenderable> rows, List<IRenderable> rows,
IReadOnlyCollection<IServiceConfiguration> registry, IReadOnlyCollection<IServiceConfiguration> registry,
SupportedServices service) SupportedServices service)
{ {
var configs = registry.GetConfigsOfType(service).ToList(); var configs = registry.GetConfigsOfType(service).ToList();
if (!configs.Any()) if (configs.Count == 0)
{ {
return; return;
} }

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

@ -11,26 +11,14 @@ namespace Recyclarr.Cli.Processors.Config;
/// opportunity to use this /// opportunity to use this
/// with the GUI. /// with the GUI.
/// </remarks> /// </remarks>
public class ConfigManipulator : IConfigManipulator public class ConfigManipulator(
IAnsiConsole console,
ConfigParser configParser,
ConfigSaver configSaver,
ConfigValidationExecutor validator)
: IConfigManipulator
{ {
private readonly IAnsiConsole _console; private static Dictionary<string, TConfig> InvokeCallbackForEach<TConfig>(
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>(
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback, Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback,
IReadOnlyDictionary<string, TConfig>? configs) IReadOnlyDictionary<string, TConfig>? configs)
where TConfig : ServiceConfigYaml where TConfig : ServiceConfigYaml
@ -60,7 +48,7 @@ public class ConfigManipulator : IConfigManipulator
// - Run validation & report issues // - Run validation & report issues
// - Consistently reformat the output file (when it is saved again) // - Consistently reformat the output file (when it is saved again)
// - Ignore stuff for diffing purposes, such as comments. // - Ignore stuff for diffing purposes, such as comments.
var config = _configParser.Load<RootConfigYaml>(source); var config = configParser.Load<RootConfigYaml>(source);
if (config is null) if (config is null)
{ {
// Do not log here, since ConfigParser already has substantial logging // Do not log here, since ConfigParser already has substantial logging
@ -73,13 +61,13 @@ public class ConfigManipulator : IConfigManipulator
Sonarr = InvokeCallbackForEach(editCallback, config.Sonarr) 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. " + "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."); "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; namespace Recyclarr.Cli.Processors.Config;
public class FileExistsException : Exception public class FileExistsException(string attemptedPath) : Exception
{ {
public FileExistsException(string attemptedPath) public string AttemptedPath { get; } = attemptedPath;
{
AttemptedPath = attemptedPath;
}
public string AttemptedPath { get; }
} }

@ -6,21 +6,9 @@ using Recyclarr.Platform;
namespace Recyclarr.Cli.Processors.Config; 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) public bool CanHandle(ICreateConfigSettings settings)
{ {
return true; return true;
@ -29,8 +17,8 @@ public class LocalConfigCreator : IConfigCreator
public void Create(ICreateConfigSettings settings) public void Create(ICreateConfigSettings settings)
{ {
var configFile = settings.Path is null var configFile = settings.Path is null
? _paths.AppDataDirectory.File("recyclarr.yml") ? paths.AppDataDirectory.File("recyclarr.yml")
: _fs.FileInfo.New(settings.Path); : fs.FileInfo.New(settings.Path);
if (configFile.Exists) if (configFile.Exists)
{ {
@ -40,9 +28,9 @@ public class LocalConfigCreator : IConfigCreator
configFile.CreateParentDirectory(); configFile.CreateParentDirectory();
using var stream = configFile.CreateText(); using var stream = configFile.CreateText();
var ymlData = _resources.ReadData("config-template.yml"); var ymlData = resources.ReadData("config-template.yml");
stream.Write(ymlData); 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; 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) public bool CanHandle(ICreateConfigSettings settings)
{ {
return settings.Templates.Any(); return settings.Templates.Count != 0;
} }
public void Create(ICreateConfigSettings settings) 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) .IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase)
.Select(x => x.TemplateFile); .Select(x => x.TemplateFile);
@ -44,7 +33,7 @@ public class TemplateConfigCreator : IConfigCreator
} }
catch (FileExistsException e) 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) catch (FileLoadException)
{ {
@ -53,7 +42,7 @@ public class TemplateConfigCreator : IConfigCreator
} }
catch (Exception e) catch (Exception e)
{ {
_log.Error(e, "Unable to save configuration template file"); log.Error(e, "Unable to save configuration template file");
throw; throw;
} }
} }
@ -61,7 +50,7 @@ public class TemplateConfigCreator : IConfigCreator
private void CopyTemplate(IFileInfo templateFile, ICreateConfigSettings settings) 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) if (destinationFile.Exists && !settings.Force)
{ {
@ -74,11 +63,11 @@ public class TemplateConfigCreator : IConfigCreator
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (destinationFile.Exists) if (destinationFile.Exists)
{ {
_log.Information("Replacing existing file: {Path}", destinationFile); log.Information("Replacing existing file: {Path}", destinationFile);
} }
else 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; 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) public async Task Process(IDeleteCustomFormatSettings settings)
{ {
var config = GetTargetConfig(settings); var config = GetTargetConfig(settings);
@ -43,7 +29,7 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (!settings.All) 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."); 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()) 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; return;
} }
@ -61,14 +47,14 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (settings.Preview) 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; return;
} }
if (!settings.Force && 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; return;
} }
@ -79,7 +65,7 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{ {
if (config is SonarrConfiguration) if (config is SonarrConfiguration)
{ {
var capabilities = await _sonarCapabilities.GetCapabilities(config); var capabilities = await sonarCapabilities.GetCapabilities(config);
if (!capabilities.SupportsCustomFormats) if (!capabilities.SupportsCustomFormats)
{ {
throw new ServiceIncompatibilityException("Custom formats are not supported in Sonarr v3"); 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")] [SuppressMessage("Design", "CA1031:Do not catch general exception types")]
private async Task DeleteCustomFormats(ICollection<CustomFormatData> cfs, IServiceConfiguration config) 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); var task = ctx.AddTask("Deleting Custom Formats").MaxValue(cfs.Count);
@ -99,13 +85,13 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{ {
try try
{ {
await _api.DeleteCustomFormat(config, cf.Id, token); await api.DeleteCustomFormat(config, cf.Id, token);
_log.Debug("Deleted {Name}", cf.Name); log.Debug("Deleted {Name}", cf.Name);
} }
catch (Exception e) catch (Exception e)
{ {
_log.Debug(e, "Failed to delete CF"); log.Debug(e, "Failed to delete CF");
_console.WriteLine($"Failed to delete CF: {cf.Name}"); console.WriteLine($"Failed to delete CF: {cf.Name}");
} }
task.Increment(1); task.Increment(1);
@ -117,9 +103,9 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{ {
IList<CustomFormatData> cfs = new List<CustomFormatData>(); 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; return cfs;
@ -141,10 +127,10 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
if (result[false].Any()) if (result[false].Any())
{ {
var cfNames = result[false].Select(x => x.Name).ToList(); 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) 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")] [SuppressMessage("ReSharper", "CoVariantArrayConversion")]
private void PrintPreview(ICollection<CustomFormatData> cfs) private void PrintPreview(ICollection<CustomFormatData> cfs)
{ {
_console.MarkupLine("The following custom formats will be [bold red]DELETED[/]:"); console.MarkupLine("The following custom formats will be [bold red]DELETED[/]:");
_console.WriteLine(); console.WriteLine();
var cfNames = cfs var cfNames = cfs
.Select(x => x.Name) .Select(x => x.Name)
@ -174,13 +160,13 @@ public class DeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
.ToArray()); .ToArray());
} }
_console.Write(grid); console.Write(grid);
_console.WriteLine(); console.WriteLine();
} }
private IServiceConfiguration GetTargetConfig(IDeleteCustomFormatSettings settings) private IServiceConfiguration GetTargetConfig(IDeleteCustomFormatSettings settings)
{ {
var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria var configs = configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
{ {
Instances = new[] {settings.InstanceName} Instances = new[] {settings.InstanceName}
}); });

@ -8,66 +8,57 @@ using Recyclarr.VersionControl;
namespace Recyclarr.Cli.Processors.ErrorHandling; 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) public async Task<bool> HandleException(Exception sourceException)
{ {
switch (sourceException) switch (sourceException)
{ {
case GitCmdException e: 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); e.ExitCode, e.Error);
break; break;
case FlurlHttpException e: case FlurlHttpException e:
_log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage()); log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
await _httpExceptionHandler.ProcessServiceErrorMessages(new ServiceErrorMessageExtractor(e)); await httpExceptionHandler.ProcessServiceErrorMessages(new ServiceErrorMessageExtractor(e));
break; break;
case NoConfigurationFilesException: case NoConfigurationFilesException:
_log.Error("No configuration files found"); log.Error("No configuration files found");
break; break;
case InvalidInstancesException e: 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; break;
case DuplicateInstancesException e: case DuplicateInstancesException e:
_log.Error("The following instance names are duplicated: {Names}", e.InstanceNames); log.Error("The following instance names are duplicated: {Names}", e.InstanceNames);
_log.Error("Instance names are unique and may not be reused"); log.Error("Instance names are unique and may not be reused");
break; break;
case SplitInstancesException e: 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); e.InstanceNames);
_log.Error( log.Error(
"Consolidate the config files manually to fix. " + "Consolidate the config files manually to fix. " +
"See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance"); "See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance");
break; break;
case InvalidConfigurationFilesException e: 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; break;
case PostProcessingException e: case PostProcessingException e:
_log.Error("Configuration post-processing failed: {Message}", e.Message); log.Error("Configuration post-processing failed: {Message}", e.Message);
break; break;
case ServiceIncompatibilityException e: case ServiceIncompatibilityException e:
_log.Error(e.Message); log.Error(e.Message);
break; break;
case CommandException e: case CommandException e:
_log.Error(e.Message); log.Error(e.Message);
break; break;
default: default:

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

@ -3,15 +3,8 @@ using Recyclarr.Common.Extensions;
namespace Recyclarr.Cli.Processors.ErrorHandling; 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")] [SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public async Task ProcessServiceErrorMessages(IServiceErrorMessageExtractor extractor) public async Task ProcessServiceErrorMessages(IServiceErrorMessageExtractor extractor)
{ {
@ -20,7 +13,7 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
switch (statusCode) switch (statusCode)
{ {
case 401: 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; break;
default: default:
@ -31,7 +24,7 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
private void ProcessBody(string responseBody) private void ProcessBody(string responseBody)
{ {
var parser = new ErrorResponseParser(_log, responseBody); var parser = new ErrorResponseParser(log, responseBody);
// Try to parse validation errors // Try to parse validation errors
if (parser.DeserializeList(s => s if (parser.DeserializeList(s => s
@ -54,6 +47,6 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
} }
// Last resort // 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; 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() public async Task<string> GetErrorMessage()
{ {
return await _e.GetResponseStringAsync(); return await e.GetResponseStringAsync();
} }
public int? GetHttpStatusCode() public int? GetHttpStatusCode()
{ {
return _e.StatusCode; return e.StatusCode;
} }
} }

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

@ -4,32 +4,21 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Processors.Sync; 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) public async Task Process(ISyncSettings settings, IServiceConfiguration config)
{ {
foreach (var cache in _caches) foreach (var cache in caches)
{ {
cache.Clear(); 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); await pipeline.Execute(settings, config);
} }
} }

@ -10,37 +10,21 @@ using Spectre.Console;
namespace Recyclarr.Cli.Processors.Sync; namespace Recyclarr.Cli.Processors.Sync;
[SuppressMessage("Design", "CA1031:Do not catch general exception types")] [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) public async Task<ExitStatus> ProcessConfigs(ISyncSettings settings)
{ {
bool failureDetected; bool failureDetected;
try try
{ {
var configs = _configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria var configs = configRegistry.FindAndLoadConfigs(new ConfigFilterCriteria
{ {
ManualConfigFiles = settings.Configs, ManualConfigFiles = settings.Configs,
Instances = settings.Instances, Instances = settings.Instances,
@ -51,7 +35,7 @@ public class SyncProcessor : ISyncProcessor
} }
catch (Exception e) catch (Exception e)
{ {
if (!await _exceptionHandler.HandleException(e)) if (!await exceptionHandler.HandleException(e))
{ {
// This means we didn't handle the exception; rethrow it. // This means we didn't handle the exception; rethrow it.
throw; throw;
@ -72,13 +56,13 @@ public class SyncProcessor : ISyncProcessor
try try
{ {
PrintProcessingHeader(config.ServiceType, config); PrintProcessingHeader(config.ServiceType, config);
await _capabilityEnforcer.Check(config); await capabilityEnforcer.Check(config);
await _pipelines.Process(settings, config); await pipelines.Process(settings, config);
_log.Information("Completed at {Date}", DateTime.Now); log.Information("Completed at {Date}", DateTime.Now);
} }
catch (Exception e) catch (Exception e)
{ {
if (!await _exceptionHandler.HandleException(e)) if (!await exceptionHandler.HandleException(e))
{ {
// This means we didn't handle the exception; rethrow it. // This means we didn't handle the exception; rethrow it.
throw; throw;
@ -95,7 +79,7 @@ public class SyncProcessor : ISyncProcessor
{ {
var instanceName = config.InstanceName; 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; 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) private static string BuildMessage(IEnumerable<string> supportedFiles)
{ {
return return

@ -7,29 +7,18 @@ public static class CollectionExtensions
// From: https://stackoverflow.com/a/34362585/157971 // From: https://stackoverflow.com/a/34362585/157971
public static IReadOnlyCollection<T> AsReadOnly<T>(this ICollection<T> source) public static IReadOnlyCollection<T> AsReadOnly<T>(this ICollection<T> source)
{ {
if (source is null) ArgumentNullException.ThrowIfNull(source);
{
throw new ArgumentNullException(nameof(source));
}
return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source); return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source);
} }
// From: https://stackoverflow.com/a/34362585/157971 // 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 int Count => source.Count;
public ReadOnlyCollectionAdapter(ICollection<T> source)
{
_source = source;
}
public int Count => _source.Count;
public IEnumerator<T> GetEnumerator() public IEnumerator<T> GetEnumerator()
{ {
return _source.GetEnumerator(); return source.GetEnumerator();
} }
IEnumerator IEnumerable.GetEnumerator() IEnumerator IEnumerable.GetEnumerator()
@ -82,7 +71,7 @@ public static class CollectionExtensions
public static IList<T>? ToListOrNull<T>(this IEnumerable<T> source) public static IList<T>? ToListOrNull<T>(this IEnumerable<T> source)
{ {
var list = source.ToList(); 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) 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}"); 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 RulesetValidatorSelector(ruleSets)
: new DefaultValidatorSelector(); : new DefaultValidatorSelector();

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

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

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

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

Loading…
Cancel
Save