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

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

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

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

View file

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

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