mirror of
https://github.com/Prowlarr/Prowlarr
synced 2026-05-07 04:00:32 +02:00
Merge d79aa8c4b8 into 18fe4ec495
This commit is contained in:
commit
7b6674f621
5 changed files with 456 additions and 0 deletions
217
src/NzbDrone.Core/Applications/CrossSeed/CrossSeed.cs
Normal file
217
src/NzbDrone.Core/Applications/CrossSeed/CrossSeed.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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.CrossSeed
|
||||
{
|
||||
public class CrossSeed : ApplicationBase<CrossSeedSettings>
|
||||
{
|
||||
public override string Name => "cross-seed";
|
||||
|
||||
private readonly ICrossSeedProxy _crossSeedProxy;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
|
||||
public CrossSeed(ICrossSeedProxy crossSeedProxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
|
||||
: base(appIndexerMapService, indexerFactory, logger)
|
||||
{
|
||||
_crossSeedProxy = crossSeedProxy;
|
||||
_configFileProvider = configFileProvider;
|
||||
}
|
||||
|
||||
public override ValidationResult Test()
|
||||
{
|
||||
var failures = new List<ValidationFailure>();
|
||||
|
||||
try
|
||||
{
|
||||
failures.AddIfNotNull(_crossSeedProxy.TestConnection(Settings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to cross-seed. {ex.Message}"));
|
||||
}
|
||||
|
||||
return new ValidationResult(failures);
|
||||
}
|
||||
|
||||
public override List<AppIndexerMap> GetIndexerMappings()
|
||||
{
|
||||
var indexers = _crossSeedProxy.GetIndexers(Settings);
|
||||
|
||||
var mappings = new List<AppIndexerMap>();
|
||||
|
||||
foreach (var indexer in indexers)
|
||||
{
|
||||
if (indexer.ApiKey == _configFileProvider.ApiKey)
|
||||
{
|
||||
if (TryExtractIndexerIdFromUrl(indexer.Url, out var indexerId))
|
||||
{
|
||||
// Add parsed mapping if it's mapped to an Indexer in this Prowlarr instance
|
||||
mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id, RemoteIndexerName = indexer.Name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
private bool TryExtractIndexerIdFromUrl(string url, out int indexerId)
|
||||
{
|
||||
indexerId = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var pathSegments = uri.AbsolutePath.Trim('/').Split('/');
|
||||
|
||||
// Expected format: /{indexerId}/api
|
||||
if (pathSegments.Length >= 2 && pathSegments[1] == "api")
|
||||
{
|
||||
return int.TryParse(pathSegments[0], out indexerId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Failed to parse indexer URL: {0}", url);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void AddIndexer(IndexerDefinition indexer)
|
||||
{
|
||||
if (indexer.Protocol != DownloadProtocol.Torrent)
|
||||
{
|
||||
_logger.Debug("Skipping non-torrent indexer {0}", indexer.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var crossSeedIndexer = BuildCrossSeedIndexer(indexer);
|
||||
|
||||
try
|
||||
{
|
||||
var addedIndexer = _crossSeedProxy.AddIndexer(crossSeedIndexer, Settings);
|
||||
_logger.Debug("Added indexer {0} in cross-seed with ID {1}", indexer.Name, addedIndexer.Id);
|
||||
|
||||
_appIndexerMapService.Insert(new AppIndexerMap
|
||||
{
|
||||
AppId = Definition.Id,
|
||||
IndexerId = indexer.Id,
|
||||
RemoteIndexerId = addedIndexer.Id,
|
||||
RemoteIndexerName = addedIndexer.Name
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false)
|
||||
{
|
||||
if (indexer.Protocol != DownloadProtocol.Torrent)
|
||||
{
|
||||
_logger.Debug("Skipping non-torrent indexer {0}", indexer.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id);
|
||||
|
||||
var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id);
|
||||
var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id);
|
||||
|
||||
if (indexerMapping == null)
|
||||
{
|
||||
_logger.Debug("No existing mapping found for indexer {0}, adding as new", indexer.Name);
|
||||
AddIndexer(indexer);
|
||||
return;
|
||||
}
|
||||
|
||||
var crossSeedIndexer = BuildCrossSeedIndexer(indexer);
|
||||
crossSeedIndexer.Id = indexerMapping.RemoteIndexerId;
|
||||
|
||||
try
|
||||
{
|
||||
var remoteIndexer = _crossSeedProxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings);
|
||||
if (remoteIndexer != null)
|
||||
{
|
||||
_logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id);
|
||||
|
||||
if (!crossSeedIndexer.Equals(remoteIndexer) || forceSync)
|
||||
{
|
||||
_logger.Debug("Syncing remote indexer with current settings");
|
||||
var updatedIndexer = _crossSeedProxy.UpdateIndexer(crossSeedIndexer, Settings);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Remote indexer not found, re-adding {0} [{1}] to cross-seed", indexer.Name, indexer.Id);
|
||||
_appIndexerMapService.Delete(indexerMapping.Id);
|
||||
AddIndexer(indexer);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Failed to update indexer {0} in cross-seed", indexer.Name);
|
||||
|
||||
// If update fails, the remote indexer might not exist anymore
|
||||
// Delete the mapping and re-add the indexer
|
||||
_logger.Debug("Update failed, removing stale mapping and re-adding indexer {0}", indexer.Name);
|
||||
_appIndexerMapService.Delete(indexerMapping.Id);
|
||||
AddIndexer(indexer);
|
||||
}
|
||||
}
|
||||
|
||||
public override void RemoveIndexer(int indexerId)
|
||||
{
|
||||
var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id);
|
||||
var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId);
|
||||
|
||||
if (indexerMapping != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_crossSeedProxy.RemoveIndexer(indexerMapping.RemoteIndexerId, Settings);
|
||||
_logger.Debug("Removed indexer {0} from cross-seed (remote ID: {1})", indexerId, indexerMapping.RemoteIndexerId);
|
||||
|
||||
_appIndexerMapService.Delete(indexerMapping.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Failed to remove indexer {0} from cross-seed", indexerId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("No mapping found for indexer ID {0}, nothing to remove", indexerId);
|
||||
}
|
||||
}
|
||||
|
||||
private CrossSeedIndexer BuildCrossSeedIndexer(IndexerDefinition indexer)
|
||||
{
|
||||
var prowlarrUrl = Settings.ProwlarrUrl?.TrimEnd('/') ?? _configFileProvider.UrlBase?.TrimEnd('/') ?? "http://localhost:9696";
|
||||
|
||||
return new CrossSeedIndexer
|
||||
{
|
||||
Name = indexer.Name,
|
||||
Url = $"{prowlarrUrl}/{indexer.Id}/api",
|
||||
ApiKey = _configFileProvider.ApiKey,
|
||||
Enabled = indexer.Enable && (indexer.AppProfile?.Value?.EnableRss ?? true)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/NzbDrone.Core/Applications/CrossSeed/CrossSeedIndexer.cs
Normal file
35
src/NzbDrone.Core/Applications/CrossSeed/CrossSeedIndexer.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Applications.CrossSeed
|
||||
{
|
||||
public class CrossSeedIndexer
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("url")]
|
||||
public string Url { get; set; }
|
||||
|
||||
[JsonProperty("apikey")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[JsonProperty("enabled")]
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public bool Equals(CrossSeedIndexer other)
|
||||
{
|
||||
if (ReferenceEquals(null, other))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return other.Name == Name &&
|
||||
other.Url == Url &&
|
||||
other.ApiKey == ApiKey &&
|
||||
other.Enabled == Enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/NzbDrone.Core/Applications/CrossSeed/CrossSeedProxy.cs
Normal file
144
src/NzbDrone.Core/Applications/CrossSeed/CrossSeedProxy.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Core.Applications.CrossSeed
|
||||
{
|
||||
public interface ICrossSeedProxy
|
||||
{
|
||||
CrossSeedStatus GetStatus(CrossSeedSettings settings);
|
||||
List<CrossSeedIndexer> GetIndexers(CrossSeedSettings settings);
|
||||
CrossSeedIndexer GetIndexer(int id, CrossSeedSettings settings);
|
||||
CrossSeedIndexer AddIndexer(CrossSeedIndexer indexer, CrossSeedSettings settings);
|
||||
CrossSeedIndexer UpdateIndexer(CrossSeedIndexer indexer, CrossSeedSettings settings);
|
||||
void RemoveIndexer(int id, CrossSeedSettings settings);
|
||||
ValidationFailure TestConnection(CrossSeedSettings settings);
|
||||
}
|
||||
|
||||
public class CrossSeedProxy : ICrossSeedProxy
|
||||
{
|
||||
private const string AppApiRoute = "/api/indexer/v1";
|
||||
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public CrossSeedProxy(IHttpClient httpClient, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public CrossSeedStatus GetStatus(CrossSeedSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppApiRoute}/status", HttpMethod.Get);
|
||||
return Execute<CrossSeedStatus>(request);
|
||||
}
|
||||
|
||||
public List<CrossSeedIndexer> GetIndexers(CrossSeedSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, AppApiRoute, HttpMethod.Get);
|
||||
return Execute<List<CrossSeedIndexer>>(request);
|
||||
}
|
||||
|
||||
public CrossSeedIndexer GetIndexer(int id, CrossSeedSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppApiRoute}/{id}", HttpMethod.Get);
|
||||
return Execute<CrossSeedIndexer>(request);
|
||||
}
|
||||
|
||||
public CrossSeedIndexer AddIndexer(CrossSeedIndexer indexer, CrossSeedSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, AppApiRoute, HttpMethod.Post);
|
||||
request.SetContent(indexer.ToJson());
|
||||
return Execute<CrossSeedIndexer>(request);
|
||||
}
|
||||
|
||||
public CrossSeedIndexer UpdateIndexer(CrossSeedIndexer indexer, CrossSeedSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppApiRoute}/{indexer.Id}", HttpMethod.Put);
|
||||
request.SetContent(indexer.ToJson());
|
||||
return Execute<CrossSeedIndexer>(request);
|
||||
}
|
||||
|
||||
public void RemoveIndexer(int id, CrossSeedSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppApiRoute}/{id}", HttpMethod.Delete);
|
||||
Execute<object>(request);
|
||||
}
|
||||
|
||||
|
||||
public ValidationFailure TestConnection(CrossSeedSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Step 1: Test basic connectivity and API key
|
||||
var status = GetStatus(settings);
|
||||
_logger.Debug("Successfully connected to cross-seed. Version: {0}", status.Version);
|
||||
|
||||
// Step 2: Test indexer list access (verifies permissions)
|
||||
GetIndexers(settings);
|
||||
_logger.Debug("Successfully accessed cross-seed indexer list");
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
switch (ex.Response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
_logger.Warn(ex, "API Key is invalid");
|
||||
return new ValidationFailure("ApiKey", "API Key is invalid");
|
||||
case HttpStatusCode.BadRequest:
|
||||
_logger.Warn(ex, "Prowlarr URL is invalid");
|
||||
return new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, cross-seed cannot connect to Prowlarr");
|
||||
case HttpStatusCode.NotFound:
|
||||
_logger.Warn(ex, "cross-seed indexer management API not found - make sure cross-seed supports Prowlarr integration");
|
||||
return new ValidationFailure("BaseUrl", "cross-seed indexer management API not found. Please ensure you're running cross-seed v7+ that supports Prowlarr integration.");
|
||||
default:
|
||||
_logger.Warn(ex, "Unable to complete application test");
|
||||
return new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to cross-seed. {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "Unable to complete application test");
|
||||
return new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to cross-seed. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequest BuildRequest(CrossSeedSettings 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.CrossSeed
|
||||
{
|
||||
public class CrossSeedSettingsValidator : AbstractValidator<CrossSeedSettings>
|
||||
{
|
||||
public CrossSeedSettingsValidator()
|
||||
{
|
||||
RuleFor(c => c.BaseUrl).IsValidUrl();
|
||||
RuleFor(c => c.ProwlarrUrl).IsValidUrl();
|
||||
RuleFor(c => c.ApiKey).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class CrossSeedSettings : IApplicationSettings
|
||||
{
|
||||
private static readonly CrossSeedSettingsValidator Validator = new();
|
||||
|
||||
public CrossSeedSettings()
|
||||
{
|
||||
ProwlarrUrl = "http://localhost:9696";
|
||||
BaseUrl = "http://localhost:2468";
|
||||
SyncCategories = new[] { 2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090 };
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as cross-seed sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")]
|
||||
public string ProwlarrUrl { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "cross-seed Server", HelpText = "URL used to connect to cross-seed server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:2468")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The API key configured in cross-seed")]
|
||||
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 (cross-seed only supports torrents)", Advanced = true)]
|
||||
public IEnumerable<int> SyncCategories { get; set; }
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/NzbDrone.Core/Applications/CrossSeed/CrossSeedStatus.cs
Normal file
13
src/NzbDrone.Core/Applications/CrossSeed/CrossSeedStatus.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Applications.CrossSeed
|
||||
{
|
||||
public class CrossSeedStatus
|
||||
{
|
||||
[JsonProperty("version")]
|
||||
public string Version { get; set; }
|
||||
|
||||
[JsonProperty("appName")]
|
||||
public string AppName { get; set; }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue