Add v5 Quality Profiles endpoints

This commit is contained in:
Mark McDowall 2025-12-29 16:53:53 -08:00
parent 4713615b17
commit e47c5bd66b
No known key found for this signature in database
6 changed files with 499 additions and 0 deletions

View file

@ -0,0 +1,28 @@
using FluentValidation;
using FluentValidation.Validators;
namespace Sonarr.Api.V5.Profiles.Quality;
public static class QualityCutoffValidator
{
public static IRuleBuilderOptions<T, int> ValidCutoff<T>(this IRuleBuilder<T, int> ruleBuilder)
{
return ruleBuilder.SetValidator(new ValidCutoffValidator<T>());
}
}
public class ValidCutoffValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Cutoff must be an allowed quality or group";
protected override bool IsValid(PropertyValidatorContext context)
{
var cutoff = (int)context.PropertyValue;
dynamic instance = context.ParentContext.InstanceToValidate;
var items = instance.Items as IList<QualityProfileQualityItemResource>;
var cutoffItem = items?.SingleOrDefault(i => (i.Quality == null && i.Id == cutoff) || i.Quality?.Id == cutoff);
return cutoffItem is { Allowed: true };
}
}

View file

@ -0,0 +1,195 @@
using FluentValidation;
using FluentValidation.Validators;
using NzbDrone.Common.Extensions;
namespace Sonarr.Api.V5.Profiles.Quality;
public static class QualityItemsValidator
{
public static IRuleBuilderOptions<T, IList<QualityProfileQualityItemResource>> ValidItems<T>(this IRuleBuilder<T, IList<QualityProfileQualityItemResource>> ruleBuilder)
{
ruleBuilder.SetValidator(new NotEmptyValidator(null));
ruleBuilder.SetValidator(new AllowedValidator<T>());
ruleBuilder.SetValidator(new QualityNameValidator<T>());
ruleBuilder.SetValidator(new GroupItemValidator<T>());
ruleBuilder.SetValidator(new ItemGroupIdValidator<T>());
ruleBuilder.SetValidator(new UniqueIdValidator<T>());
ruleBuilder.SetValidator(new UniqueQualityIdValidator<T>());
ruleBuilder.SetValidator(new AllQualitiesValidator<T>());
return ruleBuilder.SetValidator(new ItemGroupNameValidator<T>());
}
}
public class AllowedValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Must contain at least one allowed quality";
protected override bool IsValid(PropertyValidatorContext context)
{
return context.PropertyValue is IList<QualityProfileQualityItemResource> list &&
list.Any(c => c.Allowed);
}
}
public class GroupItemValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Groups must contain multiple qualities";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
return !items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Items.Count <= 1);
}
}
public class QualityNameValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Individual qualities should not be named";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
return !items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Quality != null);
}
}
public class ItemGroupNameValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Groups must have a name";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
return !items.Any(i => i.Quality == null && i.Name.IsNullOrWhiteSpace());
}
}
public class ItemGroupIdValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Groups must have an ID";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
return !items.Any(i => i.Quality == null && i.Id == 0);
}
}
public class UniqueIdValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Groups must have a unique ID";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
var ids = items.Where(i => i.Id > 0).Select(i => i.Id);
var groupedIds = ids.GroupBy(i => i);
return groupedIds.All(g => g.Count() == 1);
}
}
public class UniqueQualityIdValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Qualities can only be used once";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
var qualityIds = new HashSet<int>();
foreach (var item in items)
{
if (item.Id > 0)
{
foreach (var quality in item.Items)
{
if (qualityIds.Contains(quality.Quality!.Id))
{
return false;
}
qualityIds.Add(quality.Quality.Id);
}
}
else
{
if (qualityIds.Contains(item.Quality!.Id))
{
return false;
}
qualityIds.Add(item.Quality.Id);
}
}
return true;
}
}
public class AllQualitiesValidator<T> : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Must contain all qualities";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue is not IList<QualityProfileQualityItemResource> items)
{
return false;
}
var qualityIds = new HashSet<int>();
foreach (var item in items)
{
if (item.Id > 0)
{
foreach (var quality in item.Items)
{
qualityIds.Add(quality.Quality!.Id);
}
}
else
{
qualityIds.Add(item.Quality!.Id);
}
}
var allQualityIds = NzbDrone.Core.Qualities.Quality.All;
foreach (var quality in allQualityIds)
{
if (!qualityIds.Contains(quality.Id))
{
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,86 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles.Qualities;
using Sonarr.Http;
using Sonarr.Http.REST;
using Sonarr.Http.REST.Attributes;
namespace Sonarr.Api.V5.Profiles.Quality;
[V5ApiController]
public class QualityProfileController : RestController<QualityProfileResource>
{
private readonly IQualityProfileService _profileService;
public QualityProfileController(IQualityProfileService profileService, ICustomFormatService formatService)
{
_profileService = profileService;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.MinUpgradeFormatScore).GreaterThanOrEqualTo(1);
SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff();
SharedValidator.RuleFor(c => c.Items).ValidItems();
SharedValidator.RuleFor(c => c.FormatItems).Must(items =>
{
var all = formatService.All().Select(f => f.Id).ToList();
var ids = items.Select(i => i.Format);
return all.Except(ids).Empty();
}).WithMessage("All Custom Formats and no extra ones need to be present inside your Profile! Try refreshing your browser.");
SharedValidator.RuleFor(c => c).Custom((profile, context) =>
{
if (profile.FormatItems.Where(x => x.Score > 0).Sum(x => x.Score) < profile.MinFormatScore &&
profile.FormatItems.Max(x => x.Score) < profile.MinFormatScore)
{
context.AddFailure("Minimum Custom Format Score can never be satisfied");
}
});
SharedValidator.RuleFor(c => c)
.SetValidator(new QualityProfileResourceValidator());
}
[RestPostById]
[Consumes("application/json")]
public ActionResult<QualityProfileResource> Create([FromBody] QualityProfileResource resource)
{
var model = resource.ToModel();
model = _profileService.Add(model);
return Created(model.Id);
}
[RestDeleteById]
public ActionResult DeleteProfile(int id)
{
_profileService.Delete(id);
return NoContent();
}
[RestPutById]
[Consumes("application/json")]
public ActionResult<QualityProfileResource> Update([FromBody] QualityProfileResource resource)
{
var model = resource.ToModel();
_profileService.Update(model);
return Accepted(model.Id);
}
protected override QualityProfileResource GetResourceById(int id)
{
return _profileService.Get(id).ToResource();
}
[HttpGet]
[Produces("application/json")]
public List<QualityProfileResource> GetAll()
{
return _profileService.All().ToResource();
}
}

View file

@ -0,0 +1,125 @@
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Profiles.Qualities;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Profiles.Quality;
public class QualityProfileResource : RestResource
{
public string? Name { get; set; }
public bool UpgradeAllowed { get; set; }
public int Cutoff { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; } = [];
public int MinFormatScore { get; set; }
public int CutoffFormatScore { get; set; }
public int MinUpgradeFormatScore { get; set; }
public List<ProfileFormatItemResource> FormatItems { get; set; } = [];
}
public class QualityProfileQualityItemResource : RestResource
{
public string? Name { get; set; }
public NzbDrone.Core.Qualities.Quality? Quality { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; } = [];
public bool Allowed { get; set; }
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
}
public class ProfileFormatItemResource : RestResource
{
public int Format { get; set; }
public string? Name { get; set; }
public int Score { get; set; }
}
public static class ProfileResourceMapper
{
public static QualityProfileResource ToResource(this QualityProfile model)
{
return new QualityProfileResource
{
Id = model.Id,
Name = model.Name,
UpgradeAllowed = model.UpgradeAllowed,
Cutoff = model.Cutoff,
Items = model.Items.ConvertAll(ToResource),
MinFormatScore = model.MinFormatScore,
CutoffFormatScore = model.CutoffFormatScore,
MinUpgradeFormatScore = model.MinUpgradeFormatScore,
FormatItems = model.FormatItems.ConvertAll(ToResource)
};
}
public static QualityProfileQualityItemResource ToResource(this QualityProfileQualityItem model)
{
return new QualityProfileQualityItemResource
{
Id = model.Id,
Name = model.Name,
Quality = model.Quality,
Items = model.Items.ConvertAll(ToResource),
Allowed = model.Allowed,
MinSize = model.MinSize,
MaxSize = model.MaxSize,
PreferredSize = model.PreferredSize
};
}
public static ProfileFormatItemResource ToResource(this ProfileFormatItem model)
{
return new ProfileFormatItemResource
{
Format = model.Format.Id,
Name = model.Format.Name,
Score = model.Score
};
}
public static QualityProfile ToModel(this QualityProfileResource resource)
{
return new QualityProfile
{
Id = resource.Id,
Name = resource.Name,
UpgradeAllowed = resource.UpgradeAllowed,
Cutoff = resource.Cutoff,
Items = resource.Items.ConvertAll(ToModel),
MinFormatScore = resource.MinFormatScore,
CutoffFormatScore = resource.CutoffFormatScore,
MinUpgradeFormatScore = resource.MinUpgradeFormatScore,
FormatItems = resource.FormatItems.ConvertAll(ToModel)
};
}
public static QualityProfileQualityItem ToModel(this QualityProfileQualityItemResource resource)
{
return new QualityProfileQualityItem
{
Id = resource.Id,
Name = resource.Name,
Quality = resource.Quality != null ? (NzbDrone.Core.Qualities.Quality)resource.Quality.Id : null,
Items = resource.Items.ConvertAll(ToModel),
Allowed = resource.Allowed,
MinSize = resource.MinSize,
MaxSize = resource.MaxSize,
PreferredSize = resource.PreferredSize
};
}
public static ProfileFormatItem ToModel(this ProfileFormatItemResource resource)
{
return new ProfileFormatItem
{
Format = new CustomFormat { Id = resource.Format },
Score = resource.Score
};
}
public static List<QualityProfileResource> ToResource(this IEnumerable<QualityProfile> models)
{
return models.Select(ToResource).ToList();
}
}

View file

@ -0,0 +1,40 @@
using FluentValidation;
using NzbDrone.Core.Qualities;
namespace Sonarr.Api.V5.Profiles.Quality;
public class QualityProfileResourceValidator : AbstractValidator<QualityProfileResource>
{
public QualityProfileResourceValidator()
{
RuleForEach(c => c.Items)
.SetValidator(new QualityProfileQualityItemResourceValidator());
}
}
public class QualityProfileQualityItemResourceValidator : AbstractValidator<QualityProfileQualityItemResource>
{
public QualityProfileQualityItemResourceValidator()
{
RuleFor(c => c.MinSize)
.GreaterThanOrEqualTo(QualityDefinitionLimits.Min)
.WithErrorCode("GreaterThanOrEqualTo")
.LessThanOrEqualTo(c => c.MinSize ?? QualityDefinitionLimits.Max)
.WithErrorCode("LessThanOrEqualTo")
.When(c => c.MinSize is not null);
RuleFor(c => c.PreferredSize)
.GreaterThanOrEqualTo(c => c.MinSize ?? QualityDefinitionLimits.Min)
.WithErrorCode("GreaterThanOrEqualTo")
.LessThanOrEqualTo(c => c.MaxSize ?? QualityDefinitionLimits.Max)
.WithErrorCode("LessThanOrEqualTo")
.When(c => c.PreferredSize is not null);
RuleFor(c => c.MaxSize)
.GreaterThanOrEqualTo(c => c.PreferredSize ?? QualityDefinitionLimits.Min)
.WithErrorCode("GreaterThanOrEqualTo")
.LessThanOrEqualTo(QualityDefinitionLimits.Max)
.WithErrorCode("LessThanOrEqualTo")
.When(c => c.MaxSize is not null);
}
}

View file

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Profiles.Qualities;
using Sonarr.Http;
namespace Sonarr.Api.V5.Profiles.Quality
{
[V5ApiController("qualityprofile/schema")]
public class QualityProfileSchemaController : Controller
{
private readonly IQualityProfileService _profileService;
public QualityProfileSchemaController(IQualityProfileService profileService)
{
_profileService = profileService;
}
[HttpGet]
public QualityProfileResource GetSchema()
{
var qualityProfile = _profileService.GetDefaultProfile(string.Empty);
return qualityProfile.ToResource();
}
}
}