From d79aa8c4b88c3549d13b77868d4183e7793e88df Mon Sep 17 00:00:00 2001 From: Michael Goodnow Date: Tue, 2 Sep 2025 00:59:13 -0400 Subject: [PATCH] Add cross-seed application --- .../Applications/CrossSeed/CrossSeed.cs | 217 ++++++++++++++++++ .../CrossSeed/CrossSeedIndexer.cs | 35 +++ .../Applications/CrossSeed/CrossSeedProxy.cs | 144 ++++++++++++ .../CrossSeed/CrossSeedSettings.cs | 47 ++++ .../Applications/CrossSeed/CrossSeedStatus.cs | 13 ++ 5 files changed, 456 insertions(+) create mode 100644 src/NzbDrone.Core/Applications/CrossSeed/CrossSeed.cs create mode 100644 src/NzbDrone.Core/Applications/CrossSeed/CrossSeedIndexer.cs create mode 100644 src/NzbDrone.Core/Applications/CrossSeed/CrossSeedProxy.cs create mode 100644 src/NzbDrone.Core/Applications/CrossSeed/CrossSeedSettings.cs create mode 100644 src/NzbDrone.Core/Applications/CrossSeed/CrossSeedStatus.cs diff --git a/src/NzbDrone.Core/Applications/CrossSeed/CrossSeed.cs b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeed.cs new file mode 100644 index 000000000..6ec842339 --- /dev/null +++ b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeed.cs @@ -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 + { + 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(); + + 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 GetIndexerMappings() + { + var indexers = _crossSeedProxy.GetIndexers(Settings); + + var mappings = new List(); + + 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) + }; + } + } +} diff --git a/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedIndexer.cs b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedIndexer.cs new file mode 100644 index 000000000..c8e4d22f4 --- /dev/null +++ b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedIndexer.cs @@ -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; + } + } +} diff --git a/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedProxy.cs b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedProxy.cs new file mode 100644 index 000000000..4067a7293 --- /dev/null +++ b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedProxy.cs @@ -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 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(request); + } + + public List GetIndexers(CrossSeedSettings settings) + { + var request = BuildRequest(settings, AppApiRoute, HttpMethod.Get); + return Execute>(request); + } + + public CrossSeedIndexer GetIndexer(int id, CrossSeedSettings settings) + { + var request = BuildRequest(settings, $"{AppApiRoute}/{id}", HttpMethod.Get); + return Execute(request); + } + + public CrossSeedIndexer AddIndexer(CrossSeedIndexer indexer, CrossSeedSettings settings) + { + var request = BuildRequest(settings, AppApiRoute, HttpMethod.Post); + request.SetContent(indexer.ToJson()); + return Execute(request); + } + + public CrossSeedIndexer UpdateIndexer(CrossSeedIndexer indexer, CrossSeedSettings settings) + { + var request = BuildRequest(settings, $"{AppApiRoute}/{indexer.Id}", HttpMethod.Put); + request.SetContent(indexer.ToJson()); + return Execute(request); + } + + public void RemoveIndexer(int id, CrossSeedSettings settings) + { + var request = BuildRequest(settings, $"{AppApiRoute}/{id}", HttpMethod.Delete); + Execute(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(HttpRequest request) + where TResource : new() + { + var response = _httpClient.Execute(request); + + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } + + return Json.Deserialize(response.Content); + } + } +} diff --git a/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedSettings.cs b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedSettings.cs new file mode 100644 index 000000000..9fd4ad275 --- /dev/null +++ b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedSettings.cs @@ -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 + { + 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 SyncCategories { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedStatus.cs b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedStatus.cs new file mode 100644 index 000000000..bc02899a0 --- /dev/null +++ b/src/NzbDrone.Core/Applications/CrossSeed/CrossSeedStatus.cs @@ -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; } + } +}