parent
d3affdc4f5
commit
0707517bb8
@ -1,2 +1,3 @@
|
||||
/config/
|
||||
/artifacts/
|
||||
/debugging/apprise/
|
||||
|
@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Recyclarr.Common.FluentValidation;
|
||||
|
||||
public class ContextualValidationException(
|
||||
ValidationException originalException,
|
||||
string errorPrefix,
|
||||
string validationContext)
|
||||
: Exception
|
||||
{
|
||||
public ValidationException OriginalException { get; } = originalException;
|
||||
public string ErrorPrefix { get; } = errorPrefix;
|
||||
public string ValidationContext { get; } = validationContext;
|
||||
|
||||
public void LogErrors(ValidationLogger logger)
|
||||
{
|
||||
logger.LogValidationErrors(OriginalException.Errors, ErrorPrefix);
|
||||
logger.LogTotalErrorCount(ValidationContext);
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Recyclarr.Common.FluentValidation;
|
||||
|
||||
public class ValidationLogger(ILogger log)
|
||||
{
|
||||
private int _numErrors;
|
||||
|
||||
public bool LogValidationErrors(IEnumerable<ValidationFailure> errors, string errorPrefix)
|
||||
{
|
||||
foreach (var error in errors)
|
||||
{
|
||||
var level = ToLogLevel(error.Severity);
|
||||
if (level == LogEventLevel.Error)
|
||||
{
|
||||
++_numErrors;
|
||||
}
|
||||
|
||||
log.Write(level, "{ErrorPrefix}: {Msg}", errorPrefix, error.ErrorMessage);
|
||||
}
|
||||
|
||||
return _numErrors > 0;
|
||||
}
|
||||
|
||||
public void LogTotalErrorCount(string errorPrefix)
|
||||
{
|
||||
if (_numErrors == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
log.Error("{ErrorPrefix} failed with {Count} errors", errorPrefix, _numErrors);
|
||||
}
|
||||
|
||||
private static LogEventLevel ToLogLevel(Severity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
Severity.Error => LogEventLevel.Error,
|
||||
Severity.Warning => LogEventLevel.Warning,
|
||||
Severity.Info => LogEventLevel.Information,
|
||||
_ => LogEventLevel.Debug
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Flurl.Http;
|
||||
using Flurl.Http.Configuration;
|
||||
using Recyclarr.Http;
|
||||
using Recyclarr.Settings;
|
||||
|
||||
namespace Recyclarr.Notifications.Apprise;
|
||||
|
||||
public sealed class AppriseRequestBuilder(
|
||||
IFlurlClientCache clientCache,
|
||||
ISettings<NotificationSettings> settings,
|
||||
IEnumerable<FlurlSpecificEventHandler> eventHandlers)
|
||||
: IAppriseRequestBuilder
|
||||
{
|
||||
private readonly Lazy<Uri> _baseUrl = new(() =>
|
||||
{
|
||||
var apprise = settings.Value.Apprise;
|
||||
if (apprise is null)
|
||||
{
|
||||
throw new ArgumentException("No apprise notification settings have been defined");
|
||||
}
|
||||
|
||||
if (apprise.BaseUrl is null)
|
||||
{
|
||||
throw new ArgumentException("Apprise `base_url` setting is not present or empty");
|
||||
}
|
||||
|
||||
return apprise.BaseUrl;
|
||||
});
|
||||
|
||||
public IFlurlRequest Request(params object[] path)
|
||||
{
|
||||
var client = clientCache.GetOrAdd("apprise", _baseUrl.Value.ToString(), Configure);
|
||||
return client.Request(path);
|
||||
}
|
||||
|
||||
private void Configure(IFlurlClientBuilder builder)
|
||||
{
|
||||
foreach (var handler in eventHandlers.Select(x => (x.EventType, x)))
|
||||
{
|
||||
builder.EventHandlers.Add(handler);
|
||||
}
|
||||
|
||||
builder.WithSettings(settings =>
|
||||
{
|
||||
settings.JsonSerializer = new DefaultJsonSerializer(new JsonSerializerOptions
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
using Flurl.Http;
|
||||
using Recyclarr.Notifications.Apprise.Dto;
|
||||
using Recyclarr.Settings;
|
||||
|
||||
namespace Recyclarr.Notifications.Apprise;
|
||||
|
||||
public class AppriseStatefulNotificationApiService(IAppriseRequestBuilder api) : IAppriseNotificationApiService
|
||||
{
|
||||
public async Task Notify(
|
||||
AppriseNotificationSettings settings,
|
||||
Func<AppriseNotification, AppriseNotification> notificationBuilder)
|
||||
{
|
||||
if (settings.Key is null)
|
||||
{
|
||||
throw new ArgumentException("Stateful apprise notifications require the 'key' node");
|
||||
}
|
||||
|
||||
var notification = notificationBuilder(new AppriseStatefulNotification
|
||||
{
|
||||
Tag = settings.Tags
|
||||
});
|
||||
|
||||
await api.Request("notify", settings.Key).PostJsonAsync(notification);
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
using Flurl.Http;
|
||||
using Recyclarr.Notifications.Apprise.Dto;
|
||||
using Recyclarr.Settings;
|
||||
|
||||
namespace Recyclarr.Notifications.Apprise;
|
||||
|
||||
public class AppriseStatelessNotificationApiService(IAppriseRequestBuilder api) : IAppriseNotificationApiService
|
||||
{
|
||||
public async Task Notify(
|
||||
AppriseNotificationSettings settings,
|
||||
Func<AppriseNotification, AppriseNotification> notificationBuilder)
|
||||
{
|
||||
if (settings.Urls is null)
|
||||
{
|
||||
throw new ArgumentException("Stateless apprise notifications require the 'urls' array");
|
||||
}
|
||||
|
||||
var notification = notificationBuilder(new AppriseStatelessNotification
|
||||
{
|
||||
Urls = settings.Urls
|
||||
});
|
||||
|
||||
await api.Request("notify").PostJsonAsync(notification);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
namespace Recyclarr.Notifications.Apprise.Dto;
|
||||
|
||||
public enum AppriseMessageFormat
|
||||
{
|
||||
Text,
|
||||
Markdown,
|
||||
Html
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
namespace Recyclarr.Notifications.Apprise.Dto;
|
||||
|
||||
public enum AppriseMessageType
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Failure
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Recyclarr.Notifications.Apprise.Dto;
|
||||
|
||||
public record AppriseNotification
|
||||
{
|
||||
public string? Body { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public AppriseMessageType? Type { get; init; }
|
||||
public AppriseMessageFormat? Format { get; init; }
|
||||
}
|
||||
|
||||
public record AppriseStatefulNotification : AppriseNotification
|
||||
{
|
||||
public string? Tag { get; init; }
|
||||
}
|
||||
|
||||
public record AppriseStatelessNotification : AppriseNotification
|
||||
{
|
||||
public Collection<string> Urls { get; init; } = [];
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
using Recyclarr.Notifications.Apprise.Dto;
|
||||
using Recyclarr.Settings;
|
||||
|
||||
namespace Recyclarr.Notifications.Apprise;
|
||||
|
||||
public interface IAppriseNotificationApiService
|
||||
{
|
||||
Task Notify(
|
||||
AppriseNotificationSettings settings,
|
||||
Func<AppriseNotification, AppriseNotification> notificationBuilder);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
using Flurl.Http;
|
||||
|
||||
namespace Recyclarr.Notifications.Apprise;
|
||||
|
||||
public interface IAppriseRequestBuilder
|
||||
{
|
||||
IFlurlRequest Request(params object[] path);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace Recyclarr.Notifications.Events;
|
||||
|
||||
public record ErrorEvent(string Message) : IPresentableNotification
|
||||
{
|
||||
public string Category => "Errors";
|
||||
public string Render() => $"- {Message}";
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace Recyclarr.Notifications.Events;
|
||||
|
||||
public interface IPresentableNotification
|
||||
{
|
||||
public string Category { get; }
|
||||
public string Render();
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
namespace Recyclarr.Notifications.Events;
|
||||
|
||||
public record InformationEvent(string Description) : IPresentableNotification
|
||||
{
|
||||
public string? Statistic { get; init; }
|
||||
|
||||
public string Category => "Information";
|
||||
public string Render() => $"- {Description}{(Statistic is null ? "" : $": {Statistic}")}";
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace Recyclarr.Notifications.Events;
|
||||
|
||||
public record WarningEvent(string Message) : IPresentableNotification
|
||||
{
|
||||
public string Category => "Warnings";
|
||||
public string Render() => $"- {Message}";
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using Recyclarr.Notifications.Events;
|
||||
|
||||
namespace Recyclarr.Notifications;
|
||||
|
||||
public class NotificationEmitter
|
||||
{
|
||||
private readonly Subject<IPresentableNotification> _notifications = new();
|
||||
|
||||
public IObservable<IPresentableNotification> OnNotification => _notifications.AsObservable();
|
||||
|
||||
public void SendStatistic(string description)
|
||||
{
|
||||
_notifications.OnNext(new InformationEvent(description));
|
||||
}
|
||||
|
||||
public void SendStatistic<T>(string description, T stat) where T : notnull
|
||||
{
|
||||
_notifications.OnNext(new InformationEvent(description)
|
||||
{
|
||||
Statistic = stat.ToString() ?? "!STAT ERROR!"
|
||||
});
|
||||
}
|
||||
|
||||
public void SendError(string message)
|
||||
{
|
||||
_notifications.OnNext(new ErrorEvent(message));
|
||||
}
|
||||
|
||||
public void SendWarning(string message)
|
||||
{
|
||||
_notifications.OnNext(new WarningEvent(message));
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting;
|
||||
|
||||
namespace Recyclarr.Notifications;
|
||||
|
||||
public class NotificationLogSink(NotificationEmitter emitter, ITextFormatter formatter) : ILogEventSink
|
||||
{
|
||||
[SuppressMessage("ReSharper", "SwitchStatementMissingSomeEnumCasesNoDefault", Justification =
|
||||
"Only processes warnings & errors")]
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
switch (logEvent.Level)
|
||||
{
|
||||
case LogEventLevel.Warning:
|
||||
emitter.SendWarning(RenderLogEvent(logEvent));
|
||||
break;
|
||||
|
||||
case LogEventLevel.Error:
|
||||
case LogEventLevel.Fatal:
|
||||
emitter.SendError(RenderLogEvent(logEvent));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string RenderLogEvent(LogEvent logEvent)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
formatter.Format(logEvent, writer);
|
||||
return writer.ToString().Trim();
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
using Recyclarr.Logging;
|
||||
using Recyclarr.Settings;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
|
||||
namespace Recyclarr.Notifications;
|
||||
|
||||
public class NotificationLogSinkConfigurator(NotificationEmitter emitter, ISettings<RecyclarrSettings> settings)
|
||||
: ILogConfigurator
|
||||
{
|
||||
public void Configure(LoggerConfiguration config)
|
||||
{
|
||||
// If the user has disabled notifications, don't bother with adding the notification sink.
|
||||
if (settings.Value.Notifications is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sink = new NotificationLogSink(emitter, BuildExpressionTemplate());
|
||||
config.WriteTo.Sink(sink, LogEventLevel.Information);
|
||||
}
|
||||
|
||||
private static ExpressionTemplate BuildExpressionTemplate()
|
||||
{
|
||||
return new ExpressionTemplate(LogSetup.BaseTemplate);
|
||||
}
|
||||
}
|
@ -0,0 +1,131 @@
|
||||
using System.Reactive.Disposables;
|
||||
using System.Text;
|
||||
using Autofac.Features.Indexed;
|
||||
using Flurl.Http;
|
||||
using Recyclarr.Common.Extensions;
|
||||
using Recyclarr.Notifications.Apprise;
|
||||
using Recyclarr.Notifications.Apprise.Dto;
|
||||
using Recyclarr.Notifications.Events;
|
||||
using Recyclarr.Settings;
|
||||
|
||||
namespace Recyclarr.Notifications;
|
||||
|
||||
public sealed class NotificationService(
|
||||
ILogger log,
|
||||
IIndex<AppriseMode, IAppriseNotificationApiService> apiFactory,
|
||||
ISettings<NotificationSettings> settings,
|
||||
NotificationEmitter notificationEmitter)
|
||||
: IDisposable
|
||||
{
|
||||
private const string NoInstance = "[no instance]";
|
||||
|
||||
private readonly Dictionary<string, List<IPresentableNotification>> _events = new();
|
||||
private readonly CompositeDisposable _eventConnection = new();
|
||||
private readonly AppriseNotificationSettings? _settings = settings.OptionalValue?.Apprise;
|
||||
|
||||
private string? _activeInstanceName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_eventConnection.Dispose();
|
||||
}
|
||||
|
||||
public void SetInstanceName(string instanceName)
|
||||
{
|
||||
_activeInstanceName = instanceName;
|
||||
}
|
||||
|
||||
public void BeginWatchEvents()
|
||||
{
|
||||
_events.Clear();
|
||||
_eventConnection.Clear();
|
||||
_eventConnection.Add(notificationEmitter.OnNotification.Subscribe(x =>
|
||||
{
|
||||
var key = _activeInstanceName ?? NoInstance;
|
||||
_events.GetOrCreate(key).Add(x);
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task SendNotification(bool succeeded)
|
||||
{
|
||||
// stop receiving events while we build the report
|
||||
_eventConnection.Clear();
|
||||
|
||||
// If the user didn't configure notifications, exit early and do nothing.
|
||||
if (_settings is null)
|
||||
{
|
||||
log.Debug("Notification settings are not present, so this notification will not be sent");
|
||||
return;
|
||||
}
|
||||
|
||||
var messageType = succeeded ? AppriseMessageType.Success : AppriseMessageType.Failure;
|
||||
var body = BuildNotificationBody();
|
||||
await SendAppriseNotification(succeeded, body, messageType);
|
||||
}
|
||||
|
||||
private async Task SendAppriseNotification(bool succeeded, string body, AppriseMessageType messageType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var api = apiFactory[_settings!.Mode!.Value];
|
||||
|
||||
await api.Notify(_settings!, payload => payload with
|
||||
{
|
||||
Title = $"Recyclarr Sync {(succeeded ? "Completed" : "Failed")}",
|
||||
Body = body.Trim(),
|
||||
Type = messageType,
|
||||
Format = AppriseMessageFormat.Markdown
|
||||
});
|
||||
}
|
||||
catch (FlurlHttpException e)
|
||||
{
|
||||
log.Error(e, "Failed to send notification");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildNotificationBody()
|
||||
{
|
||||
// Apprise doesn't like empty bodies, so the hyphens are there in case there are no notifications to render.
|
||||
// This also doesn't look too bad because it creates some separation between the title and the content.
|
||||
var body = new StringBuilder("---\n");
|
||||
|
||||
foreach (var (instanceName, notifications) in _events)
|
||||
{
|
||||
RenderInstanceEvents(body, instanceName, notifications);
|
||||
}
|
||||
|
||||
return body.ToString();
|
||||
}
|
||||
|
||||
private static void RenderInstanceEvents(
|
||||
StringBuilder body,
|
||||
string instanceName,
|
||||
IEnumerable<IPresentableNotification> notifications)
|
||||
{
|
||||
if (instanceName == NoInstance)
|
||||
{
|
||||
body.AppendLine("### General");
|
||||
}
|
||||
else
|
||||
{
|
||||
body.AppendLine($"### Instance: `{instanceName}`");
|
||||
}
|
||||
|
||||
body.AppendLine();
|
||||
|
||||
var groupedEvents = notifications
|
||||
.GroupBy(x => x.Category)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
|
||||
foreach (var (category, events) in groupedEvents)
|
||||
{
|
||||
body.AppendLine(
|
||||
$"""
|
||||
{category}:
|
||||
|
||||
{string.Join('\n', events.Select(x => x.Render()))}
|
||||
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
using Autofac;
|
||||
using Recyclarr.Logging;
|
||||
using Recyclarr.Notifications.Apprise;
|
||||
using Recyclarr.Settings;
|
||||
|
||||
namespace Recyclarr.Notifications;
|
||||
|
||||
public class NotificationsAutofacModule : Module
|
||||
{
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
base.Load(builder);
|
||||
|
||||
builder.RegisterType<NotificationLogSinkConfigurator>().As<ILogConfigurator>();
|
||||
builder.RegisterType<NotificationService>().SingleInstance();
|
||||
builder.RegisterType<NotificationEmitter>().SingleInstance();
|
||||
|
||||
// Apprise
|
||||
builder.RegisterType<AppriseStatefulNotificationApiService>()
|
||||
.Keyed<IAppriseNotificationApiService>(AppriseMode.Stateful);
|
||||
|
||||
builder.RegisterType<AppriseStatelessNotificationApiService>()
|
||||
.Keyed<IAppriseNotificationApiService>(AppriseMode.Stateless);
|
||||
|
||||
builder.RegisterType<AppriseRequestBuilder>().As<IAppriseRequestBuilder>();
|
||||
}
|
||||
}
|
@ -1,3 +1,7 @@
|
||||
namespace Recyclarr.Settings;
|
||||
|
||||
internal record Settings<T>(T Value) : ISettings<T>;
|
||||
internal record Settings<T>(T? OptionalValue) : ISettings<T>
|
||||
{
|
||||
public bool IsProvided => OptionalValue is not null;
|
||||
public T Value => OptionalValue ?? throw new InvalidOperationException("Settings value is not provided");
|
||||
}
|
||||
|
@ -0,0 +1,42 @@
|
||||
using FluentValidation;
|
||||
using Recyclarr.Common.FluentValidation;
|
||||
|
||||
namespace Recyclarr.Settings;
|
||||
|
||||
public class RecyclarrSettingsValidator : AbstractValidator<RecyclarrSettings>
|
||||
{
|
||||
public RecyclarrSettingsValidator()
|
||||
{
|
||||
RuleFor(x => x.Notifications).SetNonNullableValidator(new NotificationSettingsValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationSettingsValidator : AbstractValidator<NotificationSettings>
|
||||
{
|
||||
public NotificationSettingsValidator()
|
||||
{
|
||||
RuleFor(x => x.Apprise).SetNonNullableValidator(new AppriseNotificationSettingsValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public class AppriseNotificationSettingsValidator : AbstractValidator<AppriseNotificationSettings>
|
||||
{
|
||||
public AppriseNotificationSettingsValidator()
|
||||
{
|
||||
RuleFor(x => x.Mode).NotNull()
|
||||
.WithMessage("`mode` is required for apprise notifications");
|
||||
|
||||
RuleFor(x => x.BaseUrl).NotEmpty()
|
||||
.WithMessage("`base_url` is required for apprise notifications");
|
||||
|
||||
RuleFor(x => x.Urls)
|
||||
.NotEmpty()
|
||||
.When(x => x.Mode == AppriseMode.Stateless)
|
||||
.WithMessage("`urls` is required when `mode` is set to `stateless` for apprise notifications");
|
||||
|
||||
RuleFor(x => x.Key)
|
||||
.NotEmpty()
|
||||
.When(x => x.Mode == AppriseMode.Stateful)
|
||||
.WithMessage("`key` is required when `mode` is set to `stateful` for apprise notifications");
|
||||
}
|
||||
}
|
Loading…
Reference in new issue