From e08505f931f98a6578bf252120da11fc9f09953d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 11 Jan 2026 22:35:42 -0500 Subject: [PATCH] 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; + } + } + } +}