feat: Quality profile sync

Initial implementation with sync support for the following fields:

- Name
- Upgrade Allowed
- Min Format Score
- Cutoff
- Cutoff Format Score
- Items

Quality profiles are always created if they are defined under
`quality_profiles` at the top-level. Within a quality profile
configuration, Recyclarr will not modify quality profile fields if those
corresponding properties in the config are omitted.
pull/201/head
Robert Dailey 10 months ago
parent 31896828bc
commit ce338e24f3

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `base_url` and `api_key` are now optional. These can be implicitly set via secrets that follow a
naming convention. See the Secrets reference page on the wiki for details.
- Quality Profiles can now be created & synced to Radarr, Sonarr v3, and Sonarr v4.
### Changed

@ -100,6 +100,7 @@
},
"quality_profiles": {
"type": "array",
"description": "An array of quality profiles that exist in the remote service along with any configuration properties that Recyclarr should use to modify that quality profile.",
"minItems": 1,
"items": {
"type": "object",
@ -107,12 +108,58 @@
"required": ["name"],
"properties": {
"name": {
"type": "string"
"type": "string",
"description": "The name of the quality profile to which settings should apply"
},
"reset_unmatched_scores": {
"type": "boolean",
"description": "If set to true, enables setting scores to 0 in quality profiles where either a CF was not mentioned in the trash_ids array or it was in that list but did not get a score (e.g. no score in guide).",
"default": false
},
"upgrades_allowed": {
"type": "object",
"additionalProperties": false,
"required": ["until_quality"],
"properties": {
"until_quality": {
"type": "string"
},
"until_score": {
"type": "number"
}
}
},
"min_format_score": {
"type": "number"
},
"quality_sort": {
"enum": ["bottom", "top"],
"default": "top"
},
"qualities": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"enabled": {
"type": "boolean",
"default": true
},
"qualities": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
}
}
}
}
}

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

@ -16,6 +16,7 @@ using Recyclarr.Cli.Pipelines.ReleaseProfile;
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Processors;
using Recyclarr.Common;
using Recyclarr.Common.FluentValidation;
using Recyclarr.TrashLib;
using Recyclarr.TrashLib.Interfaces;
using Recyclarr.TrashLib.Startup;
@ -46,6 +47,7 @@ public static class CompositionRoot
builder.RegisterAssemblyTypes(thisAssembly)
.AsClosedTypesOf(typeof(IValidator<>))
.Where(x => !typeof(IManualValidator).IsAssignableFrom(x))
.As<IValidator>();
}

@ -5,5 +5,7 @@ namespace Recyclarr.Cli.Pipelines.QualityProfile.Api;
public interface IQualityProfileService
{
Task<IList<QualityProfileDto>> GetQualityProfiles(IServiceConfiguration config);
Task<QualityProfileDto> UpdateQualityProfile(IServiceConfiguration config, QualityProfileDto profile);
Task UpdateQualityProfile(IServiceConfiguration config, QualityProfileDto profile);
Task<QualityProfileDto> GetSchema(IServiceConfiguration config);
Task CreateQualityProfile(IServiceConfiguration config, QualityProfileDto profile);
}

@ -1,22 +1,83 @@
using JetBrains.Annotations;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Recyclarr.Cli.Pipelines.QualityProfile.Api;
public static class DtoUtil
{
public static void SetIfNotNull<T>(ref T propertyValue, T? newValue)
{
if (newValue is not null)
{
propertyValue = newValue;
}
}
}
[UsedImplicitly]
public record QualityProfileDto
{
public int Id { get; [UsedImplicitly] init; }
public string Name { get; init; } = "";
public bool UpgradeAllowed { get; init; }
public int MinFormatScore { get; init; }
public int Cutoff { get; init; }
public int CutoffFormatScore { get; init; }
public IReadOnlyCollection<ProfileFormatItemDto> FormatItems { get; init; } = Array.Empty<ProfileFormatItemDto>();
[JsonExtensionData]
public JObject? ExtraJson { get; init; }
private readonly bool? _upgradeAllowed;
private readonly int? _minFormatScore;
private readonly int? _cutoff;
private readonly int? _cutoffFormatScore;
private readonly string _name = "";
private readonly IReadOnlyCollection<ProfileItemDto> _items = new List<ProfileItemDto>();
public int? Id { get; set; }
public string Name
{
get => _name;
init
{
if (string.IsNullOrEmpty(_name))
{
_name = value;
}
}
}
public bool? UpgradeAllowed
{
get => _upgradeAllowed;
init => DtoUtil.SetIfNotNull(ref _upgradeAllowed, value);
}
public int? MinFormatScore
{
get => _minFormatScore;
init => DtoUtil.SetIfNotNull(ref _minFormatScore, value);
}
public int? Cutoff
{
get => _cutoff;
init => DtoUtil.SetIfNotNull(ref _cutoff, value);
}
public int? CutoffFormatScore
{
get => _cutoffFormatScore;
init => DtoUtil.SetIfNotNull(ref _cutoffFormatScore, value);
}
public IReadOnlyCollection<ProfileFormatItemDto> FormatItems { get; init; } = new List<ProfileFormatItemDto>();
public IReadOnlyCollection<ProfileItemDto> Items
{
get => _items;
init
{
if (value.Count > 0)
{
_items = value;
}
}
}
[UsedImplicitly, JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new();
}
[UsedImplicitly]
@ -26,6 +87,37 @@ public record ProfileFormatItemDto
public string Name { get; init; } = "";
public int Score { get; init; }
[JsonExtensionData]
[UsedImplicitly, JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new();
}
[UsedImplicitly]
public record ProfileItemDto
{
private readonly bool? _allowed;
public int? Id { get; set; }
public string? Name { get; init; }
public bool? Allowed
{
get => _allowed;
init => DtoUtil.SetIfNotNull(ref _allowed, value);
}
public ProfileItemQualityDto? Quality { get; init; }
public ICollection<ProfileItemDto> Items { get; init; } = new List<ProfileItemDto>();
[UsedImplicitly, JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new();
}
[UsedImplicitly]
public record ProfileItemQualityDto
{
public int? Id { get; init; }
public string? Name { get; init; }
[UsedImplicitly, JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new();
}

@ -15,14 +15,37 @@ internal class QualityProfileService : IQualityProfileService
public async Task<IList<QualityProfileDto>> GetQualityProfiles(IServiceConfiguration config)
{
return await _service.Request(config, "qualityprofile")
var response = await _service.Request(config, "qualityprofile")
.GetJsonAsync<IList<QualityProfileDto>>();
return response.Select(x => x.ReverseItems()).ToList();
}
public async Task<QualityProfileDto> GetSchema(IServiceConfiguration config)
{
var response = await _service.Request(config, "qualityprofile", "schema")
.GetJsonAsync<QualityProfileDto>();
return response.ReverseItems();
}
public async Task<QualityProfileDto> UpdateQualityProfile(IServiceConfiguration config, QualityProfileDto profile)
public async Task UpdateQualityProfile(IServiceConfiguration config, QualityProfileDto profile)
{
return await _service.Request(config, "qualityprofile", profile.Id)
.PutJsonAsync(profile)
if (profile.Id is null)
{
throw new ArgumentException($"Profile's ID property must not be null: {profile.Name}");
}
await _service.Request(config, "qualityprofile", profile.Id)
.PutJsonAsync(profile.ReverseItems());
}
public async Task CreateQualityProfile(IServiceConfiguration config, QualityProfileDto profile)
{
var response = await _service.Request(config, "qualityprofile")
.PostJsonAsync(profile.ReverseItems())
.ReceiveJson<QualityProfileDto>();
profile.Id = response.Id;
}
}

@ -3,6 +3,8 @@ using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record QualityProfileServiceData(IReadOnlyList<QualityProfileDto> Profiles, QualityProfileDto Schema);
public class QualityProfileApiFetchPhase
{
private readonly IQualityProfileService _api;
@ -12,8 +14,10 @@ public class QualityProfileApiFetchPhase
_api = api;
}
public async Task<IList<QualityProfileDto>> Execute(IServiceConfiguration config)
public async Task<QualityProfileServiceData> Execute(IServiceConfiguration config)
{
return await _api.GetQualityProfiles(config);
var profiles = await _api.GetQualityProfiles(config);
var schema = await _api.GetSchema(config);
return new QualityProfileServiceData(profiles.AsReadOnly(), schema);
}
}

@ -7,66 +7,72 @@ public class QualityProfileApiPersistencePhase
{
private readonly ILogger _log;
private readonly IQualityProfileService _api;
private readonly QualityProfileStatCalculator _statCalculator;
public QualityProfileApiPersistencePhase(ILogger log, IQualityProfileService api)
public QualityProfileApiPersistencePhase(
ILogger log,
IQualityProfileService api,
QualityProfileStatCalculator statCalculator)
{
_log = log;
_api = api;
_statCalculator = statCalculator;
}
public async Task Execute(IServiceConfiguration config, QualityProfileTransactionData transactions)
{
var profilesToUpdate = transactions.UpdatedProfiles.Select(x => x.UpdatedProfile with
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.Any())
{
FormatItems = x.UpdatedScores.Select(y => y.Dto with {Score = y.NewScore}).ToList()
});
_log.Debug("These profiles have no changes and will not be persisted: {Profiles}",
unchangedProfiles.Select(x => x.Profile.ProfileName));
}
foreach (var profile in profilesToUpdate)
// Profiles with changes (true) get sent to the service
var changedProfiles = profilesWithStats[true].ToList();
foreach (var profile in changedProfiles.Select(x => x.Profile))
{
await _api.UpdateQualityProfile(config, profile);
var dto = profile.BuildUpdatedDto();
switch (profile.UpdateReason)
{
case QualityProfileUpdateReason.New:
await _api.CreateQualityProfile(config, dto);
break;
case QualityProfileUpdateReason.Changed:
await _api.UpdateQualityProfile(config, dto);
break;
default:
throw new InvalidOperationException($"Unsupported UpdateReason: {profile.UpdateReason}");
}
}
LogQualityProfileUpdates(transactions);
LogUpdates(changedProfiles);
}
private void LogQualityProfileUpdates(QualityProfileTransactionData transactions)
private void LogUpdates(IReadOnlyCollection<ProfileWithStats> changedProfiles)
{
var updatedScores = transactions.UpdatedProfiles
.Select(x => (
ProfileName: x.UpdatedProfile.Name,
Scores: x.UpdatedScores
.Where(y => y.Reason != FormatScoreUpdateReason.New && y.Dto.Score != y.NewScore)
.ToList()))
.Where(x => x.Scores.Any())
.ToList();
if (updatedScores.Count > 0)
if (changedProfiles.Count > 0)
{
foreach (var (profileName, scores) in updatedScores)
{
_log.Debug("> Scores updated for quality profile: {ProfileName}", profileName);
foreach (var (dto, newScore, reason) in scores)
{
_log.Debug(" - {Format} ({Id}): {OldScore} -> {NewScore} ({Reason})",
dto.Name, dto.Format, dto.Score, newScore, reason);
}
}
var numProfiles = changedProfiles.Count;
var numQuality = changedProfiles.Count(x => x.QualitiesChanged);
var numScores = changedProfiles.Count(x => x.ScoresChanged);
_log.Information("Updated {ProfileCount} profiles and a total of {ScoreCount} scores",
transactions.UpdatedProfiles.Count,
updatedScores.Sum(s => s.Scores.Count));
_log.Information(
"A total of {NumProfiles} profiles changed: {NumQuality} contain quality changes; " +
"{NumScores} contain updated scores",
numProfiles, numQuality, numScores);
}
else
{
_log.Information("All quality profile scores are already up to date!");
}
if (transactions.InvalidProfileNames.Count > 0)
{
_log.Warning("The following quality profile names are not valid and should either be " +
"removed or renamed in your YAML config");
_log.Warning("{QualityProfileNames}", transactions.InvalidProfileNames);
_log.Information("All quality profiles are up to date!");
}
}
}

@ -9,6 +9,7 @@ public record ProcessedQualityProfileScore(string TrashId, string CfName, int Fo
public record ProcessedQualityProfileData(QualityProfileConfig Profile)
{
public bool ShouldCreate { get; init; } = true;
public IList<ProcessedQualityProfileScore> CfScores { get; init; } = new List<ProcessedQualityProfileScore>();
}
@ -37,28 +38,31 @@ public class QualityProfileConfigPhase
.NotNull()
.Select(y => (x.Profile, Cf: y)));
var allProfiles =
new Dictionary<string, ProcessedQualityProfileData>(StringComparer.InvariantCultureIgnoreCase);
var allProfiles = config.QualityProfiles
.Select(x => new ProcessedQualityProfileData(x))
.ToDictionary(x => x.Profile.Name, x => x, StringComparer.InvariantCultureIgnoreCase);
foreach (var (profile, cf) in profileAndCfs)
{
if (!allProfiles.TryGetValue(profile.Name, out var profileCfs))
{
profileCfs = new ProcessedQualityProfileData(
config.QualityProfiles.FirstOrDefault(
x => x.Name.EqualsIgnoreCase(profile.Name),
// If the user did not specify a quality profile in their config, we still create the QP object
// for consistency (at the very least for the name).
new QualityProfileConfig {Name = profile.Name}));
allProfiles[profile.Name] = profileCfs;
_log.Debug("Implicitly adding quality profile config for {ProfileName}", profile.Name);
// If the user did not specify a quality profile in their config, we still create the QP object
// for consistency (at the very least for the name).
allProfiles[profile.Name] = profileCfs =
new ProcessedQualityProfileData(new QualityProfileConfig {Name = profile.Name})
{
// The user must explicitly specify a profile in the top-level `quality_profiles` section of
// their config, otherwise we do not implicitly create them in the service.
ShouldCreate = false
};
}
AddCustomFormatScoreData(profileCfs.CfScores, profile, cf);
}
return allProfiles.Values
.Where(x => x.CfScores.IsNotEmpty())
.ToList();
return allProfiles.Values.ToList();
}
private void AddCustomFormatScoreData(

@ -0,0 +1,57 @@
using JetBrains.Annotations;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
[UsedImplicitly]
public class QualityProfileNoticePhase
{
private readonly ILogger _log;
public QualityProfileNoticePhase(ILogger log)
{
_log = 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.Any())
.ToList();
foreach (var (profileName, invalidNames) in invalidQualityNames)
{
_log.Warning("Quality profile '{ProfileName}' references invalid quality names: {InvalidNames}",
profileName, invalidNames);
}
}
}

@ -1,4 +1,6 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
@ -13,45 +15,147 @@ public class QualityProfilePreviewPhase
public void Execute(QualityProfileTransactionData transactions)
{
var profileScoreUpdates = transactions.UpdatedProfiles
.ToDictionary(x => x.UpdatedProfile.Name, x => x.UpdatedScores);
var tree = new Tree("Quality Profile Changes [red](Preview)[/]");
var tree = new Tree("Quality Profiles Scores [red](Preview)[/]");
foreach (var (profileName, updatedScores) in profileScoreUpdates)
foreach (var profile in transactions.UpdatedProfiles)
{
var table = new Table()
.AddColumn("[bold]Custom Format[/]")
.AddColumn("[bold]Current[/]")
.AddColumn("[bold]New[/]")
.AddColumn("[bold]Reason[/]");
var profileTree = new Tree(Markup.FromInterpolated(
$"[yellow]{profile.ProfileName}[/] (Change Reason: [green]{profile.UpdateReason}[/])"));
profileTree.AddNode(new Rows(
new Markup("[b]Profile Updates[/]"),
SetupProfileTable(profile)));
foreach (var updatedScore in updatedScores.Where(x => x.Dto.Score != x.NewScore))
if (profile.ProfileConfig.Profile.Qualities.Any())
{
table.AddRow(
updatedScore.Dto.Name,
updatedScore.Dto.Score.ToString(),
updatedScore.NewScore.ToString(),
updatedScore.Reason.ToString());
profileTree.AddNode(SetupQualityItemTable(profile));
}
tree.AddNode($"[yellow]{profileName}[/]")
.AddNode(table);
profileTree.AddNode(new Rows(
new Markup("[b]Score Updates[/]"),
SetupScoreTable(profile)));
tree.AddNode(profileTree);
}
_console.WriteLine();
_console.Write(tree);
_console.WriteLine();
}
private static Table SetupProfileTable(UpdatedQualityProfile profile)
{
var table = new Table()
.AddColumn("[bold]Profile Field[/]")
.AddColumn("[bold]Current[/]")
.AddColumn("[bold]New[/]");
static string YesNo(bool? val) => val is true ? "Yes" : "No";
static string Null<T>(T? val) => val is null ? "<unset>" : val.ToString() ?? "<invalid>";
var dto = profile.ProfileDto;
var config = profile.ProfileConfig.Profile;
table.AddRow("Name", dto.Name, config.Name);
table.AddRow("Upgrades Allowed?", YesNo(dto.UpgradeAllowed), YesNo(config.UpgradeAllowed));
if (config.UpgradeUntilQuality is not null)
{
table.AddRow("Upgrade Until Quality",
Null(dto.Items.FindGroupById(dto.Cutoff)?.Name),
Null(config.UpgradeUntilQuality));
}
if (config.MinFormatScore is not null)
{
table.AddRow("Minimum Format Score",
Null(dto.MinFormatScore),
Null(config.MinFormatScore));
}
if (config.UpgradeUntilScore is not null)
{
table.AddRow("Upgrade Until Score",
Null(dto.CutoffFormatScore),
Null(config.UpgradeUntilScore));
}
return table;
}
private static IRenderable SetupQualityItemTable(UpdatedQualityProfile profile)
{
static IRenderable BuildName(ProfileItemDto item)
{
var allowedChar = item.Allowed is true ? ":check_mark:" : ":cross_mark:";
var name = item.Quality?.Name ?? item.Name ?? "NO NAME!";
return Markup.FromInterpolated($"{allowedChar} {name}");
}
if (transactions.InvalidProfileNames.Any())
static IRenderable BuildTree(ProfileItemDto item)
{
_console.MarkupLine("The following quality profiles were [red]not found[/]:");
foreach (var name in transactions.InvalidProfileNames)
var tree = new Tree(BuildName(item));
foreach (var childItem in item.Items)
{
_console.MarkupLine($"[red]x[/] {name}");
tree.AddNode(BuildName(childItem));
}
_console.WriteLine();
return tree;
}
static IRenderable MakeNode(ProfileItemDto item)
{
return item.Quality is not null ? BuildName(item) : BuildTree(item);
}
static IRenderable MakeTree(IEnumerable<ProfileItemDto> items, string header)
{
var headerMarkup = Markup.FromInterpolated($"[bold][underline]{header}[/][/]");
var rows = new Rows(new[] {headerMarkup}.Concat(items.Select(MakeNode)));
var panel = new Panel(rows).NoBorder();
panel.Width = 23;
return panel;
}
var table = new Columns(
MakeTree(profile.ProfileDto.Items, "Current"),
MakeTree(profile.UpdatedQualities.Items, "New")
);
table.Collapse();
var sortMode = profile.ProfileConfig.Profile.QualitySort;
return new Rows(
Markup.FromInterpolated($"[b]Quality Updates (Sort Mode: [green]{sortMode}[/])[/]"),
table);
}
private static IRenderable SetupScoreTable(UpdatedQualityProfile profile)
{
var updatedScores = profile.UpdatedScores
.Where(x => x.Reason != FormatScoreUpdateReason.NoChange && x.Dto.Score != x.NewScore)
.ToList();
if (!updatedScores.Any())
{
return new Markup("[hotpink]No score changes[/]");
}
var table = new Table()
.AddColumn("[bold]Custom Format[/]")
.AddColumn("[bold]Current[/]")
.AddColumn("[bold]New[/]")
.AddColumn("[bold]Reason[/]");
foreach (var score in updatedScores)
{
table.AddRow(
score.Dto.Name,
score.Dto.Score.ToString(),
score.NewScore.ToString(),
score.Reason.ToString());
}
return table;
}
}

@ -0,0 +1,92 @@
using Newtonsoft.Json.Linq;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record ProfileWithStats
{
public required UpdatedQualityProfile Profile { get; set; }
public bool ProfileChanged { get; set; }
public bool ScoresChanged { get; set; }
public bool QualitiesChanged { get; set; }
public bool HasChanges => ProfileChanged || ScoresChanged || QualitiesChanged;
}
public class QualityProfileStatCalculator
{
private readonly ILogger _log;
public QualityProfileStatCalculator(ILogger log)
{
_log = log;
}
public ProfileWithStats Calculate(UpdatedQualityProfile profile)
{
_log.Debug("Updates for profile {ProfileName}", profile.ProfileName);
var stats = new ProfileWithStats {Profile = profile};
ProfileUpdates(stats, profile);
QualityUpdates(stats, profile);
ScoreUpdates(stats, profile.ProfileDto, profile.UpdatedScores);
return stats;
}
private void ProfileUpdates(ProfileWithStats stats, UpdatedQualityProfile profile)
{
var dto = profile.ProfileDto;
var config = profile.ProfileConfig.Profile;
void Log<T>(string msg, T oldValue, T newValue)
{
_log.Debug("{Msg}: {Old} -> {New}", msg, oldValue, newValue);
stats.ProfileChanged |= !EqualityComparer<T>.Default.Equals(oldValue, newValue);
}
var upgradeAllowed = config.UpgradeAllowed is not null;
Log("Upgrade Allowed", dto.UpgradeAllowed, upgradeAllowed);
if (upgradeAllowed)
{
Log("Cutoff", dto.Items.FindCutoff(dto.Cutoff), config.UpgradeUntilQuality);
Log("Cutoff Score", dto.CutoffFormatScore, config.UpgradeUntilScore);
}
Log("Minimum Score", dto.MinFormatScore, config.MinFormatScore);
}
private static void QualityUpdates(ProfileWithStats stats, UpdatedQualityProfile profile)
{
var dtoQualities = JToken.FromObject(profile.ProfileDto.Items);
var updatedQualities = JToken.FromObject(profile.UpdatedQualities.Items);
stats.QualitiesChanged = !JToken.DeepEquals(dtoQualities, updatedQualities);
}
private void ScoreUpdates(
ProfileWithStats stats,
QualityProfileDto profileDto,
IReadOnlyCollection<UpdatedFormatScore> updatedScores)
{
var scores = updatedScores
.Where(y => y.Dto.Score != y.NewScore)
.ToList();
if (scores.Count == 0)
{
return;
}
_log.Debug("> Scores updated for quality profile: {ProfileName}", profileDto.Name);
foreach (var (dto, newScore, reason) in scores)
{
_log.Debug(" - {Format} ({Id}): {OldScore} -> {NewScore} ({Reason})",
dto.Name, dto.Format, dto.Score, newScore, reason);
}
stats.ScoresChanged = true;
}
}

@ -1,69 +1,101 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using FluentValidation.Results;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record UpdatedQualityProfile(QualityProfileDto UpdatedProfile)
public enum QualityProfileUpdateReason
{
public required IReadOnlyCollection<UpdatedFormatScore> UpdatedScores { get; init; }
New,
Changed
}
public record InvalidProfileData(UpdatedQualityProfile Profile, IReadOnlyCollection<ValidationFailure> Errors);
public record QualityProfileTransactionData
{
public Collection<string> InvalidProfileNames { get; } = new();
public Collection<UpdatedQualityProfile> UpdatedProfiles { get; } = new();
[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,
IList<QualityProfileDto> serviceData)
QualityProfileServiceData serviceData)
{
var transactions = new QualityProfileTransactionData();
UpdateProfileScores(guideData, serviceData, transactions);
BuildUpdatedProfiles(transactions, guideData, serviceData);
UpdateProfileScores(transactions.UpdatedProfiles);
ValidateProfiles(transactions);
return transactions;
}
private static void UpdateProfileScores(
IReadOnlyCollection<ProcessedQualityProfileData> guideData,
IList<QualityProfileDto> serviceData,
QualityProfileTransactionData transactions)
private static void ValidateProfiles(QualityProfileTransactionData transactions)
{
var validator = new UpdatedQualityProfileValidator();
transactions.UpdatedProfiles = transactions.UpdatedProfiles
.IsValid(validator, (errors, profile) =>
transactions.InvalidProfiles.Add(new InvalidProfileData(profile, errors)))
.ToList();
}
private static void BuildUpdatedProfiles(
QualityProfileTransactionData transactions,
IEnumerable<ProcessedQualityProfileData> guideData,
QualityProfileServiceData serviceData)
{
// Match quality profiles in Radarr to ones the user put in their config.
// Match quality profiles in the user's config to profiles in the service.
// For each match, we return a tuple including the list of custom format scores ("formatItems").
// Using GroupJoin() because we want a LEFT OUTER JOIN so we can list which quality profiles in config
// do not match profiles in Radarr.
var profilesAndScores = guideData.GroupJoin(serviceData,
x => x.Profile.Name,
x => x.Name,
(x, y) => (x, y.FirstOrDefault()),
StringComparer.InvariantCultureIgnoreCase);
var matchedProfiles = guideData
.GroupJoin(serviceData.Profiles,
x => x.Profile.Name,
x => x.Name,
(x, y) => (x, y.FirstOrDefault()),
StringComparer.InvariantCultureIgnoreCase);
foreach (var (profileData, profileDto) in profilesAndScores)
foreach (var (config, dto) in matchedProfiles)
{
if (profileDto is null)
if (dto is null && !config.ShouldCreate)
{
transactions.InvalidProfileNames.Add(profileData.Profile.Name);
transactions.NonExistentProfiles.Add(config.Profile.Name);
continue;
}
var updatedProfile = ProcessScoreUpdates(profileData, profileDto);
if (updatedProfile is null)
var newDto = dto ?? serviceData.Schema;
var updatedProfile = new UpdatedQualityProfile
{
continue;
}
ProfileConfig = config,
ProfileDto = newDto,
UpdateReason = dto is null ? QualityProfileUpdateReason.New : QualityProfileUpdateReason.Changed,
UpdatedQualities = newDto.BuildUpdatedQualityItems(config.Profile)
};
transactions.UpdatedProfiles.Add(updatedProfile);
}
}
private static UpdatedQualityProfile? ProcessScoreUpdates(
private static void UpdateProfileScores(IEnumerable<UpdatedQualityProfile> updatedProfiles)
{
foreach (var profile in updatedProfiles)
{
profile.UpdatedScores = ProcessScoreUpdates(profile.ProfileConfig, profile.ProfileDto);
}
}
private static List<UpdatedFormatScore> ProcessScoreUpdates(
ProcessedQualityProfileData profileData,
QualityProfileDto profileDto)
{
@ -73,30 +105,13 @@ public class QualityProfileTransactionPhase
x => x.Format,
// Exists in config, but not in service (these are unusual and should be errors)
// See `FormatScoreUpdateReason` for reason why we need this (it's preview mode)
l => new UpdatedFormatScore
{
Dto = new ProfileFormatItemDto {Format = l.FormatId, Name = l.CfName},
NewScore = l.Score,
Reason = FormatScoreUpdateReason.New
},
l => UpdatedFormatScore.New(l),
// Exists in service, but not in config
r => new UpdatedFormatScore
{
Dto = r,
NewScore = profileData.Profile.ResetUnmatchedScores ? 0 : r.Score,
Reason = FormatScoreUpdateReason.Reset
},
r => UpdatedFormatScore.Reset(r, profileData),
// Exists in both service and config
(l, r) => new UpdatedFormatScore
{
Dto = r,
NewScore = l.Score,
Reason = FormatScoreUpdateReason.Updated
})
(l, r) => UpdatedFormatScore.Updated(r, l))
.ToList();
return scoreMap.Any(x => x.Dto.Score != x.NewScore)
? new UpdatedQualityProfile(profileDto) {UpdatedScores = scoreMap}
: null;
return scoreMap;
}
}

@ -0,0 +1,128 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public class QualityItemOrganizer
{
private readonly List<string> _invalidItemNames = new();
public UpdatedQualities OrganizeItems(QualityProfileDto dto, QualityProfileConfig config)
{
var wanted = ProcessWantedItems(dto.Items, config.Qualities);
var unwanted = ProcessUnwantedItems(dto.Items, wanted);
var combined = CombineAndSortItems(config.QualitySort, wanted, unwanted);
AssignMissingGroupIds(combined);
return new UpdatedQualities
{
InvalidQualityNames = _invalidItemNames,
Items = combined
};
}
private List<ProfileItemDto> ProcessWantedItems(
IReadOnlyCollection<ProfileItemDto> dtoItems,
IReadOnlyCollection<QualityProfileQualityConfig> configQualities)
{
var updatedItems = new List<ProfileItemDto>();
foreach (var configQuality in configQualities)
{
void AddQualityFromDto(ICollection<ProfileItemDto> items, string name)
{
var dtoItem = dtoItems.FindQualityByName(name);
if (dtoItem is null)
{
_invalidItemNames.Add(name);
return;
}
items.Add(dtoItem with {Allowed = configQuality.Enabled});
}
// If the nested qualities list is NOT empty, then this is considered a quality group.
if (configQuality.Qualities.IsNotEmpty())
{
var dtoGroup = dtoItems.FindGroupByName(configQuality.Name) ?? new ProfileItemDto
{
Name = configQuality.Name
};
var updatedGroupItems = new List<ProfileItemDto>();
foreach (var groupQuality in configQuality.Qualities)
{
AddQualityFromDto(updatedGroupItems, groupQuality);
}
updatedItems.Add(dtoGroup with
{
Allowed = configQuality.Enabled,
Items = updatedGroupItems
});
continue;
}
AddQualityFromDto(updatedItems, configQuality.Name);
}
return updatedItems;
}
private static IEnumerable<ProfileItemDto> ProcessUnwantedItems(
IEnumerable<ProfileItemDto> dtoItems,
IReadOnlyCollection<ProfileItemDto> wantedItems)
{
// Find remaining items in the DTO that were *not* handled by the user's config.
return dtoItems
.Where(x => !ExistsInWantedItems(wantedItems, x))
.Select(x => x with
{
Allowed = false,
// If this is actually a quality instead of a group, this will effectively be a no-op since the Items
// array will already be empty.
Items = x.Items
.Where(y => wantedItems.FindQualityByName(y.Quality?.Name) is null)
.Select(y => y with {Allowed = false})
.ToList()
})
.Where(x => x is not {Quality: null, Items.Count: 0});
}
private static List<ProfileItemDto> CombineAndSortItems(
QualitySortAlgorithm sortAlgorithm,
IEnumerable<ProfileItemDto> wantedItems,
IEnumerable<ProfileItemDto> unwantedItems)
{
return sortAlgorithm switch
{
QualitySortAlgorithm.Top => wantedItems.Concat(unwantedItems).ToList(),
QualitySortAlgorithm.Bottom => unwantedItems.Concat(wantedItems).ToList(),
_ => throw new ArgumentOutOfRangeException($"Unsupported Quality Sort: {sortAlgorithm}")
};
}
private static void AssignMissingGroupIds(IReadOnlyCollection<ProfileItemDto> combinedItems)
{
// Add the IDs at the very end since we need all groups to know which IDs are taken
var nextItemId = combinedItems.NewItemId();
foreach (var item in combinedItems.Where(item => item is {Id: null, Quality: null}))
{
item.Id = nextItemId++;
}
}
private static bool ExistsInWantedItems(IEnumerable<ProfileItemDto> wantedItems, ProfileItemDto dto)
{
var existingItem = dto.Quality is null
? wantedItems.FindGroupByName(dto.Name)
: wantedItems.FindQualityByName(dto.Quality.Name);
return existingItem is not null;
}
}

