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; + } + } + } +}