New: LazyLibrarian Sync Support

Closes #469

Co-Authored-By: philborman <12158777+philborman@users.noreply.github.com>
pull/679/head
Qstick 3 years ago
parent 579b8a3d3b
commit 4eadd4cb2f

@ -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<LazyLibrarianSettings>
{
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<ValidationFailure>();
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<AppIndexerMap> GetIndexerMappings()
{
var indexers = _lazyLibrarianV1Proxy.GetIndexers(Settings);
var mappings = new List<AppIndexerMap>();
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;
}
}
}

@ -0,0 +1,8 @@
namespace NzbDrone.Core.Applications.LazyLibrarian
{
public class LazyLibrarianError
{
public int Code { get; set; }
public string Message { get; set; }
}
}

@ -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)
{
}
}
}

@ -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<LazyLibrarianIndexer> Torznabs { get; set; }
public List<LazyLibrarianIndexer> 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;
}
}
}

@ -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<LazyLibrarianSettings>
{
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<int> SyncCategories { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

@ -0,0 +1,8 @@
namespace NzbDrone.Core.Applications.LazyLibrarian
{
public class LazyLibrarianStatus
{
public bool Success { get; set; }
public LazyLibrarianError Error { get; set; }
}
}

@ -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<LazyLibrarianIndexer> 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<LazyLibrarianStatus>(request);
}
public List<LazyLibrarianIndexer> GetIndexers(LazyLibrarianSettings settings)
{
var request = BuildRequest(settings, "/api", "listProviders", HttpMethod.GET);
var response = Execute<LazyLibrarianIndexerResponse>(request);
if (!response.Success)
{
throw new LazyLibrarianException(string.Format("LazyLibrarian Error - Code {0}: {1}", response.Error.Code, response.Error.Message));
}
var indexers = new List<LazyLibrarianIndexer>();
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<string, string>
{
{ "name", indexerName },
{ "providertype", indexerType.ToString().ToLower() }
};
var request = BuildRequest(settings, "/api", "delProvider", HttpMethod.GET, parameters);
CheckForError(Execute<LazyLibrarianStatus>(request));
}
public LazyLibrarianIndexer AddIndexer(LazyLibrarianIndexer indexer, LazyLibrarianSettings settings)
{
var parameters = new Dictionary<string, string>
{
{ "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<LazyLibrarianStatus>(request));
return indexer;
}
public LazyLibrarianIndexer UpdateIndexer(LazyLibrarianIndexer indexer, LazyLibrarianSettings settings)
{
var parameters = new Dictionary<string, string>
{
{ "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<LazyLibrarianStatus>(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<string, string> 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<TResource>(HttpRequest request)
where TResource : new()
{
var response = _httpClient.Execute(request);
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
return results;
}
}
}
Loading…
Cancel
Save