diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs new file mode 100644 index 000000000..6b13f67c9 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class Listenarr : ApplicationBase + { + public override string Name => "Listenarr"; + + private readonly IListenarrV1Proxy _listenarrV1Proxy; + private readonly ICached> _schemaCache; + private readonly IConfigFileProvider _configFileProvider; + + public Listenarr(ICacheManager cacheManager, IListenarrV1Proxy listenarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) + { + _schemaCache = cacheManager.GetCache>(GetType()); + _listenarrV1Proxy = listenarrV1Proxy; + _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(_listenarrV1Proxy.TestConnection(BuildListenarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); + } + catch (HttpException ex) + { + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Listenarr cannot connect to Prowlarr")); + break; + case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "Listenarr returned redirect and is invalid"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Listenarr URL is invalid, Prowlarr cannot connect to Listenarr - are you missing a URL base?")); + break; + default: + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Listenarr. {ex.Message}")); + break; + } + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Listenarr. {ex.Message}")); + } + + return new ValidationResult(failures); + } + + public override List GetIndexerMappings() + { + var indexers = _listenarrV1Proxy.GetIndexers(Settings) + .Where(i => i.Implementation is "Newznab" or "Torznab"); + + var mappings = new List(); + + foreach (var indexer in indexers) + { + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; + + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + 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 { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); + } + } + + return mappings; + } + + public override void AddIndexer(IndexerDefinition indexer) + { + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (!indexerCapabilities.MusicSearchAvailable && !indexerCapabilities.SearchAvailable) + { + _logger.Debug("Skipping add for indexer {0} [{1}] due to missing music or basic search support by the indexer", indexer.Name, indexer.Id); + + return; + } + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + { + _logger.Debug("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 listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); + + var remoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, 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) + { + //Remove Indexer remotely and then remove the mapping + _listenarrV1Proxy.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); + + var indexerCapabilities = GetIndexerCapabilities(indexer); + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); + + var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + + var remoteIndexer = _listenarrV1Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); + + if (remoteIndexer != null) + { + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); + + if (!listenarrIndexer.Equals(remoteIndexer) || forceSync) + { + _logger.Debug("Syncing remote indexer with current settings"); + + if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && + indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + // Retain user fields not-affiliated with Prowlarr + listenarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => listenarrIndexer.Fields.All(s => s.Name != f.Name))); + + // Retain user tags not-affiliated with Prowlarr + listenarrIndexer.Tags.UnionWith(remoteIndexer.Tags); + + // Retain user settings not-affiliated with Prowlarr + listenarrIndexer.DownloadClientId = remoteIndexer.DownloadClientId; + + // Update the indexer if it still has categories that match + _listenarrV1Proxy.UpdateIndexer(listenarrIndexer, Settings); + } + else + { + // Else remove it, it no longer should be used + _listenarrV1Proxy.RemoveIndexer(remoteIndexer.Id, Settings); + _appIndexerMapService.Delete(indexerMapping.Id); + } + } + } + else + { + _appIndexerMapService.Delete(indexerMapping.Id); + + if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && + indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Listenarr", indexer.Name, indexer.Id); + listenarrIndexer.Id = 0; + var newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, Settings); + _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 Listenarr due to indexer capabilities", indexer.Name, indexer.Id); + } + } + } + + private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) + { + var cacheKey = $"{Settings.BaseUrl}"; + var schemas = _schemaCache.Get(cacheKey, () => _listenarrV1Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); + var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime" }; + + var newznab = schemas.First(i => i.Implementation == "Newznab"); + var torznab = schemas.First(i => i.Implementation == "Torznab"); + + var schema = protocol == DownloadProtocol.Usenet ? newznab : torznab; + + var listenarrIndexer = new ListenarrIndexer + { + 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 = new List(), + Tags = new HashSet() + }; + + listenarrIndexer.Fields.AddRange(schema.Fields.Where(x => syncFields.Contains(x.Name))); + + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + + return listenarrIndexer; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs new file mode 100644 index 000000000..ce6fc7564 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs @@ -0,0 +1,17 @@ +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrField + { + public string Name { 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 ListenarrField Clone() + { + return (ListenarrField)MemberwiseClone(); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs new file mode 100644 index 000000000..11b3a2b9f --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrIndexer + { + 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 int? DownloadClientId { get; set; } + public HashSet Tags { get; set; } + public List Fields { get; set; } + + public bool Equals(ListenarrIndexer 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 cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); + + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; + + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; + var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; + var apiPathCompare = apiPath.Equals(otherApiPath); + + var minimumSeeders = Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value); + var otherMinimumSeeders = other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value); + var minimumSeedersCompare = minimumSeeders == otherMinimumSeeders; + + var seedTime = Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value); + var otherSeedTime = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value); + var seedTimeCompare = seedTime == otherSeedTime; + + var seedRatio = Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); + var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); + var seedRatioCompare = seedRatio == otherSeedRatio; + + return other.EnableRss == EnableRss && + other.EnableAutomaticSearch == EnableAutomaticSearch && + other.EnableInteractiveSearch == EnableInteractiveSearch && + other.Name == Name && + other.Implementation == Implementation && + other.Priority == Priority && + other.Id == Id && + apiKeyCompare && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs new file mode 100644 index 000000000..0420a9840 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrSettingsValidator : AbstractValidator + { + public ListenarrSettingsValidator() + { + RuleFor(c => c.BaseUrl).IsValidUrl(); + RuleFor(c => c.ProwlarrUrl).IsValidUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + } + } + + public class ListenarrSettings : IApplicationSettings + { + private static readonly ListenarrSettingsValidator Validator = new(); + + public ListenarrSettings() + { + ProwlarrUrl = "http://localhost:9696"; + BaseUrl = "http://localhost:4545"; + SyncCategories = new[] { 3030 }; + } + + [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Listenarr sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")] + public string ProwlarrUrl { get; set; } + + [FieldDefinition(1, Label = "Listenarr Server", HelpText = "URL used to connect to Listenarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:4545")] + public string BaseUrl { get; set; } + + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Listenarr in Settings/General")] + public string ApiKey { get; set; } + + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] + public IEnumerable SyncCategories { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs new file mode 100644 index 000000000..075f9ce06 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrStatus + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs new file mode 100644 index 000000000..e5d7cf049 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -0,0 +1,225 @@ +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.Listenarr +{ + public interface IListenarrV1Proxy + { + ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings); + List GetIndexers(ListenarrSettings settings); + ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings); + List GetIndexerSchema(ListenarrSettings settings); + void RemoveIndexer(int indexerId, ListenarrSettings settings); + ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings); + ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSettings settings); + } + + public class ListenarrV1Proxy : IListenarrV1Proxy + { + private static Version MinimumApplicationVersion => new(0, 2, 66, 0); + + private const string AppApiRoute = "/api/v1"; + private const string AppIndexerApiRoute = $"{AppApiRoute}/prowlarr/indexer"; + + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public ListenarrV1Proxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public ListenarrStatus GetStatus(ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppApiRoute}/system/status", HttpMethod.Get); + return Execute(request); + } + + public List GetIndexers(ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get); + return Execute>(request); + } + + public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings 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 void RemoveIndexer(int indexerId, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Delete); + _httpClient.Execute(request); + } + + public List GetIndexerSchema(ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/schema", HttpMethod.Get); + return Execute>(request); + } + + public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to add indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } + } + + public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } + } + + public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + + if (applicationVersion == null) + { + return new ValidationFailure(string.Empty, "Failed to fetch Listenarr version"); + } + + if (new Version(applicationVersion) < MinimumApplicationVersion) + { + return new ValidationFailure(string.Empty, $"Listenarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); + } + + return null; + } + + private ListenarrIndexer ExecuteIndexerRequest(HttpRequest request) + { + try + { + return Execute(request); + } + catch (HttpException ex) + { + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + break; + case HttpStatusCode.BadRequest: + if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); + break; + } + + _logger.Error(ex, "Invalid Request"); + break; + case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); + break; + case HttpStatusCode.NotFound: + _logger.Warn(ex, "Remote indexer not found"); + break; + default: + _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); + break; + } + + throw; + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to add or update indexer"); + throw; + } + } + + private HttpRequest BuildRequest(ListenarrSettings 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); + } + } +}