diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs new file mode 100644 index 000000000..763735d51 --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public class LazyLibrarian : ApplicationBase + { + public override string Name => "LazyLibrarian"; + + private readonly ILazyLibrarianV1Proxy _lazyLibrarianV1Proxy; + private readonly IConfigFileProvider _configFileProvider; + + public LazyLibrarian(ILazyLibrarianV1Proxy lazyLibrarianV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) + : base(appIndexerMapService, logger) + { + _lazyLibrarianV1Proxy = lazyLibrarianV1Proxy; + _configFileProvider = configFileProvider; + } + + public override ValidationResult Test() + { + var failures = new List(); + + try + { + failures.AddIfNotNull(_lazyLibrarianV1Proxy.TestConnection(Settings)); + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to send test message"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to LazyLibrarian")); + } + + return new ValidationResult(failures); + } + + public override List GetIndexerMappings() + { + var indexers = _lazyLibrarianV1Proxy.GetIndexers(Settings); + + var mappings = new List(); + + foreach (var indexer in indexers) + { + if (indexer.Apikey == _configFileProvider.ApiKey) + { + var match = AppIndexerRegex.Match(indexer.Host); + + 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 { RemoteIndexerName = $"{indexer.Type},{indexer.Name}", IndexerId = indexerId }); + } + } + } + + return mappings; + } + + public override void AddIndexer(IndexerDefinition indexer) + { + if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + var lazyLibrarianIndexer = BuildLazyLibrarianIndexer(indexer, indexer.Protocol); + + var remoteIndexer = _lazyLibrarianV1Proxy.AddIndexer(lazyLibrarianIndexer, Settings); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerName = $"{remoteIndexer.Type},{remoteIndexer.Name}" }); + } + } + + public override void RemoveIndexer(int indexerId) + { + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId); + + if (indexerMapping != null) + { + //Remove Indexer remotely and then remove the mapping + var indexerProps = indexerMapping.RemoteIndexerName.Split(","); + _lazyLibrarianV1Proxy.RemoveIndexer(indexerProps[1], (LazyLibrarianProviderType)Enum.Parse(typeof(LazyLibrarianProviderType), indexerProps[0]), Settings); + _appIndexerMapService.Delete(indexerMapping.Id); + } + } + + public override void UpdateIndexer(IndexerDefinition indexer) + { + _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + + var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); + var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); + var indexerProps = indexerMapping.RemoteIndexerName.Split(","); + + var lazyLibrarianIndexer = BuildLazyLibrarianIndexer(indexer, indexer.Protocol, indexerProps[1]); + + //Use the old remote id to find the indexer on LazyLibrarian incase the update was from a name change in Prowlarr + var remoteIndexer = _lazyLibrarianV1Proxy.GetIndexer(indexerProps[1], lazyLibrarianIndexer.Type, Settings); + + if (remoteIndexer != null) + { + _logger.Debug("Remote indexer found, syncing with current settings"); + + if (!lazyLibrarianIndexer.Equals(remoteIndexer)) + { + _lazyLibrarianV1Proxy.UpdateIndexer(lazyLibrarianIndexer, Settings); + indexerMapping.RemoteIndexerName = $"{lazyLibrarianIndexer.Type},{lazyLibrarianIndexer.Altername}"; + _appIndexerMapService.Update(indexerMapping); + } + } + else + { + _appIndexerMapService.Delete(indexerMapping.Id); + + if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + { + _logger.Debug("Remote indexer not found, re-adding {0} to LazyLibrarian", indexer.Name); + var newRemoteIndexer = _lazyLibrarianV1Proxy.AddIndexer(lazyLibrarianIndexer, Settings); + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerName = $"{newRemoteIndexer.Type},{newRemoteIndexer.Name}" }); + } + else + { + _logger.Debug("Remote indexer not found for {0}, skipping re-add to LazyLibrarian due to indexer capabilities", indexer.Name); + } + } + } + + private LazyLibrarianIndexer BuildLazyLibrarianIndexer(IndexerDefinition indexer, DownloadProtocol protocol, string originalName = null) + { + var schema = protocol == DownloadProtocol.Usenet ? LazyLibrarianProviderType.Newznab : LazyLibrarianProviderType.Torznab; + + var lazyLibrarianIndexer = new LazyLibrarianIndexer + { + Name = originalName ?? $"{indexer.Name} (Prowlarr)", + Altername = $"{indexer.Name} (Prowlarr)", + Host = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api", + Apikey = _configFileProvider.ApiKey, + Categories = string.Join(",", indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())), + Enabled = indexer.Enable, + Type = schema, + }; + + return lazyLibrarianIndexer; + } + } +} diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianError.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianError.cs new file mode 100644 index 000000000..65338ae92 --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianError.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public class LazyLibrarianError + { + public int Code { get; set; } + public string Message { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianException.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianException.cs new file mode 100644 index 000000000..ab0d32749 --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianException.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public class LazyLibrarianException : NzbDroneException + { + public LazyLibrarianException(string message) + : base(message) + { + } + + public LazyLibrarianException(string message, params object[] args) + : base(message, args) + { + } + + public LazyLibrarianException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs new file mode 100644 index 000000000..ff77f47b7 --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public class LazyLibrarianIndexerResponse + { + public bool Success { get; set; } + public LazyLibrarianIndexerData Data { get; set; } + public LazyLibrarianError Error { get; set; } + } + + public class LazyLibrarianIndexerData + { + public List Torznabs { get; set; } + public List Newznabs { get; set; } + } + + public enum LazyLibrarianProviderType + { + Newznab, + Torznab + } + + public class LazyLibrarianIndexer + { + public string Name { get; set; } + public string Host { get; set; } + public string Apikey { get; set; } + public string Categories { get; set; } + public bool Enabled { get; set; } + public string Altername { get; set; } + public LazyLibrarianProviderType Type { get; set; } + + public bool Equals(LazyLibrarianIndexer other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + return other.Host == Host && + other.Apikey == Apikey && + other.Name == Name && + other.Categories == Categories && + other.Enabled == Enabled && + other.Altername == Altername; + } + } +} diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianSettings.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianSettings.cs new file mode 100644 index 000000000..e6c8df3dd --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianSettings.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public class LazyLibrarianSettingsValidator : AbstractValidator + { + public LazyLibrarianSettingsValidator() + { + RuleFor(c => c.BaseUrl).IsValidUrl(); + RuleFor(c => c.ProwlarrUrl).IsValidUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + RuleFor(c => c.SyncCategories).NotEmpty(); + } + } + + public class LazyLibrarianSettings : IApplicationSettings + { + private static readonly LazyLibrarianSettingsValidator Validator = new LazyLibrarianSettingsValidator(); + + public LazyLibrarianSettings() + { + ProwlarrUrl = "http://localhost:9696"; + BaseUrl = "http://localhost:5299"; + SyncCategories = new[] + { + NewznabStandardCategory.AudioAudiobook.Id, + NewznabStandardCategory.Books.Id, + NewznabStandardCategory.BooksComics.Id, + NewznabStandardCategory.BooksEBook.Id, + NewznabStandardCategory.BooksForeign.Id, + NewznabStandardCategory.BooksMags.Id, + NewznabStandardCategory.BooksOther.Id, + NewznabStandardCategory.BooksTechnical.Id, + }; + } + + [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as LazyLibrarian sees it, including http(s)://, port, and urlbase if needed")] + public string ProwlarrUrl { get; set; } + + [FieldDefinition(1, Label = "LazyLibrarian Server", HelpText = "URL used to connect to LazyLibrarian server, including http(s)://, port, and urlbase if required")] + public string BaseUrl { get; set; } + + [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by LazyLibrarian in Settings/Web Interface")] + public string ApiKey { get; set; } + + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] + public IEnumerable SyncCategories { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianStatus.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianStatus.cs new file mode 100644 index 000000000..869bd50dd --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianStatus.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public class LazyLibrarianStatus + { + public bool Success { get; set; } + public LazyLibrarianError Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs new file mode 100644 index 000000000..13cc2aa85 --- /dev/null +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Applications.LazyLibrarian +{ + public interface ILazyLibrarianV1Proxy + { + LazyLibrarianIndexer AddIndexer(LazyLibrarianIndexer indexer, LazyLibrarianSettings settings); + List GetIndexers(LazyLibrarianSettings settings); + LazyLibrarianIndexer GetIndexer(string indexerName, LazyLibrarianProviderType indexerType, LazyLibrarianSettings settings); + void RemoveIndexer(string indexerName, LazyLibrarianProviderType indexerType, LazyLibrarianSettings settings); + LazyLibrarianIndexer UpdateIndexer(LazyLibrarianIndexer indexer, LazyLibrarianSettings settings); + ValidationFailure TestConnection(LazyLibrarianSettings settings); + } + + public class LazyLibrarianV1Proxy : ILazyLibrarianV1Proxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public LazyLibrarianV1Proxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public LazyLibrarianStatus GetStatus(LazyLibrarianSettings settings) + { + var request = BuildRequest(settings, "/api", "getVersion", HttpMethod.GET); + return Execute(request); + } + + public List GetIndexers(LazyLibrarianSettings settings) + { + var request = BuildRequest(settings, "/api", "listProviders", HttpMethod.GET); + + var response = Execute(request); + + if (!response.Success) + { + throw new LazyLibrarianException(string.Format("LazyLibrarian Error - Code {0}: {1}", response.Error.Code, response.Error.Message)); + } + + var indexers = new List(); + + var torIndexers = response.Data.Torznabs; + torIndexers.ForEach(i => i.Type = LazyLibrarianProviderType.Torznab); + + var nzbIndexers = response.Data.Newznabs; + nzbIndexers.ForEach(i => i.Type = LazyLibrarianProviderType.Newznab); + + indexers.AddRange(torIndexers); + indexers.AddRange(nzbIndexers); + indexers.ForEach(i => i.Altername = i.Name); + + return indexers; + } + + public LazyLibrarianIndexer GetIndexer(string indexerName, LazyLibrarianProviderType indexerType, LazyLibrarianSettings settings) + { + var indexers = GetIndexers(settings); + + return indexers.SingleOrDefault(i => i.Name == indexerName && i.Type == indexerType); + } + + public void RemoveIndexer(string indexerName, LazyLibrarianProviderType indexerType, LazyLibrarianSettings settings) + { + var parameters = new Dictionary + { + { "name", indexerName }, + { "providertype", indexerType.ToString().ToLower() } + }; + + var request = BuildRequest(settings, "/api", "delProvider", HttpMethod.GET, parameters); + CheckForError(Execute(request)); + } + + public LazyLibrarianIndexer AddIndexer(LazyLibrarianIndexer indexer, LazyLibrarianSettings settings) + { + var parameters = new Dictionary + { + { "name", indexer.Name }, + { "providertype", indexer.Type.ToString().ToLower() }, + { "host", indexer.Host }, + { "prov_apikey", indexer.Apikey }, + { "enabled", indexer.Enabled.ToString().ToLower() }, + { "categories", indexer.Categories } + }; + + var request = BuildRequest(settings, "/api", "addProvider", HttpMethod.GET, parameters); + CheckForError(Execute(request)); + return indexer; + } + + public LazyLibrarianIndexer UpdateIndexer(LazyLibrarianIndexer indexer, LazyLibrarianSettings settings) + { + var parameters = new Dictionary + { + { "name", indexer.Name }, + { "providertype", indexer.Type.ToString().ToLower() }, + { "host", indexer.Host }, + { "prov_apikey", indexer.Apikey }, + { "enabled", indexer.Enabled.ToString().ToLower() }, + { "categories", indexer.Categories }, + { "altername", indexer.Altername } + }; + + var request = BuildRequest(settings, "/api", "changeProvider", HttpMethod.GET, parameters); + CheckForError(Execute(request)); + return indexer; + } + + private void CheckForError(LazyLibrarianStatus response) + { + if (!response.Success) + { + throw new LazyLibrarianException(string.Format("LazyLibrarian Error - Code {0}: {1}", response.Error.Code, response.Error.Message)); + } + } + + public ValidationFailure TestConnection(LazyLibrarianSettings settings) + { + try + { + var status = GetStatus(settings); + + if (!status.Success) + { + return new ValidationFailure("ApiKey", status.Error.Message); + } + } + catch (HttpException ex) + { + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("BaseUrl", "Unable to complete application test"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("", "Unable to send test message"); + } + + return null; + } + + private HttpRequest BuildRequest(LazyLibrarianSettings settings, string resource, string command, HttpMethod method, Dictionary parameters = null) + { + var baseUrl = settings.BaseUrl.TrimEnd('/'); + + var requestBuilder = new HttpRequestBuilder(baseUrl).Resource(resource) + .AddQueryParam("cmd", command) + .AddQueryParam("apikey", settings.ApiKey); + + if (parameters != null) + { + foreach (var param in parameters) + { + requestBuilder.AddQueryParam(param.Key, param.Value); + } + } + + var request = requestBuilder.Build(); + + request.Headers.ContentType = "application/json"; + + request.Method = method; + request.AllowAutoRedirect = true; + + return request; + } + + private TResource Execute(HttpRequest request) + where TResource : new() + { + var response = _httpClient.Execute(request); + + var results = JsonConvert.DeserializeObject(response.Content); + + return results; + } + } +}