1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-05 13:20:20 -05:00

Add v5 Connection endpoints

This commit is contained in:
Mark McDowall
2025-12-30 12:28:45 -08:00
parent 3e8a85ad26
commit 06c6062531
8 changed files with 579 additions and 1 deletions

View File

@@ -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")]

View File

@@ -0,0 +1,12 @@
using NzbDrone.Core.Notifications;
using Sonarr.Api.V5.Provider;
namespace Sonarr.Api.V5.Connections;
public class ConnectionBulkResource : ProviderBulkResource<ConnectionBulkResource>
{
}
public class ConnectionBulkResourceMapper : ProviderBulkResourceMapper<ConnectionBulkResource, NotificationDefinition>
{
}

View File

@@ -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<ConnectionResource, ConnectionBulkResource, INotification, NotificationDefinition>
{
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<ConnectionResource> UpdateProvider([FromBody] ConnectionBulkResource providerResource)
{
throw new NotImplementedException();
}
[NonAction]
public override ActionResult DeleteProviders([FromBody] ConnectionBulkResource resource)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,110 @@
using NzbDrone.Core.Notifications;
using Sonarr.Api.V5.Provider;
namespace Sonarr.Api.V5.Connections;
public class ConnectionResource : ProviderResource<ConnectionResource>
{
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<ConnectionResource, NotificationDefinition>
{
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;
}
}

View File

@@ -0,0 +1,26 @@
using NzbDrone.Core.ThingiProvider;
namespace Sonarr.Api.V5.Provider
{
public class ProviderBulkResource<T>
{
public List<int> Ids { get; set; } = [];
public List<int>? Tags { get; set; }
public ApplyTags ApplyTags { get; set; }
}
public class ProviderBulkResourceMapper<TProviderBulkResource, TProviderDefinition>
where TProviderBulkResource : ProviderBulkResource<TProviderBulkResource>, new()
where TProviderDefinition : ProviderDefinition, new()
{
public virtual List<TProviderDefinition> UpdateModel(TProviderBulkResource resource, List<TProviderDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<TProviderDefinition>();
}
return existingDefinitions;
}
}
}

View File

@@ -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<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestControllerWithSignalR<TProviderResource, TProviderDefinition>,
IHandle<ProviderAddedEvent<TProvider>>,
IHandle<ProviderUpdatedEvent<TProvider>>,
IHandle<ProviderDeletedEvent<TProvider>>
where TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new()
where TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new()
{
private readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper;
private readonly ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> _bulkResourceMapper;
protected ProviderControllerBase(IBroadcastSignalRMessage signalRBroadcaster,
IProviderFactory<TProvider,
TProviderDefinition> providerFactory,
string resource,
ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper,
ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> 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<TProviderResource> GetAll()
{
var providerDefinitions = _providerFactory.All();
var result = new List<TProviderResource>(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<TProviderResource> 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<TProviderResource> 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<TProviderResource> 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<int>(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<TProviderResource> GetTemplates()
{
var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList();
var result = new List<TProviderResource>(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<ProviderTestAllResult>();
foreach (var definition in providerDefinitions)
{
var validationFailures = new List<ValidationFailure>();
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<TProvider> message)
{
BroadcastResourceChange(ModelAction.Created, message.Definition.Id);
}
[NonAction]
public virtual void Handle(ProviderUpdatedEvent<TProvider> message)
{
BroadcastResourceChange(ModelAction.Updated, message.Definition.Id);
}
[NonAction]
public virtual void Handle(ProviderDeletedEvent<TProvider> 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);
}
}
}
}

View File

@@ -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<T> : RestResource
{
public string? Name { get; set; }
public List<Field> 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<int> Tags { get; set; } = [];
public List<T> Presets { get; set; } = [];
}
public class ProviderResourceMapper<TProviderResource, TProviderDefinition>
where TProviderResource : ProviderResource<TProviderResource>, 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;
}
}
}

View File

@@ -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<ValidationFailure> ValidationFailures { get; set; }
public ProviderTestAllResult()
{
ValidationFailures = new List<ValidationFailure>();
}
}
}