mirror of
https://github.com/Prowlarr/Prowlarr
synced 2026-05-08 12:43:19 +02:00
Merge 184b6ac2f4 into 18fe4ec495
This commit is contained in:
commit
dc9195612b
6 changed files with 625 additions and 0 deletions
265
src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs
Normal file
265
src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs
Normal file
|
|
@ -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<ListenarrSettings>
|
||||
{
|
||||
public override string Name => "Listenarr";
|
||||
|
||||
private readonly IListenarrV1Proxy _listenarrV1Proxy;
|
||||
private readonly ICached<List<ListenarrIndexer>> _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<List<ListenarrIndexer>>(GetType());
|
||||
_listenarrV1Proxy = listenarrV1Proxy;
|
||||
_configFileProvider = configFileProvider;
|
||||
}
|
||||
|
||||
public override ValidationResult Test()
|
||||
{
|
||||
var failures = new List<ValidationFailure>();
|
||||
|
||||
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<AppIndexerMap> GetIndexerMappings()
|
||||
{
|
||||
var indexers = _listenarrV1Proxy.GetIndexers(Settings)
|
||||
.Where(i => i.Implementation is "Newznab" or "Torznab");
|
||||
|
||||
var mappings = new List<AppIndexerMap>();
|
||||
|
||||
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<string> { "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<ListenarrField>(),
|
||||
Tags = new HashSet<int>()
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs
Normal file
17
src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs
Normal file
64
src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs
Normal file
|
|
@ -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<int> Tags { get; set; }
|
||||
public List<ListenarrField> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ListenarrSettings>
|
||||
{
|
||||
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<int> SyncCategories { get; set; }
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NzbDrone.Core.Applications.Listenarr
|
||||
{
|
||||
public class ListenarrStatus
|
||||
{
|
||||
public string Version { get; set; }
|
||||
}
|
||||
}
|
||||
225
src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs
Normal file
225
src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs
Normal file
|
|
@ -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<ListenarrIndexer> GetIndexers(ListenarrSettings settings);
|
||||
ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings);
|
||||
List<ListenarrIndexer> 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<ListenarrStatus>(request);
|
||||
}
|
||||
|
||||
public List<ListenarrIndexer> GetIndexers(ListenarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get);
|
||||
return Execute<List<ListenarrIndexer>>(request);
|
||||
}
|
||||
|
||||
public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get);
|
||||
return Execute<ListenarrIndexer>(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<ListenarrIndexer> GetIndexerSchema(ListenarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/schema", HttpMethod.Get);
|
||||
return Execute<List<ListenarrIndexer>>(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<ListenarrIndexer>(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<TResource>(HttpRequest request)
|
||||
where TResource : new()
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue