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.
This commit is contained in:
Robbie Davis 2026-01-11 22:35:42 -05:00
parent 688434ced9
commit e08505f931
7 changed files with 636 additions and 0 deletions

View file

@ -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 (
<ProviderSettingsModal
providerData={selectedApplication}
section="applications"
onModalClose={onModalClose}
/>
);
}
export default Listenarr;

View file

@ -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<NzbDrone.Core.Applications.Listenarr.Listenarr>
{
[SetUp]
public void Setup()
{
Subject.Definition = new ApplicationDefinition
{
Settings = new ListenarrSettings
{
ProwlarrUrl = "http://localhost:9696",
BaseUrl = "http://localhost:5000",
ApiKey = "abc",
SyncCategories = new List<int> { NewznabStandardCategory.Movies.Id }
}
};
Mocker.GetMock<IConfigFileProvider>().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<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 });
// 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<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 });
// Act
var mappings = Subject.GetIndexerMappings();
// Assert
mappings.Should().BeEmpty();
}
[Test]
public void Test_should_fail_when_status_null()
{
// Arrange
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetStatus(It.IsAny<ListenarrSettings>())).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<IListenarrV1Proxy>().Setup(c => c.GetStatus(It.IsAny<ListenarrSettings>())).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<AppSyncProfile>(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<IIndexer>();
mockIndexer.Setup(i => i.GetCapabilities()).Returns(indexerDefinition.Capabilities);
Mocker.GetMock<IIndexerFactory>().Setup(m => m.GetInstance(It.IsAny<IndexerDefinition>())).Returns(mockIndexer.Object);
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).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<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());
}
}
}

View file

@ -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<ListenarrV1Proxy>
{
[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<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(), 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<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<ApplicationException>(() => Subject.GetIndexers(settings));
// expected error was logged
ExceptionVerification.ExpectedErrors(1);
}
}
}

View file

@ -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<ListenarrSettings>
{
public override string Name => "Listenarr";
private readonly IListenarrV1Proxy _listenarrV1Proxy;
private readonly IConfigFileProvider _configFileProvider;
private readonly Lazy<ITagService> _tagService;
public Listenarr(
IListenarrV1Proxy listenarrV1Proxy,
IAppIndexerMapService appIndexerMapService,
IIndexerFactory indexerFactory,
IConfigFileProvider configFileProvider,
Lazy<ITagService> tagService,
Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_listenarrV1Proxy = listenarrV1Proxy;
_configFileProvider = configFileProvider;
_tagService = tagService;
}
public override ValidationResult Test()
{
var failures = new List<ValidationFailure>();
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<AppIndexerMap> GetIndexerMappings()
{
var indexers = _listenarrV1Proxy.GetIndexers(Settings)?.Where(i => i.Implementation is "Newznab" or "Torznab");
var mappings = new List<AppIndexerMap>();
foreach (var indexer in indexers ?? Enumerable.Empty<ListenarrIndexer>())
{
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<string, string> 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<ListenarrField>
{
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<int>()
}
}
};
return listenarrIndexer;
}
}
}

View file

@ -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<ListenarrField> Fields { get; set; }
public List<int> 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; }
}
}

View file

@ -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<ListenarrSettings>
{
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<int> SyncCategories { get; set; }
[FieldDefinition(5, Type = FieldType.TagSelect, SelectOptionsProviderAction = "getTags", Label = "Tags", HelpText = "Only add indexers with these tags to Listenarr")]
public IEnumerable<int> Tags { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View file

@ -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<ListenarrIndexer> GetIndexers(ListenarrSettings settings);
ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings);
void UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings);
void RemoveIndexer(int indexerId, ListenarrSettings settings);
List<ListenarrTag> 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<ListenarrStatus>(request);
}
public List<ListenarrIndexer> GetIndexers(ListenarrSettings settings)
{
var request = BuildRequest(settings, "/api/indexer", HttpMethod.Get);
return Execute<List<ListenarrIndexer>>(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<ListenarrIndexer>(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<ListenarrIndexer>(request);
}
public void RemoveIndexer(int indexerId, ListenarrSettings settings)
{
var request = BuildRequest(settings, $"/api/indexer/{indexerId}", HttpMethod.Delete);
_httpClient.Execute(request);
}
public List<ListenarrTag> GetTags(ListenarrSettings settings)
{
var request = BuildRequest(settings, "/api/tag", HttpMethod.Get);
return Execute<List<ListenarrTag>>(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<T>(HttpRequest request)
where T : new()
{
try
{
var response = _httpClient.Execute(request);
if ((int)response.StatusCode >= 300)
{
throw new HttpException(response);
}
return Json.Deserialize<T>(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;
}
}
}
}