From e47c5bd66bcb8a3c28f8175ad35e15ad41206521 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 29 Dec 2025 16:53:53 -0800 Subject: [PATCH] Add v5 Quality Profiles endpoints --- .../Quality/QualityCutoffValidator.cs | 28 +++ .../Profiles/Quality/QualityItemsValidator.cs | 195 ++++++++++++++++++ .../Quality/QualityProfileController.cs | 86 ++++++++ .../Quality/QualityProfileResource.cs | 125 +++++++++++ .../QualityProfileResourceValidator.cs | 40 ++++ .../Quality/QualityProfileSchemaController.cs | 25 +++ 6 files changed, 499 insertions(+) create mode 100644 src/Sonarr.Api.V5/Profiles/Quality/QualityCutoffValidator.cs create mode 100644 src/Sonarr.Api.V5/Profiles/Quality/QualityItemsValidator.cs create mode 100644 src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs create mode 100644 src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResource.cs create mode 100644 src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResourceValidator.cs create mode 100644 src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityCutoffValidator.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityCutoffValidator.cs new file mode 100644 index 000000000..bba079b47 --- /dev/null +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityCutoffValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using FluentValidation.Validators; + +namespace Sonarr.Api.V5.Profiles.Quality; + +public static class QualityCutoffValidator +{ + public static IRuleBuilderOptions ValidCutoff(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.SetValidator(new ValidCutoffValidator()); + } +} + +public class ValidCutoffValidator : 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; + + var cutoffItem = items?.SingleOrDefault(i => (i.Quality == null && i.Id == cutoff) || i.Quality?.Id == cutoff); + + return cutoffItem is { Allowed: true }; + } +} diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityItemsValidator.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityItemsValidator.cs new file mode 100644 index 000000000..7b34dbd6d --- /dev/null +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityItemsValidator.cs @@ -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> ValidItems(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + ruleBuilder.SetValidator(new AllowedValidator()); + ruleBuilder.SetValidator(new QualityNameValidator()); + ruleBuilder.SetValidator(new GroupItemValidator()); + ruleBuilder.SetValidator(new ItemGroupIdValidator()); + ruleBuilder.SetValidator(new UniqueIdValidator()); + ruleBuilder.SetValidator(new UniqueQualityIdValidator()); + ruleBuilder.SetValidator(new AllQualitiesValidator()); + + return ruleBuilder.SetValidator(new ItemGroupNameValidator()); + } +} + +public class AllowedValidator : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Must contain at least one allowed quality"; + + protected override bool IsValid(PropertyValidatorContext context) + { + return context.PropertyValue is IList list && + list.Any(c => c.Allowed); + } +} + +public class GroupItemValidator : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Groups must contain multiple qualities"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + return !items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Items.Count <= 1); + } +} + +public class QualityNameValidator : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Individual qualities should not be named"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + return !items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Quality != null); + } +} + +public class ItemGroupNameValidator : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Groups must have a name"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + return !items.Any(i => i.Quality == null && i.Name.IsNullOrWhiteSpace()); + } +} + +public class ItemGroupIdValidator : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Groups must have an ID"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + return !items.Any(i => i.Quality == null && i.Id == 0); + } +} + +public class UniqueIdValidator : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Groups must have a unique ID"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList 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 : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Qualities can only be used once"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + var qualityIds = new HashSet(); + + 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 : PropertyValidator +{ + protected override string GetDefaultMessageTemplate() => "Must contain all qualities"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue is not IList items) + { + return false; + } + + var qualityIds = new HashSet(); + + 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; + } +} diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs new file mode 100644 index 000000000..a827635ab --- /dev/null +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs @@ -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 +{ + 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 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 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 GetAll() + { + return _profileService.All().ToResource(); + } +} diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResource.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResource.cs new file mode 100644 index 000000000..6f4e98c2e --- /dev/null +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResource.cs @@ -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 Items { get; set; } = []; + public int MinFormatScore { get; set; } + public int CutoffFormatScore { get; set; } + public int MinUpgradeFormatScore { get; set; } + public List FormatItems { get; set; } = []; +} + +public class QualityProfileQualityItemResource : RestResource +{ + public string? Name { get; set; } + public NzbDrone.Core.Qualities.Quality? Quality { get; set; } + public List 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 ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } +} diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResourceValidator.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResourceValidator.cs new file mode 100644 index 000000000..d43d17c6f --- /dev/null +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileResourceValidator.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using NzbDrone.Core.Qualities; + +namespace Sonarr.Api.V5.Profiles.Quality; + +public class QualityProfileResourceValidator : AbstractValidator +{ + public QualityProfileResourceValidator() + { + RuleForEach(c => c.Items) + .SetValidator(new QualityProfileQualityItemResourceValidator()); + } +} + +public class QualityProfileQualityItemResourceValidator : AbstractValidator +{ + 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); + } +} diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs new file mode 100644 index 000000000..ba2a0afba --- /dev/null +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs @@ -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(); + } + } +}