This commit is contained in:
nitrobass24 2026-05-02 20:00:34 +01:00 committed by GitHub
commit 13b1bc656a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 541 additions and 0 deletions

View 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()
};
}
}
}

View 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)
{
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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));
}
}
}