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.
This commit is contained in:
Robbie Davis 2026-01-21 14:20:39 -05:00
parent 22f582af07
commit 62b1e259fe
5 changed files with 74 additions and 275 deletions

View file

@ -17,7 +17,7 @@
namespace NzbDrone.Core.Test.Applications.Listenarr
{
[TestFixture]
public class ListenarrFixture : CoreTest<NzbDrone.Core.Applications.Listenarr.Listenarr>
public class ListenarrFixture : CoreTest<Core.Applications.Listenarr.Listenarr>
{
[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<List<ListenarrIndexer>> mock via dynamic mocking to avoid depending on concrete implementation
var cached = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cached.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
var cached = new Mock<ICached<List<ListenarrIndexer>>>();
cached.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
Mocker.GetMock<ICacheManager>().Setup(m => m.GetCache<System.Collections.Generic.List<ListenarrIndexer>>(It.IsAny<Type>())).Returns(cached.Object);
Mocker.GetMock<ICacheManager>().Setup(m => m.GetCache<List<ListenarrIndexer>>(It.IsAny<Type>())).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<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { 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<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { 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<ListenarrIndexer>
{
new ListenarrIndexer
@ -116,16 +109,13 @@ public void Test_should_call_testconnection_and_return_success_when_valid()
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).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<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, 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<ICached<List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, 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<IListenarrV1Proxy>().Verify(m => m.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), 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<ListenarrIndexer>
{
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<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => cachedSchema);
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, 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<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(freshSchema);
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Returns((FluentValidation.Results.ValidationFailure)null);
// Act
var result = Subject.Test();
// Assert
result.IsValid.Should().BeTrue();
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.GetIndexerSchema(It.IsAny<ListenarrSettings>()), 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<ListenarrIndexer>
{
new ListenarrIndexer
@ -202,16 +186,13 @@ public void Test_should_handle_exception_from_testconnection()
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Throws(new Exception("boom"));
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, 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<ICached<List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, 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<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer>());
// 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<System.Reflection.TargetInvocationException>(() => 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<ListenarrIndexer>
{
new ListenarrIndexer
@ -251,13 +229,11 @@ public void Test_should_fail_when_schema_missing_required_fields()
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, 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<ICached<List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, 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<AppSyncProfile>(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<IIndexer>();
@ -311,19 +284,15 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success()
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Returns(new ListenarrIndexer { Id = 501 });
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, 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<ICached<List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, 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<IListenarrV1Proxy>().Verify(m => m.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), Times.Once());
Mocker.GetMock<IAppIndexerMapService>().Verify(m => m.Insert(It.Is<AppIndexerMap>(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<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { existing });
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, 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<ICached<List<ListenarrIndexer>>>();
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
.Returns<string, Func<List<ListenarrIndexer>>, 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<IListenarrV1Proxy>().Verify(m => m.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), Times.Never());
Mocker.GetMock<IAppIndexerMapService>().Verify(m => m.Insert(It.Is<AppIndexerMap>(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once());
}

View file

@ -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<ListenarrV1Proxy>
[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<HttpRequest>(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<IHttpClient>()
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(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<IHttpClient>()
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(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<IHttpClient>()
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
.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<NzbDrone.Common.Http.HttpException>(() => Subject.GetIndexers(settings));
// No warning is logged by GetIndexers on unauthorized (it throws before the API-key-specific log path)
ExceptionVerification.ExpectedWarns(0);
}
}

View file

@ -416,8 +416,8 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe
syncFields.AddRange(new List<string> { "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;

View file

@ -17,6 +17,7 @@ public class ListenarrIndexer
public string Name { get; set; }
public string ImplementationName { get; set; }
public string Implementation { get; set; }
public List<string> Implementations { get; set; }
public string ConfigContract { get; set; }
public string InfoLink { get; set; }
public int? DownloadClientId { get; set; }

View file

@ -13,7 +13,6 @@ namespace NzbDrone.Core.Applications.Listenarr
{
public interface IListenarrV1Proxy
{
ListenarrStatus GetStatus(ListenarrSettings settings);
ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings);
List<ListenarrIndexer> 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<ListenarrStatus>(request);
}
public List<ListenarrIndexer> GetIndexers(ListenarrSettings settings)
{
var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get);
try
{
return Execute<List<ListenarrIndexer>>(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<List<ListenarrIndexer>>(fallback);
}
return Execute<List<ListenarrIndexer>>(request);
}
public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings)
{
try
{
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Get);
return Execute<ListenarrIndexer>(fallback);
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get);
return Execute<ListenarrIndexer>(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<ListenarrIndexer> 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<ListenarrIndexer> 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<ListenarrIndexer>();
foreach (var impl in implsArray)
{
var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone();
copy.Property("implementations")?.Remove();
copy["implementation"] = impl;
results.Add(copy.ToObject<ListenarrIndexer>());
}
return results;
return new List<ListenarrIndexer> { obj.ToObject<ListenarrIndexer>() };
}
return new List<ListenarrIndexer> { obj.ToObject<ListenarrIndexer>() };
}
// 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<ListenarrIndexer>();
@ -168,7 +125,6 @@ public List<ListenarrIndexer> 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<ListenarrIndexer> 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<ListenarrIndexer>());
}
}
else
{
list.Add(obj.ToObject<ListenarrIndexer>());
}
list.Add(obj.ToObject<ListenarrIndexer>());
}
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<List<ListenarrIndexer>>();
}
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<ListenarrIndexer>();
foreach (var impl in implsArray)
{
var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone();
copy.Property("implementations")?.Remove();
copy["implementation"] = impl;
results.Add(copy.ToObject<ListenarrIndexer>());
}
return results;
}
return new List<ListenarrIndexer> { obj.ToObject<ListenarrIndexer>() };
}
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");