From e08505f931f98a6578bf252120da11fc9f09953d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 11 Jan 2026 22:35:42 -0500 Subject: [PATCH 01/16] Add Listenarr application integration Introduces Listenarr support with backend models, settings, proxy, and core logic for indexer synchronization. Adds frontend component for Listenarr settings modal and comprehensive unit tests for Listenarr integration and proxy behavior. --- .../Applications/Listenarr/Listenarr.tsx | 20 ++ .../Listenarr/ListenarrFixture.cs | 154 ++++++++++++++ .../Listenarr/ListenarrV1ProxyFixture.cs | 60 ++++++ .../Applications/Listenarr/Listenarr.cs | 197 ++++++++++++++++++ .../Applications/Listenarr/ListenarrModels.cs | 37 ++++ .../Listenarr/ListenarrSettings.cs | 52 +++++ .../Listenarr/ListenarrV1Proxy.cs | 116 +++++++++++ 7 files changed, 636 insertions(+) create mode 100644 frontend/src/Settings/Applications/Listenarr/Listenarr.tsx create mode 100644 src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs create mode 100644 src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs create mode 100644 src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs create mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs create mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs create mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs diff --git a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx new file mode 100644 index 000000000..21f2294b7 --- /dev/null +++ b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Application } from 'Settings/Applications/Application'; +import ProviderSettingsModal from 'Settings/Applications/ProviderSettingsModal'; + +interface ListenarrProps { + selectedApplication: Application; + onModalClose: () => void; +} + +function Listenarr({ selectedApplication, onModalClose }: ListenarrProps) { + return ( + + ); +} + +export default Listenarr; diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs new file mode 100644 index 000000000..8cb541f93 --- /dev/null +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Applications; +using NzbDrone.Core.Applications.Listenarr; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Profiles; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Applications.Listenarr +{ + [TestFixture] + public class ListenarrFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new ApplicationDefinition + { + Settings = new ListenarrSettings + { + ProwlarrUrl = "http://localhost:9696", + BaseUrl = "http://localhost:5000", + ApiKey = "abc", + SyncCategories = new List { NewznabStandardCategory.Movies.Id } + } + }; + + Mocker.GetMock().SetupGet(c => c.ApiKey).Returns("abc"); + } + + [Test] + public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowlarr() + { + // Arrange + var indexer = new ListenarrIndexer + { + Id = 99, + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "http://localhost:9696/45/api" }, + new ListenarrField { Name = "apiKey", Value = "abc" } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { indexer }); + + // Act + var mappings = Subject.GetIndexerMappings(); + + // Assert + mappings.Should().HaveCount(1); + mappings[0].IndexerId.Should().Be(45); + mappings[0].RemoteIndexerId.Should().Be(99); + } + + [Test] + public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl() + { + // Arrange + var indexer = new ListenarrIndexer + { + Id = 100, + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "http://external/1/api" }, + new ListenarrField { Name = "apiKey", Value = "wrong" } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { indexer }); + + // Act + var mappings = Subject.GetIndexerMappings(); + + // Assert + mappings.Should().BeEmpty(); + } + + [Test] + public void Test_should_fail_when_status_null() + { + // Arrange + Mocker.GetMock().Setup(c => c.GetStatus(It.IsAny())).Returns((ListenarrStatus)null); + + // Act + var result = Subject.Test(); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to connect to Listenarr")); + } + + [Test] + public void Test_should_fail_on_exception() + { + // Arrange + Mocker.GetMock().Setup(c => c.GetStatus(It.IsAny())).Throws(new Exception("boom")); + + // Act + var result = Subject.Test(); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to send test message")); + + // expected error was logged + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void AddIndexer_should_insert_app_indexer_mapping_on_success() + { + // Arrange + var indexerDefinition = new IndexerDefinition + { + Id = 12, + Name = "TestIndexer", + Protocol = DownloadProtocol.Usenet, + Capabilities = new IndexerCapabilities(), + Enable = true, + AppProfile = new LazyLoaded(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true }) + }; + + // Add a category that matches Settings.SyncCategories + indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.Movies); + + var mockIndexer = new Mock(); + mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities); + + Mocker.GetMock().Setup(m => m.GetInstance(It.IsAny())).Returns(mockIndexer.Object); + + Mocker.GetMock().Setup(c => c.AddIndexer(It.IsAny(), It.IsAny())).Returns(new ListenarrIndexer { Id = 501 }); + + // pre-check + indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); + + // Act + Subject.AddIndexer(indexerDefinition); + + // Assert + Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs new file mode 100644 index 000000000..10ce96084 --- /dev/null +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs @@ -0,0 +1,60 @@ +using System; +using System.Net; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Applications.Listenarr; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Applications.Listenarr +{ + [TestFixture] + public class ListenarrV1ProxyFixture : TestBase + { + [Test] + public void GetIndexers_should_deserialize_json_and_set_api_key_header() + { + // Arrange + var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "abc123" }; + + var json = "[ { \"id\": 42, \"name\": \"Test\", \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"value\": \"http://localhost:5000/1/api\" }, { \"name\": \"apiKey\", \"value\": \"x\" } ] } ]"; + + HttpRequest capturedRequest = null; + + Mocker.GetMock() + .Setup(c => c.Execute(It.IsAny())) + .Returns(req => + { + capturedRequest = req; + return new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0")); + }); + + // Act + var result = Subject.GetIndexers(settings); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(1); + capturedRequest.Headers.GetSingleValue("X-Api-Key").Should().Be("abc123"); + capturedRequest.Url.ToString().Should().Contain("/api/indexer"); + } + + [Test] + public void Execute_should_throw_application_exception_when_unauthorized() + { + // Arrange + var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "bad" }; + + Mocker.GetMock() + .Setup(c => c.Execute(It.IsAny())) + .Throws(new HttpException(new HttpResponse(new HttpRequest("http://localhost/"), new HttpHeader(), new CookieCollection(), "unauthorized", 0, HttpStatusCode.Unauthorized, new Version("1.0")))); + + // Act / Assert + Assert.Throws(() => Subject.GetIndexers(settings)); + + // expected error was logged + ExceptionVerification.ExpectedErrors(1); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs new file mode 100644 index 000000000..06f71b1f6 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -0,0 +1,197 @@ +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; +using NzbDrone.Core.Tags; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class Listenarr : ApplicationBase + { + public override string Name => "Listenarr"; + + private readonly IListenarrV1Proxy _listenarrV1Proxy; + private readonly IConfigFileProvider _configFileProvider; + private readonly Lazy _tagService; + + public Listenarr( + IListenarrV1Proxy listenarrV1Proxy, + IAppIndexerMapService appIndexerMapService, + IIndexerFactory indexerFactory, + IConfigFileProvider configFileProvider, + Lazy tagService, + Logger logger) + : base(appIndexerMapService, indexerFactory, logger) + { + _listenarrV1Proxy = listenarrV1Proxy; + _configFileProvider = configFileProvider; + _tagService = tagService; + } + + public override ValidationResult Test() + { + var failures = new List(); + + try + { + var status = _listenarrV1Proxy.GetStatus(Settings); + + if (status == null) + { + failures.Add(new ValidationFailure(string.Empty, "Unable to connect to Listenarr")); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + failures.Add(new ValidationFailure(string.Empty, "Unable to send test message")); + } + + return new ValidationResult(failures); + } + + public override List GetIndexerMappings() + { + var indexers = _listenarrV1Proxy.GetIndexers(Settings)?.Where(i => i.Implementation is "Newznab" or "Torznab"); + var mappings = new List(); + + foreach (var indexer in indexers ?? Enumerable.Empty()) + { + var baseUrl = indexer.Fields?.FirstOrDefault(x => x.Name == "baseUrl")?.Value?.ToString() ?? indexer.BaseUrl ?? string.Empty; + + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields?.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + mappings.Add(new AppIndexerMap + { + IndexerId = indexerId, + RemoteIndexerId = indexer.Id + }); + } + } + + return mappings; + } + + public override void AddIndexer(IndexerDefinition indexer) + { + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (!indexerCapabilities.SearchAvailable) + { + _logger.Debug("Skipping add for indexer {0} [{1}] due to missing search support by the indexer", indexer.Name, indexer.Id); + return; + } + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + { + _logger.Debug("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 listenarrIndexer = BuildListenarrIndexer(indexer, Settings); + + var remoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, 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 mapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId); + + if (mapping != null) + { + _listenarrV1Proxy.RemoveIndexer(mapping.RemoteIndexerId, Settings); + _appIndexerMapService.Delete(mapping.Id); + } + } + + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) + { + _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + + var indexerCapabilities = GetIndexerCapabilities(indexer); + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + var mapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); + + if (mapping != null) + { + var listenarrIndexer = BuildListenarrIndexer(indexer, Settings, mapping.RemoteIndexerId); + _listenarrV1Proxy.UpdateIndexer(listenarrIndexer, Settings); + } + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "getTags") + { + var tags = _tagService.Value.All().Select(t => new { Value = t.Id, Name = t.Label }); + return new { options = tags }; + } + + return base.RequestAction(action, query); + } + + private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, ListenarrSettings settings, int remoteId = 0) + { + var listenarrIndexer = new ListenarrIndexer + { + Id = remoteId, + Name = $"{indexer.Name} (Prowlarr)", + EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch, + Priority = indexer.Priority, + ConfigContract = "NewznabSettings", + Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", + Protocol = indexer.Protocol == DownloadProtocol.Usenet ? "usenet" : "torrent", + Fields = new List + { + new ListenarrField + { + Name = "baseUrl", + Value = $"{settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api" + }, + new ListenarrField + { + Name = "apiKey", + Value = _configFileProvider.ApiKey + }, + new ListenarrField + { + Name = "categories", + Value = Array.Empty() + } + } + }; + + return listenarrIndexer; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs new file mode 100644 index 000000000..ae70fa9c3 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrStatus + { + public string Version { get; set; } + } + + public class ListenarrIndexer + { + public int Id { get; set; } + public string Name { get; set; } + public bool EnableRss { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } + public int Priority { get; set; } + public string ConfigContract { get; set; } + public string Implementation { get; set; } + public string Protocol { get; set; } + public string BaseUrl { get; set; } + public List Fields { get; set; } + public List Tags { get; set; } + } + + public class ListenarrField + { + public string Name { get; set; } + public object Value { get; set; } + } + + public class ListenarrTag + { + public int Id { get; set; } + public string Label { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs new file mode 100644 index 000000000..201a73c50 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrSettingsValidator : AbstractValidator + { + public ListenarrSettingsValidator() + { + RuleFor(c => c.BaseUrl).IsValidUrl(); + RuleFor(c => c.ProwlarrUrl).IsValidUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + } + } + + public class ListenarrSettings : IApplicationSettings + { + private static readonly ListenarrSettingsValidator Validator = new ListenarrSettingsValidator(); + + public ListenarrSettings() + { + ProwlarrUrl = "http://localhost:9696"; + BaseUrl = "http://localhost:5000"; + SyncLevel = (int)ApplicationSyncLevel.FullSync; // default reasonable behavior + } + + [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "URL of Prowlarr server as Listenarr sees it, including http:// or https://, and port if needed")] + public string ProwlarrUrl { get; set; } + + [FieldDefinition(1, Label = "Listenarr Server", HelpText = "URL of Listenarr server, including http:// or https://, and port if needed")] + public string BaseUrl { get; set; } + + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "API Key for Listenarr")] + public string ApiKey { get; set; } + + [FieldDefinition(3, Type = FieldType.Select, SelectOptions = typeof(ApplicationSyncLevel), Label = "Sync Level", HelpText = "How should Prowlarr sync indexers to Listenarr")] + public int SyncLevel { get; set; } + + [FieldDefinition(4, Label = "Sync Categories", Advanced = true, HelpText = "Sync audiobook categories to Listenarr (must match Listenarr's category schema)")] + public IEnumerable SyncCategories { get; set; } + + [FieldDefinition(5, Type = FieldType.TagSelect, SelectOptionsProviderAction = "getTags", Label = "Tags", HelpText = "Only add indexers with these tags to Listenarr")] + public IEnumerable Tags { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs new file mode 100644 index 000000000..101fa1bac --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public interface IListenarrV1Proxy + { + ListenarrStatus GetStatus(ListenarrSettings settings); + List GetIndexers(ListenarrSettings settings); + ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings); + void UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings); + void RemoveIndexer(int indexerId, ListenarrSettings settings); + List GetTags(ListenarrSettings settings); + } + + public class ListenarrV1Proxy : IListenarrV1Proxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public ListenarrV1Proxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public ListenarrStatus GetStatus(ListenarrSettings settings) + { + var request = BuildRequest(settings, "/api/system/status", HttpMethod.Get); + return Execute(request); + } + + public List GetIndexers(ListenarrSettings settings) + { + var request = BuildRequest(settings, "/api/indexer", HttpMethod.Get); + return Execute>(request); + } + + public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, "/api/indexer", HttpMethod.Post); + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + return Execute(request); + } + + public void UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"/api/indexer/{indexer.Id}", HttpMethod.Put); + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + Execute(request); + } + + public void RemoveIndexer(int indexerId, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"/api/indexer/{indexerId}", HttpMethod.Delete); + _httpClient.Execute(request); + } + + public List GetTags(ListenarrSettings settings) + { + var request = BuildRequest(settings, "/api/tag", HttpMethod.Get); + return Execute>(request); + } + + private HttpRequest BuildRequest(ListenarrSettings 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 T Execute(HttpRequest request) + where T : new() + { + try + { + var response = _httpClient.Execute(request); + + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } + + return Json.Deserialize(response.Content); + } + catch (HttpException ex) + { + if (ex.Response != null && ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, "API Key is invalid"); + throw new ApplicationException("API Key is invalid"); + } + + throw; + } + } + } +} From b18473a9b0e4e3d9a6133e63b7a89265763deefc Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Tue, 13 Jan 2026 14:06:32 -0500 Subject: [PATCH 02/16] Enhance Listenarr integration and schema handling Improves Listenarr application integration by adding robust schema fetching, normalization, and validation logic. Updates Listenarr proxy and models to handle flexible schema responses, supports additional test and error scenarios, and expands test coverage for edge cases. Refactors frontend Listenarr application imports and adds a new ProviderSettingsModal component. --- .../Form/TagSelectInputConnector.js | 3 + .../Applications/Listenarr/Listenarr.tsx | 2 +- .../Applications/ProviderSettingsModal.tsx | 24 + .../Listenarr/ListenarrFixture.cs | 199 ++++++- .../Listenarr/ListenarrV1ProxyFixture.cs | 57 +- .../Applications/Listenarr/Listenarr.cs | 507 +++++++++++++++--- .../Applications/Listenarr/ListenarrModels.cs | 20 +- .../Listenarr/ListenarrSettings.cs | 20 +- .../Listenarr/ListenarrV1Proxy.cs | 457 ++++++++++++++-- 9 files changed, 1136 insertions(+), 153 deletions(-) create mode 100644 frontend/src/Settings/Applications/ProviderSettingsModal.tsx diff --git a/frontend/src/Components/Form/TagSelectInputConnector.js b/frontend/src/Components/Form/TagSelectInputConnector.js index 23afe6da1..375c40151 100644 --- a/frontend/src/Components/Form/TagSelectInputConnector.js +++ b/frontend/src/Components/Form/TagSelectInputConnector.js @@ -10,6 +10,9 @@ function createMapStateToProps() { (state, { value }) => value, (state, { values }) => values, (tags, tagList) => { + tags = tags || []; + tagList = tagList || []; + const sortedTags = _.sortBy(tagList, 'value'); return { diff --git a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx index 21f2294b7..72866b1ad 100644 --- a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx +++ b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Application } from 'Settings/Applications/Application'; import ProviderSettingsModal from 'Settings/Applications/ProviderSettingsModal'; +import Application from 'typings/Application'; interface ListenarrProps { selectedApplication: Application; diff --git a/frontend/src/Settings/Applications/ProviderSettingsModal.tsx b/frontend/src/Settings/Applications/ProviderSettingsModal.tsx new file mode 100644 index 000000000..0a028a8b2 --- /dev/null +++ b/frontend/src/Settings/Applications/ProviderSettingsModal.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import Application from 'typings/Application'; +import EditApplicationModalConnector from './Applications/EditApplicationModalConnector'; + +interface ProviderSettingsModalProps { + providerData: Application; + section?: string; + onModalClose: () => void; +} + +function ProviderSettingsModal({ + providerData, + onModalClose, +}: ProviderSettingsModalProps) { + return ( + + ); +} + +export default ProviderSettingsModal; diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs index 8cb541f93..dade03d40 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Common.Cache; using NzbDrone.Core.Applications; using NzbDrone.Core.Applications.Listenarr; using NzbDrone.Core.Configuration; @@ -33,6 +34,14 @@ public void Setup() }; Mocker.GetMock().SetupGet(c => c.ApiKey).Returns("abc"); + + // Ensure cache calls execute factory each time during tests to avoid stale cached values + // Use ICached> mock via dynamic mocking to avoid depending on concrete implementation + var cached = new Mock>>(); + cached.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + + Mocker.GetMock().Setup(m => m.GetCache>(It.IsAny())).Returns(cached.Object); } [Test] @@ -86,34 +95,184 @@ public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl() } [Test] - public void Test_should_fail_when_status_null() + public void Test_should_call_testconnection_and_return_success_when_valid() { // Arrange - Mocker.GetMock().Setup(c => c.GetStatus(It.IsAny())).Returns((ListenarrStatus)null); + var schema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "" }, + new ListenarrField { Name = "apiPath", Value = "" }, + new ListenarrField { Name = "apiKey", Value = "" }, + new ListenarrField { Name = "categories", Value = new List() } + } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); + Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Returns((FluentValidation.Results.ValidationFailure)null).Verifiable(); + + // Ensure the private schema cache will execute the factory so it invokes our mocked proxy + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); // Act var result = Subject.Test(); // Assert - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to connect to Listenarr")); + result.IsValid.Should().BeTrue(); + Mocker.GetMock().Verify(m => m.TestConnection(It.IsAny(), It.IsAny()), Times.Once); } [Test] - public void Test_should_fail_on_exception() + public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incomplete() { // Arrange - Mocker.GetMock().Setup(c => c.GetStatus(It.IsAny())).Throws(new Exception("boom")); + var cachedSchema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "apiKey", Value = "" } + } + } + }; + + var freshSchema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "" }, + new ListenarrField { Name = "apiPath", Value = "" }, + new ListenarrField { Name = "apiKey", Value = "" }, + new ListenarrField { Name = "categories", Value = new List() } + } + } + }; + + // Cache returns stale schema + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => cachedSchema); + + typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + + // Proxy will return fresh schema when direct fetched + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(freshSchema); + Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Returns((FluentValidation.Results.ValidationFailure)null); + + // Act + var result = Subject.Test(); + + // Assert + result.IsValid.Should().BeTrue(); + Mocker.GetMock().Verify(m => m.GetIndexerSchema(It.IsAny()), Times.AtLeastOnce); + } + + [Test] + public void Test_should_handle_exception_from_testconnection() + { + // Arrange + var schema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "" }, + new ListenarrField { Name = "apiPath", Value = "" }, + new ListenarrField { Name = "apiKey", Value = "" }, + new ListenarrField { Name = "categories", Value = new List() } + } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); + Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Throws(new Exception("boom")); + + // Ensure the private schema cache will execute the factory so it invokes our mocked proxy + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); // Act var result = Subject.Test(); // Assert result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to send test message")); + result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to complete application test")); + ExceptionVerification.ExpectedWarns(1); + } - // expected error was logged - ExceptionVerification.ExpectedErrors(1); + [Test] + public void Test_should_fail_when_schema_missing() + { + // Arrange + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(new List()); + + // Act & Assert + try + { + var result = Subject.Test(); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("indexer schema")); + } + finally + { + // Consume expected warnings even if Subject.Test throws or an assert fails so teardown does not fail + ExceptionVerification.IgnoreWarns(); + } + } + + [Test] + public void Test_should_fail_when_schema_missing_required_fields() + { + // Arrange + var schema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "apiKey", Value = "" } + } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); + + // Ensure the private schema cache will execute the factory so it invokes our mocked proxy + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + + // Act & Assert + try + { + var result = Subject.Test(); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("missing required fields")); + } + finally + { + // Consume expected warnings even if Subject.Test throws or an assert fails so teardown does not fail + ExceptionVerification.IgnoreWarns(); + } } [Test] @@ -138,8 +297,30 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success() Mocker.GetMock().Setup(m => m.GetInstance(It.IsAny())).Returns(mockIndexer.Object); + var schema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "" }, + new ListenarrField { Name = "apiPath", Value = "" }, + new ListenarrField { Name = "apiKey", Value = "" }, + new ListenarrField { Name = "categories", Value = new List() } + } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); Mocker.GetMock().Setup(c => c.AddIndexer(It.IsAny(), It.IsAny())).Returns(new ListenarrIndexer { Id = 501 }); + // Ensure the private schema cache will execute the factory so it invokes our mocked proxy + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + // pre-check indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs index 10ce96084..4a9014c7b 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs @@ -37,7 +37,56 @@ public void GetIndexers_should_deserialize_json_and_set_api_key_header() result.Should().NotBeNull(); result.Count.Should().Be(1); capturedRequest.Headers.GetSingleValue("X-Api-Key").Should().Be("abc123"); - capturedRequest.Url.ToString().Should().Contain("/api/indexer"); + + // Accept either singular /api/v1/indexer or legacy /api/indexer in requests + (capturedRequest.Url.ToString().Contains("/api/indexer") || capturedRequest.Url.ToString().Contains("/api/v1/indexer")).Should().BeTrue(); + } + + [Test] + public void GetIndexerSchema_should_handle_single_object_response_with_fields_object() + { + // Arrange + var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "abc123" }; + + // Schema returned as an object with fields as an object (name -> definition) + var json = "{ \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } }"; + + Mocker.GetMock() + .Setup(c => c.Execute(It.IsAny())) + .Returns(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"))); + + // Act + var result = Subject.GetIndexerSchema(settings); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(1); + result[0].Fields.Should().NotBeNull(); + result[0].Fields.Count.Should().Be(2); + result[0].Fields.Should().Contain(f => f.Name == "baseUrl"); + result[0].Fields.Should().Contain(f => f.Name == "apiKey"); + } + + [Test] + public void GetIndexerSchema_should_expand_implementations_array_into_multiple_schemas() + { + // Arrange + var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "abc123" }; + + var json = "{ \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] }"; + + Mocker.GetMock() + .Setup(c => c.Execute(It.IsAny())) + .Returns(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"))); + + // Act + var result = Subject.GetIndexerSchema(settings); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(2); + result.Should().Contain(r => r.Implementation == "Newznab"); + result.Should().Contain(r => r.Implementation == "Torznab"); } [Test] @@ -51,10 +100,10 @@ public void Execute_should_throw_application_exception_when_unauthorized() .Throws(new HttpException(new HttpResponse(new HttpRequest("http://localhost/"), new HttpHeader(), new CookieCollection(), "unauthorized", 0, HttpStatusCode.Unauthorized, new Version("1.0")))); // Act / Assert - Assert.Throws(() => Subject.GetIndexers(settings)); + Assert.Throws(() => Subject.GetIndexers(settings)); - // expected error was logged - ExceptionVerification.ExpectedErrors(1); + // No warning is logged by GetIndexers on unauthorized (it throws before the API-key-specific log path) + ExceptionVerification.ExpectedWarns(0); } } } diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index 06f71b1f6..485e31175 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using FluentValidation.Results; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Tags; namespace NzbDrone.Core.Applications.Listenarr { @@ -15,40 +19,73 @@ public class Listenarr : ApplicationBase public override string Name => "Listenarr"; private readonly IListenarrV1Proxy _listenarrV1Proxy; + private readonly ICached> _schemaCache; private readonly IConfigFileProvider _configFileProvider; - private readonly Lazy _tagService; - public Listenarr( - IListenarrV1Proxy listenarrV1Proxy, - IAppIndexerMapService appIndexerMapService, - IIndexerFactory indexerFactory, - IConfigFileProvider configFileProvider, - Lazy tagService, - Logger logger) + public Listenarr(ICacheManager cacheManager, IListenarrV1Proxy listenarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) : base(appIndexerMapService, indexerFactory, logger) { + _schemaCache = cacheManager.GetCache>(GetType()); _listenarrV1Proxy = listenarrV1Proxy; _configFileProvider = configFileProvider; - _tagService = tagService; } public override ValidationResult Test() { var failures = new List(); + var testIndexer = new IndexerDefinition + { + Id = 0, + Name = "Test", + Protocol = DownloadProtocol.Usenet, + Capabilities = new IndexerCapabilities() + }; + + foreach (var cat in NewznabStandardCategory.AllCats) + { + testIndexer.Capabilities.Categories.AddCategoryMapping(1, cat); + } + + // Call the application's TestConnection directly, consistent with other application implementations. + // Some applications do not expose a reliable system status endpoint, so we rely on the indexer test itself + // to provide actionable feedback (auth, version, connectivity etc.). try { - var status = _listenarrV1Proxy.GetStatus(Settings); - - if (status == null) + failures.AddIfNotNull(_listenarrV1Proxy.TestConnection(BuildListenarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); + } + catch (HttpException ex) + { + switch (ex.Response.StatusCode) { - failures.Add(new ValidationFailure(string.Empty, "Unable to connect to Listenarr")); + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Listenarr cannot connect to Prowlarr")); + break; + case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "Listenarr returned redirect and is invalid"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Listenarr URL is invalid, Prowlarr cannot connect to Listenarr - are you missing a URL base?")); + break; + default: + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Listenarr. {ex.Message}")); + break; } } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); + } catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); - failures.Add(new ValidationFailure(string.Empty, "Unable to send test message")); + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Listenarr. {ex.Message}")); } return new ValidationResult(failures); @@ -56,12 +93,14 @@ public override ValidationResult Test() public override List GetIndexerMappings() { - var indexers = _listenarrV1Proxy.GetIndexers(Settings)?.Where(i => i.Implementation is "Newznab" or "Torznab"); + var indexers = (_listenarrV1Proxy.GetIndexers(Settings) ?? new List()) + .Where(i => i.Implementation is "Newznab" or "Torznab"); + var mappings = new List(); - foreach (var indexer in indexers ?? Enumerable.Empty()) + foreach (var indexer in indexers) { - var baseUrl = indexer.Fields?.FirstOrDefault(x => x.Name == "baseUrl")?.Value?.ToString() ?? indexer.BaseUrl ?? string.Empty; + var baseUrl = (string)indexer.Fields?.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && (string)indexer.Fields?.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) @@ -73,11 +112,8 @@ public override List GetIndexerMappings() if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) { - mappings.Add(new AppIndexerMap - { - IndexerId = indexerId, - RemoteIndexerId = indexer.Id - }); + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -88,47 +124,47 @@ public override void AddIndexer(IndexerDefinition indexer) { var indexerCapabilities = GetIndexerCapabilities(indexer); - if (!indexerCapabilities.SearchAvailable) + if (!indexerCapabilities.MusicSearchAvailable && !indexerCapabilities.SearchAvailable) { - _logger.Debug("Skipping add for indexer {0} [{1}] due to missing search support by the indexer", indexer.Name, indexer.Id); + _logger.Debug("Skipping add for indexer {0} [{1}] due to missing music or basic search support by the indexer", indexer.Name, indexer.Id); + return; } if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Debug("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 listenarrIndexer = BuildListenarrIndexer(indexer, Settings); + var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, 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 - }); + _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 mapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId); - if (mapping != null) + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId); + + if (indexerMapping != null) { - _listenarrV1Proxy.RemoveIndexer(mapping.RemoteIndexerId, Settings); - _appIndexerMapService.Delete(mapping.Id); + //Remove Indexer remotely and then remove the mapping + _listenarrV1Proxy.RemoveIndexer(indexerMapping.RemoteIndexerId, Settings); + _appIndexerMapService.Delete(indexerMapping.Id); } } @@ -138,59 +174,370 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); - var mapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - if (mapping != null) + // If there is no mapping, treat this as an add instead of an update + if (indexerMapping == null) { - var listenarrIndexer = BuildListenarrIndexer(indexer, Settings, mapping.RemoteIndexerId); - _listenarrV1Proxy.UpdateIndexer(listenarrIndexer, Settings); - } - } - - public override object RequestAction(string action, IDictionary query) - { - if (action == "getTags") - { - var tags = _tagService.Value.All().Select(t => new { Value = t.Id, Name = t.Label }); - return new { options = tags }; - } - - return base.RequestAction(action, query); - } - - private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, ListenarrSettings settings, int remoteId = 0) - { - var listenarrIndexer = new ListenarrIndexer - { - Id = remoteId, - Name = $"{indexer.Name} (Prowlarr)", - EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss, - EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch, - EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch, - Priority = indexer.Priority, - ConfigContract = "NewznabSettings", - Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", - Protocol = indexer.Protocol == DownloadProtocol.Usenet ? "usenet" : "torrent", - Fields = new List + if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && + indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { - new ListenarrField + _logger.Debug("No mapping found for {0} [{1}], adding to Listenarr", indexer.Name, indexer.Id); + + var listenarrIndexerToAdd = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); + listenarrIndexerToAdd.Id = 0; + ListenarrIndexer newRemoteIndexer = null; + + try { - Name = "baseUrl", - Value = $"{settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api" - }, - new ListenarrField + newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexerToAdd, Settings); + } + catch (HttpException ex) { - Name = "apiKey", - Value = _configFileProvider.ApiKey - }, - new ListenarrField + _logger.Warn(ex, "Failed to add indexer {0} [{1}] to Listenarr: {2}", indexer.Name, indexer.Id, ex.Response?.StatusCode); + } + + if (newRemoteIndexer != null) { - Name = "categories", - Value = Array.Empty() + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); + } + else + { + // If add returned null or failed, try to discover existing remote indexer by baseUrl or name + try + { + var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); + var baseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + + var match = remoteIndexers.FirstOrDefault(r => + ((string)r.Fields.FirstOrDefault(f => f.Name == "baseUrl")?.Value ?? "").Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase) + || (r.Name?.Contains(indexer.Name, StringComparison.InvariantCultureIgnoreCase) == true)); + + if (match != null) + { + _logger.Debug("Found existing remote indexer for {0} as {1} [{2}], inserting mapping", indexer.Name, match.Name, match.Id); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = match.Id }); + } + else + { + _logger.Debug("No remote indexer found for {0} after failing to add; skipping mapping", indexer.Name); + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Error while attempting to discover existing remote indexer for {0} [{1}]", indexer.Name, indexer.Id); + } } } + else + { + _logger.Debug("No mapping found for {0} [{1}], skipping add due to indexer capabilities", indexer.Name, indexer.Id); + } + + return; + } + + // If mapping exists but contains an invalid remote id (0), remove and re-add if possible + if (indexerMapping.RemoteIndexerId == 0) + { + _logger.Warn("Mapping for indexer {0} contains invalid remote id 0, removing mapping and re-adding if possible", indexer.Id); + _appIndexerMapService.Delete(indexerMapping.Id); + + if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && + indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + var listenarrIndexerToAdd = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); + listenarrIndexerToAdd.Id = 0; + ListenarrIndexer newRemoteIndexer = null; + + try + { + newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexerToAdd, Settings); + } + catch (HttpException ex) + { + _logger.Warn(ex, "Failed to add indexer {0} [{1}] to Listenarr: {2}", indexer.Name, indexer.Id, ex.Response?.StatusCode); + } + + if (newRemoteIndexer != null) + { + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); + } + else + { + try + { + var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); + var baseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + + var match = remoteIndexers.FirstOrDefault(r => + ((string)r.Fields.FirstOrDefault(f => f.Name == "baseUrl")?.Value ?? "").Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase) + || (r.Name?.Contains(indexer.Name, StringComparison.InvariantCultureIgnoreCase) == true)); + + if (match != null) + { + _logger.Debug("Found existing remote indexer for {0} as {1} [{2}], inserting mapping", indexer.Name, match.Name, match.Id); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = match.Id }); + } + else + { + _logger.Debug("No remote indexer found for {0} after failing to add; skipping mapping", indexer.Name); + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Error while attempting to discover existing remote indexer for {0} [{1}]", indexer.Name, indexer.Id); + } + } + } + else + { + _logger.Debug("Skipping re-add due to indexer capabilities for {0} [{1}]", indexer.Name, indexer.Id); + } + + return; + } + + var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping.RemoteIndexerId); + + var remoteIndexer = _listenarrV1Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); + + if (remoteIndexer != null) + { + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); + + if (!listenarrIndexer.Equals(remoteIndexer) || forceSync) + { + _logger.Debug("Syncing remote indexer with current settings"); + + if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && + indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + // Retain user fields not-affiliated with Prowlarr + if (remoteIndexer.Fields != null) + { + listenarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => listenarrIndexer.Fields.All(s => s.Name != f.Name))); + } + + // Retain user tags not-affiliated with Prowlarr + if (remoteIndexer.Tags != null) + { + listenarrIndexer.Tags.UnionWith(remoteIndexer.Tags); + } + + // Retain user settings not-affiliated with Prowlarr + listenarrIndexer.DownloadClientId = remoteIndexer.DownloadClientId; + + // Ensure ID is in sync with remote before updating + listenarrIndexer.Id = remoteIndexer.Id; + + // Update the indexer if it still has categories that match + _listenarrV1Proxy.UpdateIndexer(listenarrIndexer, Settings); + } + else + { + // Else remove it, it no longer should be used + _listenarrV1Proxy.RemoveIndexer(remoteIndexer.Id, Settings); + _appIndexerMapService.Delete(indexerMapping.Id); + } + } + } + else + { + _appIndexerMapService.Delete(indexerMapping.Id); + + if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && + indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Listenarr", indexer.Name, indexer.Id); + listenarrIndexer.Id = 0; + var newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, Settings); + + if (newRemoteIndexer != null) + { + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); + } + } + } + } + + private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) + { + var cacheKey = $"{Settings.BaseUrl}"; + var schemas = _schemaCache.Get(cacheKey, () => _listenarrV1Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); + var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; + + // Validate schema presence and contents. Listenarr can sometimes return no schema or an unexpected shape + if (schemas == null || !schemas.Any()) + { + // Try refreshing schemas directly from proxy in case the cache is empty or stale + try + { + schemas = _listenarrV1Proxy.GetIndexerSchema(Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to fetch indexer schemas from Listenarr at {0}", Settings.BaseUrl); + schemas = null; + } + } + + if (schemas == null || !schemas.Any()) + { + _logger.Warn("No indexer schemas were returned from Listenarr at {0}", Settings.BaseUrl); + throw new ApplicationException("Listenarr returned no indexer schemas. Ensure Listenarr exposes '/api/v1/indexer/schema' and that it returns a JSON array of schemas containing 'Newznab' or 'Torznab'."); + } + + if (id == 0) + { + // Ensuring backward compatibility with older versions on first sync + syncFields.AddRange(new List { "earlyReleaseLimit", "additionalParameters" }); + } + + var newznab = schemas.FirstOrDefault(i => i.Implementation == "Newznab"); + var torznab = schemas.FirstOrDefault(i => i.Implementation == "Torznab"); + + if (newznab == null && torznab == null) + { + _logger.Warn("Indexer schemas are missing 'Newznab' and 'Torznab' implementations from Listenarr at {0}", Settings.BaseUrl); + throw new ApplicationException("Listenarr indexer schema must include at least one of 'Newznab' or 'Torznab' implementations."); + } + + var schema = protocol == DownloadProtocol.Usenet ? newznab ?? torznab : torznab ?? newznab; + + if (schema == null) + { + _logger.Warn("No schema available for protocol {0} from Listenarr at {1}", protocol, Settings.BaseUrl); + throw new ApplicationException($"Listenarr indexer schema does not contain a suitable implementation for protocol {protocol}."); + } + + var listenarrIndexer = new ListenarrIndexer + { + Id = id, + Name = $"{indexer.Name} (Prowlarr)", + EnableRss = indexer.Enable && (indexer.AppProfile?.Value?.EnableRss ?? false), + EnableAutomaticSearch = indexer.Enable && (indexer.AppProfile?.Value?.EnableAutomaticSearch ?? false), + EnableInteractiveSearch = indexer.Enable && (indexer.AppProfile?.Value?.EnableInteractiveSearch ?? false), + Priority = indexer.Priority, + Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", + ConfigContract = schema.ConfigContract, + Fields = new List(), + Tags = new HashSet() }; + listenarrIndexer.Fields.AddRange(schema.Fields.Where(x => syncFields.Contains(x.Name))); + + // Validate required fields exist to avoid NullReferenceExceptions when accessing their values + var requiredFieldNames = new List { "baseUrl", "apiPath", "apiKey", "categories" }; + var missing = requiredFieldNames.Where(f => listenarrIndexer.Fields.All(x => x.Name != f)).ToList(); + + if (missing.Any()) + { + _logger.Debug("Cached schema is missing required fields [{0}]. Attempting to refresh schema from proxy", string.Join(", ", missing)); + + // Try a single direct refresh from the proxy in case cache is stale + try + { + var freshSchemas = _listenarrV1Proxy.GetIndexerSchema(Settings); + + if (freshSchemas != null && freshSchemas.Any()) + { + var freshNewznab = freshSchemas.FirstOrDefault(i => i.Implementation == "Newznab"); + var freshTorznab = freshSchemas.FirstOrDefault(i => i.Implementation == "Torznab"); + + var freshSchema = protocol == DownloadProtocol.Usenet ? freshNewznab ?? freshTorznab : freshTorznab ?? freshNewznab; + + if (freshSchema != null) + { + listenarrIndexer.Fields = freshSchema.Fields.Where(x => syncFields.Contains(x.Name)).ToList(); + missing = requiredFieldNames.Where(f => listenarrIndexer.Fields.All(x => x.Name != f)).ToList(); + + if (!missing.Any()) + { + _logger.Debug("Fresh schema contained required fields; proceeding with fresh schema"); + } + } + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to refresh indexer schemas from Listenarr at {0}", Settings.BaseUrl); + } + } + + if (missing.Any()) + { + _logger.Warn("Indexer schema missing required fields [{0}] from Listenarr at {1}", string.Join(", ", missing), Settings.BaseUrl); + throw new ApplicationException($"Listenarr indexer schema missing required fields: {string.Join(", ", missing)}. Ensure '/api/v1/indexer/schema' includes these fields."); + } + + var field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl"); + if (field != null) + { + field.Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + } + + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath"); + if (field != null) + { + field.Value = "/api"; + } + + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey"); + if (field != null) + { + field.Value = _configFileProvider.ApiKey; + } + + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories"); + if (field != null) + { + field.Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + } + + if (indexer.Protocol == DownloadProtocol.Torrent) + { + var torrentSettings = indexer.Settings as ITorrentIndexerSettings; + + var appMinimumSeeders = torrentSettings?.TorrentBaseSettings.AppMinimumSeeders ?? indexer.AppProfile?.Value?.MinimumSeeders ?? 0; + + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "minimumSeeders"); + if (field != null) + { + field.Value = appMinimumSeeders; + } + + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio"); + if (field != null) + { + field.Value = torrentSettings?.TorrentBaseSettings.SeedRatio; + } + + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime"); + if (field != null) + { + field.Value = torrentSettings?.TorrentBaseSettings.SeedTime; + } + + if (listenarrIndexer.Fields.Any(x => x.Name == "seedCriteria.discographySeedTime")) + { + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.discographySeedTime"); + if (field != null) + { + field.Value = torrentSettings?.TorrentBaseSettings.PackSeedTime ?? torrentSettings?.TorrentBaseSettings.SeedTime; + } + } + + if (listenarrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) + { + field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing"); + if (field != null) + { + field.Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; + } + } + } + return listenarrIndexer; } } diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs index ae70fa9c3..6e18d50c3 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs @@ -10,23 +10,33 @@ public class ListenarrStatus public class ListenarrIndexer { public int Id { get; set; } - public string Name { get; set; } public bool EnableRss { get; set; } public bool EnableAutomaticSearch { get; set; } public bool EnableInteractiveSearch { get; set; } public int Priority { get; set; } - public string ConfigContract { get; set; } + public string Name { get; set; } + public string ImplementationName { get; set; } public string Implementation { get; set; } - public string Protocol { get; set; } - public string BaseUrl { get; set; } + public string ConfigContract { get; set; } + public string InfoLink { get; set; } + public int? DownloadClientId { get; set; } + public HashSet Tags { get; set; } public List Fields { get; set; } - public List Tags { get; set; } } public class ListenarrField { public string Name { get; set; } public object Value { get; set; } + public string Type { get; set; } + public bool Advanced { get; set; } + public string Section { get; set; } + public string Hidden { get; set; } + + public ListenarrField Clone() + { + return (ListenarrField)MemberwiseClone(); + } } public class ListenarrTag diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs index 201a73c50..a706a3357 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using FluentValidation; using NzbDrone.Core.Annotations; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Applications.Listenarr @@ -17,32 +18,29 @@ public ListenarrSettingsValidator() public class ListenarrSettings : IApplicationSettings { - private static readonly ListenarrSettingsValidator Validator = new ListenarrSettingsValidator(); + private static readonly ListenarrSettingsValidator Validator = new(); public ListenarrSettings() { ProwlarrUrl = "http://localhost:9696"; BaseUrl = "http://localhost:5000"; - SyncLevel = (int)ApplicationSyncLevel.FullSync; // default reasonable behavior + SyncCategories = new[] { 3030 }; } - [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "URL of Prowlarr server as Listenarr sees it, including http:// or https://, and port if needed")] + [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Listenarr sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")] public string ProwlarrUrl { get; set; } - [FieldDefinition(1, Label = "Listenarr Server", HelpText = "URL of Listenarr server, including http:// or https://, and port if needed")] + [FieldDefinition(1, Label = "Listenarr Server", HelpText = "URL used to connect to Listenarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:5000")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "API Key for Listenarr")] + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Listenarr in Settings/General")] public string ApiKey { get; set; } - [FieldDefinition(3, Type = FieldType.Select, SelectOptions = typeof(ApplicationSyncLevel), Label = "Sync Level", HelpText = "How should Prowlarr sync indexers to Listenarr")] - public int SyncLevel { get; set; } - - [FieldDefinition(4, Label = "Sync Categories", Advanced = true, HelpText = "Sync audiobook categories to Listenarr (must match Listenarr's category schema)")] + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] public IEnumerable SyncCategories { get; set; } - [FieldDefinition(5, Type = FieldType.TagSelect, SelectOptionsProviderAction = "getTags", Label = "Tags", HelpText = "Only add indexers with these tags to Listenarr")] - public IEnumerable Tags { get; set; } + [FieldDefinition(4, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index 101fa1bac..44ad81d0b 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using FluentValidation.Results; using Newtonsoft.Json; using NLog; using NzbDrone.Common.Http; @@ -12,15 +13,22 @@ namespace NzbDrone.Core.Applications.Listenarr public interface IListenarrV1Proxy { ListenarrStatus GetStatus(ListenarrSettings settings); - List GetIndexers(ListenarrSettings settings); ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings); - void UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings); + List GetIndexers(ListenarrSettings settings); + ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings); + List GetIndexerSchema(ListenarrSettings settings); void RemoveIndexer(int indexerId, ListenarrSettings settings); - List GetTags(ListenarrSettings settings); + ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings); + ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSettings settings); } public class ListenarrV1Proxy : IListenarrV1Proxy { + private static Version MinimumApplicationVersion => new(0, 2, 46, 0); + + private const string AppApiRoute = "/api/v1"; + private const string AppIndexerApiRoute = $"{AppApiRoute}/indexer"; + private readonly IHttpClient _httpClient; private readonly Logger _logger; @@ -32,42 +40,417 @@ public ListenarrV1Proxy(IHttpClient httpClient, Logger logger) public ListenarrStatus GetStatus(ListenarrSettings settings) { - var request = BuildRequest(settings, "/api/system/status", HttpMethod.Get); + var request = BuildRequest(settings, $"{AppApiRoute}/system/status", HttpMethod.Get); return Execute(request); } public List GetIndexers(ListenarrSettings settings) { - var request = BuildRequest(settings, "/api/indexer", HttpMethod.Get); - return Execute>(request); + var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get); + + try + { + return Execute>(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + // Fallback to plural resource if the app exposes /indexers + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers", HttpMethod.Get); + return Execute>(fallback); + } } - public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings) { - var request = BuildRequest(settings, "/api/indexer", HttpMethod.Post); - request.SetContent(indexer.ToJson()); - request.ContentSummary = indexer.ToJson(Formatting.None); - return Execute(request); - } + try + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get); + return Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode != HttpStatusCode.NotFound) + { + throw; + } - public void UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings) - { - var request = BuildRequest(settings, $"/api/indexer/{indexer.Id}", HttpMethod.Put); - request.SetContent(indexer.ToJson()); - request.ContentSummary = indexer.ToJson(Formatting.None); - Execute(request); + // Try plural form as a fallback + try + { + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Get); + return Execute(fallback); + } + catch (HttpException) + { + return null; + } + } } public void RemoveIndexer(int indexerId, ListenarrSettings settings) { - var request = BuildRequest(settings, $"/api/indexer/{indexerId}", HttpMethod.Delete); - _httpClient.Execute(request); + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Delete); + + try + { + _httpClient.Execute(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + // Try plural endpoint as fallback + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Delete); + _httpClient.Execute(fallback); + } } - public List GetTags(ListenarrSettings settings) + public List GetIndexerSchema(ListenarrSettings settings) { - var request = BuildRequest(settings, "/api/tag", HttpMethod.Get); - return Execute>(request); + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/schema", HttpMethod.Get); + + try + { + var response = _httpClient.Execute(request); + + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } + + // Parse and normalize flexible schema responses + var token = Newtonsoft.Json.Linq.JToken.Parse(response.Content); + + // If the schema is a single object, wrap into a list (and expand implementations arrays) + if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object) + { + var obj = (Newtonsoft.Json.Linq.JObject)token; + + // Normalize fields when they are returned as an object instead of an array + if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj) + { + var fieldsArray = new Newtonsoft.Json.Linq.JArray(); + + foreach (var prop in fieldsObj.Properties()) + { + if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object) + { + var item = (Newtonsoft.Json.Linq.JObject)prop.Value; + item["name"] = prop.Name; + fieldsArray.Add(item); + } + else + { + // Primitive -> wrap into value + var item = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; + fieldsArray.Add(item); + } + } + + obj["fields"] = fieldsArray; + } + + // If implementations is an array of strings, expand into separate schema entries + if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0) + { + var results = new List(); + + foreach (var impl in implsArray) + { + var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone(); + copy.Property("implementations")?.Remove(); + copy["implementation"] = impl; + results.Add(copy.ToObject()); + } + + return results; + } + + return new List { obj.ToObject() }; + } + + // If it's already an array, parse each item, normalize fields and expand implementations arrays + if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array) + { + var list = new List(); + + foreach (var item in (Newtonsoft.Json.Linq.JArray)token) + { + if (item.Type != Newtonsoft.Json.Linq.JTokenType.Object) + { + throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema array"); + } + + var obj = (Newtonsoft.Json.Linq.JObject)item; + + // Normalize fields if needed + if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj2) + { + var fieldsArray = new Newtonsoft.Json.Linq.JArray(); + + foreach (var prop in fieldsObj2.Properties()) + { + if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object) + { + var fieldItem = (Newtonsoft.Json.Linq.JObject)prop.Value; + fieldItem["name"] = prop.Name; + fieldsArray.Add(fieldItem); + } + else + { + var fieldItem = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; + fieldsArray.Add(fieldItem); + } + } + + obj["fields"] = fieldsArray; + } + + if (obj["implementations"] is Newtonsoft.Json.Linq.JArray impls) + { + foreach (var impl in impls) + { + var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone(); + copy.Property("implementations")?.Remove(); + copy["implementation"] = impl; + list.Add(copy.ToObject()); + } + } + else + { + list.Add(obj.ToObject()); + } + } + + return list; + } + + // Unexpected token type + throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema"); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/schema", HttpMethod.Get); + var fallbackResponse = _httpClient.Execute(fallback); + + if ((int)fallbackResponse.StatusCode >= 300) + { + throw new HttpException(fallbackResponse); + } + + var token = Newtonsoft.Json.Linq.JToken.Parse(fallbackResponse.Content); + + if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array) + { + return token.ToObject>(); + } + + if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object) + { + var obj = (Newtonsoft.Json.Linq.JObject)token; + + if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj) + { + var fieldsArray = new Newtonsoft.Json.Linq.JArray(); + + foreach (var prop in fieldsObj.Properties()) + { + if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object) + { + var item = (Newtonsoft.Json.Linq.JObject)prop.Value; + item["name"] = prop.Name; + fieldsArray.Add(item); + } + else + { + var item = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; + fieldsArray.Add(item); + } + } + + obj["fields"] = fieldsArray; + } + + if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0) + { + var results = new List(); + + foreach (var impl in implsArray) + { + var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone(); + copy.Property("implementations")?.Remove(); + copy["implementation"] = impl; + results.Add(copy.ToObject()); + } + + return results; + } + + return new List { obj.ToObject() }; + } + + throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema (fallback)"); + } + } + + public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + _logger.Debug("Request payload: {0}", request.ContentSummary); + return Execute(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to add indexer forcefully. Original response: {0}", ex.Response?.Content ?? string.Empty); + request.Url = request.Url.AddQueryParam("forceSave", "true"); + _logger.Debug("Retry payload: {0}", request.ContentSummary); + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + // Try plural form as a fallback + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers", HttpMethod.Post); + fallback.SetContent(indexer.ToJson()); + fallback.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + return ExecuteIndexerRequest(fallback); + } + catch (HttpException) + { + throw; + } + } + } + + public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + request.Url = request.Url.AddQueryParam("forceSave", "true"); + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + // Try plural form as a fallback + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexer.Id}", HttpMethod.Put); + fallback.SetContent(indexer.ToJson()); + fallback.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + return ExecuteIndexerRequest(fallback); + } + catch (HttpException) + { + throw; + } + } + } + + public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); + + request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); + + try + { + var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + + if (applicationVersion == null) + { + // Try plural endpoint as a fallback + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/test", HttpMethod.Post); + fallback.SetContent(indexer.ToJson()); + fallback.ContentSummary = indexer.ToJson(Formatting.None); + + applicationVersion = _httpClient.Post(fallback).Headers.GetSingleValue("X-Application-Version"); + } + + if (applicationVersion == null) + { + return new ValidationFailure(string.Empty, "Failed to fetch Listenarr version"); + } + + if (new Version(applicationVersion) < MinimumApplicationVersion) + { + return new ValidationFailure(string.Empty, $"Listenarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); + } + + return null; + } + catch (HttpException) + { + // Bubble HttpExceptions to be handled by the caller similar to other proxies + throw; + } + } + + private ListenarrIndexer ExecuteIndexerRequest(HttpRequest request) + { + try + { + return Execute(request); + } + catch (HttpException ex) + { + var responseContent = ex.Response?.Content ?? string.Empty; + + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid. Response: {0}", responseContent); + break; + case HttpStatusCode.BadRequest: + if (responseContent.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App. Response: {0}", responseContent); + break; + } + + _logger.Error(ex, "Invalid Request. Response: {0}", responseContent); + break; + case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL. Response: {0}", responseContent); + break; + case HttpStatusCode.NotFound: + _logger.Warn(ex, "Remote indexer not found. Response: {0}", responseContent); + break; + default: + _logger.Error(ex, "Unexpected response status code: {0}. Response: {1}", ex.Response.StatusCode, responseContent); + break; + } + + throw; + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to add or update indexer"); + throw; + } } private HttpRequest BuildRequest(ListenarrSettings settings, string resource, HttpMethod method) @@ -81,36 +464,24 @@ private HttpRequest BuildRequest(ListenarrSettings settings, string resource, Ht .Build(); request.Headers.ContentType = "application/json"; + request.Method = method; request.AllowAutoRedirect = true; return request; } - private T Execute(HttpRequest request) - where T : new() + private TResource Execute(HttpRequest request) + where TResource : new() { - try + var response = _httpClient.Execute(request); + + if ((int)response.StatusCode >= 300) { - var response = _httpClient.Execute(request); - - if ((int)response.StatusCode >= 300) - { - throw new HttpException(response); - } - - return Json.Deserialize(response.Content); + throw new HttpException(response); } - catch (HttpException ex) - { - if (ex.Response != null && ex.Response.StatusCode == HttpStatusCode.Unauthorized) - { - _logger.Error(ex, "API Key is invalid"); - throw new ApplicationException("API Key is invalid"); - } - throw; - } + return Json.Deserialize(response.Content); } } } From 8d57215938cc192349675bd3efae603850350ea9 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Tue, 13 Jan 2026 15:45:20 -0500 Subject: [PATCH 03/16] Refactor Listenarr modal and remove ProviderSettingsModal Replaced usage of ProviderSettingsModal with EditApplicationModalConnector in Listenarr component. Removed the now-unused ProviderSettingsModal component. Also cleaned up unused defaulting in TagSelectInputConnector. --- .../Form/TagSelectInputConnector.js | 3 --- .../Applications/Listenarr/Listenarr.tsx | 8 +++---- .../Applications/ProviderSettingsModal.tsx | 24 ------------------- 3 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 frontend/src/Settings/Applications/ProviderSettingsModal.tsx diff --git a/frontend/src/Components/Form/TagSelectInputConnector.js b/frontend/src/Components/Form/TagSelectInputConnector.js index 375c40151..23afe6da1 100644 --- a/frontend/src/Components/Form/TagSelectInputConnector.js +++ b/frontend/src/Components/Form/TagSelectInputConnector.js @@ -10,9 +10,6 @@ function createMapStateToProps() { (state, { value }) => value, (state, { values }) => values, (tags, tagList) => { - tags = tags || []; - tagList = tagList || []; - const sortedTags = _.sortBy(tagList, 'value'); return { diff --git a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx index 72866b1ad..62d3075c3 100644 --- a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx +++ b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ProviderSettingsModal from 'Settings/Applications/ProviderSettingsModal'; +import EditApplicationModalConnector from 'Settings/Applications/Applications/EditApplicationModalConnector'; import Application from 'typings/Application'; interface ListenarrProps { @@ -9,9 +9,9 @@ interface ListenarrProps { function Listenarr({ selectedApplication, onModalClose }: ListenarrProps) { return ( - ); diff --git a/frontend/src/Settings/Applications/ProviderSettingsModal.tsx b/frontend/src/Settings/Applications/ProviderSettingsModal.tsx deleted file mode 100644 index 0a028a8b2..000000000 --- a/frontend/src/Settings/Applications/ProviderSettingsModal.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import Application from 'typings/Application'; -import EditApplicationModalConnector from './Applications/EditApplicationModalConnector'; - -interface ProviderSettingsModalProps { - providerData: Application; - section?: string; - onModalClose: () => void; -} - -function ProviderSettingsModal({ - providerData, - onModalClose, -}: ProviderSettingsModalProps) { - return ( - - ); -} - -export default ProviderSettingsModal; From 58ca5ee886951d56de37f48c6e89bb08d95afa2a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Tue, 13 Jan 2026 16:08:45 -0500 Subject: [PATCH 04/16] Bump minimum Listenarr version to 0.2.47 Updated the MinimumApplicationVersion in ListenarrV1Proxy from 0.2.46 to 0.2.47 to require the newer version of Listenarr that will have prowlarr support --- src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index 44ad81d0b..c5499cb43 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -24,7 +24,7 @@ public interface IListenarrV1Proxy public class ListenarrV1Proxy : IListenarrV1Proxy { - private static Version MinimumApplicationVersion => new(0, 2, 46, 0); + private static Version MinimumApplicationVersion => new(0, 2, 47, 0); private const string AppApiRoute = "/api/v1"; private const string AppIndexerApiRoute = $"{AppApiRoute}/indexer"; From 95d0da9ab84e70ca757a48baf9e603961fecb8aa Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Tue, 13 Jan 2026 22:30:42 -0500 Subject: [PATCH 05/16] Fixing review comments Adjusted ListenarrFixture tests to use AudioAudiobook category and updated test logic for schema validation. Simplified ListenarrV1Proxy.GetIndexer to use a single API route and return null on failure, removing plural form fallback. --- .../Applications/Listenarr/Listenarr.tsx | 20 ---- .../Listenarr/ListenarrFixture.cs | 96 ++++++++++++++++--- .../Applications/Listenarr/Listenarr.cs | 2 +- .../Listenarr/ListenarrV1Proxy.cs | 52 ++++++---- 4 files changed, 116 insertions(+), 54 deletions(-) delete mode 100644 frontend/src/Settings/Applications/Listenarr/Listenarr.tsx diff --git a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx b/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx deleted file mode 100644 index 62d3075c3..000000000 --- a/frontend/src/Settings/Applications/Listenarr/Listenarr.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import EditApplicationModalConnector from 'Settings/Applications/Applications/EditApplicationModalConnector'; -import Application from 'typings/Application'; - -interface ListenarrProps { - selectedApplication: Application; - onModalClose: () => void; -} - -function Listenarr({ selectedApplication, onModalClose }: ListenarrProps) { - return ( - - ); -} - -export default Listenarr; diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs index dade03d40..5f5f0fccd 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs @@ -27,9 +27,9 @@ public void Setup() Settings = new ListenarrSettings { ProwlarrUrl = "http://localhost:9696", - BaseUrl = "http://localhost:5000", + BaseUrl = "http://localhost:4545", ApiKey = "abc", - SyncCategories = new List { NewznabStandardCategory.Movies.Id } + SyncCategories = new List { NewznabStandardCategory.AudioAudiobook.Id } } }; @@ -223,18 +223,14 @@ public void Test_should_fail_when_schema_missing() // Arrange Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(new List()); - // Act & Assert - try - { - var result = Subject.Test(); - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("indexer schema")); - } - finally - { - // Consume expected warnings even if Subject.Test throws or an assert fails so teardown does not fail - ExceptionVerification.IgnoreWarns(); - } + // Act & Assert - call private BuildListenarrIndexer to assert it throws + var method = typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetMethod("BuildListenarrIndexer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var indexerDef = new IndexerDefinition { Id = 1, Name = "Test", Protocol = DownloadProtocol.Usenet, Capabilities = new IndexerCapabilities() }; + + var ex = Assert.Throws(() => method.Invoke(Subject, new object[] { indexerDef, indexerDef.Capabilities, DownloadProtocol.Usenet, 0 })); + Assert.IsInstanceOf(ex.InnerException); + Assert.That(ex.InnerException.Message, Does.Contain("indexer schemas")); + ExceptionVerification.ExpectedWarns(1); } [Test] @@ -290,7 +286,7 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success() }; // Add a category that matches Settings.SyncCategories - indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.Movies); + indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook); var mockIndexer = new Mock(); mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities); @@ -331,5 +327,75 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success() Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Once()); Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); } + + [Test] + public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches() + { + // Arrange + var indexerDefinition = new IndexerDefinition + { + Id = 12, + Name = "TestIndexer", + Protocol = DownloadProtocol.Usenet, + Capabilities = new IndexerCapabilities(), + Enable = true, + AppProfile = new LazyLoaded(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true }) + }; + + indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook); + + var mockIndexer = new Mock(); + mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities); + + Mocker.GetMock().Setup(m => m.GetInstance(It.IsAny())).Returns(mockIndexer.Object); + + var schema = new List + { + new ListenarrIndexer + { + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = "" }, + new ListenarrField { Name = "apiPath", Value = "" }, + new ListenarrField { Name = "apiKey", Value = "" }, + new ListenarrField { Name = "categories", Value = new List() } + } + } + }; + + // Existing remote indexer with matching baseUrl + var existing = new ListenarrIndexer + { + Id = 501, + Implementation = "Newznab", + Fields = new List + { + new ListenarrField { Name = "baseUrl", Value = $"{((ListenarrSettings)Subject.Definition.Settings).ProwlarrUrl.TrimEnd('/')}/12/" }, + new ListenarrField { Name = "apiPath", Value = "/api" }, + new ListenarrField { Name = "apiKey", Value = "abc" }, + new ListenarrField { Name = "categories", Value = new List() } + } + }; + + Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); + Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { existing }); + + // Ensure the private schema cache will execute the factory so it invokes our mocked proxy + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + + // pre-check + indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); + + // Act + Subject.AddIndexer(indexerDefinition); + + // Assert - AddIndexer should not be called because remote already exists + Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); + } } } diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index 485e31175..a10417e61 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -391,7 +391,7 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe if (id == 0) { // Ensuring backward compatibility with older versions on first sync - syncFields.AddRange(new List { "earlyReleaseLimit", "additionalParameters" }); + syncFields.AddRange(new List { "additionalParameters" }); } var newznab = schemas.FirstOrDefault(i => i.Implementation == "Newznab"); diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index c5499cb43..ca7f9da76 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using FluentValidation.Results; @@ -64,26 +65,12 @@ public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings) { try { - var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get); - return Execute(request); + var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Get); + return Execute(fallback); } - catch (HttpException ex) + catch (HttpException) { - if (ex.Response.StatusCode != HttpStatusCode.NotFound) - { - throw; - } - - // Try plural form as a fallback - try - { - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Get); - return Execute(fallback); - } - catch (HttpException) - { - return null; - } + return null; } } @@ -293,6 +280,35 @@ public List GetIndexerSchema(ListenarrSettings settings) public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) { + // Defensive check: avoid creating duplicates if an indexer with the same baseUrl already exists on the remote app. + try + { + var incomingBaseUrl = indexer?.Fields?.FirstOrDefault(f => f.Name == "baseUrl")?.Value as string; + if (!string.IsNullOrWhiteSpace(incomingBaseUrl)) + { + var existing = GetIndexers(settings); + if (existing != null) + { + var match = existing.FirstOrDefault(e => + string.Equals( + (e.Fields?.FirstOrDefault(f => f.Name == "baseUrl")?.Value as string)?.TrimEnd('/'), + incomingBaseUrl.TrimEnd('/'), + StringComparison.InvariantCultureIgnoreCase)); + + if (match != null) + { + _logger.Debug("Found existing remote indexer matching baseUrl; skipping add and returning existing id {0}", match.Id); + return match; + } + } + } + } + catch (Exception ex) + { + // If the existence check fails for any reason, proceed with the add flow and let any resulting errors bubble up. + _logger.Debug(ex, "Failed to run pre-flight existence check before AddIndexer; proceeding to create"); + } + var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); From af4bcbc879bce9455586abf6be8284c00b470f96 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Tue, 13 Jan 2026 23:43:51 -0500 Subject: [PATCH 06/16] Update default BaseUrl port for Listenarr Changed the default BaseUrl in ListenarrSettings from port 5000 to 4545 to reflect the correct default configuration. --- src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs index a706a3357..339872a73 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs @@ -23,7 +23,7 @@ public class ListenarrSettings : IApplicationSettings public ListenarrSettings() { ProwlarrUrl = "http://localhost:9696"; - BaseUrl = "http://localhost:5000"; + BaseUrl = "http://localhost:4545"; SyncCategories = new[] { 3030 }; } From 6f55c81d5ba64440b320ee461a31d141b6e33ae4 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 14 Jan 2026 00:09:20 -0500 Subject: [PATCH 07/16] Prevent duplicate remote indexer mappings Before adding a new remote indexer, check for existing indexers with matching baseUrl or name. If found, insert a mapping and skip adding to avoid duplicates. Logs errors encountered during the check. --- .../Applications/Listenarr/Listenarr.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index a10417e61..c591df5f3 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -142,6 +142,28 @@ public override void AddIndexer(IndexerDefinition indexer) var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); + // If an existing remote indexer already points to this app indexer (matching baseUrl or name), insert mapping and skip adding + try + { + var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); + var baseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + + var match = remoteIndexers.FirstOrDefault(r => + ((string)r.Fields.FirstOrDefault(f => f.Name == "baseUrl")?.Value ?? "").Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase) + || (r.Name?.Contains(indexer.Name, StringComparison.InvariantCultureIgnoreCase) == true)); + + if (match != null) + { + _logger.Debug("Found existing remote indexer for {0} as {1} [{2}], inserting mapping", indexer.Name, match.Name, match.Id); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = match.Id }); + return; + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Error while attempting to discover existing remote indexer for {0} [{1}]", indexer.Name, indexer.Id); + } + var remoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, Settings); if (remoteIndexer == null) From 22f582af076761a24c6cd0b9dfe61ddda7734b44 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 14 Jan 2026 20:12:49 -0500 Subject: [PATCH 08/16] Update Listenarr server default port to 4545 Changed the default Listenarr server port from 5000 to 4545 in both the settings placeholder and related test cases to reflect the new standard port configuration. --- .../Applications/Listenarr/ListenarrV1ProxyFixture.cs | 10 +++++----- .../Applications/Listenarr/ListenarrSettings.cs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs index 4a9014c7b..fbc9007e8 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs @@ -16,9 +16,9 @@ public class ListenarrV1ProxyFixture : TestBase public void GetIndexers_should_deserialize_json_and_set_api_key_header() { // Arrange - var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "abc123" }; + var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - var json = "[ { \"id\": 42, \"name\": \"Test\", \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"value\": \"http://localhost:5000/1/api\" }, { \"name\": \"apiKey\", \"value\": \"x\" } ] } ]"; + var json = "[ { \"id\": 42, \"name\": \"Test\", \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"value\": \"http://localhost:4545/1/api\" }, { \"name\": \"apiKey\", \"value\": \"x\" } ] } ]"; HttpRequest capturedRequest = null; @@ -46,7 +46,7 @@ public void GetIndexers_should_deserialize_json_and_set_api_key_header() public void GetIndexerSchema_should_handle_single_object_response_with_fields_object() { // Arrange - var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "abc123" }; + var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; // Schema returned as an object with fields as an object (name -> definition) var json = "{ \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } }"; @@ -71,7 +71,7 @@ public void GetIndexerSchema_should_handle_single_object_response_with_fields_ob public void GetIndexerSchema_should_expand_implementations_array_into_multiple_schemas() { // Arrange - var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "abc123" }; + var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; var json = "{ \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] }"; @@ -93,7 +93,7 @@ public void GetIndexerSchema_should_expand_implementations_array_into_multiple_s public void Execute_should_throw_application_exception_when_unauthorized() { // Arrange - var settings = new ListenarrSettings { BaseUrl = "http://localhost:5000", ApiKey = "bad" }; + var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "bad" }; Mocker.GetMock() .Setup(c => c.Execute(It.IsAny())) diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs index 339872a73..03ffad6f8 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs @@ -30,7 +30,7 @@ public ListenarrSettings() [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Listenarr sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")] public string ProwlarrUrl { get; set; } - [FieldDefinition(1, Label = "Listenarr Server", HelpText = "URL used to connect to Listenarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:5000")] + [FieldDefinition(1, Label = "Listenarr Server", HelpText = "URL used to connect to Listenarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:4545")] public string BaseUrl { get; set; } [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Listenarr in Settings/General")] From 62b1e259fea47fe264053175284d37ee77a52251 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 21 Jan 2026 14:20:39 -0500 Subject: [PATCH 09/16] Refactor Listenarr schema handling and tests Updated Listenarr schema parsing to preserve the 'implementations' array as a list instead of expanding it into multiple schema objects. Adjusted related logic in Listenarr.cs to support case-insensitive matching for both 'Implementation' and 'Implementations'. Simplified ListenarrV1Proxy by removing fallback plural endpoint logic and unused methods. Updated tests and models to reflect these changes and improve clarity. --- .../Listenarr/ListenarrFixture.cs | 97 +++------ .../Listenarr/ListenarrV1ProxyFixture.cs | 45 ++-- .../Applications/Listenarr/Listenarr.cs | 8 +- .../Applications/Listenarr/ListenarrModels.cs | 1 + .../Listenarr/ListenarrV1Proxy.cs | 198 ++---------------- 5 files changed, 74 insertions(+), 275 deletions(-) diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs index 5f5f0fccd..3dba5e6bb 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs @@ -17,7 +17,7 @@ namespace NzbDrone.Core.Test.Applications.Listenarr { [TestFixture] - public class ListenarrFixture : CoreTest + public class ListenarrFixture : CoreTest { [SetUp] public void Setup() @@ -37,17 +37,16 @@ public void Setup() // Ensure cache calls execute factory each time during tests to avoid stale cached values // Use ICached> mock via dynamic mocking to avoid depending on concrete implementation - var cached = new Mock>>(); - cached.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); + var cached = new Mock>>(); + cached.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); - Mocker.GetMock().Setup(m => m.GetCache>(It.IsAny())).Returns(cached.Object); + Mocker.GetMock().Setup(m => m.GetCache>(It.IsAny())).Returns(cached.Object); } [Test] public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowlarr() { - // Arrange var indexer = new ListenarrIndexer { Id = 99, @@ -61,10 +60,8 @@ public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowl Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { indexer }); - // Act var mappings = Subject.GetIndexerMappings(); - // Assert mappings.Should().HaveCount(1); mappings[0].IndexerId.Should().Be(45); mappings[0].RemoteIndexerId.Should().Be(99); @@ -73,7 +70,6 @@ public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowl [Test] public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl() { - // Arrange var indexer = new ListenarrIndexer { Id = 100, @@ -87,17 +83,14 @@ public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl() Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { indexer }); - // Act var mappings = Subject.GetIndexerMappings(); - // Assert mappings.Should().BeEmpty(); } [Test] public void Test_should_call_testconnection_and_return_success_when_valid() { - // Arrange var schema = new List { new ListenarrIndexer @@ -116,16 +109,13 @@ public void Test_should_call_testconnection_and_return_success_when_valid() Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Returns((FluentValidation.Results.ValidationFailure)null).Verifiable(); - // Ensure the private schema cache will execute the factory so it invokes our mocked proxy - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - // Act var result = Subject.Test(); - // Assert result.IsValid.Should().BeTrue(); Mocker.GetMock().Verify(m => m.TestConnection(It.IsAny(), It.IsAny()), Times.Once); } @@ -133,7 +123,6 @@ public void Test_should_call_testconnection_and_return_success_when_valid() [Test] public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incomplete() { - // Arrange var cachedSchema = new List { new ListenarrIndexer @@ -161,21 +150,17 @@ public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incompl } }; - // Cache returns stale schema - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => cachedSchema); + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => cachedSchema); - typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - // Proxy will return fresh schema when direct fetched Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(freshSchema); Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Returns((FluentValidation.Results.ValidationFailure)null); - // Act var result = Subject.Test(); - // Assert result.IsValid.Should().BeTrue(); Mocker.GetMock().Verify(m => m.GetIndexerSchema(It.IsAny()), Times.AtLeastOnce); } @@ -183,7 +168,6 @@ public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incompl [Test] public void Test_should_handle_exception_from_testconnection() { - // Arrange var schema = new List { new ListenarrIndexer @@ -202,16 +186,13 @@ public void Test_should_handle_exception_from_testconnection() Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Throws(new Exception("boom")); - // Ensure the private schema cache will execute the factory so it invokes our mocked proxy - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - // Act var result = Subject.Test(); - // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to complete application test")); ExceptionVerification.ExpectedWarns(1); @@ -220,11 +201,9 @@ public void Test_should_handle_exception_from_testconnection() [Test] public void Test_should_fail_when_schema_missing() { - // Arrange Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(new List()); - // Act & Assert - call private BuildListenarrIndexer to assert it throws - var method = typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetMethod("BuildListenarrIndexer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var method = typeof(Core.Applications.Listenarr.Listenarr).GetMethod("BuildListenarrIndexer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var indexerDef = new IndexerDefinition { Id = 1, Name = "Test", Protocol = DownloadProtocol.Usenet, Capabilities = new IndexerCapabilities() }; var ex = Assert.Throws(() => method.Invoke(Subject, new object[] { indexerDef, indexerDef.Capabilities, DownloadProtocol.Usenet, 0 })); @@ -236,7 +215,6 @@ public void Test_should_fail_when_schema_missing() [Test] public void Test_should_fail_when_schema_missing_required_fields() { - // Arrange var schema = new List { new ListenarrIndexer @@ -251,13 +229,11 @@ public void Test_should_fail_when_schema_missing_required_fields() Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); - // Ensure the private schema cache will execute the factory so it invokes our mocked proxy - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - // Act & Assert try { var result = Subject.Test(); @@ -266,7 +242,6 @@ public void Test_should_fail_when_schema_missing_required_fields() } finally { - // Consume expected warnings even if Subject.Test throws or an assert fails so teardown does not fail ExceptionVerification.IgnoreWarns(); } } @@ -274,7 +249,6 @@ public void Test_should_fail_when_schema_missing_required_fields() [Test] public void AddIndexer_should_insert_app_indexer_mapping_on_success() { - // Arrange var indexerDefinition = new IndexerDefinition { Id = 12, @@ -285,7 +259,6 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success() AppProfile = new LazyLoaded(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true }) }; - // Add a category that matches Settings.SyncCategories indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook); var mockIndexer = new Mock(); @@ -311,19 +284,15 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success() Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); Mocker.GetMock().Setup(c => c.AddIndexer(It.IsAny(), It.IsAny())).Returns(new ListenarrIndexer { Id = 501 }); - // Ensure the private schema cache will execute the factory so it invokes our mocked proxy - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - // pre-check indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); - // Act Subject.AddIndexer(indexerDefinition); - // Assert Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Once()); Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); } @@ -331,7 +300,6 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success() [Test] public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches() { - // Arrange var indexerDefinition = new IndexerDefinition { Id = 12, @@ -364,7 +332,6 @@ public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches() } }; - // Existing remote indexer with matching baseUrl var existing = new ListenarrIndexer { Id = 501, @@ -381,19 +348,15 @@ public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches() Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { existing }); - // Ensure the private schema cache will execute the factory so it invokes our mocked proxy - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); + var cachedForTest = new Mock>>(); + cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((k, f, t) => f()); + typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - // pre-check indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); - // Act Subject.AddIndexer(indexerDefinition); - // Assert - AddIndexer should not be called because remote already exists Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Never()); Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); } diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs index fbc9007e8..e10bc7c73 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs @@ -4,6 +4,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Applications.Listenarr; using NzbDrone.Test.Common; @@ -15,10 +16,22 @@ public class ListenarrV1ProxyFixture : TestBase [Test] public void GetIndexers_should_deserialize_json_and_set_api_key_header() { - // Arrange var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - var json = "[ { \"id\": 42, \"name\": \"Test\", \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"value\": \"http://localhost:4545/1/api\" }, { \"name\": \"apiKey\", \"value\": \"x\" } ] } ]"; + var responseJson = new[] + { + new + { + Id = "42", + Name = "Test", + Implementation = "Newznab", + Fields = new[] + { + new { name = "baseUrl", value = "http://localhost:4545/1/api" }, + new { name = "apiKey", value = "x" }, + } + } + }.ToJson(); HttpRequest capturedRequest = null; @@ -27,38 +40,31 @@ public void GetIndexers_should_deserialize_json_and_set_api_key_header() .Returns(req => { capturedRequest = req; - return new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0")); + return new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), System.Text.Encoding.UTF8.GetBytes(responseJson), 0, HttpStatusCode.OK, new Version("1.0")); }); - // Act var result = Subject.GetIndexers(settings); - // Assert result.Should().NotBeNull(); result.Count.Should().Be(1); capturedRequest.Headers.GetSingleValue("X-Api-Key").Should().Be("abc123"); - // Accept either singular /api/v1/indexer or legacy /api/indexer in requests (capturedRequest.Url.ToString().Contains("/api/indexer") || capturedRequest.Url.ToString().Contains("/api/v1/indexer")).Should().BeTrue(); } [Test] public void GetIndexerSchema_should_handle_single_object_response_with_fields_object() { - // Arrange var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - // Schema returned as an object with fields as an object (name -> definition) - var json = "{ \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } }"; + var json = "[ { \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } } ]"; Mocker.GetMock() .Setup(c => c.Execute(It.IsAny())) .Returns(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"))); - // Act var result = Subject.GetIndexerSchema(settings); - // Assert result.Should().NotBeNull(); result.Count.Should().Be(1); result[0].Fields.Should().NotBeNull(); @@ -68,41 +74,36 @@ public void GetIndexerSchema_should_handle_single_object_response_with_fields_ob } [Test] - public void GetIndexerSchema_should_expand_implementations_array_into_multiple_schemas() + public void GetIndexerSchema_should_preserve_implementations_array_as_list() { - // Arrange var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - var json = "{ \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] }"; + var json = "[ { \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] } ]"; Mocker.GetMock() .Setup(c => c.Execute(It.IsAny())) .Returns(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"))); - // Act var result = Subject.GetIndexerSchema(settings); - // Assert result.Should().NotBeNull(); - result.Count.Should().Be(2); - result.Should().Contain(r => r.Implementation == "Newznab"); - result.Should().Contain(r => r.Implementation == "Torznab"); + result.Count.Should().Be(1); + result[0].Implementations.Should().NotBeNull(); + result[0].Implementations.Should().Contain("Newznab"); + result[0].Implementations.Should().Contain("Torznab"); } [Test] public void Execute_should_throw_application_exception_when_unauthorized() { - // Arrange var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "bad" }; Mocker.GetMock() .Setup(c => c.Execute(It.IsAny())) .Throws(new HttpException(new HttpResponse(new HttpRequest("http://localhost/"), new HttpHeader(), new CookieCollection(), "unauthorized", 0, HttpStatusCode.Unauthorized, new Version("1.0")))); - // Act / Assert Assert.Throws(() => Subject.GetIndexers(settings)); - // No warning is logged by GetIndexers on unauthorized (it throws before the API-key-specific log path) ExceptionVerification.ExpectedWarns(0); } } diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index c591df5f3..cdbea5740 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -416,8 +416,8 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe syncFields.AddRange(new List { "additionalParameters" }); } - var newznab = schemas.FirstOrDefault(i => i.Implementation == "Newznab"); - var torznab = schemas.FirstOrDefault(i => i.Implementation == "Torznab"); + var newznab = schemas.FirstOrDefault(i => string.Equals(i.Implementation, "Newznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Newznab", StringComparison.InvariantCultureIgnoreCase)))); + var torznab = schemas.FirstOrDefault(i => string.Equals(i.Implementation, "Torznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Torznab", StringComparison.InvariantCultureIgnoreCase)))); if (newznab == null && torznab == null) { @@ -464,8 +464,8 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe if (freshSchemas != null && freshSchemas.Any()) { - var freshNewznab = freshSchemas.FirstOrDefault(i => i.Implementation == "Newznab"); - var freshTorznab = freshSchemas.FirstOrDefault(i => i.Implementation == "Torznab"); + var freshNewznab = freshSchemas.FirstOrDefault(i => string.Equals(i.Implementation, "Newznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Newznab", StringComparison.InvariantCultureIgnoreCase)))); + var freshTorznab = freshSchemas.FirstOrDefault(i => string.Equals(i.Implementation, "Torznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Torznab", StringComparison.InvariantCultureIgnoreCase)))); var freshSchema = protocol == DownloadProtocol.Usenet ? freshNewznab ?? freshTorznab : freshTorznab ?? freshNewznab; diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs index 6e18d50c3..ae8634703 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs @@ -17,6 +17,7 @@ public class ListenarrIndexer public string Name { get; set; } public string ImplementationName { get; set; } public string Implementation { get; set; } + public List Implementations { get; set; } public string ConfigContract { get; set; } public string InfoLink { get; set; } public int? DownloadClientId { get; set; } diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index ca7f9da76..104b19d98 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -13,7 +13,6 @@ namespace NzbDrone.Core.Applications.Listenarr { public interface IListenarrV1Proxy { - ListenarrStatus GetStatus(ListenarrSettings settings); ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings); List GetIndexers(ListenarrSettings settings); ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings); @@ -39,34 +38,19 @@ public ListenarrV1Proxy(IHttpClient httpClient, Logger logger) _logger = logger; } - public ListenarrStatus GetStatus(ListenarrSettings settings) - { - var request = BuildRequest(settings, $"{AppApiRoute}/system/status", HttpMethod.Get); - return Execute(request); - } - public List GetIndexers(ListenarrSettings settings) { var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get); - try - { - return Execute>(request); - } - catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) - { - // Fallback to plural resource if the app exposes /indexers - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers", HttpMethod.Get); - return Execute>(fallback); - } + return Execute>(request); } public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings) { try { - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Get); - return Execute(fallback); + var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get); + return Execute(request); } catch (HttpException) { @@ -77,41 +61,26 @@ public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings) public void RemoveIndexer(int indexerId, ListenarrSettings settings) { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Delete); - - try - { - _httpClient.Execute(request); - } - catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) - { - // Try plural endpoint as fallback - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Delete); - _httpClient.Execute(fallback); - } + _httpClient.Execute(request); } public List GetIndexerSchema(ListenarrSettings settings) { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/schema", HttpMethod.Get); - try + var response = _httpClient.Execute(request); + + if ((int)response.StatusCode >= 300) { - var response = _httpClient.Execute(request); + throw new HttpException(response); + } - if ((int)response.StatusCode >= 300) - { - throw new HttpException(response); - } + var token = Newtonsoft.Json.Linq.JToken.Parse(response.Content); - // Parse and normalize flexible schema responses - var token = Newtonsoft.Json.Linq.JToken.Parse(response.Content); - - // If the schema is a single object, wrap into a list (and expand implementations arrays) - if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object) - { + if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object) + { var obj = (Newtonsoft.Json.Linq.JObject)token; - // Normalize fields when they are returned as an object instead of an array if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj) { var fieldsArray = new Newtonsoft.Json.Linq.JArray(); @@ -135,27 +104,15 @@ public List GetIndexerSchema(ListenarrSettings settings) obj["fields"] = fieldsArray; } - // If implementations is an array of strings, expand into separate schema entries if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0) { - var results = new List(); - - foreach (var impl in implsArray) - { - var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone(); - copy.Property("implementations")?.Remove(); - copy["implementation"] = impl; - results.Add(copy.ToObject()); - } - - return results; + return new List { obj.ToObject() }; } return new List { obj.ToObject() }; } - // If it's already an array, parse each item, normalize fields and expand implementations arrays - if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array) + if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array) { var list = new List(); @@ -168,7 +125,6 @@ public List GetIndexerSchema(ListenarrSettings settings) var obj = (Newtonsoft.Json.Linq.JObject)item; - // Normalize fields if needed if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj2) { var fieldsArray = new Newtonsoft.Json.Linq.JArray(); @@ -191,96 +147,17 @@ public List GetIndexerSchema(ListenarrSettings settings) obj["fields"] = fieldsArray; } - if (obj["implementations"] is Newtonsoft.Json.Linq.JArray impls) - { - foreach (var impl in impls) - { - var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone(); - copy.Property("implementations")?.Remove(); - copy["implementation"] = impl; - list.Add(copy.ToObject()); - } - } - else - { - list.Add(obj.ToObject()); - } + list.Add(obj.ToObject()); } return list; } - // Unexpected token type - throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema"); - } - catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) - { - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/schema", HttpMethod.Get); - var fallbackResponse = _httpClient.Execute(fallback); - - if ((int)fallbackResponse.StatusCode >= 300) - { - throw new HttpException(fallbackResponse); - } - - var token = Newtonsoft.Json.Linq.JToken.Parse(fallbackResponse.Content); - - if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array) - { - return token.ToObject>(); - } - - if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object) - { - var obj = (Newtonsoft.Json.Linq.JObject)token; - - if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj) - { - var fieldsArray = new Newtonsoft.Json.Linq.JArray(); - - foreach (var prop in fieldsObj.Properties()) - { - if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object) - { - var item = (Newtonsoft.Json.Linq.JObject)prop.Value; - item["name"] = prop.Name; - fieldsArray.Add(item); - } - else - { - var item = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; - fieldsArray.Add(item); - } - } - - obj["fields"] = fieldsArray; - } - - if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0) - { - var results = new List(); - - foreach (var impl in implsArray) - { - var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone(); - copy.Property("implementations")?.Remove(); - copy["implementation"] = impl; - results.Add(copy.ToObject()); - } - - return results; - } - - return new List { obj.ToObject() }; - } - - throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema (fallback)"); - } + throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema"); } public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) { - // Defensive check: avoid creating duplicates if an indexer with the same baseUrl already exists on the remote app. try { var incomingBaseUrl = indexer?.Fields?.FirstOrDefault(f => f.Name == "baseUrl")?.Value as string; @@ -305,7 +182,6 @@ public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings s } catch (Exception ex) { - // If the existence check fails for any reason, proceed with the add flow and let any resulting errors bubble up. _logger.Debug(ex, "Failed to run pre-flight existence check before AddIndexer; proceeding to create"); } @@ -326,22 +202,6 @@ public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings s _logger.Debug("Retry payload: {0}", request.ContentSummary); return ExecuteIndexerRequest(request); } - catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) - { - // Try plural form as a fallback - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers", HttpMethod.Post); - fallback.SetContent(indexer.ToJson()); - fallback.ContentSummary = indexer.ToJson(Formatting.None); - - try - { - return ExecuteIndexerRequest(fallback); - } - catch (HttpException) - { - throw; - } - } } public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings) @@ -361,22 +221,6 @@ public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSetting request.Url = request.Url.AddQueryParam("forceSave", "true"); return ExecuteIndexerRequest(request); } - catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) - { - // Try plural form as a fallback - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexer.Id}", HttpMethod.Put); - fallback.SetContent(indexer.ToJson()); - fallback.ContentSummary = indexer.ToJson(Formatting.None); - - try - { - return ExecuteIndexerRequest(fallback); - } - catch (HttpException) - { - throw; - } - } } public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSettings settings) @@ -390,16 +234,6 @@ public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSetti { var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); - if (applicationVersion == null) - { - // Try plural endpoint as a fallback - var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/test", HttpMethod.Post); - fallback.SetContent(indexer.ToJson()); - fallback.ContentSummary = indexer.ToJson(Formatting.None); - - applicationVersion = _httpClient.Post(fallback).Headers.GetSingleValue("X-Application-Version"); - } - if (applicationVersion == null) { return new ValidationFailure(string.Empty, "Failed to fetch Listenarr version"); From ebba206c1b2bb9d64bf867f158011dc2d789be71 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 21 Jan 2026 14:25:50 -0500 Subject: [PATCH 10/16] Remove redundant comments from Listenarr codebase Cleaned up unnecessary and explanatory comments in Listenarr-related classes and tests to improve code readability and maintain consistency. No functional changes were made. --- .../Listenarr/ListenarrFixture.cs | 2 -- .../Applications/Listenarr/Listenarr.cs | 20 +------------------ .../Listenarr/ListenarrV1Proxy.cs | 2 -- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs index 3dba5e6bb..84f1fc873 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs @@ -35,8 +35,6 @@ public void Setup() Mocker.GetMock().SetupGet(c => c.ApiKey).Returns("abc"); - // Ensure cache calls execute factory each time during tests to avoid stale cached values - // Use ICached> mock via dynamic mocking to avoid depending on concrete implementation var cached = new Mock>>(); cached.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) .Returns>, TimeSpan>((k, f, t) => f()); diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index cdbea5740..eaea83e67 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -47,9 +47,6 @@ public override ValidationResult Test() testIndexer.Capabilities.Categories.AddCategoryMapping(1, cat); } - // Call the application's TestConnection directly, consistent with other application implementations. - // Some applications do not expose a reliable system status endpoint, so we rely on the indexer test itself - // to provide actionable feedback (auth, version, connectivity etc.). try { failures.AddIfNotNull(_listenarrV1Proxy.TestConnection(BuildListenarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); @@ -112,7 +109,6 @@ public override List GetIndexerMappings() if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) { - // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -142,7 +138,6 @@ public override void AddIndexer(IndexerDefinition indexer) var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); - // If an existing remote indexer already points to this app indexer (matching baseUrl or name), insert mapping and skip adding try { var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); @@ -198,7 +193,6 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - // If there is no mapping, treat this as an add instead of an update if (indexerMapping == null) { if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && @@ -225,7 +219,6 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f } else { - // If add returned null or failed, try to discover existing remote indexer by baseUrl or name try { var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); @@ -259,7 +252,6 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f return; } - // If mapping exists but contains an invalid remote id (0), remove and re-add if possible if (indexerMapping.RemoteIndexerId == 0) { _logger.Warn("Mapping for indexer {0} contains invalid remote id 0, removing mapping and re-adding if possible", indexer.Id); @@ -335,30 +327,25 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { - // Retain user fields not-affiliated with Prowlarr + if (remoteIndexer.Fields != null) { listenarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => listenarrIndexer.Fields.All(s => s.Name != f.Name))); } - // Retain user tags not-affiliated with Prowlarr if (remoteIndexer.Tags != null) { listenarrIndexer.Tags.UnionWith(remoteIndexer.Tags); } - // Retain user settings not-affiliated with Prowlarr listenarrIndexer.DownloadClientId = remoteIndexer.DownloadClientId; - // Ensure ID is in sync with remote before updating listenarrIndexer.Id = remoteIndexer.Id; - // Update the indexer if it still has categories that match _listenarrV1Proxy.UpdateIndexer(listenarrIndexer, Settings); } else { - // Else remove it, it no longer should be used _listenarrV1Proxy.RemoveIndexer(remoteIndexer.Id, Settings); _appIndexerMapService.Delete(indexerMapping.Id); } @@ -389,10 +376,8 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe var schemas = _schemaCache.Get(cacheKey, () => _listenarrV1Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; - // Validate schema presence and contents. Listenarr can sometimes return no schema or an unexpected shape if (schemas == null || !schemas.Any()) { - // Try refreshing schemas directly from proxy in case the cache is empty or stale try { schemas = _listenarrV1Proxy.GetIndexerSchema(Settings); @@ -412,7 +397,6 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe if (id == 0) { - // Ensuring backward compatibility with older versions on first sync syncFields.AddRange(new List { "additionalParameters" }); } @@ -449,7 +433,6 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe listenarrIndexer.Fields.AddRange(schema.Fields.Where(x => syncFields.Contains(x.Name))); - // Validate required fields exist to avoid NullReferenceExceptions when accessing their values var requiredFieldNames = new List { "baseUrl", "apiPath", "apiKey", "categories" }; var missing = requiredFieldNames.Where(f => listenarrIndexer.Fields.All(x => x.Name != f)).ToList(); @@ -457,7 +440,6 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe { _logger.Debug("Cached schema is missing required fields [{0}]. Attempting to refresh schema from proxy", string.Join(", ", missing)); - // Try a single direct refresh from the proxy in case cache is stale try { var freshSchemas = _listenarrV1Proxy.GetIndexerSchema(Settings); diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index 104b19d98..b60c1dc69 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -95,7 +95,6 @@ public List GetIndexerSchema(ListenarrSettings settings) } else { - // Primitive -> wrap into value var item = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; fieldsArray.Add(item); } @@ -248,7 +247,6 @@ public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSetti } catch (HttpException) { - // Bubble HttpExceptions to be handled by the caller similar to other proxies throw; } } From 28a90d27e00ec5a17a092bec24b4814e42b1e36a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 21 Jan 2026 15:43:33 -0500 Subject: [PATCH 11/16] Fix Listenarr indexer schema test and remove extra newline Updated the ListenarrV1ProxyFixture test to use the correct JSON structure for 'fields' as an array of objects. Also removed an unnecessary newline in Listenarr.cs for code cleanliness. --- .../Applications/Listenarr/ListenarrV1ProxyFixture.cs | 2 +- src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs index e10bc7c73..5500b696a 100644 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs +++ b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs @@ -57,7 +57,7 @@ public void GetIndexerSchema_should_handle_single_object_response_with_fields_ob { var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - var json = "[ { \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } } ]"; + var json = "[ { \"id\": 1, \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" }, { \"name\": \"apiKey\", \"type\": \"text\" } ] } ]"; Mocker.GetMock() .Setup(c => c.Execute(It.IsAny())) diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index eaea83e67..636f2ab86 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -327,7 +327,6 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { - if (remoteIndexer.Fields != null) { listenarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => listenarrIndexer.Fields.All(s => s.Name != f.Name))); From bf8b66431f5a6fd485c2f7b898f5014b579c4032 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 21 Jan 2026 16:32:01 -0500 Subject: [PATCH 12/16] Remove torrent-specific settings from Listenarr sync Eliminated handling and syncing of torrent-specific fields such as minimum seeders, seed ratio, seed time, discography seed time, and blocklisted torrent hashes from Listenarr indexer configuration. Also removed the related setting from ListenarrSettings and array parsing logic from ListenarrV1Proxy, simplifying the indexer schema handling. --- .../Applications/Listenarr/Listenarr.cs | 43 ------------------- .../Listenarr/ListenarrSettings.cs | 3 -- .../Listenarr/ListenarrV1Proxy.cs | 41 ------------------ 3 files changed, 87 deletions(-) diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index 636f2ab86..b7579983c 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -498,49 +498,6 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe field.Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); } - if (indexer.Protocol == DownloadProtocol.Torrent) - { - var torrentSettings = indexer.Settings as ITorrentIndexerSettings; - - var appMinimumSeeders = torrentSettings?.TorrentBaseSettings.AppMinimumSeeders ?? indexer.AppProfile?.Value?.MinimumSeeders ?? 0; - - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "minimumSeeders"); - if (field != null) - { - field.Value = appMinimumSeeders; - } - - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio"); - if (field != null) - { - field.Value = torrentSettings?.TorrentBaseSettings.SeedRatio; - } - - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime"); - if (field != null) - { - field.Value = torrentSettings?.TorrentBaseSettings.SeedTime; - } - - if (listenarrIndexer.Fields.Any(x => x.Name == "seedCriteria.discographySeedTime")) - { - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.discographySeedTime"); - if (field != null) - { - field.Value = torrentSettings?.TorrentBaseSettings.PackSeedTime ?? torrentSettings?.TorrentBaseSettings.SeedTime; - } - } - - if (listenarrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) - { - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing"); - if (field != null) - { - field.Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; - } - } - } - return listenarrIndexer; } } diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs index 03ffad6f8..0420a9840 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrSettings.cs @@ -39,9 +39,6 @@ public ListenarrSettings() [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] public IEnumerable SyncCategories { get; set; } - [FieldDefinition(4, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] - public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } - public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index b60c1dc69..a00dbbcef 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -111,47 +111,6 @@ public List GetIndexerSchema(ListenarrSettings settings) return new List { obj.ToObject() }; } - if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array) - { - var list = new List(); - - foreach (var item in (Newtonsoft.Json.Linq.JArray)token) - { - if (item.Type != Newtonsoft.Json.Linq.JTokenType.Object) - { - throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema array"); - } - - var obj = (Newtonsoft.Json.Linq.JObject)item; - - if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj2) - { - var fieldsArray = new Newtonsoft.Json.Linq.JArray(); - - foreach (var prop in fieldsObj2.Properties()) - { - if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object) - { - var fieldItem = (Newtonsoft.Json.Linq.JObject)prop.Value; - fieldItem["name"] = prop.Name; - fieldsArray.Add(fieldItem); - } - else - { - var fieldItem = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; - fieldsArray.Add(fieldItem); - } - } - - obj["fields"] = fieldsArray; - } - - list.Add(obj.ToObject()); - } - - return list; - } - throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema"); } From 9d5ee7537a97607e2edee0428d4e9b977b9dd2ed Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 21 Jan 2026 20:52:51 -0500 Subject: [PATCH 13/16] Refactor Listenarr models and update proxy logic Split Listenarr models into separate files for ListenarrField, ListenarrIndexer, and ListenarrStatus. Enhanced ListenarrIndexer with equality logic. Updated ListenarrV1Proxy to use new models, simplified schema handling, improved error handling, and bumped minimum application version requirement. --- .../Applications/Listenarr/ListenarrField.cs | 17 +++ .../Listenarr/ListenarrIndexer.cs | 55 +++++++ .../Applications/Listenarr/ListenarrModels.cs | 48 ------ .../Applications/Listenarr/ListenarrStatus.cs | 7 + .../Listenarr/ListenarrV1Proxy.cs | 143 +++++------------- 5 files changed, 116 insertions(+), 154 deletions(-) create mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs create mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs delete mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs create mode 100644 src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs new file mode 100644 index 000000000..ce6fc7564 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrField.cs @@ -0,0 +1,17 @@ +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrField + { + public string Name { get; set; } + public object Value { get; set; } + public string Type { get; set; } + public bool Advanced { get; set; } + public string Section { get; set; } + public string Hidden { get; set; } + + public ListenarrField Clone() + { + return (ListenarrField)MemberwiseClone(); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs new file mode 100644 index 000000000..06fafb09f --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrIndexer + { + public int Id { get; set; } + public bool EnableRss { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } + public int Priority { get; set; } + public string Name { get; set; } + public string ImplementationName { get; set; } + public string Implementation { get; set; } + public List Implementations { get; set; } + public string ConfigContract { get; set; } + public string InfoLink { get; set; } + public int? DownloadClientId { get; set; } + public HashSet Tags { get; set; } + public List Fields { get; set; } + public bool Equals(ListenarrIndexer other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + // baseUrl comparison (case-insensitive) + var baseUrlEqual = string.Equals( + (string)Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value, + (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value, + StringComparison.InvariantCultureIgnoreCase); + + // categories deep equality + var catsEqual = JToken.DeepEquals( + (JArray)Fields.FirstOrDefault(x => x.Name == "categories")?.Value, + (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories")?.Value); + + // apiKey: treat masked remote key as equal + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyEqual = apiKey == otherApiKey || otherApiKey == "********"; + + // apiPath compare (could be null) + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value; + var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value; + var apiPathEqual = Equals(apiPath, otherApiPath); + + return apiKeyEqual && apiPathEqual && baseUrlEqual && catsEqual; + } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs deleted file mode 100644 index ae8634703..000000000 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrModels.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Applications.Listenarr -{ - public class ListenarrStatus - { - public string Version { get; set; } - } - - public class ListenarrIndexer - { - public int Id { get; set; } - public bool EnableRss { get; set; } - public bool EnableAutomaticSearch { get; set; } - public bool EnableInteractiveSearch { get; set; } - public int Priority { get; set; } - public string Name { get; set; } - public string ImplementationName { get; set; } - public string Implementation { get; set; } - public List Implementations { get; set; } - public string ConfigContract { get; set; } - public string InfoLink { get; set; } - public int? DownloadClientId { get; set; } - public HashSet Tags { get; set; } - public List Fields { get; set; } - } - - public class ListenarrField - { - public string Name { get; set; } - public object Value { get; set; } - public string Type { get; set; } - public bool Advanced { get; set; } - public string Section { get; set; } - public string Hidden { get; set; } - - public ListenarrField Clone() - { - return (ListenarrField)MemberwiseClone(); - } - } - - public class ListenarrTag - { - public int Id { get; set; } - public string Label { get; set; } - } -} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs new file mode 100644 index 000000000..075f9ce06 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrStatus.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Applications.Listenarr +{ + public class ListenarrStatus + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index a00dbbcef..ad0823ef7 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using FluentValidation.Results; @@ -24,7 +23,7 @@ public interface IListenarrV1Proxy public class ListenarrV1Proxy : IListenarrV1Proxy { - private static Version MinimumApplicationVersion => new(0, 2, 47, 0); + private static Version MinimumApplicationVersion => new(0, 2, 48, 0); private const string AppApiRoute = "/api/v1"; private const string AppIndexerApiRoute = $"{AppApiRoute}/indexer"; @@ -38,10 +37,15 @@ public ListenarrV1Proxy(IHttpClient httpClient, Logger logger) _logger = logger; } + public ListenarrStatus GetStatus(ListenarrSettings settings) + { + var request = BuildRequest(settings, $"{AppApiRoute}/system/status", HttpMethod.Get); + return Execute(request); + } + public List GetIndexers(ListenarrSettings settings) { var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get); - return Execute>(request); } @@ -52,10 +56,15 @@ public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings) var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get); return Execute(request); } - catch (HttpException) + catch (HttpException ex) { - return null; + if (ex.Response.StatusCode != HttpStatusCode.NotFound) + { + throw; + } } + + return null; } public void RemoveIndexer(int indexerId, ListenarrSettings settings) @@ -67,82 +76,11 @@ public void RemoveIndexer(int indexerId, ListenarrSettings settings) public List GetIndexerSchema(ListenarrSettings settings) { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/schema", HttpMethod.Get); - - var response = _httpClient.Execute(request); - - if ((int)response.StatusCode >= 300) - { - throw new HttpException(response); - } - - var token = Newtonsoft.Json.Linq.JToken.Parse(response.Content); - - if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object) - { - var obj = (Newtonsoft.Json.Linq.JObject)token; - - if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj) - { - var fieldsArray = new Newtonsoft.Json.Linq.JArray(); - - foreach (var prop in fieldsObj.Properties()) - { - if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object) - { - var item = (Newtonsoft.Json.Linq.JObject)prop.Value; - item["name"] = prop.Name; - fieldsArray.Add(item); - } - else - { - var item = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value }; - fieldsArray.Add(item); - } - } - - obj["fields"] = fieldsArray; - } - - if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0) - { - return new List { obj.ToObject() }; - } - - return new List { obj.ToObject() }; - } - - throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema"); + return Execute>(request); } public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings) { - try - { - var incomingBaseUrl = indexer?.Fields?.FirstOrDefault(f => f.Name == "baseUrl")?.Value as string; - if (!string.IsNullOrWhiteSpace(incomingBaseUrl)) - { - var existing = GetIndexers(settings); - if (existing != null) - { - var match = existing.FirstOrDefault(e => - string.Equals( - (e.Fields?.FirstOrDefault(f => f.Name == "baseUrl")?.Value as string)?.TrimEnd('/'), - incomingBaseUrl.TrimEnd('/'), - StringComparison.InvariantCultureIgnoreCase)); - - if (match != null) - { - _logger.Debug("Found existing remote indexer matching baseUrl; skipping add and returning existing id {0}", match.Id); - return match; - } - } - } - } - catch (Exception ex) - { - _logger.Debug(ex, "Failed to run pre-flight existence check before AddIndexer; proceeding to create"); - } - var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); @@ -150,14 +88,14 @@ public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings s try { - _logger.Debug("Request payload: {0}", request.ContentSummary); - return Execute(request); + return ExecuteIndexerRequest(request); } catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) { - _logger.Debug("Retrying to add indexer forcefully. Original response: {0}", ex.Response?.Content ?? string.Empty); + _logger.Debug("Retrying to add indexer forcefully"); + request.Url = request.Url.AddQueryParam("forceSave", "true"); - _logger.Debug("Retry payload: {0}", request.ContentSummary); + return ExecuteIndexerRequest(request); } } @@ -176,7 +114,9 @@ public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSetting catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) { _logger.Debug("Retrying to update indexer forcefully"); + request.Url = request.Url.AddQueryParam("forceSave", "true"); + return ExecuteIndexerRequest(request); } } @@ -188,26 +128,19 @@ public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSetti request.SetContent(indexer.ToJson()); request.ContentSummary = indexer.ToJson(Formatting.None); - try + var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + + if (applicationVersion == null) { - var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); - - if (applicationVersion == null) - { - return new ValidationFailure(string.Empty, "Failed to fetch Listenarr version"); - } - - if (new Version(applicationVersion) < MinimumApplicationVersion) - { - return new ValidationFailure(string.Empty, $"Listenarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); - } - - return null; + return new ValidationFailure(string.Empty, "Failed to fetch Listenarr version"); } - catch (HttpException) + + if (new Version(applicationVersion) < MinimumApplicationVersion) { - throw; + return new ValidationFailure(string.Empty, $"Listenarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); } + + return null; } private ListenarrIndexer ExecuteIndexerRequest(HttpRequest request) @@ -218,31 +151,29 @@ private ListenarrIndexer ExecuteIndexerRequest(HttpRequest request) } catch (HttpException ex) { - var responseContent = ex.Response?.Content ?? string.Empty; - switch (ex.Response.StatusCode) { case HttpStatusCode.Unauthorized: - _logger.Warn(ex, "API Key is invalid. Response: {0}", responseContent); + _logger.Warn(ex, "API Key is invalid"); break; case HttpStatusCode.BadRequest: - if (responseContent.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) + if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App. Response: {0}", responseContent); + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); break; } - _logger.Error(ex, "Invalid Request. Response: {0}", responseContent); + _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: case HttpStatusCode.TemporaryRedirect: - _logger.Warn(ex, "App returned redirect and is invalid. Check App URL. Response: {0}", responseContent); + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: - _logger.Warn(ex, "Remote indexer not found. Response: {0}", responseContent); + _logger.Warn(ex, "Remote indexer not found"); break; default: - _logger.Error(ex, "Unexpected response status code: {0}. Response: {1}", ex.Response.StatusCode, responseContent); + _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); break; } From 112bc590b540ca934b9b9dca22491ddd92c60d5a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 22 Jan 2026 10:34:17 -0500 Subject: [PATCH 14/16] Refactor Listenarr integration and remove tests Removed ListenarrFixture and ListenarrV1ProxyFixture test files. Refactored Listenarr.cs to simplify indexer mapping, addition, and update logic, removing redundant error handling and schema validation. Simplified ListenarrIndexer equality logic and removed unused properties. --- .../Listenarr/ListenarrFixture.cs | 362 ------------------ .../Listenarr/ListenarrV1ProxyFixture.cs | 110 ------ .../Applications/Listenarr/Listenarr.cs | 295 ++------------ .../Listenarr/ListenarrIndexer.cs | 45 ++- 4 files changed, 55 insertions(+), 757 deletions(-) delete mode 100644 src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs delete mode 100644 src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs deleted file mode 100644 index 84f1fc873..000000000 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrFixture.cs +++ /dev/null @@ -1,362 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Cache; -using NzbDrone.Core.Applications; -using NzbDrone.Core.Applications.Listenarr; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.Applications.Listenarr -{ - [TestFixture] - public class ListenarrFixture : CoreTest - { - [SetUp] - public void Setup() - { - Subject.Definition = new ApplicationDefinition - { - Settings = new ListenarrSettings - { - ProwlarrUrl = "http://localhost:9696", - BaseUrl = "http://localhost:4545", - ApiKey = "abc", - SyncCategories = new List { NewznabStandardCategory.AudioAudiobook.Id } - } - }; - - Mocker.GetMock().SetupGet(c => c.ApiKey).Returns("abc"); - - var cached = new Mock>>(); - cached.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - - Mocker.GetMock().Setup(m => m.GetCache>(It.IsAny())).Returns(cached.Object); - } - - [Test] - public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowlarr() - { - var indexer = new ListenarrIndexer - { - Id = 99, - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "http://localhost:9696/45/api" }, - new ListenarrField { Name = "apiKey", Value = "abc" } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { indexer }); - - var mappings = Subject.GetIndexerMappings(); - - mappings.Should().HaveCount(1); - mappings[0].IndexerId.Should().Be(45); - mappings[0].RemoteIndexerId.Should().Be(99); - } - - [Test] - public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl() - { - var indexer = new ListenarrIndexer - { - Id = 100, - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "http://external/1/api" }, - new ListenarrField { Name = "apiKey", Value = "wrong" } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { indexer }); - - var mappings = Subject.GetIndexerMappings(); - - mappings.Should().BeEmpty(); - } - - [Test] - public void Test_should_call_testconnection_and_return_success_when_valid() - { - var schema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "" }, - new ListenarrField { Name = "apiPath", Value = "" }, - new ListenarrField { Name = "apiKey", Value = "" }, - new ListenarrField { Name = "categories", Value = new List() } - } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); - Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Returns((FluentValidation.Results.ValidationFailure)null).Verifiable(); - - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - - var result = Subject.Test(); - - result.IsValid.Should().BeTrue(); - Mocker.GetMock().Verify(m => m.TestConnection(It.IsAny(), It.IsAny()), Times.Once); - } - - [Test] - public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incomplete() - { - var cachedSchema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "apiKey", Value = "" } - } - } - }; - - var freshSchema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "" }, - new ListenarrField { Name = "apiPath", Value = "" }, - new ListenarrField { Name = "apiKey", Value = "" }, - new ListenarrField { Name = "categories", Value = new List() } - } - } - }; - - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => cachedSchema); - - typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(freshSchema); - Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Returns((FluentValidation.Results.ValidationFailure)null); - - var result = Subject.Test(); - - result.IsValid.Should().BeTrue(); - Mocker.GetMock().Verify(m => m.GetIndexerSchema(It.IsAny()), Times.AtLeastOnce); - } - - [Test] - public void Test_should_handle_exception_from_testconnection() - { - var schema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "" }, - new ListenarrField { Name = "apiPath", Value = "" }, - new ListenarrField { Name = "apiKey", Value = "" }, - new ListenarrField { Name = "categories", Value = new List() } - } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); - Mocker.GetMock().Setup(c => c.TestConnection(It.IsAny(), It.IsAny())).Throws(new Exception("boom")); - - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - - var result = Subject.Test(); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to complete application test")); - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void Test_should_fail_when_schema_missing() - { - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(new List()); - - var method = typeof(Core.Applications.Listenarr.Listenarr).GetMethod("BuildListenarrIndexer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var indexerDef = new IndexerDefinition { Id = 1, Name = "Test", Protocol = DownloadProtocol.Usenet, Capabilities = new IndexerCapabilities() }; - - var ex = Assert.Throws(() => method.Invoke(Subject, new object[] { indexerDef, indexerDef.Capabilities, DownloadProtocol.Usenet, 0 })); - Assert.IsInstanceOf(ex.InnerException); - Assert.That(ex.InnerException.Message, Does.Contain("indexer schemas")); - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void Test_should_fail_when_schema_missing_required_fields() - { - var schema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "apiKey", Value = "" } - } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); - - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - - try - { - var result = Subject.Test(); - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("missing required fields")); - } - finally - { - ExceptionVerification.IgnoreWarns(); - } - } - - [Test] - public void AddIndexer_should_insert_app_indexer_mapping_on_success() - { - var indexerDefinition = new IndexerDefinition - { - Id = 12, - Name = "TestIndexer", - Protocol = DownloadProtocol.Usenet, - Capabilities = new IndexerCapabilities(), - Enable = true, - AppProfile = new LazyLoaded(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true }) - }; - - indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook); - - var mockIndexer = new Mock(); - mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities); - - Mocker.GetMock().Setup(m => m.GetInstance(It.IsAny())).Returns(mockIndexer.Object); - - var schema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "" }, - new ListenarrField { Name = "apiPath", Value = "" }, - new ListenarrField { Name = "apiKey", Value = "" }, - new ListenarrField { Name = "categories", Value = new List() } - } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); - Mocker.GetMock().Setup(c => c.AddIndexer(It.IsAny(), It.IsAny())).Returns(new ListenarrIndexer { Id = 501 }); - - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - - indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); - - Subject.AddIndexer(indexerDefinition); - - Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Once()); - Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); - } - - [Test] - public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches() - { - var indexerDefinition = new IndexerDefinition - { - Id = 12, - Name = "TestIndexer", - Protocol = DownloadProtocol.Usenet, - Capabilities = new IndexerCapabilities(), - Enable = true, - AppProfile = new LazyLoaded(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true }) - }; - - indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook); - - var mockIndexer = new Mock(); - mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities); - - Mocker.GetMock().Setup(m => m.GetInstance(It.IsAny())).Returns(mockIndexer.Object); - - var schema = new List - { - new ListenarrIndexer - { - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = "" }, - new ListenarrField { Name = "apiPath", Value = "" }, - new ListenarrField { Name = "apiKey", Value = "" }, - new ListenarrField { Name = "categories", Value = new List() } - } - } - }; - - var existing = new ListenarrIndexer - { - Id = 501, - Implementation = "Newznab", - Fields = new List - { - new ListenarrField { Name = "baseUrl", Value = $"{((ListenarrSettings)Subject.Definition.Settings).ProwlarrUrl.TrimEnd('/')}/12/" }, - new ListenarrField { Name = "apiPath", Value = "/api" }, - new ListenarrField { Name = "apiKey", Value = "abc" }, - new ListenarrField { Name = "categories", Value = new List() } - } - }; - - Mocker.GetMock().Setup(c => c.GetIndexerSchema(It.IsAny())).Returns(schema); - Mocker.GetMock().Setup(c => c.GetIndexers(It.IsAny())).Returns(new List { existing }); - - var cachedForTest = new Mock>>(); - cachedForTest.Setup(c => c.Get(It.IsAny(), It.IsAny>>(), It.IsAny())) - .Returns>, TimeSpan>((k, f, t) => f()); - typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object); - - indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty(); - - Subject.AddIndexer(indexerDefinition); - - Mocker.GetMock().Verify(m => m.AddIndexer(It.IsAny(), It.IsAny()), Times.Never()); - Mocker.GetMock().Verify(m => m.Insert(It.Is(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs b/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs deleted file mode 100644 index 5500b696a..000000000 --- a/src/NzbDrone.Core.Test/Applications/Listenarr/ListenarrV1ProxyFixture.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Net; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Applications.Listenarr; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.Applications.Listenarr -{ - [TestFixture] - public class ListenarrV1ProxyFixture : TestBase - { - [Test] - public void GetIndexers_should_deserialize_json_and_set_api_key_header() - { - var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - - var responseJson = new[] - { - new - { - Id = "42", - Name = "Test", - Implementation = "Newznab", - Fields = new[] - { - new { name = "baseUrl", value = "http://localhost:4545/1/api" }, - new { name = "apiKey", value = "x" }, - } - } - }.ToJson(); - - HttpRequest capturedRequest = null; - - Mocker.GetMock() - .Setup(c => c.Execute(It.IsAny())) - .Returns(req => - { - capturedRequest = req; - return new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), System.Text.Encoding.UTF8.GetBytes(responseJson), 0, HttpStatusCode.OK, new Version("1.0")); - }); - - var result = Subject.GetIndexers(settings); - - result.Should().NotBeNull(); - result.Count.Should().Be(1); - capturedRequest.Headers.GetSingleValue("X-Api-Key").Should().Be("abc123"); - - (capturedRequest.Url.ToString().Contains("/api/indexer") || capturedRequest.Url.ToString().Contains("/api/v1/indexer")).Should().BeTrue(); - } - - [Test] - public void GetIndexerSchema_should_handle_single_object_response_with_fields_object() - { - var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - - var json = "[ { \"id\": 1, \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" }, { \"name\": \"apiKey\", \"type\": \"text\" } ] } ]"; - - Mocker.GetMock() - .Setup(c => c.Execute(It.IsAny())) - .Returns(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"))); - - var result = Subject.GetIndexerSchema(settings); - - result.Should().NotBeNull(); - result.Count.Should().Be(1); - result[0].Fields.Should().NotBeNull(); - result[0].Fields.Count.Should().Be(2); - result[0].Fields.Should().Contain(f => f.Name == "baseUrl"); - result[0].Fields.Should().Contain(f => f.Name == "apiKey"); - } - - [Test] - public void GetIndexerSchema_should_preserve_implementations_array_as_list() - { - var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" }; - - var json = "[ { \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] } ]"; - - Mocker.GetMock() - .Setup(c => c.Execute(It.IsAny())) - .Returns(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"))); - - var result = Subject.GetIndexerSchema(settings); - - result.Should().NotBeNull(); - result.Count.Should().Be(1); - result[0].Implementations.Should().NotBeNull(); - result[0].Implementations.Should().Contain("Newznab"); - result[0].Implementations.Should().Contain("Torznab"); - } - - [Test] - public void Execute_should_throw_application_exception_when_unauthorized() - { - var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "bad" }; - - Mocker.GetMock() - .Setup(c => c.Execute(It.IsAny())) - .Throws(new HttpException(new HttpResponse(new HttpRequest("http://localhost/"), new HttpHeader(), new CookieCollection(), "unauthorized", 0, HttpStatusCode.Unauthorized, new Version("1.0")))); - - Assert.Throws(() => Subject.GetIndexers(settings)); - - ExceptionVerification.ExpectedWarns(0); - } - } -} diff --git a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs index b7579983c..6b13f67c9 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/Listenarr.cs @@ -90,17 +90,17 @@ public override ValidationResult Test() public override List GetIndexerMappings() { - var indexers = (_listenarrV1Proxy.GetIndexers(Settings) ?? new List()) + var indexers = _listenarrV1Proxy.GetIndexers(Settings) .Where(i => i.Implementation is "Newznab" or "Torznab"); var mappings = new List(); foreach (var indexer in indexers) { - var baseUrl = (string)indexer.Fields?.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && - (string)indexer.Fields?.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) { continue; } @@ -109,6 +109,7 @@ public override List GetIndexerMappings() if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) { + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -138,27 +139,6 @@ public override void AddIndexer(IndexerDefinition indexer) var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); - try - { - var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); - var baseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; - - var match = remoteIndexers.FirstOrDefault(r => - ((string)r.Fields.FirstOrDefault(f => f.Name == "baseUrl")?.Value ?? "").Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase) - || (r.Name?.Contains(indexer.Name, StringComparison.InvariantCultureIgnoreCase) == true)); - - if (match != null) - { - _logger.Debug("Found existing remote indexer for {0} as {1} [{2}], inserting mapping", indexer.Name, match.Name, match.Id); - _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = match.Id }); - return; - } - } - catch (Exception ex) - { - _logger.Warn(ex, "Error while attempting to discover existing remote indexer for {0} [{1}]", indexer.Name, indexer.Id); - } - var remoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, Settings); if (remoteIndexer == null) @@ -193,126 +173,7 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - if (indexerMapping == null) - { - if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && - indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) - { - _logger.Debug("No mapping found for {0} [{1}], adding to Listenarr", indexer.Name, indexer.Id); - - var listenarrIndexerToAdd = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); - listenarrIndexerToAdd.Id = 0; - ListenarrIndexer newRemoteIndexer = null; - - try - { - newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexerToAdd, Settings); - } - catch (HttpException ex) - { - _logger.Warn(ex, "Failed to add indexer {0} [{1}] to Listenarr: {2}", indexer.Name, indexer.Id, ex.Response?.StatusCode); - } - - if (newRemoteIndexer != null) - { - _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); - } - else - { - try - { - var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); - var baseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; - - var match = remoteIndexers.FirstOrDefault(r => - ((string)r.Fields.FirstOrDefault(f => f.Name == "baseUrl")?.Value ?? "").Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase) - || (r.Name?.Contains(indexer.Name, StringComparison.InvariantCultureIgnoreCase) == true)); - - if (match != null) - { - _logger.Debug("Found existing remote indexer for {0} as {1} [{2}], inserting mapping", indexer.Name, match.Name, match.Id); - _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = match.Id }); - } - else - { - _logger.Debug("No remote indexer found for {0} after failing to add; skipping mapping", indexer.Name); - } - } - catch (Exception ex) - { - _logger.Warn(ex, "Error while attempting to discover existing remote indexer for {0} [{1}]", indexer.Name, indexer.Id); - } - } - } - else - { - _logger.Debug("No mapping found for {0} [{1}], skipping add due to indexer capabilities", indexer.Name, indexer.Id); - } - - return; - } - - if (indexerMapping.RemoteIndexerId == 0) - { - _logger.Warn("Mapping for indexer {0} contains invalid remote id 0, removing mapping and re-adding if possible", indexer.Id); - _appIndexerMapService.Delete(indexerMapping.Id); - - if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && - indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) - { - var listenarrIndexerToAdd = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol); - listenarrIndexerToAdd.Id = 0; - ListenarrIndexer newRemoteIndexer = null; - - try - { - newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexerToAdd, Settings); - } - catch (HttpException ex) - { - _logger.Warn(ex, "Failed to add indexer {0} [{1}] to Listenarr: {2}", indexer.Name, indexer.Id, ex.Response?.StatusCode); - } - - if (newRemoteIndexer != null) - { - _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); - } - else - { - try - { - var remoteIndexers = _listenarrV1Proxy.GetIndexers(Settings); - var baseUrl = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; - - var match = remoteIndexers.FirstOrDefault(r => - ((string)r.Fields.FirstOrDefault(f => f.Name == "baseUrl")?.Value ?? "").Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase) - || (r.Name?.Contains(indexer.Name, StringComparison.InvariantCultureIgnoreCase) == true)); - - if (match != null) - { - _logger.Debug("Found existing remote indexer for {0} as {1} [{2}], inserting mapping", indexer.Name, match.Name, match.Id); - _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = match.Id }); - } - else - { - _logger.Debug("No remote indexer found for {0} after failing to add; skipping mapping", indexer.Name); - } - } - catch (Exception ex) - { - _logger.Warn(ex, "Error while attempting to discover existing remote indexer for {0} [{1}]", indexer.Name, indexer.Id); - } - } - } - else - { - _logger.Debug("Skipping re-add due to indexer capabilities for {0} [{1}]", indexer.Name, indexer.Id); - } - - return; - } - - var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping.RemoteIndexerId); + var listenarrIndexer = BuildListenarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); var remoteIndexer = _listenarrV1Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); @@ -327,24 +188,21 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f if ((indexerCapabilities.MusicSearchAvailable || indexerCapabilities.SearchAvailable) && indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { - if (remoteIndexer.Fields != null) - { - listenarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => listenarrIndexer.Fields.All(s => s.Name != f.Name))); - } + // Retain user fields not-affiliated with Prowlarr + listenarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => listenarrIndexer.Fields.All(s => s.Name != f.Name))); - if (remoteIndexer.Tags != null) - { - listenarrIndexer.Tags.UnionWith(remoteIndexer.Tags); - } + // Retain user tags not-affiliated with Prowlarr + listenarrIndexer.Tags.UnionWith(remoteIndexer.Tags); + // Retain user settings not-affiliated with Prowlarr listenarrIndexer.DownloadClientId = remoteIndexer.DownloadClientId; - listenarrIndexer.Id = remoteIndexer.Id; - + // Update the indexer if it still has categories that match _listenarrV1Proxy.UpdateIndexer(listenarrIndexer, Settings); } else { + // Else remove it, it no longer should be used _listenarrV1Proxy.RemoveIndexer(remoteIndexer.Id, Settings); _appIndexerMapService.Delete(indexerMapping.Id); } @@ -360,11 +218,11 @@ public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = f _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Listenarr", indexer.Name, indexer.Id); listenarrIndexer.Id = 0; var newRemoteIndexer = _listenarrV1Proxy.AddIndexer(listenarrIndexer, Settings); - - if (newRemoteIndexer != null) - { - _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = newRemoteIndexer.Id }); - } + _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 Listenarr due to indexer capabilities", indexer.Name, indexer.Id); } } } @@ -373,56 +231,20 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe { var cacheKey = $"{Settings.BaseUrl}"; var schemas = _schemaCache.Get(cacheKey, () => _listenarrV1Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); - var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; + var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime" }; - if (schemas == null || !schemas.Any()) - { - try - { - schemas = _listenarrV1Proxy.GetIndexerSchema(Settings); - } - catch (Exception ex) - { - _logger.Warn(ex, "Unable to fetch indexer schemas from Listenarr at {0}", Settings.BaseUrl); - schemas = null; - } - } + var newznab = schemas.First(i => i.Implementation == "Newznab"); + var torznab = schemas.First(i => i.Implementation == "Torznab"); - if (schemas == null || !schemas.Any()) - { - _logger.Warn("No indexer schemas were returned from Listenarr at {0}", Settings.BaseUrl); - throw new ApplicationException("Listenarr returned no indexer schemas. Ensure Listenarr exposes '/api/v1/indexer/schema' and that it returns a JSON array of schemas containing 'Newznab' or 'Torznab'."); - } - - if (id == 0) - { - syncFields.AddRange(new List { "additionalParameters" }); - } - - var newznab = schemas.FirstOrDefault(i => string.Equals(i.Implementation, "Newznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Newznab", StringComparison.InvariantCultureIgnoreCase)))); - var torznab = schemas.FirstOrDefault(i => string.Equals(i.Implementation, "Torznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Torznab", StringComparison.InvariantCultureIgnoreCase)))); - - if (newznab == null && torznab == null) - { - _logger.Warn("Indexer schemas are missing 'Newznab' and 'Torznab' implementations from Listenarr at {0}", Settings.BaseUrl); - throw new ApplicationException("Listenarr indexer schema must include at least one of 'Newznab' or 'Torznab' implementations."); - } - - var schema = protocol == DownloadProtocol.Usenet ? newznab ?? torznab : torznab ?? newznab; - - if (schema == null) - { - _logger.Warn("No schema available for protocol {0} from Listenarr at {1}", protocol, Settings.BaseUrl); - throw new ApplicationException($"Listenarr indexer schema does not contain a suitable implementation for protocol {protocol}."); - } + var schema = protocol == DownloadProtocol.Usenet ? newznab : torznab; var listenarrIndexer = new ListenarrIndexer { Id = id, Name = $"{indexer.Name} (Prowlarr)", - EnableRss = indexer.Enable && (indexer.AppProfile?.Value?.EnableRss ?? false), - EnableAutomaticSearch = indexer.Enable && (indexer.AppProfile?.Value?.EnableAutomaticSearch ?? false), - EnableInteractiveSearch = indexer.Enable && (indexer.AppProfile?.Value?.EnableInteractiveSearch ?? false), + EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch, Priority = indexer.Priority, Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", ConfigContract = schema.ConfigContract, @@ -432,71 +254,10 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe listenarrIndexer.Fields.AddRange(schema.Fields.Where(x => syncFields.Contains(x.Name))); - var requiredFieldNames = new List { "baseUrl", "apiPath", "apiKey", "categories" }; - var missing = requiredFieldNames.Where(f => listenarrIndexer.Fields.All(x => x.Name != f)).ToList(); - - if (missing.Any()) - { - _logger.Debug("Cached schema is missing required fields [{0}]. Attempting to refresh schema from proxy", string.Join(", ", missing)); - - try - { - var freshSchemas = _listenarrV1Proxy.GetIndexerSchema(Settings); - - if (freshSchemas != null && freshSchemas.Any()) - { - var freshNewznab = freshSchemas.FirstOrDefault(i => string.Equals(i.Implementation, "Newznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Newznab", StringComparison.InvariantCultureIgnoreCase)))); - var freshTorznab = freshSchemas.FirstOrDefault(i => string.Equals(i.Implementation, "Torznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Torznab", StringComparison.InvariantCultureIgnoreCase)))); - - var freshSchema = protocol == DownloadProtocol.Usenet ? freshNewznab ?? freshTorznab : freshTorznab ?? freshNewznab; - - if (freshSchema != null) - { - listenarrIndexer.Fields = freshSchema.Fields.Where(x => syncFields.Contains(x.Name)).ToList(); - missing = requiredFieldNames.Where(f => listenarrIndexer.Fields.All(x => x.Name != f)).ToList(); - - if (!missing.Any()) - { - _logger.Debug("Fresh schema contained required fields; proceeding with fresh schema"); - } - } - } - } - catch (Exception ex) - { - _logger.Warn(ex, "Unable to refresh indexer schemas from Listenarr at {0}", Settings.BaseUrl); - } - } - - if (missing.Any()) - { - _logger.Warn("Indexer schema missing required fields [{0}] from Listenarr at {1}", string.Join(", ", missing), Settings.BaseUrl); - throw new ApplicationException($"Listenarr indexer schema missing required fields: {string.Join(", ", missing)}. Ensure '/api/v1/indexer/schema' includes these fields."); - } - - var field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl"); - if (field != null) - { - field.Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; - } - - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath"); - if (field != null) - { - field.Value = "/api"; - } - - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey"); - if (field != null) - { - field.Value = _configFileProvider.ApiKey; - } - - field = listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories"); - if (field != null) - { - field.Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); - } + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; + listenarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); return listenarrIndexer; } diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs index 06fafb09f..11b3a2b9f 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrIndexer.cs @@ -15,12 +15,12 @@ public class ListenarrIndexer public string Name { get; set; } public string ImplementationName { get; set; } public string Implementation { get; set; } - public List Implementations { get; set; } public string ConfigContract { get; set; } public string InfoLink { get; set; } public int? DownloadClientId { get; set; } public HashSet Tags { get; set; } public List Fields { get; set; } + public bool Equals(ListenarrIndexer other) { if (ReferenceEquals(null, other)) @@ -28,28 +28,37 @@ public bool Equals(ListenarrIndexer other) return false; } - // baseUrl comparison (case-insensitive) - var baseUrlEqual = string.Equals( - (string)Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value, - (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value, - StringComparison.InvariantCultureIgnoreCase); + var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; + var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); - // categories deep equality - var catsEqual = JToken.DeepEquals( - (JArray)Fields.FirstOrDefault(x => x.Name == "categories")?.Value, - (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories")?.Value); - - // apiKey: treat masked remote key as equal var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; - var apiKeyEqual = apiKey == otherApiKey || otherApiKey == "********"; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; - // apiPath compare (could be null) - var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value; - var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value; - var apiPathEqual = Equals(apiPath, otherApiPath); + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; + var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; + var apiPathCompare = apiPath.Equals(otherApiPath); - return apiKeyEqual && apiPathEqual && baseUrlEqual && catsEqual; + var minimumSeeders = Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value); + var otherMinimumSeeders = other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value); + var minimumSeedersCompare = minimumSeeders == otherMinimumSeeders; + + var seedTime = Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value); + var otherSeedTime = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value); + var seedTimeCompare = seedTime == otherSeedTime; + + var seedRatio = Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); + var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); + var seedRatioCompare = seedRatio == otherSeedRatio; + + return other.EnableRss == EnableRss && + other.EnableAutomaticSearch == EnableAutomaticSearch && + other.EnableInteractiveSearch == EnableInteractiveSearch && + other.Name == Name && + other.Implementation == Implementation && + other.Priority == Priority && + other.Id == Id && + apiKeyCompare && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare; } } } From 3b9877a9c1167f25043eb8f52599b118b90ebcd3 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Apr 2026 20:54:10 -0400 Subject: [PATCH 15/16] Bump Listenarr min version and update indexer route Increase the minimum Listenarr application version to 0.2.65.0 and change the indexer API route from /api/v1/indexer to /api/v1/prowlarr/indexer. This will will keep things separated and more maintainable. --- src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index ad0823ef7..c541f117b 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -23,10 +23,10 @@ public interface IListenarrV1Proxy public class ListenarrV1Proxy : IListenarrV1Proxy { - private static Version MinimumApplicationVersion => new(0, 2, 48, 0); + private static Version MinimumApplicationVersion => new(0, 2, 65, 0); private const string AppApiRoute = "/api/v1"; - private const string AppIndexerApiRoute = $"{AppApiRoute}/indexer"; + private const string AppIndexerApiRoute = $"{AppApiRoute}/prowlarr/indexer"; private readonly IHttpClient _httpClient; private readonly Logger _logger; From 184b6ac2f45599fc42882d677b9061a7a882a071 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Apr 2026 20:54:38 -0400 Subject: [PATCH 16/16] Update ListenarrV1Proxy.cs --- src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs index c541f117b..e5d7cf049 100644 --- a/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Listenarr/ListenarrV1Proxy.cs @@ -23,7 +23,7 @@ public interface IListenarrV1Proxy public class ListenarrV1Proxy : IListenarrV1Proxy { - private static Version MinimumApplicationVersion => new(0, 2, 65, 0); + private static Version MinimumApplicationVersion => new(0, 2, 66, 0); private const string AppApiRoute = "/api/v1"; private const string AppIndexerApiRoute = $"{AppApiRoute}/prowlarr/indexer";