diff --git a/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs b/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs new file mode 100644 index 000000000..fe0f294ab --- /dev/null +++ b/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Applications.Whisparr +{ + public class Radarr : ApplicationBase + { + public override string Name => "Whisparr"; + + private readonly IWhisparrV3Proxy _whisparrV3Proxy; + private readonly ICached> _schemaCache; + private readonly IConfigFileProvider _configFileProvider; + + public Radarr(ICacheManager cacheManager, IWhisparrV3Proxy whisparrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) + : base(appIndexerMapService, logger) + { + _schemaCache = cacheManager.GetCache>(GetType()); + _whisparrV3Proxy = whisparrV3Proxy; + _configFileProvider = configFileProvider; + } + + public override ValidationResult Test() + { + var failures = new List(); + + var testIndexer = new IndexerDefinition + { + Id = 0, + Name = "Test", + Protocol = DownloadProtocol.Usenet, + Capabilities = new IndexerCapabilities() + }; + + foreach (var cat in NewznabStandardCategory.AllCats) + { + testIndexer.Capabilities.Categories.AddCategoryMapping(1, cat); + } + + try + { + failures.AddIfNotNull(_whisparrV3Proxy.TestConnection(BuildWhisparrIndexer(testIndexer, DownloadProtocol.Usenet), Settings)); + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to send test message"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Whisparr")); + } + + return new ValidationResult(failures); + } + + public override List GetIndexerMappings() + { + var indexers = _whisparrV3Proxy.GetIndexers(Settings) + .Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab"); + + var mappings = new List(); + + foreach (var indexer in indexers) + { + if ((string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value == _configFileProvider.ApiKey) + { + var match = AppIndexerRegex.Match((string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + //Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { RemoteIndexerId = indexer.Id, IndexerId = indexerId }); + } + } + } + + return mappings; + } + + public override void AddIndexer(IndexerDefinition indexer) + { + if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + var radarrIndexer = BuildWhisparrIndexer(indexer, indexer.Protocol); + + var remoteIndexer = _whisparrV3Proxy.AddIndexer(radarrIndexer, Settings); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = remoteIndexer.Id }); + } + } + + public override void RemoveIndexer(int indexerId) + { + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId); + + if (indexerMapping != null) + { + //Remove Indexer remotely and then remove the mapping + _whisparrV3Proxy.RemoveIndexer(indexerMapping.RemoteIndexerId, Settings); + _appIndexerMapService.Delete(indexerMapping.Id); + } + } + + public override void UpdateIndexer(IndexerDefinition indexer) + { + _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); + + var radarrIndexer = BuildWhisparrIndexer(indexer, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + + var remoteIndexer = _whisparrV3Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); + + if (remoteIndexer != null) + { + _logger.Debug("Remote indexer found, syncing with current settings"); + + if (!radarrIndexer.Equals(remoteIndexer)) + { + _whisparrV3Proxy.UpdateIndexer(radarrIndexer, Settings); + } + } + else + { + _appIndexerMapService.Delete(indexerMapping.Id); + + if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + _logger.Debug("Remote indexer not found, re-adding {0} to Whisparr", indexer.Name); + radarrIndexer.Id = 0; + var newRemoteIndexer = _whisparrV3Proxy.AddIndexer(radarrIndexer, Settings); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); + } + else + { + _logger.Debug("Remote indexer not found for {0}, skipping re-add to Radarr due to indexer capabilities", indexer.Name); + } + } + } + + private WhisparrIndexer BuildWhisparrIndexer(IndexerDefinition indexer, DownloadProtocol protocol, int id = 0) + { + var cacheKey = $"{Settings.BaseUrl}"; + var schemas = _schemaCache.Get(cacheKey, () => _whisparrV3Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); + + var newznab = schemas.Where(i => i.Implementation == "Newznab").First(); + var torznab = schemas.Where(i => i.Implementation == "Torznab").First(); + + var schema = protocol == DownloadProtocol.Usenet ? newznab : torznab; + + var whisparrIndexer = new WhisparrIndexer + { + Id = id, + Name = $"{indexer.Name} (Prowlarr)", + EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch, + Priority = indexer.Priority, + Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", + ConfigContract = schema.ConfigContract, + Fields = schema.Fields, + }; + + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + + return whisparrIndexer; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs new file mode 100644 index 000000000..e3b1139b1 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs @@ -0,0 +1,22 @@ +namespace NzbDrone.Core.Applications.Whisparr +{ + public class WhisparrField + { + public int Order { get; set; } + public string Name { get; set; } + public string Label { get; set; } + public string Unit { get; set; } + public string HelpText { get; set; } + public string HelpLink { get; set; } + public object Value { get; set; } + public string Type { get; set; } + public bool Advanced { get; set; } + public string Section { get; set; } + public string Hidden { get; set; } + + public WhisparrField Clone() + { + return (WhisparrField)MemberwiseClone(); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs new file mode 100644 index 000000000..cd8c5df01 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Core.Applications.Whisparr +{ + public class WhisparrIndexer + { + public int Id { get; set; } + public bool EnableRss { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } + public int Priority { get; set; } + public string Name { get; set; } + public string ImplementationName { get; set; } + public string Implementation { get; set; } + public string ConfigContract { get; set; } + public string InfoLink { get; set; } + public HashSet Tags { get; set; } + public List Fields { get; set; } + + public bool Equals(WhisparrIndexer other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; + var apiPath = (string)Fields.FirstOrDefault(x => x.Name == "apiPath").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey").Value; + var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); + + return other.EnableRss == EnableRss && + other.EnableAutomaticSearch == EnableAutomaticSearch && + other.EnableInteractiveSearch == EnableInteractiveSearch && + other.Name == Name && + other.Implementation == Implementation && + other.Priority == Priority && + other.Id == Id && + apiKey && apiPath && baseUrl && cats; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs new file mode 100644 index 000000000..a6be4ebdc --- /dev/null +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Applications.Whisparr +{ + public class WhisparrSettingsValidator : AbstractValidator + { + public WhisparrSettingsValidator() + { + RuleFor(c => c.BaseUrl).IsValidUrl(); + RuleFor(c => c.ProwlarrUrl).IsValidUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + RuleFor(c => c.SyncCategories).NotEmpty(); + } + } + + public class WhisparrSettings : IApplicationSettings + { + private static readonly WhisparrSettingsValidator Validator = new WhisparrSettingsValidator(); + + public WhisparrSettings() + { + SyncCategories = new[] { 6000, 6010, 6020, 6030, 6040, 6045, 6050, 6070, 6080, 6090 }; + } + + [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Whisparr sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")] + public string ProwlarrUrl { get; set; } + + [FieldDefinition(1, Label = "Whisparr Server", HelpText = "URL used to connect to Whisparr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:6969")] + public string BaseUrl { get; set; } + + [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Whisparr in Settings/General")] + public string ApiKey { get; set; } + + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] + public IEnumerable SyncCategories { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrStatus.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrStatus.cs new file mode 100644 index 000000000..59b8c6e44 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrStatus.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Applications.Whisparr +{ + public class WhisparrStatus + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs new file mode 100644 index 000000000..cedd8fc29 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using FluentValidation.Results; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Applications.Whisparr +{ + public interface IWhisparrV3Proxy + { + WhisparrIndexer AddIndexer(WhisparrIndexer indexer, WhisparrSettings settings); + List GetIndexers(WhisparrSettings settings); + WhisparrIndexer GetIndexer(int indexerId, WhisparrSettings settings); + List GetIndexerSchema(WhisparrSettings settings); + void RemoveIndexer(int indexerId, WhisparrSettings settings); + WhisparrIndexer UpdateIndexer(WhisparrIndexer indexer, WhisparrSettings settings); + ValidationFailure TestConnection(WhisparrIndexer indexer, WhisparrSettings settings); + } + + public class WhisparrV3Proxy : IWhisparrV3Proxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public WhisparrV3Proxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public WhisparrStatus GetStatus(WhisparrSettings settings) + { + var request = BuildRequest(settings, "/api/v3/system/status", HttpMethod.Get); + return Execute(request); + } + + public List GetIndexers(WhisparrSettings settings) + { + var request = BuildRequest(settings, "/api/v3/indexer", HttpMethod.Get); + return Execute>(request); + } + + public WhisparrIndexer GetIndexer(int indexerId, WhisparrSettings settings) + { + try + { + var request = BuildRequest(settings, $"/api/v3/indexer/{indexerId}", HttpMethod.Get); + return Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode != HttpStatusCode.NotFound) + { + throw; + } + } + + return null; + } + + public void RemoveIndexer(int indexerId, WhisparrSettings settings) + { + var request = BuildRequest(settings, $"/api/v3/indexer/{indexerId}", HttpMethod.Delete); + _httpClient.Execute(request); + } + + public List GetIndexerSchema(WhisparrSettings settings) + { + var request = BuildRequest(settings, "/api/v3/indexer/schema", HttpMethod.Get); + return Execute>(request); + } + + public WhisparrIndexer AddIndexer(WhisparrIndexer indexer, WhisparrSettings settings) + { + var request = BuildRequest(settings, "/api/v3/indexer", HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + + return Execute(request); + } + + public WhisparrIndexer UpdateIndexer(WhisparrIndexer indexer, WhisparrSettings settings) + { + var request = BuildRequest(settings, $"/api/v3/indexer/{indexer.Id}", HttpMethod.Put); + + request.SetContent(indexer.ToJson()); + + return Execute(request); + } + + public ValidationFailure TestConnection(WhisparrIndexer indexer, WhisparrSettings settings) + { + var request = BuildRequest(settings, $"/api/v3/indexer/test", HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + + try + { + Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, "API Key is invalid"); + return new ValidationFailure("ApiKey", "API Key is invalid"); + } + + if (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Error(ex, "Prowlarr URL is invalid"); + return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Whisparr cannot connect to Prowlarr"); + } + + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("BaseUrl", "Unable to complete application test"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("", "Unable to send test message"); + } + + return null; + } + + private HttpRequest BuildRequest(WhisparrSettings settings, string resource, HttpMethod method) + { + var baseUrl = settings.BaseUrl.TrimEnd('/'); + + var request = new HttpRequestBuilder(baseUrl).Resource(resource) + .SetHeader("X-Api-Key", settings.ApiKey) + .Build(); + + request.Headers.ContentType = "application/json"; + + request.Method = method; + request.AllowAutoRedirect = true; + + return request; + } + + private TResource Execute(HttpRequest request) + where TResource : new() + { + var response = _httpClient.Execute(request); + + var results = JsonConvert.DeserializeObject(response.Content); + + return results; + } + } +}