refactor: Use chain of responsibility for pipeline phases

pull/432/head
Robert Dailey 2 months ago
parent 5bcd1122e9
commit e92e0c8e68

@ -26,8 +26,9 @@
</PropertyGroup>
<!-- For only NON-TEST projects -->
<ItemGroup Condition="!$(ProjectName.EndsWith('.Tests'))">
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<ItemGroup Condition="!$(MSBuildProjectName.EndsWith('Tests'))">
<InternalsVisibleTo Include="$(MSBuildProjectName).Tests" />
<InternalsVisibleTo Include="$(MSBuildProjectName).IntegrationTests" />
</ItemGroup>
<!-- ReferenceTrimmer - run build with /p:EnableReferenceTrimmer=true to enable -->

@ -75,4 +75,4 @@
<!-- Cannot use the official Jetbrains.Annotations package because it doesn't work with GlobalPackageReference -->
<GlobalPackageReference Include="Rocket.Surgery.MSBuild.JetBrains.Annotations" Version="1.2.1" />
</ItemGroup>
</Project>
</Project>

@ -1,20 +1,16 @@
using Recyclarr.Cli.Pipelines.CustomFormat.Cache;
using Recyclarr.Cli.Pipelines.CustomFormat.Models;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.TrashGuide;
using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat;
public class CustomFormatPipelineContext : IPipelineContext
internal class CustomFormatPipelineContext : PipelineContext
{
public string PipelineDescription => "Custom Format";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } =
[SupportedServices.Sonarr, SupportedServices.Radarr];
public override string PipelineDescription => "Custom Format";
public IList<CustomFormatData> ConfigOutput { get; init; } = [];
public IList<CustomFormatData> ApiFetchOutput { get; init; } = [];
public CustomFormatTransactionData TransactionOutput { get; set; } = default!;
public IReadOnlyCollection<string> InvalidFormats { get; set; } = default!;
public CustomFormatCache Cache { get; set; } = default!;
public CustomFormatTransactionData TransactionOutput { get; set; } = null!;
public IReadOnlyCollection<string> InvalidFormats { get; set; } = null!;
public CustomFormatCache Cache { get; set; } = null!;
}

@ -1,16 +1,16 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions;
using Recyclarr.ServarrApi.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatApiFetchPhase(ICustomFormatApiService api)
: IApiFetchPipelinePhase<CustomFormatPipelineContext>
internal class CustomFormatApiFetchPhase(ICustomFormatApiService api)
: IPipelinePhase<CustomFormatPipelineContext>
{
public async Task Execute(CustomFormatPipelineContext context, CancellationToken ct)
public async Task<bool> Execute(CustomFormatPipelineContext context, CancellationToken ct)
{
var result = await api.GetCustomFormats(ct);
context.ApiFetchOutput.AddRange(result);
context.Cache.RemoveStale(result);
return true;
}
}

@ -1,16 +1,16 @@
using Recyclarr.Cache;
using Recyclarr.Cli.Pipelines.CustomFormat.Cache;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatApiPersistencePhase(
internal class CustomFormatApiPersistencePhase(
ICustomFormatApiService api,
ICachePersister<CustomFormatCache> cachePersister
) : IApiPersistencePipelinePhase<CustomFormatPipelineContext>
ICachePersister<CustomFormatCache> cachePersister,
CustomFormatTransactionLogger cfLogger
) : IPipelinePhase<CustomFormatPipelineContext>
{
public async Task Execute(CustomFormatPipelineContext context, CancellationToken ct)
public async Task<bool> Execute(CustomFormatPipelineContext context, CancellationToken ct)
{
var transactions = context.TransactionOutput;
@ -35,5 +35,8 @@ public class CustomFormatApiPersistencePhase(
context.Cache.Update(transactions);
cachePersister.Save(context.Cache);
cfLogger.LogTransactions(context);
return true;
}
}

@ -1,21 +1,21 @@
using Recyclarr.Cache;
using Recyclarr.Cli.Pipelines.CustomFormat.Cache;
using Recyclarr.Cli.Pipelines.CustomFormat.Models;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatConfigPhase(
internal class CustomFormatConfigPhase(
ILogger log,
ICustomFormatGuideService guide,
ProcessedCustomFormatCache cache,
ICachePersister<CustomFormatCache> cachePersister,
IServiceConfiguration config
) : IConfigPipelinePhase<CustomFormatPipelineContext>
) : IPipelinePhase<CustomFormatPipelineContext>
{
public Task Execute(CustomFormatPipelineContext context, CancellationToken ct)
public Task<bool> Execute(CustomFormatPipelineContext context, CancellationToken ct)
{
// Match custom formats in the YAML config to those in the guide, by Trash ID
//
@ -40,6 +40,21 @@ public class CustomFormatConfigPhase(
context.Cache = cachePersister.Load();
cache.AddCustomFormats(context.ConfigOutput);
return Task.CompletedTask;
return Task.FromResult(LogConfigPhaseAndExitIfNeeded(context));
}
// Returning 'true' means to exit. 'false' means to proceed.
private bool LogConfigPhaseAndExitIfNeeded(CustomFormatPipelineContext context)
{
if (context.InvalidFormats.Count != 0)
{
log.Warning(
"These Custom Formats do not exist in the guide and will be skipped: {Cfs}",
context.InvalidFormats
);
}
// Do not exit when the config has zero custom formats. We still may need to delete old custom formats.
return false;
}
}

@ -1,29 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
internal class CustomFormatLogPhase(CustomFormatTransactionLogger cfLogger, ILogger log)
: ILogPipelinePhase<CustomFormatPipelineContext>
{
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(CustomFormatPipelineContext context)
{
if (context.InvalidFormats.Count != 0)
{
log.Warning(
"These Custom Formats do not exist in the guide and will be skipped: {Cfs}",
context.InvalidFormats
);
}
// Do not exit when the config has zero custom formats. We still may need to delete old custom formats.
return false;
}
public void LogTransactionNotices(CustomFormatPipelineContext context) { }
public void LogPersistenceResults(CustomFormatPipelineContext context)
{
cfLogger.LogTransactions(context);
}
}

@ -1,11 +1,9 @@
using Recyclarr.Cli.Pipelines.Generic;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
internal class CustomFormatPreviewPhase(CustomFormatTransactionLogger logger)
: IPreviewPipelinePhase<CustomFormatPipelineContext>
: PreviewPipelinePhase<CustomFormatPipelineContext>
{
public void Execute(CustomFormatPipelineContext context)
protected override void RenderPreview(CustomFormatPipelineContext context)
{
logger.LogTransactions(context);
}

@ -1,15 +1,14 @@
using Recyclarr.Cli.Pipelines.CustomFormat.Models;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatTransactionPhase(ILogger log, IServiceConfiguration config)
: ITransactionPipelinePhase<CustomFormatPipelineContext>
internal class CustomFormatTransactionPhase(ILogger log, IServiceConfiguration config)
: IPipelinePhase<CustomFormatPipelineContext>
{
public void Execute(CustomFormatPipelineContext context)
public Task<bool> Execute(CustomFormatPipelineContext context, CancellationToken ct)
{
var transactions = new CustomFormatTransactionData();
@ -57,6 +56,7 @@ public class CustomFormatTransactionPhase(ILogger log, IServiceConfiguration con
}
context.TransactionOutput = transactions;
return Task.FromResult(true);
}
private void ProcessExistingCf(

@ -1,13 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
[UsedImplicitly(ImplicitUseKindFlags.Assign, ImplicitUseTargetFlags.Members)]
public class GenericPipelinePhases<TContext>
where TContext : IPipelineContext
{
public required IConfigPipelinePhase<TContext> ConfigPhase { get; init; }
public required ILogPipelinePhase<TContext> LogPhase { get; init; }
public required IApiFetchPipelinePhase<TContext> ApiFetchPhase { get; init; }
public required ITransactionPipelinePhase<TContext> TransactionPhase { get; init; }
public required IPreviewPipelinePhase<TContext> PreviewPhase { get; init; }
public required IApiPersistencePipelinePhase<TContext> ApiPersistencePhase { get; init; }
}

@ -1,48 +0,0 @@
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Generic;
public class GenericSyncPipeline<TContext>(
ILogger log,
GenericPipelinePhases<TContext> phases,
IServiceConfiguration config
) : ISyncPipeline
where TContext : IPipelineContext, new()
{
public async Task Execute(ISyncSettings settings, CancellationToken ct)
{
var context = new TContext();
log.Debug("Executing Pipeline: {Pipeline}", context.PipelineDescription);
if (!context.SupportedServiceTypes.Contains(config.ServiceType))
{
log.Debug(
"Skipping this pipeline because it does not support service type {Service}",
config.ServiceType
);
return;
}
await phases.ConfigPhase.Execute(context, ct);
if (phases.LogPhase.LogConfigPhaseAndExitIfNeeded(context))
{
return;
}
await phases.ApiFetchPhase.Execute(context, ct);
phases.TransactionPhase.Execute(context);
phases.LogPhase.LogTransactionNotices(context);
if (settings.Preview)
{
phases.PreviewPhase.Execute(context);
return;
}
await phases.ApiPersistencePhase.Execute(context, ct);
phases.LogPhase.LogPersistenceResults(context);
}
}

@ -1,7 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
public interface IApiFetchPipelinePhase<in TContext>
where TContext : IPipelineContext
{
Task Execute(TContext context, CancellationToken ct);
}

@ -1,7 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
public interface IApiPersistencePipelinePhase<in TContext>
where TContext : IPipelineContext
{
Task Execute(TContext context, CancellationToken ct);
}

@ -1,7 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
public interface IConfigPipelinePhase<in TContext>
where TContext : IPipelineContext
{
Task Execute(TContext context, CancellationToken ct);
}

@ -1,9 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
public interface ILogPipelinePhase<in TContext>
where TContext : IPipelineContext
{
bool LogConfigPhaseAndExitIfNeeded(TContext context);
void LogTransactionNotices(TContext context);
void LogPersistenceResults(TContext context);
}

@ -1,9 +0,0 @@
using Recyclarr.TrashGuide;
namespace Recyclarr.Cli.Pipelines.Generic;
public interface IPipelineContext
{
string PipelineDescription { get; }
IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; }
}

@ -1,7 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
public interface IPreviewPipelinePhase<in TContext>
where TContext : IPipelineContext
{
void Execute(TContext context);
}

@ -1,7 +0,0 @@
namespace Recyclarr.Cli.Pipelines.Generic;
public interface ITransactionPipelinePhase<in TContext>
where TContext : IPipelineContext
{
void Execute(TContext context);
}

@ -0,0 +1,25 @@
using Recyclarr.Cli.Console.Settings;
namespace Recyclarr.Cli.Pipelines;
internal class GenericSyncPipeline<TContext>(
ILogger log,
IReadOnlyCollection<IPipelinePhase<TContext>> phases
) : ISyncPipeline
where TContext : PipelineContext, new()
{
public async Task Execute(ISyncSettings settings, CancellationToken ct)
{
var context = new TContext { SyncSettings = settings };
log.Debug("Executing Pipeline: {Pipeline}", context.PipelineDescription);
foreach (var phase in phases)
{
if (!await phase.Execute(context, ct))
{
break;
}
}
}
}

@ -0,0 +1,6 @@
namespace Recyclarr.Cli.Pipelines;
internal interface IPipelinePhase<in TContext>
{
Task<bool> Execute(TContext context, CancellationToken ct);
}

@ -1,17 +1,13 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.TrashGuide;
namespace Recyclarr.Cli.Pipelines.MediaNaming;
public class MediaNamingPipelineContext : IPipelineContext
internal class MediaNamingPipelineContext : PipelineContext
{
public string PipelineDescription => "Media Naming";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } =
[SupportedServices.Sonarr, SupportedServices.Radarr];
public override string PipelineDescription => "Media Naming";
public ProcessedNamingConfig ConfigOutput { get; set; } = default!;
public MediaNamingDto ApiFetchOutput { get; set; } = default!;
public MediaNamingDto TransactionOutput { get; set; } = default!;
public ProcessedNamingConfig ConfigOutput { get; set; } = null!;
public MediaNamingDto ApiFetchOutput { get; set; } = null!;
public MediaNamingDto TransactionOutput { get; set; } = null!;
}

@ -1,13 +1,13 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingApiFetchPhase(IMediaNamingApiService api)
: IApiFetchPipelinePhase<MediaNamingPipelineContext>
internal class MediaNamingApiFetchPhase(IMediaNamingApiService api)
: IPipelinePhase<MediaNamingPipelineContext>
{
public async Task Execute(MediaNamingPipelineContext context, CancellationToken ct)
public async Task<bool> Execute(MediaNamingPipelineContext context, CancellationToken ct)
{
context.ApiFetchOutput = await api.GetNaming(ct);
return true;
}
}

@ -1,13 +1,41 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingApiPersistencePhase(IMediaNamingApiService api)
: IApiPersistencePipelinePhase<MediaNamingPipelineContext>
internal class MediaNamingApiPersistencePhase(
ILogger log,
IMediaNamingApiService api,
NotificationEmitter notificationEmitter
) : IPipelinePhase<MediaNamingPipelineContext>
{
public async Task Execute(MediaNamingPipelineContext context, CancellationToken ct)
public async Task<bool> Execute(MediaNamingPipelineContext context, CancellationToken ct)
{
await api.UpdateNaming(context.TransactionOutput, ct);
LogPersistenceResults(context);
return true;
}
private void LogPersistenceResults(MediaNamingPipelineContext context)
{
var differences = context.ApiFetchOutput switch
{
RadarrMediaNamingDto x => x.GetDifferences(context.TransactionOutput),
SonarrMediaNamingDto x => x.GetDifferences(context.TransactionOutput),
_ => throw new ArgumentException(
"Unsupported configuration type in LogPersistenceResults method"
),
};
if (differences.Count != 0)
{
log.Information("Media naming has been updated");
log.Debug("Naming differences: {Diff}", differences);
notificationEmitter.SendStatistic("Media Naming Synced");
}
else
{
log.Information("Media naming is up to date!");
}
}
}

@ -1,5 +1,4 @@
using Autofac.Features.Indexed;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases.Config;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming;
@ -16,13 +15,14 @@ public record ProcessedNamingConfig
public IReadOnlyCollection<InvalidNamingConfig> InvalidNaming { get; init; } = [];
}
public class MediaNamingConfigPhase(
internal class MediaNamingConfigPhase(
ILogger log,
IMediaNamingGuideService guide,
IIndex<SupportedServices, IServiceBasedMediaNamingConfigPhase> configPhaseStrategyFactory,
IServiceConfiguration config
) : IConfigPipelinePhase<MediaNamingPipelineContext>
) : IPipelinePhase<MediaNamingPipelineContext>
{
public async Task Execute(MediaNamingPipelineContext context, CancellationToken ct)
public async Task<bool> Execute(MediaNamingPipelineContext context, CancellationToken ct)
{
var lookup = new NamingFormatLookup();
var strategy = configPhaseStrategyFactory[config.ServiceType];
@ -33,5 +33,44 @@ public class MediaNamingConfigPhase(
Dto = dto,
InvalidNaming = lookup.Errors,
};
return LogConfigPhaseAndExitIfNeeded(context);
}
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(MediaNamingPipelineContext context)
{
var configOutput = context.ConfigOutput;
if (configOutput.InvalidNaming.Count != 0)
{
foreach (var (topic, invalidValue) in configOutput.InvalidNaming)
{
log.Error(
"An invalid media naming format is specified for {Topic}: {Value}",
topic,
invalidValue
);
}
return true;
}
var differences = configOutput.Dto switch
{
RadarrMediaNamingDto x => x.GetDifferences(new RadarrMediaNamingDto()),
SonarrMediaNamingDto x => x.GetDifferences(new SonarrMediaNamingDto()),
_ => throw new ArgumentException(
"Unsupported configuration type in LogConfigPhase method"
),
};
if (differences.Count == 0)
{
log.Debug("No media naming changes to process");
return true;
}
return false;
}
}

@ -1,71 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingLogPhase(ILogger log, NotificationEmitter notificationEmitter)
: ILogPipelinePhase<MediaNamingPipelineContext>
{
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(MediaNamingPipelineContext context)
{
var config = context.ConfigOutput;
if (config.InvalidNaming.Count != 0)
{
foreach (var (topic, invalidValue) in config.InvalidNaming)
{
log.Error(
"An invalid media naming format is specified for {Topic}: {Value}",
topic,
invalidValue
);
}
return true;
}
var differences = config.Dto switch
{
RadarrMediaNamingDto x => x.GetDifferences(new RadarrMediaNamingDto()),
SonarrMediaNamingDto x => x.GetDifferences(new SonarrMediaNamingDto()),
_ => throw new ArgumentException(
"Unsupported configuration type in LogConfigPhase method"
),
};
if (differences.Count == 0)
{
log.Debug("No media naming changes to process");
return true;
}
return false;
}
public void LogTransactionNotices(MediaNamingPipelineContext context) { }
public void LogPersistenceResults(MediaNamingPipelineContext context)
{
var differences = context.ApiFetchOutput switch
{
RadarrMediaNamingDto x => x.GetDifferences(context.TransactionOutput),
SonarrMediaNamingDto x => x.GetDifferences(context.TransactionOutput),
_ => throw new ArgumentException(
"Unsupported configuration type in LogPersistenceResults method"
),
};
if (differences.Count != 0)
{
log.Information("Media naming has been updated");
log.Debug("Naming differences: {Diff}", differences);
notificationEmitter.SendStatistic("Media Naming Synced");
}
else
{
log.Information("Media naming is up to date!");
}
}
}

@ -1,15 +1,14 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.MediaNaming;
using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingPreviewPhase(IAnsiConsole console)
: IPreviewPipelinePhase<MediaNamingPipelineContext>
internal class MediaNamingPreviewPhase(IAnsiConsole console)
: PreviewPipelinePhase<MediaNamingPipelineContext>
{
private Table? _table;
public void Execute(MediaNamingPipelineContext context)
protected override void RenderPreview(MediaNamingPipelineContext context)
{
_table = new Table()
.Title("Media Naming [red](Preview)[/]")

@ -1,20 +1,21 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingTransactionPhase : ITransactionPipelinePhase<MediaNamingPipelineContext>
internal class MediaNamingTransactionPhase : IPipelinePhase<MediaNamingPipelineContext>
{
public void Execute(MediaNamingPipelineContext context)
public Task<bool> Execute(MediaNamingPipelineContext context, CancellationToken ct)
{
context.TransactionOutput = context.ApiFetchOutput switch
{
RadarrMediaNamingDto dto => UpdateRadarrDto(dto, context.ConfigOutput),
SonarrMediaNamingDto dto => UpdateSonarrDto(dto, context.ConfigOutput),
_ => throw new ArgumentException(
"Config type not supported in media naming transation phase"
"Config type not supported in media naming transaction phase"
),
};
return Task.FromResult(true);
}
private static RadarrMediaNamingDto UpdateRadarrDto(

@ -5,7 +5,6 @@ using Recyclarr.Cli.Pipelines.CustomFormat;
using Recyclarr.Cli.Pipelines.CustomFormat.Cache;
using Recyclarr.Cli.Pipelines.CustomFormat.Models;
using Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.MediaNaming;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases.Config;
@ -24,7 +23,6 @@ public class PipelineAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterGeneric(typeof(GenericPipelinePhases<>));
builder.RegisterComposite<CompositeSyncPipeline, ISyncPipeline>();
builder
.RegisterTypes(
@ -61,8 +59,7 @@ public class PipelineAutofacModule : Module
typeof(MediaNamingApiFetchPhase),
typeof(MediaNamingTransactionPhase),
typeof(MediaNamingPreviewPhase),
typeof(MediaNamingApiPersistencePhase),
typeof(MediaNamingLogPhase)
typeof(MediaNamingApiPersistencePhase)
)
.AsImplementedInterfaces();
}
@ -70,6 +67,7 @@ public class PipelineAutofacModule : Module
private static void RegisterQualityProfile(ContainerBuilder builder)
{
builder.RegisterType<QualityProfileStatCalculator>();
builder.RegisterType<QualityProfileLogger>();
builder
.RegisterTypes(
@ -77,8 +75,7 @@ public class PipelineAutofacModule : Module
typeof(QualityProfilePreviewPhase),
typeof(QualityProfileApiFetchPhase),
typeof(QualityProfileTransactionPhase),
typeof(QualityProfileApiPersistencePhase),
typeof(QualityProfileLogPhase)
typeof(QualityProfileApiPersistencePhase)
)
.AsImplementedInterfaces();
}
@ -104,8 +101,7 @@ public class PipelineAutofacModule : Module
typeof(QualitySizePreviewPhase),
typeof(QualitySizeApiFetchPhase),
typeof(QualitySizeTransactionPhase),
typeof(QualitySizeApiPersistencePhase),
typeof(QualitySizeLogPhase)
typeof(QualitySizeApiPersistencePhase)
)
.AsImplementedInterfaces();
}
@ -128,9 +124,9 @@ public class PipelineAutofacModule : Module
typeof(CustomFormatApiFetchPhase),
typeof(CustomFormatTransactionPhase),
typeof(CustomFormatPreviewPhase),
typeof(CustomFormatApiPersistencePhase),
typeof(CustomFormatLogPhase)
typeof(CustomFormatApiPersistencePhase)
)
.AsImplementedInterfaces();
.AsImplementedInterfaces()
.OrderByRegistration();
}
}

@ -0,0 +1,12 @@
using Recyclarr.Cli.Console.Settings;
namespace Recyclarr.Cli.Pipelines;
internal abstract class PipelineContext
{
public abstract string PipelineDescription { get; }
// Set from `GenericSyncPipeline.Execute()`. Not able to make this `required` because of the
// `new()` constraint.
public ISyncSettings SyncSettings { get; init; } = null!;
}

@ -0,0 +1,18 @@
namespace Recyclarr.Cli.Pipelines;
internal abstract class PreviewPipelinePhase<T> : IPipelinePhase<T>
where T : PipelineContext
{
public Task<bool> Execute(T context, CancellationToken ct)
{
if (!context.SyncSettings.Preview)
{
return Task.FromResult(true);
}
RenderPreview(context);
return Task.FromResult(false);
}
protected abstract void RenderPreview(T context);
}

@ -0,0 +1,8 @@
using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.Models;
public record QualityProfileServiceData(
IReadOnlyList<QualityProfileDto> Profiles,
QualityProfileDto Schema
);

@ -1,20 +1,16 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record QualityProfileServiceData(
IReadOnlyList<QualityProfileDto> Profiles,
QualityProfileDto Schema
);
public class QualityProfileApiFetchPhase(IQualityProfileApiService api)
: IApiFetchPipelinePhase<QualityProfilePipelineContext>
internal class QualityProfileApiFetchPhase(IQualityProfileApiService api)
: IPipelinePhase<QualityProfilePipelineContext>
{
public async Task Execute(QualityProfilePipelineContext context, CancellationToken ct)
public async Task<bool> Execute(QualityProfilePipelineContext context, CancellationToken ct)
{
var profiles = await api.GetQualityProfiles(ct);
var schema = await api.GetSchema(ct);
context.ApiFetchOutput = new QualityProfileServiceData(profiles.AsReadOnly(), schema);
return true;
}
}

@ -1,13 +1,14 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileApiPersistencePhase(IQualityProfileApiService api)
: IApiPersistencePipelinePhase<QualityProfilePipelineContext>
internal class QualityProfileApiPersistencePhase(
IQualityProfileApiService api,
QualityProfileLogger logger
) : IPipelinePhase<QualityProfilePipelineContext>
{
public async Task Execute(QualityProfilePipelineContext context, CancellationToken ct)
public async Task<bool> Execute(QualityProfilePipelineContext context, CancellationToken ct)
{
var changedProfiles = context.TransactionOutput.ChangedProfiles;
foreach (var profile in changedProfiles.Select(x => x.Profile))
@ -30,5 +31,8 @@ public class QualityProfileApiPersistencePhase(IQualityProfileApiService api)
);
}
}
logger.LogPersistenceResults(context);
return true;
}
}

@ -1,5 +1,4 @@
using Recyclarr.Cli.Pipelines.CustomFormat.Models;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
@ -7,13 +6,13 @@ using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileConfigPhase(
internal class QualityProfileConfigPhase(
ILogger log,
ProcessedCustomFormatCache cache,
IServiceConfiguration config
) : IConfigPipelinePhase<QualityProfilePipelineContext>
) : IPipelinePhase<QualityProfilePipelineContext>
{
public Task Execute(QualityProfilePipelineContext context, CancellationToken ct)
public Task<bool> Execute(QualityProfilePipelineContext context, CancellationToken ct)
{
// 1. For each group of CFs that has a quality profile specified
// 2. For each quality profile score config in that CF group
@ -54,7 +53,14 @@ public class QualityProfileConfigPhase(
var profilesToReturn = allProfiles.Values.ToList();
PrintDiagnostics(profilesToReturn);
context.ConfigOutput = profilesToReturn;
return Task.CompletedTask;
if (!context.ConfigOutput.Any())
{
log.Debug("No Quality Profiles to process");
return Task.FromResult(false);
}
return Task.FromResult(true);
}
private void PrintDiagnostics(IEnumerable<ProcessedQualityProfileData> profiles)

@ -1,15 +1,14 @@
using System.Globalization;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.QualityProfile;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfilePreviewPhase(IAnsiConsole console)
: IPreviewPipelinePhase<QualityProfilePipelineContext>
internal class QualityProfilePreviewPhase(IAnsiConsole console)
: PreviewPipelinePhase<QualityProfilePipelineContext>
{
public void Execute(QualityProfilePipelineContext context)
protected override void RenderPreview(QualityProfilePipelineContext context)
{
var tree = new Tree("Quality Profile Changes [red](Preview)[/]");

@ -1,4 +1,3 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation;
@ -7,10 +6,12 @@ using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileTransactionPhase(QualityProfileStatCalculator statCalculator)
: ITransactionPipelinePhase<QualityProfilePipelineContext>
internal class QualityProfileTransactionPhase(
QualityProfileStatCalculator statCalculator,
QualityProfileLogger logger
) : IPipelinePhase<QualityProfilePipelineContext>
{
public void Execute(QualityProfilePipelineContext context)
public Task<bool> Execute(QualityProfilePipelineContext context, CancellationToken ct)
{
var transactions = new QualityProfileTransactionData();
@ -25,6 +26,9 @@ public class QualityProfileTransactionPhase(QualityProfileStatCalculator statCal
AssignProfiles(transactions, updatedProfiles);
context.TransactionOutput = transactions;
logger.LogTransactionNotices(context);
return Task.FromResult(true);
}
private void AssignProfiles(

@ -1,27 +1,15 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Notifications;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public class QualityProfileLogPhase(
internal class QualityProfileLogger(
ILogger log,
ValidationLogger validationLogger,
NotificationEmitter notificationEmitter
) : ILogPipelinePhase<QualityProfilePipelineContext>
)
{
public bool LogConfigPhaseAndExitIfNeeded(QualityProfilePipelineContext context)
{
if (!context.ConfigOutput.Any())
{
log.Debug("No Quality Profiles to process");
return true;
}
return false;
}
public void LogTransactionNotices(QualityProfilePipelineContext context)
{
var transactions = context.TransactionOutput;

@ -1,23 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.TrashGuide;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
[SuppressMessage(
"Usage",
"CA2227:Collection properties should be read only",
Justification = "Context objects are similar to DTOs; for usability we want to assign not append"
)]
public class QualityProfilePipelineContext : IPipelineContext
internal class QualityProfilePipelineContext : PipelineContext
{
public string PipelineDescription => "Quality Definition";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } =
[SupportedServices.Sonarr, SupportedServices.Radarr];
public override string PipelineDescription => "Quality Profile";
public IList<ProcessedQualityProfileData> ConfigOutput { get; set; } = default!;
public QualityProfileServiceData ApiFetchOutput { get; set; } = default!;
public QualityProfileTransactionData TransactionOutput { get; set; } = default!;
public IList<ProcessedQualityProfileData> ConfigOutput { get; set; } = null!;
public QualityProfileServiceData ApiFetchOutput { get; set; } = null!;
public QualityProfileTransactionData TransactionOutput { get; set; } = null!;
}

@ -1,8 +0,0 @@
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.Models;
public record ProcessedQualitySizeData(
string Type,
IReadOnlyCollection<QualityItemWithLimits> Qualities
);

@ -1,13 +1,13 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.QualityDefinition;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeApiFetchPhase(IQualityDefinitionApiService api)
: IApiFetchPipelinePhase<QualitySizePipelineContext>
internal class QualitySizeApiFetchPhase(IQualityDefinitionApiService api)
: IPipelinePhase<QualitySizePipelineContext>
{
public async Task Execute(QualitySizePipelineContext context, CancellationToken ct)
public async Task<bool> Execute(QualitySizePipelineContext context, CancellationToken ct)
{
context.ApiFetchOutput = await api.GetQualityDefinition(ct);
return true;
}
}

@ -1,20 +1,48 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
using Recyclarr.ServarrApi.QualityDefinition;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeApiPersistencePhase(ILogger log, IQualityDefinitionApiService api)
: IApiPersistencePipelinePhase<QualitySizePipelineContext>
internal class QualitySizeApiPersistencePhase(
ILogger log,
IQualityDefinitionApiService api,
NotificationEmitter notificationEmitter
) : IPipelinePhase<QualitySizePipelineContext>
{
public async Task Execute(QualitySizePipelineContext context, CancellationToken ct)
public async Task<bool> Execute(QualitySizePipelineContext context, CancellationToken ct)
{
var sizeData = context.TransactionOutput;
if (sizeData.Count == 0)
{
log.Debug("No size data available to persist; skipping API call");
return;
return false;
}
await api.UpdateQualityDefinition(sizeData, ct);
LogPersistenceResults(context);
return true;
}
private void LogPersistenceResults(QualitySizePipelineContext context)
{
var qualityDefinitionName = context.QualitySizeType;
var totalCount = context.TransactionOutput.Count;
if (totalCount > 0)
{
log.Information(
"Total of {Count} sizes were synced for quality definition {Name}",
totalCount,
qualityDefinitionName
);
notificationEmitter.SendStatistic("Quality Sizes Synced", totalCount);
}
else
{
log.Information(
"All sizes for quality definition {Name} are already up to date!",
qualityDefinitionName
);
}
}
}

@ -1,5 +1,3 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualitySize.Models;
using Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases.Limits;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
@ -7,31 +5,35 @@ using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeConfigPhase(
internal class QualitySizeConfigPhase(
ILogger log,
IQualitySizeGuideService guide,
IServiceConfiguration config,
IQualityItemLimitFactory limitFactory
) : IConfigPipelinePhase<QualitySizePipelineContext>
) : IPipelinePhase<QualitySizePipelineContext>
{
public async Task Execute(QualitySizePipelineContext context, CancellationToken ct)
public async Task<bool> Execute(QualitySizePipelineContext context, CancellationToken ct)
{
var configSizeData = config.QualityDefinition;
if (configSizeData is null)
{
log.Debug("{Instance} has no quality definition", config.InstanceName);
return;
return false;
}
ClampPreferredRatio(configSizeData);
var guideSizeData = guide
.GetQualitySizeData(config.ServiceType)
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(configSizeData.Type));
if (guideSizeData == null)
{
context.ConfigError =
$"The specified quality definition type does not exist: {configSizeData.Type}";
return;
log.Error(
"The specified quality definition type does not exist: {Type}",
configSizeData.Type
);
return false;
}
var itemLimits = await limitFactory.Create(config.ServiceType, ct);
@ -40,11 +42,37 @@ public class QualitySizeConfigPhase(
.Qualities.Select(x => new QualityItemWithLimits(x, itemLimits))
.ToList();
if (sizeDataWithThresholds.Count == 0)
{
log.Debug("No Quality Definitions to process");
return false;
}
AdjustPreferredRatio(configSizeData, sizeDataWithThresholds);
context.ConfigOutput = new ProcessedQualitySizeData(
configSizeData.Type,
sizeDataWithThresholds
context.QualitySizeType = configSizeData.Type;
context.Qualities = sizeDataWithThresholds;
return true;
}
private void ClampPreferredRatio(QualityDefinitionConfig configSizeData)
{
if (configSizeData.PreferredRatio is not (< 0 or > 1))
{
return;
}
// Fix an out of range ratio and warn the user
var clampedRatio = Math.Clamp(configSizeData.PreferredRatio.Value, 0, 1);
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}",
configSizeData.PreferredRatio,
clampedRatio
);
configSizeData.PreferredRatio = clampedRatio;
}
private void AdjustPreferredRatio(
@ -61,20 +89,6 @@ public class QualitySizeConfigPhase(
"Using an explicit preferred ratio which will override values from the guide"
);
// Fix an out of range ratio and warn the user
if (configSizeData.PreferredRatio is < 0 or > 1)
{
var clampedRatio = Math.Clamp(configSizeData.PreferredRatio.Value, 0, 1);
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}",
configSizeData.PreferredRatio,
clampedRatio
);
configSizeData.PreferredRatio = clampedRatio;
}
// Apply a calculated preferred size
foreach (var quality in guideSizeData)
{

@ -1,51 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeLogPhase(ILogger log, NotificationEmitter notificationEmitter)
: ILogPipelinePhase<QualitySizePipelineContext>
{
public bool LogConfigPhaseAndExitIfNeeded(QualitySizePipelineContext context)
{
if (context.ConfigError is not null)
{
log.Error(context.ConfigError);
return true;
}
if (context.ConfigOutput is not { Qualities.Count: > 0 })
{
log.Debug("No Quality Definitions to process");
return true;
}
return false;
}
public void LogTransactionNotices(QualitySizePipelineContext context) { }
public void LogPersistenceResults(QualitySizePipelineContext context)
{
// Do not check ConfigOutput for null here since that is done for us in the LogConfigPhase method
var qualityDefinitionName = context.ConfigOutput!.Type;
var totalCount = context.TransactionOutput.Count;
if (totalCount > 0)
{
log.Information(
"Total of {Count} sizes were synced for quality definition {Name}",
totalCount,
qualityDefinitionName
);
notificationEmitter.SendStatistic("Quality Sizes Synced", totalCount);
}
else
{
log.Information(
"All sizes for quality definition {Name} are already up to date!",
qualityDefinitionName
);
}
}
}

@ -1,12 +1,11 @@
using Recyclarr.Cli.Pipelines.Generic;
using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizePreviewPhase(IAnsiConsole console)
: IPreviewPipelinePhase<QualitySizePipelineContext>
internal class QualitySizePreviewPhase(IAnsiConsole console)
: PreviewPipelinePhase<QualitySizePipelineContext>
{
public void Execute(QualitySizePipelineContext context)
protected override void RenderPreview(QualitySizePipelineContext context)
{
var table = new Table();
@ -16,8 +15,8 @@ public class QualitySizePreviewPhase(IAnsiConsole console)
table.AddColumn("[bold]Max[/]");
table.AddColumn("[bold]Preferred[/]");
// Do not check ConfigOutput for null here since the LogPhase checks that for us
foreach (var q in context.ConfigOutput!.Qualities)
// Do not check ConfigOutput for null here since the Config Phase checks that for us
foreach (var q in context.Qualities)
{
var quality = $"[dodgerblue1]{q.Item.Quality}[/]";
table.AddRow(quality, q.AnnotatedMin, q.AnnotatedMax, q.AnnotatedPreferred);
@ -25,5 +24,6 @@ public class QualitySizePreviewPhase(IAnsiConsole console)
console.WriteLine();
console.Write(table);
throw new NotImplementedException();
}
}

@ -1,17 +1,15 @@
using System.Collections.ObjectModel;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.QualityDefinition;
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeTransactionPhase(ILogger log)
: ITransactionPipelinePhase<QualitySizePipelineContext>
internal class QualitySizeTransactionPhase(ILogger log) : IPipelinePhase<QualitySizePipelineContext>
{
public void Execute(QualitySizePipelineContext context)
public Task<bool> Execute(QualitySizePipelineContext context, CancellationToken ct)
{
// Do not check ConfigOutput for null since the LogPhase does it for us
var guideQuality = context.ConfigOutput!.Qualities;
// Do not check ConfigOutput for null since the Config Phase does it for us
var guideQuality = context.Qualities;
var serverQuality = context.ApiFetchOutput;
var newQuality = new Collection<ServiceQualityDefinitionItem>();
@ -62,6 +60,7 @@ public class QualitySizeTransactionPhase(ILogger log)
}
context.TransactionOutput = newQuality;
return Task.FromResult(true);
}
private static bool QualityIsDifferent(ServiceQualityDefinitionItem a, QualityItemWithLimits b)

@ -1,8 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualitySize.Models;
using Recyclarr.ServarrApi.QualityDefinition;
using Recyclarr.TrashGuide;
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize;
@ -11,14 +9,12 @@ namespace Recyclarr.Cli.Pipelines.QualitySize;
"CA2227:Collection properties should be read only",
Justification = "Context objects are similar to DTOs; for usability we want to assign not append"
)]
public class QualitySizePipelineContext : IPipelineContext
internal class QualitySizePipelineContext : PipelineContext
{
public string PipelineDescription => "Quality Definition";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } =
[SupportedServices.Sonarr, SupportedServices.Radarr];
public override string PipelineDescription => "Quality Definition";
public ProcessedQualitySizeData? ConfigOutput { get; set; }
public IList<ServiceQualityDefinitionItem> ApiFetchOutput { get; set; } = default!;
public IList<ServiceQualityDefinitionItem> TransactionOutput { get; set; } = default!;
public string? ConfigError { get; set; }
public string QualitySizeType { get; set; } = "";
public IReadOnlyCollection<QualityItemWithLimits> Qualities { get; set; } = [];
public IList<ServiceQualityDefinitionItem> ApiFetchOutput { get; set; } = null!;
public IList<ServiceQualityDefinitionItem> TransactionOutput { get; set; } = null!;
}

@ -5,7 +5,7 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Processors.Sync;
public class CompositeSyncPipeline(
internal class CompositeSyncPipeline(
ILogger log,
IOrderedEnumerable<ISyncPipeline> pipelines,
IEnumerable<IPipelineCache> caches,

@ -12,6 +12,7 @@ namespace Recyclarr.Cli.IntegrationTests;
[TestFixture]
public class CompositionRootTest
{
[UsedImplicitly]
private sealed class ConcreteTypeEnumerator : IEnumerable
{
public IEnumerator GetEnumerator()

@ -13,7 +13,7 @@ namespace Recyclarr.Cli.IntegrationTests;
internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
{
[Test]
public void Add_new_cf()
public async Task Add_new_cf()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(NewConfig.Radarr());
@ -26,7 +26,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("one", "cf1")],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -36,7 +36,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Update_cf_by_matching_name()
public async Task Update_cf_by_matching_name()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(NewConfig.Radarr());
@ -58,7 +58,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -77,7 +77,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Update_cf_by_matching_id_different_names()
public async Task Update_cf_by_matching_id_different_names()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(NewConfig.Radarr());
@ -99,7 +99,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -118,7 +118,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Update_cf_by_matching_id_same_names()
public async Task Update_cf_by_matching_id_same_names()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -146,7 +146,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -165,7 +165,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Conflicting_cf_when_new_cf_has_name_of_existing()
public async Task Conflicting_cf_when_new_cf_has_name_of_existing()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -188,7 +188,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("one", "cf1")],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -204,7 +204,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Conflicting_cf_when_cached_cf_has_name_of_existing()
public async Task Conflicting_cf_when_cached_cf_has_name_of_existing()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -227,7 +227,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("one", "cf1")],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -243,7 +243,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Updated_cf_with_matching_name_and_id()
public async Task Updated_cf_with_matching_name_and_id()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -275,7 +275,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -294,7 +294,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Unchanged_cfs_with_replace_enabled()
public async Task Unchanged_cfs_with_replace_enabled()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -313,7 +313,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("one", "cf1")],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -326,7 +326,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Unchanged_cfs_without_replace()
public async Task Unchanged_cfs_without_replace()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -345,7 +345,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("one", "cf1")],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -358,7 +358,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void Deleted_cfs_when_enabled()
public async Task Deleted_cfs_when_enabled()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -377,7 +377,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -390,7 +390,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
}
[Test]
public void No_deleted_cfs_when_disabled()
public async Task No_deleted_cfs_when_disabled()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(
@ -409,13 +409,13 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.TransactionOutput.Should().BeEquivalentTo(new CustomFormatTransactionData());
}
[Test]
public void Do_not_delete_cfs_in_config()
public async Task Do_not_delete_cfs_in_config()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(NewConfig.Radarr());
@ -429,13 +429,13 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("two", "cf2", 2)],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.TransactionOutput.DeletedCustomFormats.Should().BeEmpty();
}
[Test]
public void Add_new_cf_when_in_cache_but_not_in_service()
public async Task Add_new_cf_when_in_cache_but_not_in_service()
{
var scopeFactory = Resolve<ConfigurationScopeFactory>();
using var scope = scopeFactory.Start<TestConfigurationScope>(NewConfig.Radarr());
@ -449,7 +449,7 @@ internal class CustomFormatTransactionPhaseTest : CliIntegrationFixture
ConfigOutput = [NewCf.Data("two", "cf2", 2)],
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()

@ -11,10 +11,10 @@ namespace Recyclarr.Cli.Tests.Pipelines.MediaNaming;
"CA2000:Dispose objects before losing scope",
Justification = "Do not care about disposal in a testing context"
)]
public class MediaNamingTransactionPhaseRadarrTest
internal class MediaNamingTransactionPhaseRadarrTest
{
[Test, AutoMockData]
public void Radarr_left_null(MediaNamingTransactionPhase sut)
public async Task Radarr_left_null(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -30,7 +30,7 @@ public class MediaNamingTransactionPhaseRadarrTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -38,7 +38,7 @@ public class MediaNamingTransactionPhaseRadarrTest
}
[Test, AutoMockData]
public void Radarr_right_null(MediaNamingTransactionPhase sut)
public async Task Radarr_right_null(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -51,7 +51,7 @@ public class MediaNamingTransactionPhaseRadarrTest
ConfigOutput = new ProcessedNamingConfig { Dto = new RadarrMediaNamingDto() },
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -59,7 +59,7 @@ public class MediaNamingTransactionPhaseRadarrTest
}
[Test, AutoMockData]
public void Radarr_right_and_left_with_rename(MediaNamingTransactionPhase sut)
public async Task Radarr_right_and_left_with_rename(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -80,7 +80,7 @@ public class MediaNamingTransactionPhaseRadarrTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -88,7 +88,7 @@ public class MediaNamingTransactionPhaseRadarrTest
}
[Test, AutoMockData]
public void Radarr_right_and_left_without_rename(MediaNamingTransactionPhase sut)
public async Task Radarr_right_and_left_without_rename(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -109,7 +109,7 @@ public class MediaNamingTransactionPhaseRadarrTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()

@ -7,10 +7,10 @@ namespace Recyclarr.Cli.Tests.Pipelines.MediaNaming;
[TestFixture]
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")]
public class MediaNamingTransactionPhaseSonarrTest
internal sealed class MediaNamingTransactionPhaseSonarrTest
{
[Test, AutoMockData]
public void Sonarr_left_null(MediaNamingTransactionPhase sut)
public async Task Sonarr_left_null(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -29,7 +29,7 @@ public class MediaNamingTransactionPhaseSonarrTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -37,7 +37,7 @@ public class MediaNamingTransactionPhaseSonarrTest
}
[Test, AutoMockData]
public void Sonarr_right_null(MediaNamingTransactionPhase sut)
public async Task Sonarr_right_null(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -53,7 +53,7 @@ public class MediaNamingTransactionPhaseSonarrTest
ConfigOutput = new ProcessedNamingConfig { Dto = new SonarrMediaNamingDto() },
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -61,7 +61,7 @@ public class MediaNamingTransactionPhaseSonarrTest
}
[Test, AutoMockData]
public void Sonarr_right_and_left_with_rename(MediaNamingTransactionPhase sut)
public async Task Sonarr_right_and_left_with_rename(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -88,7 +88,7 @@ public class MediaNamingTransactionPhaseSonarrTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -96,7 +96,7 @@ public class MediaNamingTransactionPhaseSonarrTest
}
[Test, AutoMockData]
public void Sonarr_right_and_left_without_rename(MediaNamingTransactionPhase sut)
public async Task Sonarr_right_and_left_without_rename(MediaNamingTransactionPhase sut)
{
var context = new MediaNamingPipelineContext
{
@ -123,7 +123,7 @@ public class MediaNamingTransactionPhaseSonarrTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()

@ -7,10 +7,10 @@ using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile.PipelinePhases;
[TestFixture]
public class QualityProfileTransactionPhaseTest
internal class QualityProfileTransactionPhaseTest
{
[Test, AutoMockData]
public void Non_existent_profile_names_mixed_with_valid_profiles(
public async Task Non_existent_profile_names_mixed_with_valid_profiles(
QualityProfileTransactionPhase sut
)
{
@ -29,7 +29,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -54,7 +54,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void New_profiles(QualityProfileTransactionPhase sut)
public async Task New_profiles(QualityProfileTransactionPhase sut)
{
var dtos = new[] { new QualityProfileDto { Name = "irrelevant_profile" } };
@ -89,7 +89,7 @@ public class QualityProfileTransactionPhaseTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()
@ -129,7 +129,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void Updated_scores(QualityProfileTransactionPhase sut)
public async Task Updated_scores(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -160,7 +160,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.ChangedProfiles.Should()
@ -176,7 +176,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void No_updated_profiles_when_no_custom_formats(QualityProfileTransactionPhase sut)
public async Task No_updated_profiles_when_no_custom_formats(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -207,13 +207,13 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.TransactionOutput.Should().BeEquivalentTo(new QualityProfileTransactionData());
}
[Test, AutoMockData]
public void Unchanged_scores(QualityProfileTransactionPhase sut)
public async Task Unchanged_scores(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -246,7 +246,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.UnchangedProfiles.Should()
@ -262,7 +262,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void Reset_scores_with_reset_unmatched_true(QualityProfileTransactionPhase sut)
public async Task Reset_scores_with_reset_unmatched_true(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -301,7 +301,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.ChangedProfiles.Should()
@ -319,7 +319,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void Reset_scores_with_reset_unmatched_false(QualityProfileTransactionPhase sut)
public async Task Reset_scores_with_reset_unmatched_false(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -355,8 +355,8 @@ public class QualityProfileTransactionPhaseTest
ResetUnmatchedScores = new ResetUnmatchedScoresConfig
{
Enabled = false,
// Throw in some exceptions here, just to test whether or not these somehow affect the
// result despite Enable being set to false.
// Throw in some exceptions here, just to test whether these somehow
// affect the result despite Enable being set to false.
Except = ["cf1"],
},
},
@ -367,7 +367,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.ChangedProfiles.Should()
@ -385,7 +385,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void Reset_scores_with_reset_exceptions(QualityProfileTransactionPhase sut)
public async Task Reset_scores_with_reset_exceptions(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -431,7 +431,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.ChangedProfiles.Should()
@ -449,7 +449,9 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void Reset_scores_with_invalid_except_list_items(QualityProfileTransactionPhase sut)
public async Task Reset_scores_with_invalid_except_list_items(
QualityProfileTransactionPhase sut
)
{
var dtos = new[]
{
@ -493,7 +495,7 @@ public class QualityProfileTransactionPhaseTest
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto()),
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.ChangedProfiles.Should()
@ -503,7 +505,7 @@ public class QualityProfileTransactionPhaseTest
}
[Test, AutoMockData]
public void Missing_required_qualities_are_readded(QualityProfileTransactionPhase sut)
public async Task Missing_required_qualities_are_readded(QualityProfileTransactionPhase sut)
{
var dtos = new[]
{
@ -556,7 +558,7 @@ public class QualityProfileTransactionPhaseTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
var profiles = context.TransactionOutput.ChangedProfiles;
profiles.Should().ContainSingle();

@ -9,7 +9,7 @@ using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Tests.Pipelines.QualitySize.PipelinePhases;
[TestFixture]
public class QualitySizeConfigPhaseTest
internal class QualitySizeConfigPhaseTest
{
[Test, AutoMockData]
public async Task Do_nothing_if_no_quality_definition(
@ -22,7 +22,8 @@ public class QualitySizeConfigPhaseTest
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeNull();
context.QualitySizeType.Should().BeEmpty();
context.Qualities.Should().BeEmpty();
}
[Test, AutoMockData]
@ -42,7 +43,8 @@ public class QualitySizeConfigPhaseTest
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeNull();
context.QualitySizeType.Should().BeEmpty();
context.Qualities.Should().BeEmpty();
}
[Test]
@ -106,9 +108,8 @@ public class QualitySizeConfigPhaseTest
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().NotBeNull();
context
.ConfigOutput!.Qualities.Select(x => x.Item)
.Qualities.Select(x => x.Item)
.Should()
.BeEquivalentTo([new QualityItem("quality1", 0, 100, 50)]);
}
@ -139,9 +140,8 @@ public class QualitySizeConfigPhaseTest
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().NotBeNull();
context
.ConfigOutput!.Qualities.Select(x => x.Item)
.Qualities.Select(x => x.Item)
.Should()
.BeEquivalentTo([new QualityItem("quality1", 0, 100, 90)]);
}

@ -1,5 +1,4 @@
using Recyclarr.Cli.Pipelines.QualitySize;
using Recyclarr.Cli.Pipelines.QualitySize.Models;
using Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
using Recyclarr.ServarrApi.QualityDefinition;
using Recyclarr.Tests.TestLibrary;
@ -7,45 +6,43 @@ using Recyclarr.Tests.TestLibrary;
namespace Recyclarr.Cli.Tests.Pipelines.QualitySize.PipelinePhases;
[TestFixture]
public class QualitySizeTransactionPhaseTest
internal class QualitySizeTransactionPhaseTest
{
[Test, AutoMockData]
public void Skip_guide_qualities_that_do_not_exist_in_service(QualitySizeTransactionPhase sut)
public async Task Skip_guide_qualities_that_do_not_exist_in_service(
QualitySizeTransactionPhase sut
)
{
var context = new QualitySizePipelineContext
{
ConfigOutput = new ProcessedQualitySizeData(
"",
[
NewQualitySize.WithLimits("non_existent1", 0, 2, 1),
NewQualitySize.WithLimits("non_existent2", 0, 2, 1),
]
),
Qualities =
[
NewQualitySize.WithLimits("non_existent1", 0, 2, 1),
NewQualitySize.WithLimits("non_existent2", 0, 2, 1),
],
ApiFetchOutput = new List<ServiceQualityDefinitionItem>
{
new() { Quality = new ServiceQualityItem { Name = "exists" } },
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.TransactionOutput.Should().BeEmpty();
}
[Test, AutoMockData]
public void Skip_guide_qualities_that_are_not_different_from_service(
public async Task Skip_guide_qualities_that_are_not_different_from_service(
QualitySizeTransactionPhase sut
)
{
var context = new QualitySizePipelineContext
{
ConfigOutput = new ProcessedQualitySizeData(
"",
[
NewQualitySize.WithLimits("same1", 0, 2, 1),
NewQualitySize.WithLimits("same2", 0, 2, 1),
]
),
Qualities =
[
NewQualitySize.WithLimits("same1", 0, 2, 1),
NewQualitySize.WithLimits("same2", 0, 2, 1),
],
ApiFetchOutput = new List<ServiceQualityDefinitionItem>
{
new()
@ -65,25 +62,23 @@ public class QualitySizeTransactionPhaseTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.TransactionOutput.Should().BeEmpty();
}
[Test, AutoMockData]
public void Sync_guide_qualities_that_are_different_from_service(
public async Task Sync_guide_qualities_that_are_different_from_service(
QualitySizeTransactionPhase sut
)
{
var context = new QualitySizePipelineContext
{
ConfigOutput = new ProcessedQualitySizeData(
"",
[
NewQualitySize.WithLimits("same1", 0, 2, 1),
NewQualitySize.WithLimits("different1", 0, 3, 1),
]
),
Qualities =
[
NewQualitySize.WithLimits("same1", 0, 2, 1),
NewQualitySize.WithLimits("different1", 0, 3, 1),
],
ApiFetchOutput = new List<ServiceQualityDefinitionItem>
{
new()
@ -103,7 +98,7 @@ public class QualitySizeTransactionPhaseTest
},
};
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context
.TransactionOutput.Should()

Loading…
Cancel
Save