diff --git a/src/NzbDrone.Core/Applications/Qui/Qui.cs b/src/NzbDrone.Core/Applications/Qui/Qui.cs new file mode 100644 index 000000000..dcdc7f852 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Qui/Qui.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Applications.Qui +{ + public class Qui : ApplicationBase + { + public override string Name => "qui"; + + private readonly IQuiProxy _quiProxy; + private readonly IConfigFileProvider _configFileProvider; + + public Qui(IQuiProxy quiProxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) + { + _quiProxy = quiProxy; + _configFileProvider = configFileProvider; + } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_quiProxy.TestConnection(Settings)); + + return new ValidationResult(failures); + } + + public override List GetIndexerMappings() + { + var indexers = _quiProxy.GetIndexers(Settings); + + var mappings = new List(); + + foreach (var indexer in indexers) + { + var baseUrl = Settings.ProwlarrUrl.TrimEnd('/'); + + if (indexer.Backend == "prowlarr" && + (indexer.BaseUrl?.TrimEnd('/').Equals(baseUrl, StringComparison.OrdinalIgnoreCase) == true || + indexer.BaseUrl?.StartsWith(baseUrl + "/", StringComparison.OrdinalIgnoreCase) == true) && + int.TryParse(indexer.IndexerId, out var indexerId)) + { + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); + } + } + + return mappings; + } + + public override void AddIndexer(IndexerDefinition indexer) + { + if (indexer.Protocol != DownloadProtocol.Torrent) + { + return; + } + + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + { + _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); + + return; + } + + _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); + + var quiIndexer = BuildQuiIndexer(indexer, indexerCapabilities); + + var remoteIndexer = _quiProxy.AddIndexer(quiIndexer, Settings); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + + _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) + { + _quiProxy.RemoveIndexer(indexerMapping.RemoteIndexerId, Settings); + _appIndexerMapService.Delete(indexerMapping.Id); + } + } + + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) + { + _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + + if (indexer.Protocol != DownloadProtocol.Torrent) + { + return; + } + + var indexerCapabilities = GetIndexerCapabilities(indexer); + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); + + var quiIndexer = BuildQuiIndexer(indexer, indexerCapabilities, indexerMapping?.RemoteIndexerId ?? 0); + + var remoteIndexer = indexerMapping?.RemoteIndexerId > 0 + ? _quiProxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings) + : null; + + if (remoteIndexer != null) + { + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); + + if (!quiIndexer.Equals(remoteIndexer) || forceSync) + { + _logger.Debug("Syncing remote indexer with current settings"); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + _quiProxy.UpdateIndexer(quiIndexer, Settings); + } + else + { + _quiProxy.RemoveIndexer(remoteIndexer.Id, Settings); + + if (indexerMapping != null) + { + _appIndexerMapService.Delete(indexerMapping.Id); + } + } + } + } + else + { + if (indexerMapping != null) + { + _appIndexerMapService.Delete(indexerMapping.Id); + } + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to qui", indexer.Name, indexer.Id); + quiIndexer.Id = 0; + var newRemoteIndexer = _quiProxy.AddIndexer(quiIndexer, Settings); + + if (newRemoteIndexer == null) + { + _logger.Debug("Failed to re-add {0} [{1}] to qui", indexer.Name, indexer.Id); + return; + } + + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); + } + else + { + _logger.Debug("Remote indexer not found for {0} [{1}], skipping re-add to qui due to indexer capabilities", indexer.Name, indexer.Id); + } + } + } + + private QuiIndexer BuildQuiIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, int id = 0) + { + var supportedCategories = indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()); + + var capabilities = new List { "search" }; + + if (indexerCapabilities.TvSearchAvailable) + { + capabilities.Add("tv-search"); + } + + if (indexerCapabilities.MovieSearchAvailable) + { + capabilities.Add("movie-search"); + } + + if (indexerCapabilities.MusicSearchAvailable) + { + capabilities.Add("music-search"); + } + + if (indexerCapabilities.BookSearchAvailable) + { + capabilities.Add("book-search"); + } + + return new QuiIndexer + { + Id = id, + Name = $"{indexer.Name} (Prowlarr)", + BaseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/", + ApiKey = _configFileProvider.ApiKey, + Backend = "prowlarr", + Enabled = indexer.Enable, + Priority = indexer.Priority, + TimeoutSeconds = 30, + LimitDefault = 100, + LimitMax = 200, + IndexerId = indexer.Id.ToString(), + Capabilities = capabilities, + Categories = supportedCategories.Select(c => new QuiCategory { CategoryId = c, CategoryName = NewznabStandardCategory.GetCatDesc(c) }).ToList() + }; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Qui/QuiException.cs b/src/NzbDrone.Core/Applications/Qui/QuiException.cs new file mode 100644 index 000000000..02be1c757 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Qui/QuiException.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Applications.Qui +{ + public class QuiException : NzbDroneException + { + public QuiException(string message) + : base(message) + { + } + + public QuiException(string message, params object[] args) + : base(message, args) + { + } + + public QuiException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Applications/Qui/QuiIndexer.cs b/src/NzbDrone.Core/Applications/Qui/QuiIndexer.cs new file mode 100644 index 000000000..3834d5da4 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Qui/QuiIndexer.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Applications.Qui +{ + public class QuiCategory + { + [JsonProperty("indexer_id")] + public int IndexerId { get; set; } + + [JsonProperty("category_id")] + public int CategoryId { get; set; } + + [JsonProperty("category_name")] + public string CategoryName { get; set; } + + [JsonProperty("parent_category_id")] + public int? ParentCategoryId { get; set; } + } + + public class QuiIndexer : IEquatable + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("base_url")] + public string BaseUrl { get; set; } + + [JsonProperty("api_key")] + public string ApiKey { get; set; } + + [JsonProperty("backend")] + public string Backend { get; set; } + + [JsonProperty("enabled")] + public bool Enabled { get; set; } + + [JsonProperty("priority")] + public int Priority { get; set; } + + [JsonProperty("timeout_seconds")] + public int TimeoutSeconds { get; set; } + + [JsonProperty("limit_default")] + public int LimitDefault { get; set; } + + [JsonProperty("limit_max")] + public int LimitMax { get; set; } + + [JsonProperty("indexer_id")] + public string IndexerId { get; set; } + + [JsonProperty("capabilities")] + public List Capabilities { get; set; } + + [JsonProperty("categories")] + public List Categories { get; set; } + + public bool Equals(QuiIndexer other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + var thisCategories = (Categories ?? Enumerable.Empty()).Select(c => c.CategoryId).OrderBy(c => c); + var otherCategories = (other.Categories ?? Enumerable.Empty()).Select(c => c.CategoryId).OrderBy(c => c); + + var thisCapabilities = (Capabilities ?? Enumerable.Empty()).OrderBy(c => c); + var otherCapabilities = (other.Capabilities ?? Enumerable.Empty()).OrderBy(c => c); + + return other.BaseUrl == BaseUrl && + other.ApiKey == ApiKey && + other.Name == Name && + other.Backend == Backend && + other.Enabled == Enabled && + other.Priority == Priority && + other.TimeoutSeconds == TimeoutSeconds && + other.LimitDefault == LimitDefault && + other.LimitMax == LimitMax && + other.IndexerId == IndexerId && + otherCapabilities.SequenceEqual(thisCapabilities) && + otherCategories.SequenceEqual(thisCategories); + } + + public override bool Equals(object obj) => Equals(obj as QuiIndexer); + + public override int GetHashCode() + { + return HashCode.Combine(BaseUrl, ApiKey, Name, Backend, Enabled, Priority, IndexerId); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Qui/QuiProxy.cs b/src/NzbDrone.Core/Applications/Qui/QuiProxy.cs new file mode 100644 index 000000000..2924a59d3 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Qui/QuiProxy.cs @@ -0,0 +1,156 @@ +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.Qui +{ + public interface IQuiProxy + { + QuiIndexer AddIndexer(QuiIndexer indexer, QuiSettings settings); + List GetIndexers(QuiSettings settings); + QuiIndexer GetIndexer(int indexerId, QuiSettings settings); + void RemoveIndexer(int indexerId, QuiSettings settings); + QuiIndexer UpdateIndexer(QuiIndexer indexer, QuiSettings settings); + ValidationFailure TestConnection(QuiSettings settings); + } + + public class QuiProxy : IQuiProxy + { + private const string AppIndexerApiRoute = "/api/torznab/indexers"; + + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public QuiProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public List GetIndexers(QuiSettings settings) + { + var request = BuildRequest(settings, AppIndexerApiRoute, HttpMethod.Get); + return Execute>(request); + } + + public QuiIndexer GetIndexer(int indexerId, QuiSettings settings) + { + try + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get); + return Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode != HttpStatusCode.NotFound) + { + throw; + } + } + + return null; + } + + public QuiIndexer AddIndexer(QuiIndexer indexer, QuiSettings settings) + { + var request = BuildRequest(settings, AppIndexerApiRoute, HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + return Execute(request); + } + + public QuiIndexer UpdateIndexer(QuiIndexer indexer, QuiSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + return Execute(request); + } + + public void RemoveIndexer(int indexerId, QuiSettings settings) + { + try + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Delete); + _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode != HttpStatusCode.NotFound) + { + throw; + } + } + } + + public ValidationFailure TestConnection(QuiSettings settings) + { + try + { + GetIndexers(settings); + } + catch (HttpException ex) + { + _logger.Error(ex, "Unable to complete application test"); + + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + return new ValidationFailure("ApiKey", "API Key is invalid"); + case HttpStatusCode.NotFound: + return new ValidationFailure("BaseUrl", "qui URL is invalid, Prowlarr cannot connect to qui. Is qui running and accessible?"); + default: + return new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to qui. {ex.Message}"); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to complete application test"); + return new ValidationFailure("", $"Unable to complete application test. {ex.Message}"); + } + + return null; + } + + private HttpRequest BuildRequest(QuiSettings settings, string resource, HttpMethod method) + { + var baseUrl = settings.BaseUrl.TrimEnd('/'); + + var request = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) + .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); + + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } + + return Json.Deserialize(response.Content); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Qui/QuiSettings.cs b/src/NzbDrone.Core/Applications/Qui/QuiSettings.cs new file mode 100644 index 000000000..a9e834b16 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Qui/QuiSettings.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Applications.Qui +{ + public class QuiSettingsValidator : AbstractValidator + { + public QuiSettingsValidator() + { + RuleFor(c => c.BaseUrl).IsValidUrl(); + RuleFor(c => c.ProwlarrUrl).IsValidUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + RuleFor(c => c.SyncCategories).NotEmpty(); + } + } + + public class QuiSettings : IApplicationSettings + { + private static readonly QuiSettingsValidator Validator = new(); + + public QuiSettings() + { + ProwlarrUrl = "http://localhost:9696"; + BaseUrl = "http://localhost:7476"; + SyncCategories = new[] { 2000, 3000, 4000, 5000, 6000, 7000, 8000 }; + } + + [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as qui sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")] + public string ProwlarrUrl { get; set; } + + [FieldDefinition(1, Label = "qui Server", HelpText = "URL used to connect to qui server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:7476")] + public string BaseUrl { get; set; } + + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by qui in Settings")] + 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)); + } + } +}