@ -12,6 +12,7 @@ public class QualityProfileAutofacModule : Module
base.Load(builder);
builder.RegisterType<QualityProfileService>().As<IQualityProfileService>();
builder.RegisterType<QualityProfileStatCalculator>();
builder.RegisterAggregateService<IQualityProfilePipelinePhases>();
builder.RegisterType<QualityProfileConfigPhase>();
@ -19,5 +20,6 @@ public class QualityProfileAutofacModule : Module
builder.RegisterType<QualityProfileTransactionPhase>();
builder.RegisterType<QualityProfilePreviewPhase>();
builder.RegisterType<QualityProfileApiPersistencePhase>();
builder.RegisterType<QualityProfileNoticePhase>();
}
}

@ -0,0 +1,122 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public static class QualityProfileExtensions
{
private static IEnumerable<ProfileItemDto> FlattenItems(IEnumerable<ProfileItemDto> items)
{
return items.Flatten(x => x.Items);
}
public static ProfileItemDto? FindGroupById(this IEnumerable<ProfileItemDto> items, int? id)
{
if (id is null)
{
return null;
}
return FlattenItems(items)
.Where(x => x.Quality is null)
.FirstOrDefault(x => x.Id == id);
}
public static ProfileItemDto? FindGroupByName(this IEnumerable<ProfileItemDto> items, string? name)
{
if (name is null)
{
return null;
}
return FlattenItems(items)
.Where(x => x.Quality is null)
.FirstOrDefault(x => x.Name.EqualsIgnoreCase(name));
}
public static ProfileItemDto? FindQualityById(this IEnumerable<ProfileItemDto> items, int? id)
{
if (id is null)
{
return null;
}
return FlattenItems(items)
.Where(x => x.Quality is not null)
.FirstOrDefault(x => x.Quality!.Id == id);
}
public static ProfileItemDto? FindQualityByName(this IEnumerable<ProfileItemDto> items, string? name)
{
if (name is null)
{
return null;
}
return FlattenItems(items)
.Where(x => x.Quality is not null)
.FirstOrDefault(x => x.Quality!.Name.EqualsIgnoreCase(name));
}
public static int? FindCutoff(this IEnumerable<ProfileItemDto> items, string? name)
{
if (name is null)
{
return null;
}
var result = items
.Select(x => x.Quality is null ? (x.Name, x.Id) : (x.Quality.Name, x.Quality.Id))
.Where(x => x.Name is not null)
.FirstOrDefault(x => x.Name.EqualsIgnoreCase(name));
return result.Id;
}
public static string? FindCutoff(this IEnumerable<ProfileItemDto> items, int? id)
{
if (id is null)
{
return null;
}
var result = items
.Select(x => x.Quality is null ? (x.Name, x.Id) : (x.Quality.Name, x.Quality.Id))
.Where(x => x.Name is not null)
.FirstOrDefault(x => x.Id == id);
return result.Name;
}
public static int NewItemId(this IEnumerable<ProfileItemDto> items)
{
// This implementation is based on how the Radarr frontend calculates IDs.
// This calculation will be applied to new quality item groups.
// See `getQualityItemGroupId()` here:
// https://github.com/Radarr/Radarr/blob/c214a6b67bf747e02462066cd1c6db7bc06db1f0/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js#L11C8-L11C8
var maxExisting = FlattenItems(items)
.Select(x => x.Id)
.NotNull()
.DefaultIfEmpty(0)
.Max();
return Math.Max(1000, maxExisting) + 1;
}
public static UpdatedQualities BuildUpdatedQualityItems(
this QualityProfileDto dto,
QualityProfileConfig configProfile)
{
var organizer = new QualityItemOrganizer();
return organizer.OrganizeItems(dto, configProfile);
}
public static QualityProfileDto ReverseItems(this QualityProfileDto dto)
{
static ICollection<ProfileItemDto> ReverseItemsImpl(IEnumerable<ProfileItemDto> items)
=> items.Reverse().Select(x => x with {Items = ReverseItemsImpl(x.Items)}).ToList();
return dto with {Items = ReverseItemsImpl(dto.Items).AsReadOnly()};
}
}

@ -11,6 +11,7 @@ public interface IQualityProfilePipelinePhases
QualityProfileTransactionPhase TransactionPhase { get; }
Lazy<QualityProfilePreviewPhase> PreviewPhase { get; }
QualityProfileApiPersistencePhase ApiPersistencePhase { get; }
QualityProfileNoticePhase NoticePhase { get; }
}
public class QualityProfileSyncPipeline : ISyncPipeline
@ -36,6 +37,8 @@ public class QualityProfileSyncPipeline : ISyncPipeline
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);

@ -1,9 +1,15 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public enum FormatScoreUpdateReason
{
/// <summary>
/// A score who's value did not change.
/// </summary>
NoChange,
/// <summary>
/// A score that is changed.
/// </summary>
@ -22,16 +28,23 @@ public enum FormatScoreUpdateReason
New
}
public record UpdatedFormatScore
public record UpdatedFormatScore(ProfileFormatItemDto Dto, int NewScore, FormatScoreUpdateReason Reason)
{
public required ProfileFormatItemDto Dto { get; init; }
public required int NewScore { get; init; }
public required FormatScoreUpdateReason Reason { get; init; }
public static UpdatedFormatScore New(ProcessedQualityProfileScore score)
{
var dto = new ProfileFormatItemDto {Format = score.FormatId, Name = score.CfName};
return new UpdatedFormatScore(dto, score.Score, FormatScoreUpdateReason.New);
}
public static UpdatedFormatScore Reset(ProfileFormatItemDto dto, ProcessedQualityProfileData profileData)
{
var score = profileData.Profile.ResetUnmatchedScores ? 0 : dto.Score;
return new UpdatedFormatScore(dto, score, FormatScoreUpdateReason.Reset);
}
public void Deconstruct(out ProfileFormatItemDto dto, out int newScore, out FormatScoreUpdateReason reason)
public static UpdatedFormatScore Updated(ProfileFormatItemDto dto, ProcessedQualityProfileScore score)
{
dto = Dto;
newScore = NewScore;
reason = Reason;
var reason = dto.Score == score.Score ? FormatScoreUpdateReason.NoChange : FormatScoreUpdateReason.Updated;
return new UpdatedFormatScore(dto, score.Score, reason);
}
}

@ -0,0 +1,49 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public record UpdatedQualities
{
public ICollection<string> InvalidQualityNames { get; init; } = new List<string>();
public IReadOnlyCollection<ProfileItemDto> Items { get; init; } = new List<ProfileItemDto>();
}
public record UpdatedQualityProfile
{
public required QualityProfileDto ProfileDto { get; init; }
public required ProcessedQualityProfileData ProfileConfig { get; init; }
public required QualityProfileUpdateReason UpdateReason { get; set; }
public IReadOnlyCollection<UpdatedFormatScore> UpdatedScores { get; set; } = Array.Empty<UpdatedFormatScore>();
public UpdatedQualities UpdatedQualities { get; init; } = new();
public string ProfileName
{
get
{
var name = ProfileDto.Name;
if (string.IsNullOrEmpty(name))
{
name = ProfileConfig.Profile.Name;
}
return name;
}
}
public QualityProfileDto BuildUpdatedDto()
{
var config = ProfileConfig.Profile;
return ProfileDto with
{
Name = config.Name,
UpgradeAllowed = config.UpgradeAllowed,
MinFormatScore = config.MinFormatScore,
Cutoff = ProfileDto.Items.FindCutoff(config.UpgradeUntilQuality),
CutoffFormatScore = config.UpgradeUntilScore,
FormatItems = UpdatedScores.Select(x => x.Dto with {Score = x.NewScore}).ToList(),
Items = UpdatedQualities.Items
};
}
}

@ -0,0 +1,26 @@
using FluentValidation;
namespace Recyclarr.Cli.Pipelines.QualityProfile;
public class UpdatedQualityProfileValidator : AbstractValidator<UpdatedQualityProfile>
{
public UpdatedQualityProfileValidator()
{
RuleFor(x => x.ProfileConfig.Profile.MinFormatScore).Custom((minScore, context) =>
{
var scores = context.InstanceToValidate.UpdatedScores;
var totalScores = scores.Select(x => x.NewScore).Where(x => x > 0).Sum();
if (totalScores < minScore)
{
context.AddFailure(
$"Minimum Custom Format Score of {minScore} can never be satisfied because the total of all " +
$"positive scores is {totalScores}");
}
});
RuleFor(x => x.ProfileConfig.Profile.UpgradeUntilQuality)
.Must((o, x)
=> !o.UpdatedQualities.InvalidQualityNames.Contains(x, StringComparer.InvariantCultureIgnoreCase))
.WithMessage((_, x) => $"`until_quality` references invalid quality '{x}'");
}
}

@ -12,7 +12,7 @@ public class ReleaseProfileDataValidationFilterer
_log = log;
}
private void LogInvalidTerm(List<ValidationFailure> failures, string filterDescription)
private void LogInvalidTerm(IReadOnlyCollection<ValidationFailure> failures, string filterDescription)
{
_log.Debug("Validation failed on term data ({Filter}): {Failures}", filterDescription, failures);
}

@ -78,4 +78,9 @@ public static class CollectionExtensions
var list = source.ToList();
return list.Any() ? list : null;
}
public static IEnumerable<T> Flatten<T>(this IEnumerable<T> items, Func<T, IEnumerable<T>> flattenWhich)
{
return items.SelectMany(x => flattenWhich(x).Flatten(flattenWhich).Append(x));
}
}

@ -1,6 +1,7 @@
using FluentValidation;
using FluentValidation.Results;
using FluentValidation.Validators;
using Serilog.Events;
namespace Recyclarr.Common.FluentValidation;
@ -21,10 +22,26 @@ public static class FluentValidationExtensions
return ruleBuilder.SetAsyncValidator(adapter);
}
// ReSharper disable once UnusedMethodReturnValue.Global
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty, TValidator>(
this IRuleBuilder<T, TProperty?> ruleBuilder,
Func<T, TValidator> validatorProvider,
params string[] ruleSets)
where TValidator : IValidator<TProperty>
{
var adapter = new ChildValidatorAdaptor<T, TProperty?>(
(context, _) => validatorProvider(context.InstanceToValidate), typeof(TValidator))
{
RuleSets = ruleSets
};
return ruleBuilder.SetAsyncValidator(adapter);
}
public static IEnumerable<TSource> IsValid<TSource, TValidator>(
this IEnumerable<TSource> source,
TValidator validator,
Action<List<ValidationFailure>, TSource>? handleInvalid = null)
Action<IReadOnlyCollection<ValidationFailure>, TSource>? handleInvalid = null)
where TValidator : IValidator<TSource>
{
foreach (var s in source)
@ -40,4 +57,35 @@ public static class FluentValidationExtensions
}
}
}
public static LogEventLevel ToLogLevel(this Severity severity)
{
return severity switch
{
Severity.Error => LogEventLevel.Error,
Severity.Warning => LogEventLevel.Warning,
Severity.Info => LogEventLevel.Information,
_ => LogEventLevel.Debug
};
}
public static int LogValidationErrors(
this IReadOnlyCollection<ValidationFailure> errors,
ILogger log,
string errorPrefix)
{
var numErrors = 0;
foreach (var (error, level) in errors.Select(x => (x, x.Severity.ToLogLevel())))
{
if (level == LogEventLevel.Error)
{
++numErrors;
}
log.Write(level, "{ErrorPrefix}: {Msg}", errorPrefix, error.ErrorMessage);
}
return numErrors;
}
}

@ -0,0 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.Common.FluentValidation;
[SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification =
"Used by AutoFac to exclude IValidator implementations from DI registration")]
public interface IManualValidator
{
}

@ -40,5 +40,23 @@ public class SonarrCapabilityEnforcer
"Custom formats require Sonarr v4 or greater. " +
"Please use `release_profiles` instead or use the right version of Sonarr.");
}
// Check for aspects of quality profile sync that are not supported by Sonarr v3
if (!capabilities.SupportsCustomFormats)
{
if (config.QualityProfiles.Any(x => x.UpgradeUntilScore is not null))
{
throw new ServiceIncompatibilityException(
"`until_score` under `upgrades_allowed` is not supported by Sonarr v3. " +
"Remove the until_score property or use Sonarr v4.");
}
if (config.QualityProfiles.Any(x => x.MinFormatScore is not null))
{
throw new ServiceIncompatibilityException(
"`min_format_score` under `quality_profiles` is not supported by Sonarr v3. " +
"Remove the min_format_score property or use Sonarr v4.");
}
}
}
}

@ -1,7 +1,5 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.FluentValidation;
using Serilog.Events;
namespace Recyclarr.TrashLib.Config.Parsing;
@ -25,27 +23,13 @@ public class ConfigValidationExecutor
return true;
}
var anyErrorsDetected = false;
foreach (var error in result.Errors)
{
var level = error.Severity switch
{
Severity.Error => LogEventLevel.Error,
Severity.Warning => LogEventLevel.Warning,
Severity.Info => LogEventLevel.Information,
_ => LogEventLevel.Debug
};
anyErrorsDetected |= level == LogEventLevel.Error;
_log.Write(level, "Config Validation: {Msg}", error.ErrorMessage);
}
if (anyErrorsDetected)
var numErrors = result.Errors.LogValidationErrors(_log, "Config Validation");
if (numErrors == 0)
{
_log.Error("Config validation failed with {Count} errors", result.Errors.Count);
return true;
}
return !anyErrorsDetected;
_log.Error("Config validation failed with {Count} errors", numErrors);
return false;
}
}

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Recyclarr.TrashLib.Config.Services;
using YamlDotNet.Serialization;
namespace Recyclarr.TrashLib.Config.Parsing;
@ -22,10 +23,27 @@ public record QualitySizeConfigYaml
public decimal? PreferredRatio { get; [UsedImplicitly] init; }
}
public record QualityProfileFormatUpgradeYaml
{
public int? UntilScore { get; init; }
public string? UntilQuality { get; init; }
}
public record QualityProfileQualityConfigYaml
{
public string? Name { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyCollection<string>? Qualities { get; init; }
}
public record QualityProfileConfigYaml
{
public string? Name { get; [UsedImplicitly] init; }
public QualityProfileFormatUpgradeYaml? UpgradesAllowed { get; init; }
public int? MinFormatScore { get; init; }
public bool ResetUnmatchedScores { get; [UsedImplicitly] init; }
public QualitySortAlgorithm? QualitySort { get; init; }
public IReadOnlyCollection<QualityProfileQualityConfigYaml>? Qualities { get; init; }
}
public record ServiceConfigYaml

@ -1,5 +1,6 @@
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.TrashLib.Config.Parsing;
@ -76,13 +77,75 @@ public class QualitySizeConfigYamlValidator : AbstractValidator<QualitySizeConfi
}
}
[UsedImplicitly]
public class QualityProfileFormatUpgradeYamlValidator : AbstractValidator<QualityProfileFormatUpgradeYaml>
{
public QualityProfileFormatUpgradeYamlValidator()
{
RuleFor(x => x.UntilQuality)
.NotEmpty()
.WithMessage("'until_quality' is required when allowing profile upgrades");
}
}
[UsedImplicitly]
public class QualityProfileConfigYamlValidator : AbstractValidator<QualityProfileConfigYaml>
{
public QualityProfileConfigYamlValidator()
{
RuleFor(x => x.Name).NotEmpty()
.WithMessage("'name' is required for root-level 'quality_profiles' elements");
RuleFor(x => x.Name)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.WithMessage(x => $"For profile {x.Name}, 'name' is required for root-level 'quality_profiles' elements");
RuleFor(x => x.UpgradesAllowed)
.SetNonNullableValidator(new QualityProfileFormatUpgradeYamlValidator());
RuleFor(x => x.Qualities)
.Cascade(CascadeMode.Stop)
.Must((o, x) => !x!
.Where(y => y.Qualities is not null)
.SelectMany(y => y.Qualities!)
.Contains(o.UpgradesAllowed!.UntilQuality))
.WithMessage(o =>
$"For profile {o.Name}, 'until_quality' must not refer to qualities contained within groups")
.Must((o, x) => !x!
.Where(y => y is {Enabled: false, Name: not null})
.Select(y => y.Name!)
.Contains(o.UpgradesAllowed!.UntilQuality))
.WithMessage(o =>
$"For profile {o.Name}, 'until_quality' must not refer to explicitly disabled qualities")
.Must((o, x) => x!
.Select(y => y.Name)
.Contains(o.UpgradesAllowed!.UntilQuality))
.WithMessage(o =>
$"For profile {o.Name}, 'qualities' must contain the quality mentioned in 'until_quality', " +
$"which is '{o.UpgradesAllowed!.UntilQuality}'")
.When(x => x is {UpgradesAllowed: not null, Qualities.Count: > 0});
RuleFor(x => x.Qualities)
.Custom(ValidateHaveNoDuplicates!)
.When(x => x is {Qualities.Count: > 0});
}
private static void ValidateHaveNoDuplicates(
IReadOnlyCollection<QualityProfileQualityConfigYaml> qualities,
ValidationContext<QualityProfileConfigYaml> context)
{
var dupes = qualities
.Select(x => x.Name)
.Concat(qualities.Where(x => x.Qualities is not null).SelectMany(x => x.Qualities!))
.NotNull()
.GroupBy(x => x)
.Select(x => x.Skip(1).FirstOrDefault())
.NotNull();
foreach (var dupe in dupes)
{
var x = context.InstanceToValidate;
context.AddFailure(
$"For profile {x.Name}, 'qualities' contains duplicates for quality '{dupe}'");
}
}
}

@ -5,16 +5,22 @@ using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Config.Parsing;
[UsedImplicitly]
public class ConfigurationMapperProfile : Profile
public class ConfigYamlMapperProfile : Profile
{
public ConfigurationMapperProfile()
public ConfigYamlMapperProfile()
{
CreateMap<QualityScoreConfigYaml, QualityProfileScoreConfig>();
CreateMap<CustomFormatConfigYaml, CustomFormatConfig>();
CreateMap<QualitySizeConfigYaml, QualityDefinitionConfig>();
CreateMap<QualityProfileConfigYaml, QualityProfileConfig>();
CreateMap<ReleaseProfileConfigYaml, ReleaseProfileConfig>();
CreateMap<ReleaseProfileFilterConfigYaml, SonarrProfileFilterConfig>();
CreateMap<QualityProfileQualityConfigYaml, QualityProfileQualityConfig>()
.ForMember(x => x.Enabled, o => o.NullSubstitute(true));
CreateMap<QualityProfileConfigYaml, QualityProfileConfig>()
.ForMember(x => x.UpgradeUntilQuality, o => o.MapFrom(x => x.UpgradesAllowed!.UntilQuality))
.ForMember(x => x.UpgradeUntilScore, o => o.MapFrom(x => x.UpgradesAllowed!.UntilScore))
.ForMember(x => x.QualitySort, o => o.NullSubstitute(QualitySortAlgorithm.Top));
CreateMap<ServiceConfigYaml, ServiceConfiguration>()
.ForMember(x => x.InstanceName, o => o.Ignore());

@ -45,8 +45,29 @@ public record QualityDefinitionConfig
public decimal? PreferredRatio { get; set; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record QualityProfileQualityConfig
{
public string Name { get; init; } = "";
public bool Enabled { get; init; }
public IReadOnlyCollection<string> Qualities { get; init; } = Array.Empty<string>();
}
public enum QualitySortAlgorithm
{
Top,
Bottom
}
public record QualityProfileConfig
{
public bool ResetUnmatchedScores { get; init; }
public string Name { get; init; } = "";
public bool? UpgradeAllowed => UpgradeUntilQuality is not null;
public string? UpgradeUntilQuality { get; init; }
public int? UpgradeUntilScore { get; init; }
public int? MinFormatScore { get; init; }
public bool ResetUnmatchedScores { get; init; }
public QualitySortAlgorithm QualitySort { get; init; }
public IReadOnlyCollection<QualityProfileQualityConfig> Qualities { get; init; } =
Array.Empty<QualityProfileQualityConfig>();
}

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
@ -30,7 +31,8 @@ public static class NewQp
{
return new ProcessedQualityProfileData(new QualityProfileConfig
{
Name = profileName, ResetUnmatchedScores = resetUnmatchedScores
Name = profileName,
ResetUnmatchedScores = resetUnmatchedScores
})
{
CfScores = scores
@ -45,11 +47,63 @@ public static class NewQp
int newScore,
FormatScoreUpdateReason reason)
{
return new UpdatedFormatScore
return new UpdatedFormatScore(
new ProfileFormatItemDto {Name = name, Score = oldScore},
newScore,
reason);
}
public static ProfileItemDto GroupDto(
int itemId,
string itemName,
bool enabled,
params ProfileItemDto[] nestedItems)
{
return new ProfileItemDto
{
Id = itemId,
Name = itemName,
Allowed = enabled,
Items = nestedItems
};
}
public static ProfileItemDto QualityDto(int itemId, string itemName, bool enabled)
{
return new ProfileItemDto
{
Allowed = enabled,
Quality = new ProfileItemQualityDto
{
Id = itemId,
Name = itemName
}
};
}
[SuppressMessage("ReSharper", "IntroduceOptionalParameters.Global", Justification =
"This is for unit test purposes and we want to be explicit sometimes")]
public static QualityProfileQualityConfig QualityConfig(string itemName)
{
return QualityConfig(itemName, true);
}
public static QualityProfileQualityConfig QualityConfig(string itemName, bool enabled)
{
return new QualityProfileQualityConfig
{
Dto = new ProfileFormatItemDto {Name = name, Score = oldScore},
NewScore = newScore,
Reason = reason
Enabled = enabled,
Name = itemName
};
}
public static QualityProfileQualityConfig GroupConfig(string itemName, params string[] nestedItems)
{
return GroupConfig(itemName, true, nestedItems);
}
public static QualityProfileQualityConfig GroupConfig(string itemName, bool enabled, params string[] nestedItems)
{
return QualityConfig(itemName, enabled) with {Qualities = nestedItems};
}
}

@ -0,0 +1,82 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.TestLibrary;
namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile.Api;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityProfileDtoTest
{
[TestCase(null, false)]
[TestCase(true, true)]
public void Upgrade_allowed_set_behavior(bool? value, bool? expected)
{
var dto = new QualityProfileDto
{
UpgradeAllowed = false
};
var result = dto with {UpgradeAllowed = value};
result.UpgradeAllowed.Should().Be(expected);
}
[TestCase(null, 10)]
[TestCase(20, 20)]
public void Min_format_score_set_behavior(int? value, int? expected)
{
var dto = new QualityProfileDto
{
MinFormatScore = 10
};
var result = dto with {MinFormatScore = value};
result.MinFormatScore.Should().Be(expected);
}
[TestCase(null, 10)]
[TestCase(20, 20)]
public void Cutoff_set_behavior(int? value, int? expected)
{
var dto = new QualityProfileDto
{
Cutoff = 10
};
var result = dto with {Cutoff = value};
result.Cutoff.Should().Be(expected);
}
[TestCase(null, 10)]
[TestCase(20, 20)]
public void Cutoff_format_score_set_behavior(int? value, int? expected)
{
var dto = new QualityProfileDto
{
CutoffFormatScore = 10
};
var result = dto with {CutoffFormatScore = value};
result.CutoffFormatScore.Should().Be(expected);
}
[Test]
public void Items_no_change_when_assigning_empty_collection()
{
var dto = new QualityProfileDto
{
Items = new[]
{
NewQp.QualityDto(1, "one", true),
NewQp.QualityDto(2, "two", true)
}
};
var result = dto with {Items = Array.Empty<ProfileItemDto>()};
result.Items.Should().BeEquivalentTo(dto.Items);
}
}

@ -45,9 +45,10 @@ public class QualityProfileConfigPhaseTest
var result = sut.Execute(config);
result.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 100))
});
{
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 100))
},
o => o.Excluding(x => x.ShouldCreate));
}
[Test, AutoMockData]
@ -76,9 +77,10 @@ public class QualityProfileConfigPhaseTest
var result = sut.Execute(config);
result.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 200))
});
{
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 200))
},
o => o.Excluding(x => x.ShouldCreate));
}
[Test, AutoMockData]
@ -106,7 +108,11 @@ public class QualityProfileConfigPhaseTest
var result = sut.Execute(config);
result.Should().BeEmpty();
result.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile")
},
o => o.Excluding(x => x.ShouldCreate));
}
[Test, AutoMockData]
@ -161,9 +167,10 @@ public class QualityProfileConfigPhaseTest
var result = sut.Execute(config);
result.Should().BeEquivalentTo(new[]
{
NewQp.Processed("test_profile1", ("id1", 1, 100)),
NewQp.Processed("test_profile2", ("id1", 1, 200))
});
{
NewQp.Processed("test_profile1", ("id1", 1, 100)),
NewQp.Processed("test_profile2", ("id1", 1, 200))
},
o => o.Excluding(x => x.ShouldCreate));
}
}

@ -10,28 +10,74 @@ namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileTransactionPhaseTest
{
[Test, AutoMockData]
public void Invalid_profile_names(
public void Non_existent_profile_names_with_updated(
QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
NewQp.Processed("invalid_profile_name", ("id1", 1, 100))
NewQp.Processed("invalid_profile_name", ("id1", 1, 100)) with
{
ShouldCreate = false
},
NewQp.Processed("profile1", ("id1", 1, 100), ("id2", 2, 500))
};
var serviceData = new[]
var dtos = new[]
{
new QualityProfileDto
{
Name = "profile1"
}
new QualityProfileDto {Name = "profile1"}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.Should().BeEquivalentTo(new QualityProfileTransactionData
{
NonExistentProfiles = new[] {"invalid_profile_name"},
UpdatedProfiles =
{
new UpdatedQualityProfile
{
ProfileConfig = guideData[1],
ProfileDto = dtos[0],
UpdateReason = QualityProfileUpdateReason.Changed
}
}
},
o => o.Excluding(x => x.Name.Contains(nameof(UpdatedQualityProfile.UpdatedScores))));
}
[Test, AutoMockData]
public void New_profiles(
QualityProfileTransactionPhase sut)
{
var guideData = new[]
{
InvalidProfileNames = {"invalid_profile_name"}
});
NewQp.Processed("profile1", ("id1", 1, 100), ("id2", 2, 500))
};
var dtos = new[]
{
new QualityProfileDto {Name = "irrelevant_profile"}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.Should().BeEquivalentTo(new QualityProfileTransactionData
{
UpdatedProfiles =
{
new UpdatedQualityProfile
{
ProfileConfig = guideData[0],
ProfileDto = serviceData.Schema,
UpdateReason = QualityProfileUpdateReason.New
}
}
},
o => o.Excluding(x => x.Name.Contains(nameof(UpdatedQualityProfile.UpdatedScores))));
}
[Test, AutoMockData]
@ -43,7 +89,7 @@ public class QualityProfileTransactionPhaseTest
NewQp.Processed("profile1", ("id1", 1, 100), ("id2", 2, 500))
};
var serviceData = new[]
var dtos = new[]
{
new QualityProfileDto
{
@ -66,6 +112,8 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.UpdatedProfiles.Should()
@ -83,7 +131,7 @@ public class QualityProfileTransactionPhaseTest
{
var guideData = Array.Empty<ProcessedQualityProfileData>();
var serviceData = new[]
var dtos = new[]
{
new QualityProfileDto
{
@ -106,13 +154,15 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.Should().BeEquivalentTo(new QualityProfileTransactionData());
}
[Test, AutoMockData]
public void Skip_unchanged_scores(
public void Unchanged_scores(
QualityProfileTransactionPhase sut)
{
// Must simulate at least 1 custom format coming from configuration otherwise processing doesn't happen.
@ -122,7 +172,7 @@ public class QualityProfileTransactionPhaseTest
NewQp.Processed("profile1", ("id1", 1, 200), ("id2", 2, 300))
};
var serviceData = new[]
var dtos = new[]
{
new QualityProfileDto
{
@ -145,9 +195,17 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.Should().BeEquivalentTo(new QualityProfileTransactionData());
result.UpdatedProfiles.Should()
.ContainSingle().Which.UpdatedScores.Should()
.BeEquivalentTo(new[]
{
NewQp.UpdatedScore("quality1", 200, 200, FormatScoreUpdateReason.NoChange),
NewQp.UpdatedScore("quality2", 300, 300, FormatScoreUpdateReason.NoChange)
}, o => o.Excluding(x => x.Dto.Format));
}
[Test, AutoMockData]
@ -159,7 +217,7 @@ public class QualityProfileTransactionPhaseTest
NewQp.Processed("profile1", true, ("quality3", "id3", 3, 100), ("quality4", "id4", 4, 500))
};
var serviceData = new[]
var dtos = new[]
{
new QualityProfileDto
{
@ -182,6 +240,8 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.UpdatedProfiles.Should()
@ -203,7 +263,7 @@ public class QualityProfileTransactionPhaseTest
NewQp.Processed("profile1", false, ("quality3", "id3", 3, 100), ("quality4", "id4", 4, 500))
};
var serviceData = new[]
var dtos = new[]
{
new QualityProfileDto
{
@ -226,6 +286,8 @@ public class QualityProfileTransactionPhaseTest
}
};
var serviceData = new QualityProfileServiceData(dtos, new QualityProfileDto());
var result = sut.Execute(guideData, serviceData);
result.UpdatedProfiles.Should()

@ -0,0 +1,117 @@
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityItemOrganizerTest
{
private readonly QualityProfileConfig _config = new()
{
Qualities = new[]
{
NewQp.QualityConfig("one"),
NewQp.QualityConfig("three"),
NewQp.QualityConfig("six", false),
NewQp.QualityConfig("seven"),
NewQp.QualityConfig("nonexistent1"),
NewQp.GroupConfig("group3", "eight"),
NewQp.GroupConfig("group4", false, "nine", "ten"),
NewQp.GroupConfig("group5", "eleven")
}
};
private readonly QualityProfileDto _dto = new()
{
Items = new[]
{
NewQp.QualityDto(1, "one", true),
NewQp.QualityDto(2, "two", true),
NewQp.QualityDto(3, "three", true),
NewQp.QualityDto(9, "nine", true),
NewQp.GroupDto(50, "group5", true,
NewQp.QualityDto(11, "eleven", true)),
NewQp.QualityDto(10, "ten", true),
NewQp.QualityDto(4, "four", true),
NewQp.GroupDto(1001, "group1", true,
NewQp.QualityDto(5, "five", true),
NewQp.QualityDto(6, "six", true)),
NewQp.GroupDto(1002, "group2", true,
NewQp.QualityDto(7, "seven", true)),
NewQp.QualityDto(8, "eight", true)
}
};
[Test]
public void Update_qualities_top_sort()
{
var sut = new QualityItemOrganizer();
var result = sut.OrganizeItems(_dto, _config with
{
QualitySort = QualitySortAlgorithm.Top
});
result.Should().BeEquivalentTo(new UpdatedQualities
{
InvalidQualityNames = new[] {"nonexistent1"},
Items = new[]
{
// ------ IN CONFIG ------
NewQp.QualityDto(1, "one", true),
NewQp.QualityDto(3, "three", true),
NewQp.QualityDto(6, "six", false),
NewQp.QualityDto(7, "seven", true),
NewQp.GroupDto(1002, "group3", true,
NewQp.QualityDto(8, "eight", true)),
NewQp.GroupDto(1003, "group4", false,
NewQp.QualityDto(9, "nine", false),
NewQp.QualityDto(10, "ten", false)),
NewQp.GroupDto(50, "group5", true,
NewQp.QualityDto(11, "eleven", true)),
// ------ NOT IN CONFIG ------
NewQp.QualityDto(2, "two", false),
NewQp.QualityDto(4, "four", false),
NewQp.GroupDto(1001, "group1", false,
NewQp.QualityDto(5, "five", false))
}
});
}
[Test]
public void Update_qualities_bottom_sort()
{
var sut = new QualityItemOrganizer();
var result = sut.OrganizeItems(_dto, _config with
{
QualitySort = QualitySortAlgorithm.Bottom
});
result.Should().BeEquivalentTo(new UpdatedQualities
{
InvalidQualityNames = new[] {"nonexistent1"},
Items = new[]
{
// ------ NOT IN CONFIG ------
NewQp.QualityDto(2, "two", false),
NewQp.QualityDto(4, "four", false),
NewQp.GroupDto(1001, "group1", false,
NewQp.QualityDto(5, "five", false)),
// ------ IN CONFIG ------
NewQp.QualityDto(1, "one", true),
NewQp.QualityDto(3, "three", true),
NewQp.QualityDto(6, "six", false),
NewQp.QualityDto(7, "seven", true),
NewQp.GroupDto(1002, "group3", true,
NewQp.QualityDto(8, "eight", true)),
NewQp.GroupDto(1003, "group4", false,
NewQp.QualityDto(9, "nine", false),
NewQp.QualityDto(10, "ten", false)),
NewQp.GroupDto(50, "group5", true,
NewQp.QualityDto(11, "eleven", true))
}
});
}
}

@ -0,0 +1,578 @@
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.TestLibrary;
// ReSharper disable CollectionNeverUpdated.Local
namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityProfileExtensionsTest
{
[Test]
public void Find_group_by_id_with_null_input()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindGroupById(null);
result.Should().BeNull();
}
[Test]
public void Find_group_by_id_with_match()
{
var targetItem = NewQp.GroupDto(6, "Group 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true),
targetItem,
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindGroupById(6);
result.Should().Be(targetItem);
}
[Test]
public void Find_group_by_id_with_nested_match()
{
var targetItem = NewQp.GroupDto(6, "Quality Item 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true, targetItem),
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindGroupById(6);
result.Should().Be(targetItem);
}
[Test]
public void Find_group_by_id_with_no_items()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindGroupById(5);
result.Should().BeNull();
}
[Test]
public void Find_group_by_id_with_no_match()
{
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.QualityDto(5, "Quality 5", true),
NewQp.GroupDto(6, "Group 6", true)
};
var result = dto.FindGroupById(5);
result.Should().BeNull();
}
[Test]
public void Find_group_by_name_with_null_input()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindGroupByName(null);
result.Should().BeNull();
}
[Test]
public void Find_group_by_name_with_case_insensitive_match()
{
var targetItem = NewQp.GroupDto(6, "Group 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true),
targetItem,
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindGroupByName("grOUp 6");
result.Should().Be(targetItem);
}
[Test]
public void Find_group_by_name_with_nested_match()
{
var targetItem = NewQp.GroupDto(6, "Group 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true, targetItem),
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindGroupByName("Group 6");
result.Should().Be(targetItem);
}
[Test]
public void Find_group_by_name_with_no_items()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindGroupByName("Group 5");
result.Should().BeNull();
}
[Test]
public void Find_group_by_name_with_no_match()
{
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.QualityDto(5, "Group 5", true),
NewQp.GroupDto(6, "Group 6", true)
};
var result = dto.FindGroupByName("Group 5");
result.Should().BeNull();
}
[Test]
public void Find_quality_by_id_with_null_input()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindQualityById(null);
result.Should().BeNull();
}
[Test]
public void Find_quality_by_id_with_match()
{
var targetItem = NewQp.QualityDto(6, "Quality 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true),
targetItem,
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindQualityById(6);
result.Should().Be(targetItem);
}
[Test]
public void Find_quality_by_id_with_nested_match()
{
var targetItem = NewQp.QualityDto(6, "Quality 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true, targetItem),
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindQualityById(6);
result.Should().Be(targetItem);
}
[Test]
public void Find_quality_by_id_with_no_items()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindQualityById(5);
result.Should().BeNull();
}
[Test]
public void Find_quality_by_id_with_no_match()
{
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(5, "Group 5", true),
NewQp.GroupDto(6, "Group 6", true)
};
var result = dto.FindQualityById(5);
result.Should().BeNull();
}
//----------------------------------------------------------------------------
[Test]
public void Find_quality_by_name_with_null_input()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindQualityByName(null);
result.Should().BeNull();
}
[Test]
public void Find_quality_by_name_with_case_insensitive_match()
{
var targetItem = NewQp.QualityDto(6, "Quality 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true),
targetItem,
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindQualityByName("quALIty 6");
result.Should().Be(targetItem);
}
[Test]
public void Find_quality_by_name_with_nested_match()
{
var targetItem = NewQp.QualityDto(6, "Quality 6", true);
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(1, "Group 1", true,
NewQp.QualityDto(5, "Quality 5", true)),
NewQp.GroupDto(2, "Group 2", true, targetItem),
NewQp.GroupDto(3, "Group 3", true)
};
var result = dto.FindQualityByName("Quality 6");
result.Should().Be(targetItem);
}
[Test]
public void Find_quality_by_name_with_no_items()
{
var dto = new List<ProfileItemDto>();
var result = dto.FindQualityByName("Quality 5");
result.Should().BeNull();
}
[Test]
public void Find_quality_by_name_with_no_match()
{
var dto = new List<ProfileItemDto>
{
NewQp.QualityDto(4, "Quality 4", true),
NewQp.GroupDto(5, "Quality 5", true),
NewQp.GroupDto(6, "Group 6", true)
};
var result = dto.FindQualityByName("Quality 5");
result.Should().BeNull();
}
[Test]
public void Create_new_item_id_with_no_items()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>()
};
var result = dto.Items.NewItemId();
result.Should().Be(1001);
}
[Test]
public void Create_new_item_id_with_items_below_1000()
{
var dto = new List<ProfileItemDto>
{
NewQp.GroupDto(1, "Group 1", true),
NewQp.QualityDto(2, "Quality 2", true),
NewQp.GroupDto(3, "Group 3", true,
NewQp.GroupDto(6, "Group 6", true),
NewQp.QualityDto(7, "Quality 7", true)),
NewQp.GroupDto(4, "Group 4", true,
NewQp.QualityDto(5, "Quality 5", true))
};
var result = dto.NewItemId();
result.Should().Be(1001);
}
[Test]
public void Create_new_item_id_with_leaf_items_above_1000()
{
var dto = new List<ProfileItemDto>
{
NewQp.GroupDto(1, "Group 1", true),
NewQp.QualityDto(2, "Quality 2", true),
NewQp.GroupDto(3, "Group 3", true,
NewQp.GroupDto(1006, "Group 6", true),
NewQp.QualityDto(1007, "Quality 7", true)),
NewQp.GroupDto(4, "Group 4", true,
NewQp.QualityDto(5, "Quality 5", true))
};
var result = dto.NewItemId();
result.Should().Be(1007);
}
[Test]
public void Create_new_item_id_with_parent_items_above_1000()
{
var dto = new List<ProfileItemDto>
{
NewQp.GroupDto(1, "Group 1", true),
NewQp.QualityDto(2, "Quality 2", true),
NewQp.GroupDto(1008, "Group 3", true,
NewQp.GroupDto(1006, "Group 6", true),
NewQp.QualityDto(1007, "Quality 7", true)),
NewQp.GroupDto(4, "Group 4", true,
NewQp.QualityDto(5, "Quality 5", true))
};
var result = dto.NewItemId();
result.Should().Be(1009);
}
[Test]
public void Reverse_items_works()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.ReverseItems();
result.Items.Should().BeEquivalentTo(new[]
{
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true)),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(3, "Quality 3", true),
NewQp.QualityDto(2, "Quality 2", true)),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1001, "Group 1", true)
});
}
[Test]
public void Find_cutoff_id_with_group_name()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff("Group 2");
result.Should().Be(1002);
}
[Test]
public void Find_cutoff_id_with_quality_name()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff("Quality 1");
result.Should().Be(1);
}
[Test]
public void Find_cutoff_id_with_nested_quality_name()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff("Quality 2");
result.Should().BeNull();
}
[Test]
public void Find_cutoff_id_with_null_name()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff((string?) null);
result.Should().BeNull();
}
[Test]
public void Find_cutoff_name_with_group_id()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff(1002);
result.Should().Be("Group 2");
}
[Test]
public void Find_cutoff_name_with_quality_id()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff(1);
result.Should().Be("Quality 1");
}
[Test]
public void Find_cutoff_name_with_nested_quality_id()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff(2);
result.Should().BeNull();
}
[Test]
public void Find_cutoff_name_with_null_id()
{
var dto = new QualityProfileDto
{
Items = new List<ProfileItemDto>
{
NewQp.GroupDto(1001, "Group 1", true),
NewQp.QualityDto(1, "Quality 1", true),
NewQp.GroupDto(1002, "Group 2", true,
NewQp.QualityDto(2, "Quality 2", true),
NewQp.QualityDto(3, "Quality 3", true)),
NewQp.GroupDto(1003, "Group 3", true,
NewQp.QualityDto(4, "Quality 4", true))
}
};
var result = dto.Items.FindCutoff((int?) null);
result.Should().BeNull();
}
}

@ -0,0 +1,119 @@
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class UpdatedQualityProfileTest
{
[Test]
public void Profile_name_uses_dto_first()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto
{
Name = "dto_name"
},
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
Name = "config_name"
}),
UpdateReason = QualityProfileUpdateReason.New
};
profile.ProfileName.Should().Be("dto_name");
}
[Test]
public void Profile_name_uses_config_second()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto(),
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
Name = "config_name"
}),
UpdateReason = QualityProfileUpdateReason.New
};
profile.ProfileName.Should().Be("config_name");
}
[Test]
public void Dto_updated_from_config()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto
{
Id = 1,
Name = "dto_name",
MinFormatScore = 100,
CutoffFormatScore = 200,
UpgradeAllowed = false,
Cutoff = 1,
Items = new List<ProfileItemDto>
{
NewQp.QualityDto(1, "Quality Item 1", true),
NewQp.QualityDto(2, "Quality Item 2", true),
NewQp.GroupDto(3, "Quality Item 3", true,
NewQp.QualityDto(4, "Quality Item 4", true))
}
},
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
Name = "config_name",
MinFormatScore = 110,
UpgradeUntilScore = 220,
UpgradeUntilQuality = "Quality Item 3"
}),
UpdateReason = QualityProfileUpdateReason.New
};
var result = profile.BuildUpdatedDto();
result.Should().BeEquivalentTo(new QualityProfileDto
{
// For right now, names are used for lookups (since QPs have no cache yet). As such, two profiles with
// different names will never be matched and so the names should normally be identical. However, for testing
// purposes, I made them different to make sure it doesn't get overwritten.
Name = "dto_name",
Id = 1,
MinFormatScore = 110,
CutoffFormatScore = 220,
UpgradeAllowed = true,
Cutoff = 3,
// Since we didn't process quality items, the assignment in BuildUpdatedDto() will not change the Items
// collection.
Items = profile.ProfileDto.Items
});
}
[Test]
public void Dto_name_is_updated_when_empty()
{
var profile = new UpdatedQualityProfile
{
ProfileDto = new QualityProfileDto
{
Name = ""
},
ProfileConfig = new ProcessedQualityProfileData(new QualityProfileConfig
{
Name = "config_name"
}),
UpdateReason = QualityProfileUpdateReason.New
};
var dto = profile.BuildUpdatedDto();
dto.Name.Should().Be("config_name");
}
}

@ -0,0 +1,52 @@
using FluentValidation.TestHelper;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.Cli.Tests.Pipelines.QualityProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class UpdatedQualityProfileValidatorTest
{
[TestCase(399, true)]
[TestCase(400, true)]
[TestCase(401, false)]
public void Min_score_never_satisfied(int minScore, bool expectSatisfied)
{
var profileConfig = new QualityProfileConfig {MinFormatScore = minScore};
var updatedProfile = new UpdatedQualityProfile
{
UpdatedScores = new[]
{
NewQp.UpdatedScore("foo1", 0, 100, FormatScoreUpdateReason.New),
NewQp.UpdatedScore("foo2", 0, -100, FormatScoreUpdateReason.Updated),
NewQp.UpdatedScore("foo3", 0, 200, FormatScoreUpdateReason.NoChange),
NewQp.UpdatedScore("foo4", 0, 100, FormatScoreUpdateReason.Reset)
},
ProfileDto = new QualityProfileDto {Name = "ProfileName"},
ProfileConfig = new ProcessedQualityProfileData(profileConfig),
UpdateReason = QualityProfileUpdateReason.New
};
var validator = new UpdatedQualityProfileValidator();
var result = validator.TestValidate(updatedProfile);
if (expectSatisfied)
{
result.ShouldNotHaveAnyValidationErrors();
}
else
{
const int expectedTotalScore = 400;
result.ShouldHaveValidationErrorFor(x => x.ProfileConfig.Profile.MinFormatScore)
.WithErrorMessage(
$"Minimum Custom Format Score of {minScore} can never be satisfied because the total of all " +
$"positive scores is {expectedTotalScore}");
}
}
}

@ -85,6 +85,36 @@ public class SonarrCapabilityEnforcerTest
var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*v4*");
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*custom formats*v4*");
}
[Test, AutoMockData]
public void Qualities_not_allowed_in_v3(
[Frozen] ISonarrCapabilityFetcher fetcher,
SonarrCapabilityEnforcer sut)
{
var config = NewConfig.Sonarr() with
{
QualityProfiles = new[]
{
new QualityProfileConfig
{
Qualities = new[]
{
new QualityProfileQualityConfig()
}
}
}
};
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsNamedReleaseProfiles = true,
SupportsCustomFormats = false
});
var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*qualities*v4*");
}
}

@ -0,0 +1,169 @@
using FluentValidation.TestHelper;
using Recyclarr.TrashLib.Config.Parsing;
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigYamlDataObjectsValidationTest
{
[Test]
public void Quality_profile_name_required()
{
var data = new QualityProfileConfigYaml();
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Test]
public void Quality_profile_until_quality_required()
{
var data = new QualityProfileConfigYaml
{
UpgradesAllowed = new QualityProfileFormatUpgradeYaml()
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.UpgradesAllowed!.UntilQuality);
}
[Test]
public void Quality_profile_qualities_must_have_cutoff_quality()
{
var data = new QualityProfileConfigYaml
{
Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml
{
UntilQuality = "Test Quality"
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml {Name = "Another Quality"}
}
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Qualities);
result.Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(
$"For profile {data.Name}, 'qualities' must contain the quality mentioned in 'until_quality', " +
$"which is '{data.UpgradesAllowed!.UntilQuality}'");
}
[Test]
public void Quality_profile_qualities_cutoff_required()
{
var data = new QualityProfileConfigYaml
{
Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml()
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.UpgradesAllowed!.UntilQuality)
.WithErrorMessage("'until_quality' is required when allowing profile upgrades");
}
[Test]
public void Quality_profile_cutoff_must_not_reference_child_qualities()
{
var data = new QualityProfileConfigYaml
{
Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml
{
UntilQuality = "Child Quality"
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Name = "Parent Group",
Qualities = new[] {"Child Quality"}
}
}
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Qualities);
result.Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(
$"For profile {data.Name}, 'until_quality' must not refer to qualities contained within groups");
}
[Test]
public void Quality_profile_qualities_must_have_no_duplicates()
{
var data = new QualityProfileConfigYaml
{
Name = "My QP",
Qualities = new[]
{
new QualityProfileQualityConfigYaml {Name = "Dupe Quality"},
new QualityProfileQualityConfigYaml {Name = "Dupe Quality"},
new QualityProfileQualityConfigYaml {Name = "Dupe Quality"},
new QualityProfileQualityConfigYaml {Name = "Dupe Quality 2"},
new QualityProfileQualityConfigYaml {Name = "Dupe Quality 2"},
new QualityProfileQualityConfigYaml {Name = "Dupe Quality 3"},
new QualityProfileQualityConfigYaml {Name = "Dupe Quality 4"},
new QualityProfileQualityConfigYaml
{
Name = "Dupe Quality 3",
Qualities = new[] {"Dupe Quality 4"}
}
}
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Qualities);
result.Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(
$"For profile {data.Name}, 'qualities' contains duplicates for quality 'Dupe Quality'",
$"For profile {data.Name}, 'qualities' contains duplicates for quality 'Dupe Quality 2'",
$"For profile {data.Name}, 'qualities' contains duplicates for quality 'Dupe Quality 3'",
$"For profile {data.Name}, 'qualities' contains duplicates for quality 'Dupe Quality 4'");
}
[Test]
public void Quality_profile_cutoff_quality_should_not_refer_to_disabled_qualities()
{
var data = new QualityProfileConfigYaml
{
Name = "My QP",
UpgradesAllowed = new QualityProfileFormatUpgradeYaml
{
UntilQuality = "Test Quality"
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Name = "Test Quality",
Enabled = false
}
}
};
var validator = new QualityProfileConfigYamlValidator();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Qualities);
result.Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(
$"For profile {data.Name}, 'until_quality' must not refer to explicitly disabled qualities");
}
}

@ -0,0 +1,44 @@
using AutoMapper;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.Config.Services;
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigYamlMapperProfileTest
{
private static IMapper CreateMapper()
{
return new MapperConfiguration(c => c.AddProfile<ConfigYamlMapperProfile>())
.CreateMapper();
}
[Test]
public void Profile_quality_null_substitutions()
{
var yaml = new QualityProfileQualityConfigYaml
{
Enabled = null
};
var mapper = CreateMapper();
var result = mapper.Map<QualityProfileQualityConfig>(yaml);
result.Enabled.Should().BeTrue();
}
[Test]
public void Profile_null_substitutions()
{
var yaml = new QualityProfileConfigYaml
{
QualitySort = null
};
var mapper = CreateMapper();
var result = mapper.Map<QualityProfileConfig>(yaml);
result.QualitySort.Should().Be(QualitySortAlgorithm.Top);
}
}
Loading…
Cancel
Save