mirror of
https://github.com/Prowlarr/Prowlarr
synced 2026-05-07 04:00:32 +02:00
Merge 72a1b15bc0 into 18fe4ec495
This commit is contained in:
commit
13b1bc656a
5 changed files with 541 additions and 0 deletions
216
src/NzbDrone.Core/Applications/Qui/Qui.cs
Normal file
216
src/NzbDrone.Core/Applications/Qui/Qui.cs
Normal file
|
|
@ -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<QuiSettings>
|
||||
{
|
||||
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<ValidationFailure>();
|
||||
|
||||
failures.AddIfNotNull(_quiProxy.TestConnection(Settings));
|
||||
|
||||
return new ValidationResult(failures);
|
||||
}
|
||||
|
||||
public override List<AppIndexerMap> GetIndexerMappings()
|
||||
{
|
||||
var indexers = _quiProxy.GetIndexers(Settings);
|
||||
|
||||
var mappings = new List<AppIndexerMap>();
|
||||
|
||||
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<string> { "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()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/NzbDrone.Core/Applications/Qui/QuiException.cs
Normal file
23
src/NzbDrone.Core/Applications/Qui/QuiException.cs
Normal file
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/NzbDrone.Core/Applications/Qui/QuiIndexer.cs
Normal file
98
src/NzbDrone.Core/Applications/Qui/QuiIndexer.cs
Normal file
|
|
@ -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<QuiIndexer>
|
||||
{
|
||||
[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<string> Capabilities { get; set; }
|
||||
|
||||
[JsonProperty("categories")]
|
||||
public List<QuiCategory> Categories { get; set; }
|
||||
|
||||
public bool Equals(QuiIndexer other)
|
||||
{
|
||||
if (ReferenceEquals(null, other))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var thisCategories = (Categories ?? Enumerable.Empty<QuiCategory>()).Select(c => c.CategoryId).OrderBy(c => c);
|
||||
var otherCategories = (other.Categories ?? Enumerable.Empty<QuiCategory>()).Select(c => c.CategoryId).OrderBy(c => c);
|
||||
|
||||
var thisCapabilities = (Capabilities ?? Enumerable.Empty<string>()).OrderBy(c => c);
|
||||
var otherCapabilities = (other.Capabilities ?? Enumerable.Empty<string>()).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);
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/NzbDrone.Core/Applications/Qui/QuiProxy.cs
Normal file
156
src/NzbDrone.Core/Applications/Qui/QuiProxy.cs
Normal file
|
|
@ -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<QuiIndexer> 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<QuiIndexer> GetIndexers(QuiSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, AppIndexerApiRoute, HttpMethod.Get);
|
||||
return Execute<List<QuiIndexer>>(request);
|
||||
}
|
||||
|
||||
public QuiIndexer GetIndexer(int indexerId, QuiSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get);
|
||||
return Execute<QuiIndexer>(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<QuiIndexer>(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<QuiIndexer>(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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/NzbDrone.Core/Applications/Qui/QuiSettings.cs
Normal file
48
src/NzbDrone.Core/Applications/Qui/QuiSettings.cs
Normal file
|
|
@ -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<QuiSettings>
|
||||
{
|
||||
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<int> SyncCategories { get; set; }
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue