From 14d74f2ecac416fab448af07b0db2a6123e449f0 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 2 Oct 2022 18:41:38 -0500 Subject: [PATCH] New: Kavita Connection (#1880) * Added ability for Readarr to inform Kavita when a change occurs for rescan. * Use the existing API with a POST rather than a new API. * Updated some wording * Fixed PR comments --- .../Notifications/Kavita/Kavita.cs | 74 ++++++++++++++++ .../Kavita/KavitaAuthenticationException.cs | 14 +++ .../Kavita/KavitaAuthenticationResult.cs | 11 +++ .../Notifications/Kavita/KavitaException.cs | 16 ++++ .../Notifications/Kavita/KavitaService.cs | 56 ++++++++++++ .../Kavita/KavitaServiceProxy.cs | 87 +++++++++++++++++++ .../Notifications/Kavita/KavitaSettings.cs | 46 ++++++++++ 7 files changed, 304 insertions(+) create mode 100644 src/NzbDrone.Core/Notifications/Kavita/Kavita.cs create mode 100644 src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationException.cs create mode 100644 src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationResult.cs create mode 100644 src/NzbDrone.Core/Notifications/Kavita/KavitaException.cs create mode 100644 src/NzbDrone.Core/Notifications/Kavita/KavitaService.cs create mode 100644 src/NzbDrone.Core/Notifications/Kavita/KavitaServiceProxy.cs create mode 100644 src/NzbDrone.Core/Notifications/Kavita/KavitaSettings.cs diff --git a/src/NzbDrone.Core/Notifications/Kavita/Kavita.cs b/src/NzbDrone.Core/Notifications/Kavita/Kavita.cs new file mode 100644 index 000000000..2b347b297 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/Kavita.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Notifications.Kavita; + +public class Kavita : NotificationBase +{ + private readonly IKavitaService _kavitaService; + private readonly Logger _logger; + + public Kavita(IKavitaService kavitaService, Logger logger) + { + _kavitaService = kavitaService; + _logger = logger; + } + + public override string Link => "https://www.kavitareader.com/"; + + public override void OnReleaseImport(BookDownloadMessage message) + { + var allPaths = message.BookFiles.Select(v => v.Path).Distinct(); + var path = Directory.GetParent(allPaths.First())?.FullName; + Notify(Settings, BOOK_DOWNLOADED_TITLE_BRANDED, path); + } + + public override void OnBookDelete(BookDeleteMessage deleteMessage) + { + var allPaths = deleteMessage.Book.BookFiles.Value.Select(v => v.Path).Distinct(); + var path = Directory.GetParent(allPaths.First())?.FullName; + Notify(Settings, BOOK_FILE_DELETED_TITLE_BRANDED, path); + } + + public override void OnBookFileDelete(BookFileDeleteMessage message) + { + Notify(Settings, BOOK_FILE_DELETED_TITLE_BRANDED, Directory.GetParent(message.BookFile.Path)?.FullName); + } + + public override void OnBookRetag(BookRetagMessage message) + { + Notify(Settings, BOOK_RETAGGED_TITLE_BRANDED, Directory.GetParent(message.BookFile.Path)?.FullName); + } + + public override string Name => "Kavita"; + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_kavitaService.Test(Settings, "Success! Kavita has been successfully configured!")); + + return new ValidationResult(failures); + } + + private void Notify(KavitaSettings settings, string header, string message) + { + try + { + if (Settings.Notify) + { + _kavitaService.Notify(Settings, $"{header} - {message}"); + } + } + catch (SocketException ex) + { + var logMessage = $"Unable to connect to Subsonic Host: {Settings.Host}:{Settings.Port}"; + _logger.Debug(ex, logMessage); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationException.cs b/src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationException.cs new file mode 100644 index 000000000..5a2a69afd --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationException.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Notifications.Kavita; + +public class KavitaAuthenticationException : KavitaException +{ + public KavitaAuthenticationException(string message) + : base(message) + { + } + + public KavitaAuthenticationException(string message, params object[] args) + : base(message, args) + { + } +} diff --git a/src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationResult.cs b/src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationResult.cs new file mode 100644 index 000000000..51d86fa61 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/KavitaAuthenticationResult.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace NzbDrone.Core.Notifications.Kavita; + +public class KavitaAuthenticationResult +{ + [JsonPropertyName("token")] + public string Token { get; set; } + [JsonPropertyName("apiKey")] + public string ApiKey { get; init; } +} diff --git a/src/NzbDrone.Core/Notifications/Kavita/KavitaException.cs b/src/NzbDrone.Core/Notifications/Kavita/KavitaException.cs new file mode 100644 index 000000000..7cef33ee3 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/KavitaException.cs @@ -0,0 +1,16 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Kavita; + +public class KavitaException : NzbDroneException +{ + public KavitaException(string message) + : base(message) + { + } + + public KavitaException(string message, params object[] args) + : base(message, args) + { + } +} diff --git a/src/NzbDrone.Core/Notifications/Kavita/KavitaService.cs b/src/NzbDrone.Core/Notifications/Kavita/KavitaService.cs new file mode 100644 index 000000000..5b0e0dec0 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/KavitaService.cs @@ -0,0 +1,56 @@ +using System; +using FluentValidation.Results; +using NLog; + +namespace NzbDrone.Core.Notifications.Kavita; + +public interface IKavitaService +{ + void Notify(KavitaSettings settings, string message); + ValidationFailure Test(KavitaSettings settings, string message); +} + +public class KavitaService : IKavitaService +{ + private readonly IKavitaServiceProxy _proxy; + private readonly Logger _logger; + + public KavitaService(IKavitaServiceProxy proxy, + Logger logger) + { + _proxy = proxy; + _logger = logger; + } + + public void Notify(KavitaSettings settings, string folderPath) + { + _proxy.Notify(settings, folderPath); + } + + private string GetToken(KavitaSettings settings) + { + return _proxy.GetToken(settings); + } + + public ValidationFailure Test(KavitaSettings settings, string message) + { + try + { + _logger.Debug("Determining Authentication of Host: {0}", _proxy.GetBaseUrl(settings)); + var token = GetToken(settings); + _logger.Debug("Token is: {0}", token); + } + catch (KavitaAuthenticationException ex) + { + _logger.Error(ex, "Unable to connect to Kavita Server"); + return new ValidationFailure("ApiKey", "Incorrect ApiKey"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to connect to Kavita Server"); + return new ValidationFailure("Host", "Unable to connect to Kavita Server"); + } + + return null; + } +} diff --git a/src/NzbDrone.Core/Notifications/Kavita/KavitaServiceProxy.cs b/src/NzbDrone.Core/Notifications/Kavita/KavitaServiceProxy.cs new file mode 100644 index 000000000..c80963468 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/KavitaServiceProxy.cs @@ -0,0 +1,87 @@ +using System.Net.Http; +using System.Text.Json; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Notifications.Kavita; + +public interface IKavitaServiceProxy +{ + string GetBaseUrl(KavitaSettings settings, string relativePath = null); + void Notify(KavitaSettings settings, string message); + string GetToken(KavitaSettings settings); +} + +public class KavitaServiceProxy : IKavitaServiceProxy +{ + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public KavitaServiceProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public string GetBaseUrl(KavitaSettings settings, string relativePath = null) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, string.Empty); + baseUrl = HttpUri.CombinePath(baseUrl, relativePath); + + return baseUrl; + } + + public void Notify(KavitaSettings settings, string folderPath) + { + var request = GetKavitaServerRequest("library/scan-folder", HttpMethod.Post, settings); + request.Headers.ContentType = "application/json"; + var postRequest = request.Build(); + postRequest.SetContent(new + { + ApiKey = settings.ApiKey, + FolderPath = folderPath.Replace("/", "//") + }.ToJson()); + + var response = _httpClient.Post(postRequest); + _logger.Trace("Update response: {0}", string.IsNullOrEmpty(response.Content) ? "Success" : response.Content); + } + + public string GetToken(KavitaSettings settings) + { + var request = GetKavitaServerRequest("plugin/authenticate", HttpMethod.Post, settings); + request.AddQueryParam("apiKey", settings.ApiKey) + .AddQueryParam("pluginName", BuildInfo.AppName); + var response = _httpClient.Execute(request.Build()); + + _logger.Trace("Authenticate response: {0}", response.Content); + + var authResult = JsonSerializer.Deserialize(response.Content); + + if (authResult == null) + { + throw new KavitaException("Could not authenticate with Kavita"); + } + + return authResult.Token; + } + + private HttpRequestBuilder GetKavitaServerRequest(string resource, HttpMethod method, KavitaSettings settings) + { + var client = new HttpRequestBuilder(GetBaseUrl(settings, "api")); + + client.Resource(resource); + + if (settings.ApiKey.IsNotNullOrWhiteSpace()) + { + client.Headers["x-kavita-apikey"] = settings.ApiKey; + client.Headers["x-kavita-plugin"] = BuildInfo.AppName; + } + + client.Method = method; + + return client; + } +} diff --git a/src/NzbDrone.Core/Notifications/Kavita/KavitaSettings.cs b/src/NzbDrone.Core/Notifications/Kavita/KavitaSettings.cs new file mode 100644 index 000000000..8bb71b533 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Kavita/KavitaSettings.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Kavita; + +public class KavitaSettingsValidator : AbstractValidator +{ + public KavitaSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.ApiKey).NotEmpty(); + } +} + +public class KavitaSettings : IProviderConfig +{ + private static readonly KavitaSettingsValidator Validator = new KavitaSettingsValidator(); + + public KavitaSettings() + { + Port = 4040; + } + + [FieldDefinition(0, Label = "Host")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://wiki.kavitareader.com/en/guides/settings/opds")] + public string ApiKey { get; set; } + + [FieldDefinition(3, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Connect to Kavita over HTTPS instead of HTTP")] + public bool UseSsl { get; set; } + + [FieldDefinition(4, Label = "Update Library", Type = FieldType.Checkbox)] + public bool Notify { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } +}