From 06c6062531d87f77787e435d57366c34bd8f2f79 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 30 Dec 2025 12:28:45 -0800 Subject: [PATCH] Add v5 Connection endpoints --- .../Plex/Server/PlexServerSettings.cs | 2 +- .../Connections/ConnectionBulkResource.cs | 12 + .../Connections/ConnectionController.cs | 31 ++ .../Connections/ConnectionResource.cs | 110 ++++++ .../Provider/ProviderBulkResource.cs | 26 ++ .../Provider/ProviderControllerBase.cs | 320 ++++++++++++++++++ .../Provider/ProviderResource.cs | 62 ++++ .../Provider/ProviderTestAllResult.cs | 17 + 8 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 src/Sonarr.Api.V5/Connections/ConnectionBulkResource.cs create mode 100644 src/Sonarr.Api.V5/Connections/ConnectionController.cs create mode 100644 src/Sonarr.Api.V5/Connections/ConnectionResource.cs create mode 100644 src/Sonarr.Api.V5/Provider/ProviderBulkResource.cs create mode 100644 src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs create mode 100644 src/Sonarr.Api.V5/Provider/ProviderResource.cs create mode 100644 src/Sonarr.Api.V5/Provider/ProviderTestAllResult.cs diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs index 7271efd4c..a79f50a4b 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs @@ -36,7 +36,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server [FieldDefinition(1, Label = "Host")] public string Host { get; set; } - [FieldDefinition(2, Label = "Port")] + [FieldDefinition(2, Label = "Port", Type = FieldType.Number)] public int Port { get; set; } [FieldDefinition(3, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "NotificationsSettingsUseSslHelpText")] diff --git a/src/Sonarr.Api.V5/Connections/ConnectionBulkResource.cs b/src/Sonarr.Api.V5/Connections/ConnectionBulkResource.cs new file mode 100644 index 000000000..8bf449e58 --- /dev/null +++ b/src/Sonarr.Api.V5/Connections/ConnectionBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Notifications; +using Sonarr.Api.V5.Provider; + +namespace Sonarr.Api.V5.Connections; + +public class ConnectionBulkResource : ProviderBulkResource +{ +} + +public class ConnectionBulkResourceMapper : ProviderBulkResourceMapper +{ +} diff --git a/src/Sonarr.Api.V5/Connections/ConnectionController.cs b/src/Sonarr.Api.V5/Connections/ConnectionController.cs new file mode 100644 index 000000000..5349a6d8b --- /dev/null +++ b/src/Sonarr.Api.V5/Connections/ConnectionController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Notifications; +using NzbDrone.SignalR; +using Sonarr.Api.V5.Provider; +using Sonarr.Http; + +namespace Sonarr.Api.V5.Connections; + +[V5ApiController] +public class ConnectionController : ProviderControllerBase +{ + public static readonly ConnectionResourceMapper ResourceMapper = new(); + public static readonly ConnectionBulkResourceMapper BulkResourceMapper = new(); + + public ConnectionController(IBroadcastSignalRMessage signalRBroadcaster, NotificationFactory notificationFactory) + : base(signalRBroadcaster, notificationFactory, "connection", ResourceMapper, BulkResourceMapper) + { + } + + [NonAction] + public override ActionResult UpdateProvider([FromBody] ConnectionBulkResource providerResource) + { + throw new NotImplementedException(); + } + + [NonAction] + public override ActionResult DeleteProviders([FromBody] ConnectionBulkResource resource) + { + throw new NotImplementedException(); + } +} diff --git a/src/Sonarr.Api.V5/Connections/ConnectionResource.cs b/src/Sonarr.Api.V5/Connections/ConnectionResource.cs new file mode 100644 index 000000000..e40e8052a --- /dev/null +++ b/src/Sonarr.Api.V5/Connections/ConnectionResource.cs @@ -0,0 +1,110 @@ +using NzbDrone.Core.Notifications; +using Sonarr.Api.V5.Provider; + +namespace Sonarr.Api.V5.Connections; + +public class ConnectionResource : ProviderResource +{ + public string? Link { get; set; } + public bool OnGrab { get; set; } + public bool OnDownload { get; set; } + public bool OnUpgrade { get; set; } + public bool OnImportComplete { get; set; } + public bool OnRename { get; set; } + public bool OnSeriesAdd { get; set; } + public bool OnSeriesDelete { get; set; } + public bool OnEpisodeFileDelete { get; set; } + public bool OnEpisodeFileDeleteForUpgrade { get; set; } + public bool OnHealthIssue { get; set; } + public bool IncludeHealthWarnings { get; set; } + public bool OnHealthRestored { get; set; } + public bool OnApplicationUpdate { get; set; } + public bool OnManualInteractionRequired { get; set; } + public bool SupportsOnGrab { get; set; } + public bool SupportsOnDownload { get; set; } + public bool SupportsOnUpgrade { get; set; } + public bool SupportsOnImportComplete { get; set; } + public bool SupportsOnRename { get; set; } + public bool SupportsOnSeriesAdd { get; set; } + public bool SupportsOnSeriesDelete { get; set; } + public bool SupportsOnEpisodeFileDelete { get; set; } + public bool SupportsOnEpisodeFileDeleteForUpgrade { get; set; } + public bool SupportsOnHealthIssue { get; set; } + public bool SupportsOnHealthRestored { get; set; } + public bool SupportsOnApplicationUpdate { get; set; } + public bool SupportsOnManualInteractionRequired { get; set; } + public string? TestCommand { get; set; } +} + +public class ConnectionResourceMapper : ProviderResourceMapper +{ + public override ConnectionResource ToResource(NotificationDefinition definition) + { + var resource = base.ToResource(definition); + + resource.OnGrab = definition.OnGrab; + resource.OnDownload = definition.OnDownload; + resource.OnUpgrade = definition.OnUpgrade; + resource.OnImportComplete = definition.OnImportComplete; + resource.OnRename = definition.OnRename; + resource.OnSeriesAdd = definition.OnSeriesAdd; + resource.OnSeriesDelete = definition.OnSeriesDelete; + resource.OnEpisodeFileDelete = definition.OnEpisodeFileDelete; + resource.OnEpisodeFileDeleteForUpgrade = definition.OnEpisodeFileDeleteForUpgrade; + resource.OnHealthIssue = definition.OnHealthIssue; + resource.IncludeHealthWarnings = definition.IncludeHealthWarnings; + resource.OnHealthRestored = definition.OnHealthRestored; + resource.OnApplicationUpdate = definition.OnApplicationUpdate; + resource.OnManualInteractionRequired = definition.OnManualInteractionRequired; + resource.SupportsOnGrab = definition.SupportsOnGrab; + resource.SupportsOnDownload = definition.SupportsOnDownload; + resource.SupportsOnUpgrade = definition.SupportsOnUpgrade; + resource.SupportsOnImportComplete = definition.SupportsOnImportComplete; + resource.SupportsOnRename = definition.SupportsOnRename; + resource.SupportsOnSeriesAdd = definition.SupportsOnSeriesAdd; + resource.SupportsOnSeriesDelete = definition.SupportsOnSeriesDelete; + resource.SupportsOnEpisodeFileDelete = definition.SupportsOnEpisodeFileDelete; + resource.SupportsOnEpisodeFileDeleteForUpgrade = definition.SupportsOnEpisodeFileDeleteForUpgrade; + resource.SupportsOnHealthIssue = definition.SupportsOnHealthIssue; + resource.SupportsOnHealthRestored = definition.SupportsOnHealthRestored; + resource.SupportsOnApplicationUpdate = definition.SupportsOnApplicationUpdate; + resource.SupportsOnManualInteractionRequired = definition.SupportsOnManualInteractionRequired; + + return resource; + } + + public override NotificationDefinition ToModel(ConnectionResource resource, NotificationDefinition? existingDefinition) + { + var definition = base.ToModel(resource, existingDefinition); + + definition.OnGrab = resource.OnGrab; + definition.OnDownload = resource.OnDownload; + definition.OnUpgrade = resource.OnUpgrade; + definition.OnImportComplete = resource.OnImportComplete; + definition.OnRename = resource.OnRename; + definition.OnSeriesAdd = resource.OnSeriesAdd; + definition.OnSeriesDelete = resource.OnSeriesDelete; + definition.OnEpisodeFileDelete = resource.OnEpisodeFileDelete; + definition.OnEpisodeFileDeleteForUpgrade = resource.OnEpisodeFileDeleteForUpgrade; + definition.OnHealthIssue = resource.OnHealthIssue; + definition.IncludeHealthWarnings = resource.IncludeHealthWarnings; + definition.OnHealthRestored = resource.OnHealthRestored; + definition.OnApplicationUpdate = resource.OnApplicationUpdate; + definition.OnManualInteractionRequired = resource.OnManualInteractionRequired; + definition.SupportsOnGrab = resource.SupportsOnGrab; + definition.SupportsOnDownload = resource.SupportsOnDownload; + definition.SupportsOnUpgrade = resource.SupportsOnUpgrade; + definition.SupportsOnImportComplete = resource.SupportsOnImportComplete; + definition.SupportsOnRename = resource.SupportsOnRename; + definition.SupportsOnSeriesAdd = resource.SupportsOnSeriesAdd; + definition.SupportsOnSeriesDelete = resource.SupportsOnSeriesDelete; + definition.SupportsOnEpisodeFileDelete = resource.SupportsOnEpisodeFileDelete; + definition.SupportsOnEpisodeFileDeleteForUpgrade = resource.SupportsOnEpisodeFileDeleteForUpgrade; + definition.SupportsOnHealthIssue = resource.SupportsOnHealthIssue; + definition.SupportsOnHealthRestored = resource.SupportsOnHealthRestored; + definition.SupportsOnApplicationUpdate = resource.SupportsOnApplicationUpdate; + definition.SupportsOnManualInteractionRequired = resource.SupportsOnManualInteractionRequired; + + return definition; + } +} diff --git a/src/Sonarr.Api.V5/Provider/ProviderBulkResource.cs b/src/Sonarr.Api.V5/Provider/ProviderBulkResource.cs new file mode 100644 index 000000000..058d8509b --- /dev/null +++ b/src/Sonarr.Api.V5/Provider/ProviderBulkResource.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.ThingiProvider; + +namespace Sonarr.Api.V5.Provider +{ + public class ProviderBulkResource + { + public List Ids { get; set; } = []; + public List? Tags { get; set; } + public ApplyTags ApplyTags { get; set; } + } + + public class ProviderBulkResourceMapper + where TProviderBulkResource : ProviderBulkResource, new() + where TProviderDefinition : ProviderDefinition, new() + { + public virtual List UpdateModel(TProviderBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + return existingDefinitions; + } + } +} diff --git a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs new file mode 100644 index 000000000..6b5ceb3d0 --- /dev/null +++ b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs @@ -0,0 +1,320 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.ThingiProvider.Events; +using NzbDrone.Core.Validation; +using NzbDrone.SignalR; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V5.Provider +{ + public abstract class ProviderControllerBase : RestControllerWithSignalR, + IHandle>, + IHandle>, + IHandle> + where TProviderDefinition : ProviderDefinition, new() + where TProvider : IProvider + where TProviderResource : ProviderResource, new() + where TBulkProviderResource : ProviderBulkResource, new() + { + private readonly IProviderFactory _providerFactory; + private readonly ProviderResourceMapper _resourceMapper; + private readonly ProviderBulkResourceMapper _bulkResourceMapper; + + protected ProviderControllerBase(IBroadcastSignalRMessage signalRBroadcaster, + IProviderFactory providerFactory, + string resource, + ProviderResourceMapper resourceMapper, + ProviderBulkResourceMapper bulkResourceMapper) + : base(signalRBroadcaster) + { + _providerFactory = providerFactory; + _resourceMapper = resourceMapper; + _bulkResourceMapper = bulkResourceMapper; + + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name.EqualsIgnoreCase(c) && p.Id != v.Id)).WithMessage("Should be unique"); + SharedValidator.RuleFor(c => c.Implementation).NotEmpty(); + SharedValidator.RuleFor(c => c.ConfigContract).NotEmpty(); + + PostValidator.RuleFor(c => c.Fields).NotNull(); + } + + protected override TProviderResource GetResourceById(int id) + { + var definition = _providerFactory.Get(id); + _providerFactory.SetProviderCharacteristics(definition); + + return _resourceMapper.ToResource(definition); + } + + [HttpGet] + [Produces("application/json")] + public List GetAll() + { + var providerDefinitions = _providerFactory.All(); + + var result = new List(providerDefinitions.Count); + + foreach (var definition in providerDefinitions.OrderBy(p => p.ImplementationName)) + { + _providerFactory.SetProviderCharacteristics(definition); + + result.Add(_resourceMapper.ToResource(definition)); + } + + return result.OrderBy(p => p.Name).ToList(); + } + + [RestPostById] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false) + { + var providerDefinition = GetDefinition(providerResource, null, true, !forceSave, false); + + if (providerDefinition.Enable) + { + Test(providerDefinition, !forceSave); + } + + providerDefinition = _providerFactory.Create(providerDefinition); + + return Created(providerDefinition.Id); + } + + [RestPutById] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false) + { + // TODO: Remove fallback to Id from body in next API version bump + var existingDefinition = _providerFactory.Find(id) ?? _providerFactory.Find(providerResource.Id); + + if (existingDefinition == null) + { + return NotFound(); + } + + var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceSave, false); + + // Compare settings separately because they are not serialized with the definition. + var hasDefinitionChanged = !existingDefinition.Equals(providerDefinition) || !existingDefinition.Settings.Equals(providerDefinition.Settings); + + // Only test existing definitions if it is enabled and forceSave isn't set and the definition has changed. + if (providerDefinition.Enable && !forceSave && hasDefinitionChanged) + { + Test(providerDefinition, true); + } + + if (hasDefinitionChanged) + { + _providerFactory.Update(providerDefinition); + } + + return Accepted(existingDefinition.Id); + } + + [HttpPut("bulk")] + [Consumes("application/json")] + [Produces("application/json")] + public virtual ActionResult UpdateProvider([FromBody] TBulkProviderResource providerResource) + { + if (!providerResource.Ids.Any()) + { + throw new BadRequestException("ids must be provided"); + } + + var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList(); + + foreach (var definition in definitionsToUpdate) + { + _providerFactory.SetProviderCharacteristics(definition); + + if (providerResource.Tags != null) + { + var newTags = providerResource.Tags; + var applyTags = providerResource.ApplyTags; + + switch (applyTags) + { + case ApplyTags.Add: + newTags.ForEach(t => definition.Tags.Add(t)); + break; + case ApplyTags.Remove: + newTags.ForEach(t => definition.Tags.Remove(t)); + break; + case ApplyTags.Replace: + definition.Tags = new HashSet(newTags); + break; + } + } + } + + _bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate); + + return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); + } + + private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, bool validate, bool includeWarnings, bool forceValidate) + { + var definition = _resourceMapper.ToModel(providerResource, existingDefinition); + + if (validate && (definition.Enable || forceValidate)) + { + Validate(definition, includeWarnings); + } + + return definition; + } + + [RestDeleteById] + public ActionResult DeleteProvider(int id) + { + _providerFactory.Delete(id); + + return NoContent(); + } + + [HttpDelete("bulk")] + [Consumes("application/json")] + public virtual ActionResult DeleteProviders([FromBody] TBulkProviderResource resource) + { + _providerFactory.Delete(resource.Ids); + + return NoContent(); + } + + [HttpGet("schema")] + [Produces("application/json")] + public List GetTemplates() + { + var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); + + var result = new List(defaultDefinitions.Count); + + foreach (var providerDefinition in defaultDefinitions) + { + var providerResource = _resourceMapper.ToResource(providerDefinition); + var presetDefinitions = _providerFactory.GetPresetDefinitions(providerDefinition); + + providerResource.Presets = presetDefinitions + .Select(v => _resourceMapper.ToResource(v)) + .ToList(); + + result.Add(providerResource); + } + + return result; + } + + [SkipValidation(true, false)] + [HttpPost("test")] + [Consumes("application/json")] + public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] bool forceTest = false) + { + var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; + var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceTest, true); + + Test(providerDefinition, true); + + return NoContent(); + } + + [HttpPost("testall")] + [Produces("application/json")] + public IActionResult TestAll() + { + var providerDefinitions = _providerFactory.All() + .Where(c => c.Settings.Validate().IsValid && c.Enable) + .ToList(); + var result = new List(); + + foreach (var definition in providerDefinitions) + { + var validationFailures = new List(); + + validationFailures.AddRange(definition.Settings.Validate().Errors); + validationFailures.AddRange(_providerFactory.Test(definition).Errors); + + result.Add(new ProviderTestAllResult + { + Id = definition.Id, + ValidationFailures = validationFailures + }); + } + + return result.Any(c => !c.IsValid) ? BadRequest(result) : Ok(result); + } + + [SkipValidation] + [HttpPost("action/{name}")] + [Consumes("application/json")] + [Produces("application/json")] + public IActionResult RequestAction([FromRoute] string name, [FromBody] TProviderResource providerResource) + { + var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; + var providerDefinition = GetDefinition(providerResource, existingDefinition, false, false, false); + + var query = Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()); + + var data = _providerFactory.RequestAction(providerDefinition, name, query); + + return Content(data.ToJson(), "application/json"); + } + + [NonAction] + public virtual void Handle(ProviderAddedEvent message) + { + BroadcastResourceChange(ModelAction.Created, message.Definition.Id); + } + + [NonAction] + public virtual void Handle(ProviderUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Definition.Id); + } + + [NonAction] + public virtual void Handle(ProviderDeletedEvent message) + { + BroadcastResourceChange(ModelAction.Deleted, message.ProviderId); + } + + private void Validate(TProviderDefinition definition, bool includeWarnings) + { + var validationResult = definition.Settings.Validate(); + + VerifyValidationResult(validationResult, includeWarnings); + } + + protected virtual void Test(TProviderDefinition definition, bool includeWarnings) + { + var validationResult = _providerFactory.Test(definition); + + VerifyValidationResult(validationResult, includeWarnings); + } + + protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings) + { + var result = validationResult as NzbDroneValidationResult ?? new NzbDroneValidationResult(validationResult.Errors); + + if (includeWarnings && (!result.IsValid || result.HasWarnings)) + { + throw new ValidationException(result.Failures); + } + + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } + } +} diff --git a/src/Sonarr.Api.V5/Provider/ProviderResource.cs b/src/Sonarr.Api.V5/Provider/ProviderResource.cs new file mode 100644 index 000000000..57e78e8d8 --- /dev/null +++ b/src/Sonarr.Api.V5/Provider/ProviderResource.cs @@ -0,0 +1,62 @@ +using NzbDrone.Common.Reflection; +using NzbDrone.Core.ThingiProvider; +using Sonarr.Http.ClientSchema; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Provider +{ + public class ProviderResource : RestResource + { + public string? Name { get; set; } + public List Fields { get; set; } = []; + public string? ImplementationName { get; set; } + public string? Implementation { get; set; } + public string? ConfigContract { get; set; } + public string? InfoLink { get; set; } + public ProviderMessage? Message { get; set; } + public HashSet Tags { get; set; } = []; + public List Presets { get; set; } = []; + } + + public class ProviderResourceMapper + where TProviderResource : ProviderResource, new() + where TProviderDefinition : ProviderDefinition, new() + { + public virtual TProviderResource ToResource(TProviderDefinition definition) + { + return new TProviderResource + { + Id = definition.Id, + + Name = definition.Name, + ImplementationName = definition.ImplementationName, + Implementation = definition.Implementation, + ConfigContract = definition.ConfigContract, + Message = definition.Message, + Tags = definition.Tags, + Fields = SchemaBuilder.ToSchema(definition.Settings), + + InfoLink = $"https://wiki.servarr.com/sonarr/supported#{definition.Implementation.ToLower()}" + }; + } + + public virtual TProviderDefinition ToModel(TProviderResource resource, TProviderDefinition? existingDefinition) + { + var definition = new TProviderDefinition + { + Id = resource.Id, + Name = resource.Name, + ImplementationName = resource.ImplementationName, + Implementation = resource.Implementation, + ConfigContract = resource.ConfigContract, + Message = resource.Message, + Tags = resource.Tags + }; + + var configContract = ReflectionExtensions.CoreAssembly.FindTypeByName(definition.ConfigContract); + definition.Settings = (IProviderConfig)SchemaBuilder.ReadFromSchema(resource.Fields, configContract, existingDefinition?.Settings); + + return definition; + } + } +} diff --git a/src/Sonarr.Api.V5/Provider/ProviderTestAllResult.cs b/src/Sonarr.Api.V5/Provider/ProviderTestAllResult.cs new file mode 100644 index 000000000..3a16fa64a --- /dev/null +++ b/src/Sonarr.Api.V5/Provider/ProviderTestAllResult.cs @@ -0,0 +1,17 @@ +using FluentValidation.Results; +using NzbDrone.Common.Extensions; + +namespace Sonarr.Api.V5.Provider +{ + public class ProviderTestAllResult + { + public int Id { get; set; } + public bool IsValid => ValidationFailures.Empty(); + public List ValidationFailures { get; set; } + + public ProviderTestAllResult() + { + ValidationFailures = new List(); + } + } +}