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.
This commit is contained in:
Robbie Davis 2026-01-22 10:34:17 -05:00
parent 9d5ee7537a
commit 112bc590b5
4 changed files with 55 additions and 757 deletions

View file

@ -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<Core.Applications.Listenarr.Listenarr>
{
[SetUp]
public void Setup()
{
Subject.Definition = new ApplicationDefinition
{
Settings = new ListenarrSettings
{
ProwlarrUrl = "http://localhost:9696",
BaseUrl = "http://localhost:4545",
ApiKey = "abc",
SyncCategories = new List<int> { NewznabStandardCategory.AudioAudiobook.Id }
}
};
Mocker.GetMock<IConfigFileProvider>().SetupGet(c => c.ApiKey).Returns("abc");
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<List<ListenarrIndexer>>(It.IsAny<Type>())).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<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "http://localhost:9696/45/api" },
new ListenarrField { Name = "apiKey", Value = "abc" }
}
};
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { 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<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "http://external/1/api" },
new ListenarrField { Name = "apiKey", Value = "wrong" }
}
};
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { indexer });
var mappings = Subject.GetIndexerMappings();
mappings.Should().BeEmpty();
}
[Test]
public void Test_should_call_testconnection_and_return_success_when_valid()
{
var schema = new List<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "" },
new ListenarrField { Name = "apiPath", Value = "" },
new ListenarrField { Name = "apiKey", Value = "" },
new ListenarrField { Name = "categories", Value = new List<int>() }
}
}
};
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();
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);
var result = Subject.Test();
result.IsValid.Should().BeTrue();
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), Times.Once);
}
[Test]
public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incomplete()
{
var cachedSchema = new List<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "apiKey", Value = "" }
}
}
};
var freshSchema = new List<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "" },
new ListenarrField { Name = "apiPath", Value = "" },
new ListenarrField { Name = "apiKey", Value = "" },
new ListenarrField { Name = "categories", Value = new List<int>() }
}
}
};
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(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
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);
var result = Subject.Test();
result.IsValid.Should().BeTrue();
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.GetIndexerSchema(It.IsAny<ListenarrSettings>()), Times.AtLeastOnce);
}
[Test]
public void Test_should_handle_exception_from_testconnection()
{
var schema = new List<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "" },
new ListenarrField { Name = "apiPath", Value = "" },
new ListenarrField { Name = "apiKey", Value = "" },
new ListenarrField { Name = "categories", Value = new List<int>() }
}
}
};
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"));
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);
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<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer>());
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 }));
Assert.IsInstanceOf<ApplicationException>(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<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "apiKey", Value = "" }
}
}
};
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
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);
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<AppSyncProfile>(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true })
};
indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook);
var mockIndexer = new Mock<IIndexer>();
mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities);
Mocker.GetMock<IIndexerFactory>().Setup(m => m.GetInstance(It.IsAny<IndexerDefinition>())).Returns(mockIndexer.Object);
var schema = new List<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "" },
new ListenarrField { Name = "apiPath", Value = "" },
new ListenarrField { Name = "apiKey", Value = "" },
new ListenarrField { Name = "categories", Value = new List<int>() }
}
}
};
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 });
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);
indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty();
Subject.AddIndexer(indexerDefinition);
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());
}
[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<AppSyncProfile>(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true })
};
indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook);
var mockIndexer = new Mock<IIndexer>();
mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities);
Mocker.GetMock<IIndexerFactory>().Setup(m => m.GetInstance(It.IsAny<IndexerDefinition>())).Returns(mockIndexer.Object);
var schema = new List<ListenarrIndexer>
{
new ListenarrIndexer
{
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
new ListenarrField { Name = "baseUrl", Value = "" },
new ListenarrField { Name = "apiPath", Value = "" },
new ListenarrField { Name = "apiKey", Value = "" },
new ListenarrField { Name = "categories", Value = new List<int>() }
}
}
};
var existing = new ListenarrIndexer
{
Id = 501,
Implementation = "Newznab",
Fields = new List<ListenarrField>
{
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<int>() }
}
};
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 });
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);
indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty();
Subject.AddIndexer(indexerDefinition);
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

@ -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<ListenarrV1Proxy>
{
[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<IHttpClient>()
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(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<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")));
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<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")));
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<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"))));
Assert.Throws<NzbDrone.Common.Http.HttpException>(() => Subject.GetIndexers(settings));
ExceptionVerification.ExpectedWarns(0);
}
}
}

View file

@ -90,17 +90,17 @@ public override ValidationResult Test()
public override List<AppIndexerMap> GetIndexerMappings()
{
var indexers = (_listenarrV1Proxy.GetIndexers(Settings) ?? new List<ListenarrIndexer>())
var indexers = _listenarrV1Proxy.GetIndexers(Settings)
.Where(i => i.Implementation is "Newznab" or "Torznab");
var mappings = new List<AppIndexerMap>();
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<AppIndexerMap> 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<string> { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" };
var syncFields = new List<string> { "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<string> { "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<string> { "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;
}

View file

@ -15,12 +15,12 @@ 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; }
public HashSet<int> Tags { get; set; }
public List<ListenarrField> 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;
}
}
}