diff --git a/src/NzbDrone.Core/Notifications/Apprise/Apprise.cs b/src/NzbDrone.Core/Notifications/Apprise/Apprise.cs new file mode 100644 index 000000000..0c39daf25 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/Apprise.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Movies; + +namespace NzbDrone.Core.Notifications.Apprise +{ + public class Apprise : NotificationBase + { + public override string Name => "Apprise"; + + public override string Link => "https://github.com/caronc/apprise"; + + private readonly IAppriseProxy _proxy; + + public Apprise(IAppriseProxy proxy) + { + _proxy = proxy; + } + + public override void OnGrab(GrabMessage grabMessage) + { + _proxy.SendNotification(MOVIE_GRABBED_TITLE, grabMessage.Message, Settings); + } + + public override void OnDownload(DownloadMessage message) + { + _proxy.SendNotification(MOVIE_DOWNLOADED_TITLE, message.Message, Settings); + } + + public override void OnMovieAdded(Movie movie) + { + _proxy.SendNotification(MOVIE_ADDED_TITLE, $"{movie.Title} added to library", Settings); + } + + public override void OnMovieFileDelete(MovieFileDeleteMessage deleteMessage) + { + _proxy.SendNotification(MOVIE_FILE_DELETED_TITLE, deleteMessage.Message, Settings); + } + + public override void OnMovieDelete(MovieDeleteMessage deleteMessage) + { + _proxy.SendNotification(MOVIE_DELETED_TITLE, deleteMessage.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE, healthCheck.Message, Settings); + } + + public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage) + { + _proxy.SendNotification(APPLICATION_UPDATE_TITLE, updateMessage.Message, Settings); + } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_proxy.Test(Settings)); + + return new ValidationResult(failures); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Apprise/AppriseError.cs b/src/NzbDrone.Core/Notifications/Apprise/AppriseError.cs new file mode 100644 index 000000000..581c223fa --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/AppriseError.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Apprise +{ + public class AppriseError + { + public string Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Apprise/AppriseException.cs b/src/NzbDrone.Core/Notifications/Apprise/AppriseException.cs new file mode 100644 index 000000000..13ad743e7 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/AppriseException.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Apprise +{ + public class AppriseException : NzbDroneException + { + public AppriseException(string message) + : base(message) + { + } + + public AppriseException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Apprise/AppriseNotificationType.cs b/src/NzbDrone.Core/Notifications/Apprise/AppriseNotificationType.cs new file mode 100644 index 000000000..bd96b0444 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/AppriseNotificationType.cs @@ -0,0 +1,19 @@ +using System.Runtime.Serialization; + +namespace NzbDrone.Core.Notifications.Apprise +{ + public enum AppriseNotificationType + { + [EnumMember(Value = "info")] + Info, + + [EnumMember(Value = "success")] + Success, + + [EnumMember(Value = "warning")] + Warning, + + [EnumMember(Value = "failure")] + Failure, + } +} diff --git a/src/NzbDrone.Core/Notifications/Apprise/ApprisePayload.cs b/src/NzbDrone.Core/Notifications/Apprise/ApprisePayload.cs new file mode 100644 index 000000000..71ab0ea61 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/ApprisePayload.cs @@ -0,0 +1,15 @@ +namespace NzbDrone.Core.Notifications.Apprise +{ + public class ApprisePayload + { + public string Urls { get; set; } + + public string Title { get; set; } + + public string Body { get; set; } + + public AppriseNotificationType Type { get; set; } + + public string Tag { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Apprise/AppriseProxy.cs b/src/NzbDrone.Core/Notifications/Apprise/AppriseProxy.cs new file mode 100644 index 000000000..3a0cb26e0 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/AppriseProxy.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Notifications.Apprise +{ + public interface IAppriseProxy + { + void SendNotification(string title, string message, AppriseSettings settings); + ValidationFailure Test(AppriseSettings settings); + } + + public class AppriseProxy : IAppriseProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public AppriseProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public void SendNotification(string title, string message, AppriseSettings settings) + { + var payload = new ApprisePayload + { + Title = title, + Body = message, + Type = (AppriseNotificationType)settings.NotificationType + }; + + var requestBuilder = new HttpRequestBuilder(settings.ServerUrl.TrimEnd('/', ' ')) + .Post() + .Accept(HttpAccept.Json); + + if (settings.ConfigurationKey.IsNotNullOrWhiteSpace()) + { + requestBuilder + .Resource("/notify/{configurationKey}") + .SetSegment("configurationKey", settings.ConfigurationKey); + } + else if (settings.StatelessUrls.IsNotNullOrWhiteSpace()) + { + requestBuilder.Resource("/notify"); + + payload.Urls = settings.StatelessUrls; + } + + if (settings.Tags.Any()) + { + payload.Tag = settings.Tags.Join(","); + } + + if (settings.AuthUsername.IsNotNullOrWhiteSpace() || settings.AuthPassword.IsNotNullOrWhiteSpace()) + { + requestBuilder.NetworkCredential = new BasicNetworkCredential(settings.AuthUsername, settings.AuthPassword); + } + + var request = requestBuilder.Build(); + + request.Headers.ContentType = "application/json"; + request.SetContent(payload.ToJson()); + + try + { + _httpClient.Execute(request); + } + catch (HttpException ex) + { + _logger.Error(ex, "Unable to send message"); + throw new AppriseException("Unable to send Apprise notifications: {0}", ex, ex.Message); + } + } + + public ValidationFailure Test(AppriseSettings settings) + { + const string title = "Radarr - Test Notification"; + const string body = "Success! You have properly configured your apprise notification settings."; + + try + { + SendNotification(title, body, settings); + } + catch (AppriseException ex) when (ex.InnerException is HttpException httpException) + { + if (httpException.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, $"HTTP Auth credentials are invalid: {0}", ex.Message); + return new ValidationFailure("AuthUsername", $"HTTP Auth credentials are invalid: {ex.Message}"); + } + + if (httpException.Response.Content.IsNotNullOrWhiteSpace()) + { + var error = Json.Deserialize(httpException.Response.Content); + + _logger.Error(ex, $"Unable to send test message. Response from API: {0}", error.Error); + return new ValidationFailure(string.Empty, $"Unable to send test message. Response from API: {error.Error}"); + } + + _logger.Error(ex, "Unable to send test message. Server connection failed: ({0}) {1}", httpException.Response.StatusCode, ex.Message); + return new ValidationFailure("Url", $"Unable to connect to Apprise API. Server connection failed: ({httpException.Response.StatusCode}) {ex.Message}"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message: {0}", ex.Message); + return new ValidationFailure("Url", $"Unable to send test message: {ex.Message}"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Apprise/AppriseSettings.cs b/src/NzbDrone.Core/Notifications/Apprise/AppriseSettings.cs new file mode 100644 index 000000000..31c709253 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Apprise/AppriseSettings.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Apprise +{ + public class AppriseSettingsValidator : AbstractValidator + { + public AppriseSettingsValidator() + { + RuleFor(c => c.ServerUrl).IsValidUrl(); + + RuleFor(c => c.ConfigurationKey).NotEmpty() + .When(c => c.StatelessUrls.IsNullOrWhiteSpace()) + .WithMessage("Use either Configuration Key or Stateless URLs"); + + RuleFor(c => c.ConfigurationKey).Matches("^[a-z0-9-]*$") + .WithMessage("Allowed characters a-z, 0-9 and -"); + + RuleFor(c => c.StatelessUrls).NotEmpty() + .When(c => c.ConfigurationKey.IsNullOrWhiteSpace()) + .WithMessage("Use either Configuration Key or Stateless URLs"); + + RuleFor(c => c.StatelessUrls).Empty() + .When(c => c.ConfigurationKey.IsNotNullOrWhiteSpace()) + .WithMessage("Use either Configuration Key or Stateless URLs"); + + RuleFor(c => c.Tags).Empty() + .When(c => c.StatelessUrls.IsNotNullOrWhiteSpace()) + .WithMessage("Stateless URLs do not support tags"); + } + } + + public class AppriseSettings : IProviderConfig + { + private static readonly AppriseSettingsValidator Validator = new (); + + public AppriseSettings() + { + NotificationType = (int)AppriseNotificationType.Info; + Tags = Array.Empty(); + } + + [FieldDefinition(1, Label = "Apprise Server URL", Type = FieldType.Url, Placeholder = "http://localhost:8000", HelpText = "Apprise server URL, including http(s):// and port if needed", HelpLink = "https://github.com/caronc/apprise-api")] + public string ServerUrl { get; set; } + + [FieldDefinition(2, Label = "Apprise Configuration Key", Type = FieldType.Textbox, HelpText = "Configuration Key for the Persistent Storage Solution. Leave empty if Stateless URLs is used.", HelpLink = "https://github.com/caronc/apprise-api#persistent-storage-solution")] + public string ConfigurationKey { get; set; } + + [FieldDefinition(3, Label = "Apprise Stateless URLs", Type = FieldType.Textbox, HelpText = "One or more URLs separated by commas identifying where the notification should be sent to. Leave empty if Persistent Storage is used.", HelpLink = "https://github.com/caronc/apprise#productivity-based-notifications")] + public string StatelessUrls { get; set; } + + [FieldDefinition(4, Label = "Apprise Notification Type", Type = FieldType.Select, SelectOptions = typeof(AppriseNotificationType))] + public int NotificationType { get; set; } + + [FieldDefinition(5, Label = "Apprise Tags", Type = FieldType.Tag, HelpText = "Optionally notify only those tagged accordingly.")] + public IEnumerable Tags { get; set; } + + [FieldDefinition(6, Label = "Username", Type = FieldType.Textbox, HelpText = "HTTP Basic Auth Username", Privacy = PrivacyLevel.UserName)] + public string AuthUsername { get; set; } + + [FieldDefinition(7, Label = "Password", Type = FieldType.Password, HelpText = "HTTP Basic Auth Password", Privacy = PrivacyLevel.Password)] + public string AuthPassword { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +}