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
parent
31896828bc
commit
ce338e24f3
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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()};
|
||||
}
|
||||
}
|
@ -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}'");
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
@ -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…
Reference in new issue