refactor: Make quality profile pipeline generic

spectre-console-remove-di-hacks
Robert Dailey 6 months ago
parent b6a53e497c
commit b14787e471

@ -82,7 +82,7 @@ public static class CompositionRoot
// There are indirect dependencies between pipelines.
typeof(GenericSyncPipeline<TagPipelineContext>),
typeof(CustomFormatSyncPipeline),
typeof(QualityProfileSyncPipeline),
typeof(GenericSyncPipeline<QualityProfilePipelineContext>),
typeof(GenericSyncPipeline<QualitySizePipelineContext>),
typeof(GenericSyncPipeline<ReleaseProfilePipelineContext>),
typeof(GenericSyncPipeline<MediaNamingPipelineContext>))

@ -24,6 +24,8 @@ public class GenericSyncPipeline<TContext>(ILogger log, GenericPipelinePhases<TC
await phases.ApiFetchPhase.Execute(context, config);
phases.TransactionPhase.Execute(context);
phases.LogPhase.LogTransactionNotices(context);
if (settings.Preview)
{
phases.PreviewPhase.Execute(context);

@ -4,5 +4,6 @@ public interface ILogPipelinePhase<in TContext>
where TContext : IPipelineContext
{
bool LogConfigPhaseAndExitIfNeeded(TContext context);
void LogTransactionNotices(TContext context);
void LogPersistenceResults(TContext context);
}

@ -36,6 +36,10 @@ public class MediaNamingLogPhase(ILogger log) : ILogPipelinePhase<MediaNamingPip
return false;
}
public void LogTransactionNotices(MediaNamingPipelineContext context)
{
}
public void LogPersistenceResults(MediaNamingPipelineContext context)
{
var differences = context.ApiFetchOutput switch

@ -0,0 +1,14 @@
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.QualityProfile.Models;
public record ProcessedQualityProfileScore(string TrashId, string CfName, int FormatId, int Score);
public record ProcessedQualityProfileData
{
public required QualityProfileConfig Profile { get; init; }
public bool ShouldCreate { get; init; } = true;
public IList<ProcessedQualityProfileScore> CfScores { get; init; } = new List<ProcessedQualityProfileScore>();
public IList<CustomFormatData> ScorelessCfs { get; } = new List<CustomFormatData>();
}

@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using FluentValidation.Results;
namespace Recyclarr.Cli.Pipelines.QualityProfile.Models;
public record InvalidProfileData(UpdatedQualityProfile Profile, IReadOnlyCollection<ValidationFailure> Errors);
[SuppressMessage("Usage", "CA2227:Collection properties should be read only")]
public record QualityProfileTransactionData
{
public ICollection<string> NonExistentProfiles { get; init; } = new List<string>();
public ICollection<InvalidProfileData> InvalidProfiles { get; init; } = new List<InvalidProfileData>();
public ICollection<ProfileWithStats> UnchangedProfiles { get; set; } = new List<ProfileWithStats>();
public ICollection<ProfileWithStats> ChangedProfiles { get; set; } = new List<ProfileWithStats>();
}

@ -0,0 +1,7 @@
namespace Recyclarr.Cli.Pipelines.QualityProfile.Models;
public enum QualityProfileUpdateReason
{
New,
Changed
}

@ -1,3 +1,4 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.QualityProfile;
@ -6,11 +7,12 @@ namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record QualityProfileServiceData(IReadOnlyList<QualityProfileDto> Profiles, QualityProfileDto Schema);
public class QualityProfileApiFetchPhase(IQualityProfileApiService api)
: IApiFetchPipelinePhase<QualityProfilePipelineContext>
{
public async Task<QualityProfileServiceData> Execute(IServiceConfiguration config)
public async Task Execute(QualityProfilePipelineContext context, IServiceConfiguration config)
{
var profiles = await api.GetQualityProfiles(config);
var schema = await api.GetSchema(config);
return new QualityProfileServiceData(profiles.AsReadOnly(), schema);
context.ApiFetchOutput = new QualityProfileServiceData(profiles.AsReadOnly(), schema);
}
}

@ -1,29 +1,16 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileApiPersistencePhase(
ILogger log,
IQualityProfileApiService api,
QualityProfileStatCalculator statCalculator)
public class QualityProfileApiPersistencePhase(IQualityProfileApiService api)
: IApiPersistencePipelinePhase<QualityProfilePipelineContext>
{
public async Task Execute(IServiceConfiguration config, QualityProfileTransactionData transactions)
public async Task Execute(QualityProfilePipelineContext context, IServiceConfiguration config)
{
var profilesWithStats = transactions.UpdatedProfiles
.Select(x => statCalculator.Calculate(x))
.ToLookup(x => x.HasChanges);
// Profiles without changes (false) get logged
var unchangedProfiles = profilesWithStats[false].ToList();
if (unchangedProfiles.Count != 0)
{
log.Debug("These profiles have no changes and will not be persisted: {Profiles}",
unchangedProfiles.Select(x => x.Profile.ProfileName));
}
// Profiles with changes (true) get sent to the service
var changedProfiles = profilesWithStats[true].ToList();
var changedProfiles = context.TransactionOutput.ChangedProfiles;
foreach (var profile in changedProfiles.Select(x => x.Profile))
{
var dto = profile.BuildUpdatedDto();
@ -42,46 +29,5 @@ public class QualityProfileApiPersistencePhase(
throw new InvalidOperationException($"Unsupported UpdateReason: {profile.UpdateReason}");
}
}
LogUpdates(changedProfiles);
}
private void LogUpdates(IReadOnlyCollection<ProfileWithStats> changedProfiles)
{
var createdProfiles = changedProfiles
.Where(x => x.Profile.UpdateReason == QualityProfileUpdateReason.New)
.Select(x => x.Profile.ProfileName)
.ToList();
if (createdProfiles.Count > 0)
{
log.Information("Created {Count} Profiles: {Names}", createdProfiles.Count, createdProfiles);
}
var updatedProfiles = changedProfiles
.Where(x => x.Profile.UpdateReason == QualityProfileUpdateReason.Changed)
.Select(x => x.Profile.ProfileName)
.ToList();
if (updatedProfiles.Count > 0)
{
log.Information("Updated {Count} Profiles: {Names}", updatedProfiles.Count, updatedProfiles);
}
if (changedProfiles.Count != 0)
{
var numProfiles = changedProfiles.Count;
var numQuality = changedProfiles.Count(x => x.QualitiesChanged);
var numScores = changedProfiles.Count(x => x.ScoresChanged);
log.Information(
"A total of {NumProfiles} profiles were synced. {NumQuality} contain quality changes and " +
"{NumScores} contain updated scores",
numProfiles, numQuality, numScores);
}
else
{
log.Information("All quality profiles are up to date!");
}
}
}

@ -1,23 +1,16 @@
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;
using Recyclarr.TrashGuide.CustomFormat;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record ProcessedQualityProfileScore(string TrashId, string CfName, int FormatId, int Score);
public record ProcessedQualityProfileData
{
public required QualityProfileConfig Profile { get; init; }
public bool ShouldCreate { get; init; } = true;
public IList<ProcessedQualityProfileScore> CfScores { get; init; } = new List<ProcessedQualityProfileScore>();
public IList<CustomFormatData> ScorelessCfs { get; } = new List<CustomFormatData>();
}
public class QualityProfileConfigPhase(ILogger log, ProcessedCustomFormatCache cache)
: IConfigPipelinePhase<QualityProfilePipelineContext>
{
public IReadOnlyCollection<ProcessedQualityProfileData> Execute(IServiceConfiguration config)
public Task Execute(QualityProfilePipelineContext context, IServiceConfiguration config)
{
// 1. For each group of CFs that has a quality profile specified
// 2. For each quality profile score config in that CF group
@ -57,7 +50,8 @@ public class QualityProfileConfigPhase(ILogger log, ProcessedCustomFormatCache c
var profilesToReturn = allProfiles.Values.ToList();
PrintDiagnostics(profilesToReturn);
return profilesToReturn;
context.ConfigOutput = profilesToReturn;
return Task.CompletedTask;
}
private void PrintDiagnostics(IEnumerable<ProcessedQualityProfileData> profiles)

@ -0,0 +1,123 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileLogPhase(ILogger log) : 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;
if (transactions.NonExistentProfiles.Count > 0)
{
log.Warning(
"The following quality profile names have no definition in the top-level `quality_profiles` " +
"list *and* do not exist in the remote service. Either create them manually in the service *or* add " +
"them to the top-level `quality_profiles` section so that Recyclarr can create the profiles for you");
log.Warning("{QualityProfileNames}", transactions.NonExistentProfiles);
}
if (transactions.InvalidProfiles.Count > 0)
{
log.Warning(
"The following validation errors occurred for one or more quality profiles. " +
"These profiles will *not* be synced");
var numErrors = 0;
foreach (var (profile, errors) in transactions.InvalidProfiles)
{
numErrors += errors.LogValidationErrors(log, $"Profile '{profile.ProfileName}'");
}
if (numErrors > 0)
{
log.Error("Profile validation failed with {Count} errors", numErrors);
}
}
var invalidQualityNames = transactions.ChangedProfiles
.Select(x => (x.Profile.ProfileName, x.Profile.UpdatedQualities.InvalidQualityNames))
.Where(x => x.InvalidQualityNames.Count != 0)
.ToList();
foreach (var (profileName, invalidNames) in invalidQualityNames)
{
log.Warning("Quality profile '{ProfileName}' references invalid quality names: {InvalidNames}",
profileName, invalidNames);
}
var invalidCfExceptNames = transactions.ChangedProfiles
.Where(x => x.Profile.InvalidExceptCfNames.Count != 0)
.Select(x => (x.Profile.ProfileName, x.Profile.InvalidExceptCfNames));
foreach (var (profileName, invalidNames) in invalidCfExceptNames)
{
log.Warning(
"`except` under `reset_unmatched_scores` in quality profile '{ProfileName}' has invalid " +
"CF names: {CfNames}", profileName, invalidNames);
}
}
public void LogPersistenceResults(QualityProfilePipelineContext context)
{
var changedProfiles = context.TransactionOutput.ChangedProfiles;
// Profiles without changes get logged
var unchangedProfiles = context.TransactionOutput.UnchangedProfiles;
if (unchangedProfiles.Count != 0)
{
log.Debug("These profiles have no changes and will not be persisted: {Profiles}",
unchangedProfiles.Select(x => x.Profile.ProfileName));
}
var createdProfiles = changedProfiles
.Where(x => x.Profile.UpdateReason == QualityProfileUpdateReason.New)
.Select(x => x.Profile.ProfileName)
.ToList();
if (createdProfiles.Count > 0)
{
log.Information("Created {Count} Profiles: {Names}", createdProfiles.Count, createdProfiles);
}
var updatedProfiles = changedProfiles
.Where(x => x.Profile.UpdateReason == QualityProfileUpdateReason.Changed)
.Select(x => x.Profile.ProfileName)
.ToList();
if (updatedProfiles.Count > 0)
{
log.Information("Updated {Count} Profiles: {Names}", updatedProfiles.Count, updatedProfiles);
}
if (changedProfiles.Count != 0)
{
var numProfiles = changedProfiles.Count;
var numQuality = changedProfiles.Count(x => x.QualitiesChanged);
var numScores = changedProfiles.Count(x => x.ScoresChanged);
log.Information(
"A total of {NumProfiles} profiles were synced. {NumQuality} contain quality changes and " +
"{NumScores} contain updated scores",
numProfiles, numQuality, numScores);
}
else
{
log.Information("All quality profiles are up to date!");
}
}
}

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

@ -1,16 +1,17 @@
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)
public class QualityProfilePreviewPhase(IAnsiConsole console) : IPreviewPipelinePhase<QualityProfilePipelineContext>
{
public void Execute(QualityProfileTransactionData transactions)
public void Execute(QualityProfilePipelineContext context)
{
var tree = new Tree("Quality Profile Changes [red](Preview)[/]");
foreach (var profile in transactions.UpdatedProfiles)
foreach (var profile in context.TransactionOutput.ChangedProfiles.Select(x => x.Profile))
{
var profileTree = new Tree(Markup.FromInterpolated(
$"[yellow]{profile.ProfileName}[/] (Change Reason: [green]{profile.UpdateReason}[/])"));

@ -1,5 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using FluentValidation.Results;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Config.Models;
@ -7,52 +7,47 @@ using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public enum QualityProfileUpdateReason
public class QualityProfileTransactionPhase(QualityProfileStatCalculator statCalculator)
: ITransactionPipelinePhase<QualityProfilePipelineContext>
{
New,
Changed
}
public record InvalidProfileData(UpdatedQualityProfile Profile, IReadOnlyCollection<ValidationFailure> Errors);
public record QualityProfileTransactionData
{
[SuppressMessage("Usage", "CA2227:Collection properties should be read only")]
public ICollection<UpdatedQualityProfile> UpdatedProfiles { get; set; } = new List<UpdatedQualityProfile>();
public ICollection<string> NonExistentProfiles { get; init; } = new List<string>();
public ICollection<InvalidProfileData> InvalidProfiles { get; init; } = new List<InvalidProfileData>();
}
public class QualityProfileTransactionPhase
{
[SuppressMessage("ReSharper", "MemberCanBeMadeStatic.Global")]
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification =
"This non-static method establishes a pattern that will eventually become an interface")]
public QualityProfileTransactionData Execute(
IReadOnlyCollection<ProcessedQualityProfileData> guideData,
QualityProfileServiceData serviceData)
public void Execute(QualityProfilePipelineContext context)
{
var transactions = new QualityProfileTransactionData();
BuildUpdatedProfiles(transactions, guideData, serviceData);
UpdateProfileScores(transactions.UpdatedProfiles);
var updatedProfiles = BuildUpdatedProfiles(transactions, context.ConfigOutput, context.ApiFetchOutput);
UpdateProfileScores(updatedProfiles);
ValidateProfiles(transactions);
updatedProfiles = ValidateProfiles(updatedProfiles, transactions.InvalidProfiles);
return transactions;
AssignProfiles(transactions, updatedProfiles);
context.TransactionOutput = transactions;
}
private static void ValidateProfiles(QualityProfileTransactionData transactions)
private void AssignProfiles(
QualityProfileTransactionData transactions,
IEnumerable<UpdatedQualityProfile> updatedProfiles)
{
var profilesWithStats = updatedProfiles
.Select(statCalculator.Calculate)
.ToLookup(x => x.HasChanges);
transactions.UnchangedProfiles = profilesWithStats[false].ToList();
transactions.ChangedProfiles = profilesWithStats[true].ToList();
}
private static List<UpdatedQualityProfile> ValidateProfiles(
IEnumerable<UpdatedQualityProfile> transactions,
ICollection<InvalidProfileData> invalidProfiles)
{
var validator = new UpdatedQualityProfileValidator();
transactions.UpdatedProfiles = transactions.UpdatedProfiles
return transactions
.IsValid(validator, (errors, profile) =>
transactions.InvalidProfiles.Add(new InvalidProfileData(profile, errors)))
invalidProfiles.Add(new InvalidProfileData(profile, errors)))
.ToList();
}
private static void BuildUpdatedProfiles(
private static List<UpdatedQualityProfile> BuildUpdatedProfiles(
QualityProfileTransactionData transactions,
IEnumerable<ProcessedQualityProfileData> guideData,
QualityProfileServiceData serviceData)
@ -68,6 +63,8 @@ public class QualityProfileTransactionPhase
(x, y) => (x, y.FirstOrDefault()),
StringComparer.InvariantCultureIgnoreCase);
var updatedProfiles = new List<UpdatedQualityProfile>();
foreach (var (config, dto) in matchedProfiles)
{
if (dto is null && !config.ShouldCreate)
@ -79,7 +76,7 @@ public class QualityProfileTransactionPhase
var organizer = new QualityItemOrganizer();
var newDto = dto ?? serviceData.Schema;
transactions.UpdatedProfiles.Add(new UpdatedQualityProfile
updatedProfiles.Add(new UpdatedQualityProfile
{
ProfileConfig = config,
ProfileDto = newDto,
@ -87,6 +84,8 @@ public class QualityProfileTransactionPhase
UpdatedQualities = organizer.OrganizeItems(newDto, config.Profile)
});
}
return updatedProfiles;
}
private static void UpdateProfileScores(IEnumerable<UpdatedQualityProfile> updatedProfiles)

@ -1,5 +1,4 @@
using Autofac;
using Autofac.Extras.AggregateService;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
@ -12,12 +11,13 @@ public class QualityProfileAutofacModule : Module
builder.RegisterType<QualityProfileStatCalculator>();
builder.RegisterAggregateService<IQualityProfilePipelinePhases>();
builder.RegisterType<QualityProfileConfigPhase>();
builder.RegisterType<QualityProfileApiFetchPhase>();
builder.RegisterType<QualityProfileTransactionPhase>();
builder.RegisterType<QualityProfilePreviewPhase>();
builder.RegisterType<QualityProfileApiPersistencePhase>();
builder.RegisterType<QualityProfileNoticePhase>();
builder.RegisterTypes(
typeof(QualityProfileConfigPhase),
typeof(QualityProfilePreviewPhase),
typeof(QualityProfileApiFetchPhase),
typeof(QualityProfileTransactionPhase),
typeof(QualityProfileApiPersistencePhase),
typeof(QualityProfileLogPhase))
.AsImplementedInterfaces();
}
}

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Common;
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
{
public string PipelineDescription => "Quality Definition Pipeline";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } = new[]
{
SupportedServices.Sonarr,
SupportedServices.Radarr
};
public IList<ProcessedQualityProfileData> ConfigOutput { get; set; } = default!;
public QualityProfileServiceData ApiFetchOutput { get; set; } = default!;
public QualityProfileTransactionData TransactionOutput { get; set; } = default!;
}

@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.JsonDiffPatch;
using Recyclarr.ServarrApi.QualityProfile;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public record ProfileWithStats
{

@ -1,41 +0,0 @@
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public interface IQualityProfilePipelinePhases
{
QualityProfileConfigPhase ConfigPhase { get; }
QualityProfileApiFetchPhase ApiFetchPhase { get; }
QualityProfileTransactionPhase TransactionPhase { get; }
Lazy<QualityProfilePreviewPhase> PreviewPhase { get; }
QualityProfileApiPersistencePhase ApiPersistencePhase { get; }
QualityProfileNoticePhase NoticePhase { get; }
}
public class QualityProfileSyncPipeline(ILogger log, IQualityProfilePipelinePhases phases) : ISyncPipeline
{
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
var guideData = phases.ConfigPhase.Execute(config);
if (guideData.Count == 0)
{
log.Debug("No quality profiles to process");
return;
}
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = phases.TransactionPhase.Execute(guideData, serviceData);
phases.NoticePhase.Execute(transactions);
if (settings.Preview)
{
phases.PreviewPhase.Value.Execute(transactions);
return;
}
await phases.ApiPersistencePhase.Execute(config, transactions);
}
}

@ -1,3 +1,4 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Common.Extensions;
using Recyclarr.ServarrApi.QualityProfile;

@ -1,3 +1,4 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.ServarrApi.QualityProfile;

@ -1,4 +1,5 @@
using FluentValidation;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Common.Extensions;

@ -21,6 +21,10 @@ public class QualitySizeLogPhase(ILogger log) : ILogPipelinePhase<QualitySizePip
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

@ -15,6 +15,10 @@ public class ReleaseProfileLogPhase(ILogger log) : ILogPipelinePhase<ReleaseProf
return true;
}
public void LogTransactionNotices(ReleaseProfilePipelineContext context)
{
}
public void LogPersistenceResults(ReleaseProfilePipelineContext context)
{
var transactions = context.TransactionOutput;

@ -15,6 +15,10 @@ public class TagLogPhase(ILogger log) : ILogPipelinePhase<TagPipelineContext>
return false;
}
public void LogTransactionNotices(TagPipelineContext context)
{
}
public void LogPersistenceResults(TagPipelineContext context)
{
if (context.TransactionOutput.Any())

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.QualityProfile;

@ -1,4 +1,5 @@
using Recyclarr.Cli.Pipelines.CustomFormat.Models;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary;
@ -40,9 +41,10 @@ public class QualityProfileConfigPhaseTest
}
});
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEquivalentTo(new[]
context.ConfigOutput.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 100))
},
@ -72,9 +74,10 @@ public class QualityProfileConfigPhaseTest
}
});
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEquivalentTo(new[]
context.ConfigOutput.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 200))
},
@ -104,9 +107,10 @@ public class QualityProfileConfigPhaseTest
}
});
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEquivalentTo(new[]
context.ConfigOutput.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile")
},
@ -162,9 +166,10 @@ public class QualityProfileConfigPhaseTest
}
);
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEquivalentTo(new[]
context.ConfigOutput.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile1", ("id1", 1, 100)),
NewQp.Processed("test_profile2", ("id1", 1, 200))
@ -206,9 +211,10 @@ public class QualityProfileConfigPhaseTest
}
};
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEquivalentTo(new[]
context.ConfigOutput.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile", ("id1", 1, 102), ("id2", 2, 201)) with
{
@ -236,9 +242,10 @@ public class QualityProfileConfigPhaseTest
}
});
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEmpty();
context.ConfigOutput.Should().BeEmpty();
}
[Test, AutoMockData]
@ -258,8 +265,9 @@ public class QualityProfileConfigPhaseTest
QualityProfiles = Array.Empty<QualityProfileScoreConfig>()
});
var result = sut.Execute(config);
var context = new QualityProfilePipelineContext();
sut.Execute(context, config);
result.Should().BeEmpty();
context.ConfigOutput.Should().BeEmpty();
}
}

@ -1,4 +1,5 @@
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.QualityProfile;
@ -9,37 +10,42 @@ namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileTransactionPhaseTest
{
[Test, AutoMockData]
public void Non_existent_profile_names_with_updated(
public void Non_existent_profile_names_mixed_with_valid_profiles(
QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed("invalid_profile_name") with
{
ShouldCreate = false
},
NewQp.Processed("profile1")
};
var dtos = new[]
{
new QualityProfileDto {Name = "profile1"}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = new[]
{
NewQp.Processed("invalid_profile_name") with
{
ShouldCreate = false
},
NewQp.Processed("profile1")
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.Should().BeEquivalentTo(new QualityProfileTransactionData
context.TransactionOutput.Should().BeEquivalentTo(new QualityProfileTransactionData
{
NonExistentProfiles = new[] {"invalid_profile_name"},
UpdatedProfiles =
UnchangedProfiles = new List<ProfileWithStats>
{
new UpdatedQualityProfile
new()
{
ProfileConfig = guideData[1],
ProfileDto = dtos[0],
UpdateReason = QualityProfileUpdateReason.Changed
Profile = new UpdatedQualityProfile
{
ProfileConfig = context.ConfigOutput[1],
ProfileDto = dtos[0],
UpdateReason = QualityProfileUpdateReason.Changed
}
}
}
});
@ -49,59 +55,65 @@ public class QualityProfileTransactionPhaseTest
public void New_profiles(
QualityProfileTransactionPhase sut)
{
var configData = new[]
{
new ProcessedQualityProfileData
{
Profile = new QualityProfileConfig
{
Name = "profile1",
Qualities = new[]
{
new QualityProfileQualityConfig {Name = "quality1", Enabled = true}
}
}
}
};
var dtos = new[]
{
new QualityProfileDto {Name = "irrelevant_profile"}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto())
var context = new QualityProfilePipelineContext
{
Schema = new QualityProfileDto
ConfigOutput = new[]
{
Items = new[]
new ProcessedQualityProfileData
{
new ProfileItemDto {Quality = new ProfileItemQualityDto {Name = "quality1"}}
Profile = new QualityProfileConfig
{
Name = "profile1",
Qualities = new[]
{
new QualityProfileQualityConfig {Name = "quality1", Enabled = true}
}
}
}
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
{
Schema = new QualityProfileDto
{
Items = new[]
{
new ProfileItemDto {Quality = new ProfileItemQualityDto {Name = "quality1"}}
}
}
}
};
var result = sut.Execute(configData, serviceData);
sut.Execute(context);
result.Should().BeEquivalentTo(new QualityProfileTransactionData
context.TransactionOutput.Should().BeEquivalentTo(new QualityProfileTransactionData
{
UpdatedProfiles =
ChangedProfiles = new List<ProfileWithStats>
{
new UpdatedQualityProfile
new()
{
ProfileConfig = configData[0],
ProfileDto = serviceData.Schema,
UpdateReason = QualityProfileUpdateReason.New,
UpdatedQualities = new UpdatedQualities
QualitiesChanged = true,
Profile = new UpdatedQualityProfile
{
NumWantedItems = 1,
Items = new[]
ProfileConfig = context.ConfigOutput[0],
ProfileDto = context.ApiFetchOutput.Schema,
UpdateReason = QualityProfileUpdateReason.New,
UpdatedQualities = new UpdatedQualities
{
new ProfileItemDto
NumWantedItems = 1,
Items = new[]
{
Allowed = true,
Quality = new ProfileItemQualityDto
new ProfileItemDto
{
Name = "quality1"
Allowed = true,
Quality = new ProfileItemQualityDto
{
Name = "quality1"
}
}
}
}
@ -115,11 +127,6 @@ public class QualityProfileTransactionPhaseTest
public void Updated_scores(
QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed("profile1", ("id1", 1, 100), ("id2", 2, 500))
};
var dtos = new[]
{
new QualityProfileDto
@ -143,12 +150,19 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = new[]
{
NewQp.Processed("profile1", ("id1", 1, 100), ("id2", 2, 500))
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.UpdatedProfiles.Should()
.ContainSingle().Which.UpdatedScores.Should()
context.TransactionOutput.ChangedProfiles.Should()
.ContainSingle().Which.Profile.UpdatedScores.Should()
.BeEquivalentTo(new[]
{
NewQp.UpdatedScore("quality1", 200, 100, FormatScoreUpdateReason.Updated),
@ -160,8 +174,6 @@ public class QualityProfileTransactionPhaseTest
public void No_updated_profiles_when_no_custom_formats(
QualityProfileTransactionPhase sut)
{
var guideData = Array.Empty<ProcessedQualityProfileData>();
var dtos = new[]
{
new QualityProfileDto
@ -185,24 +197,21 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = Array.Empty<ProcessedQualityProfileData>(),
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.Should().BeEquivalentTo(new QualityProfileTransactionData());
context.TransactionOutput.Should().BeEquivalentTo(new QualityProfileTransactionData());
}
[Test, AutoMockData]
public void Unchanged_scores(
QualityProfileTransactionPhase sut)
{
// Must simulate at least 1 custom format coming from configuration otherwise processing doesn't happen.
// Profile name must match but the format IDs for each quality should not
var guideData = new[]
{
NewQp.Processed("profile1", ("id1", 1, 200), ("id2", 2, 300))
};
var dtos = new[]
{
new QualityProfileDto
@ -226,12 +235,21 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
// Must simulate at least 1 custom format coming from configuration otherwise processing doesn't happen.
// Profile name must match but the format IDs for each quality should not
ConfigOutput = new[]
{
NewQp.Processed("profile1", ("id1", 1, 200), ("id2", 2, 300))
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.UpdatedProfiles.Should()
.ContainSingle().Which.UpdatedScores.Should()
context.TransactionOutput.UnchangedProfiles.Should()
.ContainSingle().Which.Profile.UpdatedScores.Should()
.BeEquivalentTo(new[]
{
NewQp.UpdatedScore("quality1", 200, 200, FormatScoreUpdateReason.NoChange),
@ -243,11 +261,6 @@ public class QualityProfileTransactionPhaseTest
public void Reset_scores_with_reset_unmatched_true(
QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed("profile1", true, ("quality3", "id3", 3, 100), ("quality4", "id4", 4, 500))
};
var dtos = new[]
{
new QualityProfileDto
@ -271,12 +284,19 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = new[]
{
NewQp.Processed("profile1", true, ("quality3", "id3", 3, 100), ("quality4", "id4", 4, 500))
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.UpdatedProfiles.Should()
.ContainSingle().Which.UpdatedScores.Should()
context.TransactionOutput.ChangedProfiles.Should()
.ContainSingle().Which.Profile.UpdatedScores.Should()
.BeEquivalentTo(new[]
{
NewQp.UpdatedScore("quality1", 200, 0, FormatScoreUpdateReason.Reset),
@ -289,22 +309,6 @@ public class QualityProfileTransactionPhaseTest
[Test, AutoMockData]
public void Reset_scores_with_reset_unmatched_false(QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed(new QualityProfileConfig
{
Name = "profile1",
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.
Except = new[] {"cf1"}
}
},
("cf3", "id3", 3, 100), ("cf4", "id4", 4, 500))
};
var dtos = new[]
{
new QualityProfileDto
@ -328,12 +332,30 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = new[]
{
NewQp.Processed(new QualityProfileConfig
{
Name = "profile1",
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.
Except = new[] {"cf1"}
}
},
("cf3", "id3", 3, 100), ("cf4", "id4", 4, 500))
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.UpdatedProfiles.Should()
.ContainSingle().Which.UpdatedScores.Should()
context.TransactionOutput.ChangedProfiles.Should()
.ContainSingle().Which.Profile.UpdatedScores.Should()
.BeEquivalentTo(new[]
{
NewQp.UpdatedScore("cf1", 200, 200, FormatScoreUpdateReason.NoChange),
@ -346,20 +368,6 @@ public class QualityProfileTransactionPhaseTest
[Test, AutoMockData]
public void Reset_scores_with_reset_exceptions(QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed(new QualityProfileConfig
{
Name = "profile1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfig
{
Enabled = true,
Except = new[] {"cf1"}
}
},
("cf3", "id3", 3, 100), ("cf4", "id4", 4, 500))
};
var dtos = new[]
{
new QualityProfileDto
@ -383,12 +391,28 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = new[]
{
NewQp.Processed(new QualityProfileConfig
{
Name = "profile1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfig
{
Enabled = true,
Except = new[] {"cf1"}
}
},
("cf3", "id3", 3, 100), ("cf4", "id4", 4, 500))
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.UpdatedProfiles.Should()
.ContainSingle().Which.UpdatedScores.Should()
context.TransactionOutput.ChangedProfiles.Should()
.ContainSingle().Which.Profile.UpdatedScores.Should()
.BeEquivalentTo(new[]
{
NewQp.UpdatedScore("cf1", 200, 200, FormatScoreUpdateReason.NoChange),
@ -401,19 +425,6 @@ public class QualityProfileTransactionPhaseTest
[Test, AutoMockData]
public void Reset_scores_with_invalid_except_list_items(QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed(new QualityProfileConfig
{
Name = "profile1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfig
{
Enabled = true,
Except = new[] {"cf50"}
}
})
};
var dtos = new[]
{
new QualityProfileDto
@ -437,12 +448,27 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var context = new QualityProfilePipelineContext
{
ConfigOutput = new[]
{
NewQp.Processed(new QualityProfileConfig
{
Name = "profile1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfig
{
Enabled = true,
Except = new[] {"cf50"}
}
})
},
ApiFetchOutput = new QualityProfileServiceData(dtos, new QualityProfileDto())
};
var result = sut.Execute(guideData, serviceData);
sut.Execute(context);
result.UpdatedProfiles.Should()
.ContainSingle().Which.InvalidExceptCfNames.Should()
context.TransactionOutput.ChangedProfiles.Should()
.ContainSingle().Which.Profile.InvalidExceptCfNames.Should()
.BeEquivalentTo("cf50");
}
}

@ -1,4 +1,5 @@
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.QualityProfile;

@ -1,5 +1,6 @@
using FluentValidation.TestHelper;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.QualityProfile;

Loading…
Cancel
Save