feat: Notifications support through Apprise

pull/351/head
Robert Dailey 7 months ago
parent d3affdc4f5
commit 0707517bb8

@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Notifications support through Apprise
## [7.2.4] - 2024-09-14
### Fixed

1
docker/.gitignore vendored

@ -1,2 +1,3 @@
/config/
/artifacts/
/debugging/apprise/

@ -65,3 +65,21 @@ services:
- sonarr_stable:/sonarr/stable
- radarr_develop:/radarr/develop
- radarr_stable:/radarr/stable
# http://localhost:8000
apprise:
image: caronc/apprise
container_name: apprise
networks: [recyclarr]
ports: [8000:8000]
init: true
tmpfs:
- /attach
- /plugin
volumes:
- ./apprise:/config
environment:
- TZ=America/Chicago
- APPRISE_DEFAULT_THEM=dark
- APPRISE_STATEFUL_MODE=simple
- DEBUG=yes

@ -5,18 +5,36 @@
"additionalProperties": false,
"properties": {
"apprise": {
"description": "Apprise-specific notification settings",
"type": "object",
"additionalProperties": false,
"required": ["base_url", "key"],
"required": ["base_url", "mode"],
"properties": {
"mode": {
"description": "The operating mode for Apprise itself. In 'stateful' mode, 'key' is required, 'tags' is optional, and 'urls' is ignored. In 'stateless' mode, 'urls' is optional, while 'key' and 'tags' are ignored.",
"type": "string",
"enum": ["stateless", "stateful"]
},
"base_url": {
"type": "string"
"description": "The base URL for the Apprise API",
"type": "string",
"format": "uri"
},
"key": {
"description": "A key identifying the configuration (urls) to use in your Apprise instance. Required in 'stateful' mode, ignored in 'stateless' mode.",
"type": "string"
},
"tags": {
"description": "Filtering tags. Use the format documented here: https://github.com/caronc/apprise-api#tagging. Used in 'stateful' mode, ignored in 'stateless' mode.",
"type": "string"
},
"urls": {
"description": "List of notification service URLs. Used in 'stateless' mode, ignored in 'stateful' mode.",
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
}
}
}

@ -22,6 +22,7 @@ using Recyclarr.Config;
using Recyclarr.Http;
using Recyclarr.Json;
using Recyclarr.Logging;
using Recyclarr.Notifications;
using Recyclarr.Platform;
using Recyclarr.Repo;
using Recyclarr.ServarrApi;
@ -60,6 +61,7 @@ public static class CompositionRoot
builder.RegisterModule<JsonAutofacModule>();
builder.RegisterModule<PlatformAutofacModule>();
builder.RegisterModule<CommonAutofacModule>();
builder.RegisterModule<NotificationsAutofacModule>();
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.Register(_ => new ResourceDataReader(thisAssembly)).As<IResourceDataReader>();
@ -93,9 +95,7 @@ public static class CompositionRoot
private static void RegisterLogger(ContainerBuilder builder)
{
// Log Configurators
builder.RegisterTypes(
typeof(FileLogSinkConfigurator))
.As<ILogConfigurator>();
builder.RegisterType<FileLogSinkConfigurator>().As<ILogConfigurator>();
builder.RegisterType<LoggingLevelSwitch>().SingleInstance();
builder.RegisterType<LoggerFactory>().SingleInstance();

@ -25,6 +25,5 @@ public class LoggerSetupTask(
public void OnFinish()
{
throw new NotImplementedException();
}
}

@ -25,8 +25,6 @@ public class LoggerFactory(IEnvironment env, LoggingLevelSwitch levelSwitch)
var config = LogSetup.BaseConfiguration()
.WriteTo.Logger(Logger);
// throw new InvalidOperationException("testing only"); // testing only
foreach (var configurator in configurators)
{
configurator.Configure(config);

@ -1,6 +1,8 @@
using Recyclarr.Notifications;
namespace Recyclarr.Cli.Pipelines.CustomFormat;
internal class CustomFormatTransactionLogger(ILogger log)
internal class CustomFormatTransactionLogger(ILogger log, NotificationEmitter notify)
{
public void LogTransactions(CustomFormatPipelineContext context)
{
@ -62,6 +64,7 @@ internal class CustomFormatTransactionLogger(ILogger log)
if (totalCount > 0)
{
log.Information("Total of {Count} custom formats were synced", totalCount);
notify.SendStatistic("Custom Formats Synced", totalCount);
}
else
{

@ -1,9 +1,11 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingLogPhase(ILogger log) : ILogPipelinePhase<MediaNamingPipelineContext>
public class MediaNamingLogPhase(ILogger log, NotificationEmitter notificationEmitter)
: ILogPipelinePhase<MediaNamingPipelineContext>
{
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(MediaNamingPipelineContext context)
@ -53,6 +55,7 @@ public class MediaNamingLogPhase(ILogger log) : ILogPipelinePhase<MediaNamingPip
{
log.Information("Media naming has been updated");
log.Debug("Naming differences: {Diff}", differences);
notificationEmitter.SendStatistic("Media Naming Synced");
}
else
{

@ -1,10 +1,15 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.QualityProfile.Models;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Notifications;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileLogPhase(ILogger log) : ILogPipelinePhase<QualityProfilePipelineContext>
public class QualityProfileLogPhase(
ILogger log,
ValidationLogger validationLogger,
NotificationEmitter notificationEmitter)
: ILogPipelinePhase<QualityProfilePipelineContext>
{
public bool LogConfigPhaseAndExitIfNeeded(QualityProfilePipelineContext context)
{
@ -36,17 +41,12 @@ public class QualityProfileLogPhase(ILogger log) : ILogPipelinePhase<QualityProf
"The following validation errors occurred for one or more quality profiles. " +
"These profiles will *not* be synced");
var numErrors = 0;
foreach (var (profile, errors) in transactions.InvalidProfiles)
{
numErrors += errors.LogValidationErrors(log, $"Profile '{profile.ProfileName}'");
validationLogger.LogValidationErrors(errors, $"Profile '{profile.ProfileName}'");
}
if (numErrors > 0)
{
log.Error("Profile validation failed with {Count} errors", numErrors);
}
validationLogger.LogTotalErrorCount("Profile validation");
}
foreach (var profile in transactions.ChangedProfiles.Select(x => x.Profile))
@ -118,6 +118,8 @@ public class QualityProfileLogPhase(ILogger log) : ILogPipelinePhase<QualityProf
"A total of {NumProfiles} profiles were synced. {NumQuality} contain quality changes and " +
"{NumScores} contain updated scores",
numProfiles, numQuality, numScores);
notificationEmitter.SendStatistic("Quality Profiles Synced", numProfiles);
}
else
{

@ -1,8 +1,10 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeLogPhase(ILogger log) : ILogPipelinePhase<QualitySizePipelineContext>
public class QualitySizeLogPhase(ILogger log, NotificationEmitter notificationEmitter)
: ILogPipelinePhase<QualitySizePipelineContext>
{
public bool LogConfigPhaseAndExitIfNeeded(QualitySizePipelineContext context)
{
@ -35,6 +37,7 @@ public class QualitySizeLogPhase(ILogger log) : ILogPipelinePhase<QualitySizePip
{
log.Information("Total of {Count} sizes were synced for quality definition {Name}", totalCount,
qualityDefinitionName);
notificationEmitter.SendStatistic("Quality Sizes Synced", totalCount);
}
else
{

@ -1,5 +1,7 @@
using Autofac.Core;
using Flurl.Http;
using Recyclarr.Cli.Console;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Compatibility;
using Recyclarr.Config.ExceptionTypes;
using Recyclarr.Config.Parsing.ErrorHandling;
@ -42,7 +44,7 @@ public class ConsoleExceptionHandler(ILogger log)
e.InstanceNames);
log.Error(
"Consolidate the config files manually to fix. " +
"See: https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance");
"See: <https://recyclarr.dev/wiki/yaml/config-examples/#merge-single-instance>");
break;
case InvalidConfigurationFilesException e:
@ -70,6 +72,13 @@ public class ConsoleExceptionHandler(ILogger log)
break;
case ContextualValidationException e:
e.LogErrors(new ValidationLogger(log));
break;
case DependencyResolutionException {InnerException: not null} e:
return await HandleException(e.InnerException!);
default:
return false;
}

@ -5,6 +5,7 @@ using Recyclarr.Cli.Pipelines;
using Recyclarr.Cli.Processors.ErrorHandling;
using Recyclarr.Config;
using Recyclarr.Config.Models;
using Recyclarr.Notifications;
using Spectre.Console;
namespace Recyclarr.Cli.Processors.Sync;
@ -20,10 +21,19 @@ public class SyncProcessor(
IAnsiConsole console,
IConfigurationRegistry configRegistry,
ConfigurationScopeFactory configScopeFactory,
ConsoleExceptionHandler exceptionHandler)
ConsoleExceptionHandler exceptionHandler,
NotificationService notify)
: ISyncProcessor
{
public async Task<ExitStatus> Process(ISyncSettings settings, CancellationToken ct)
{
notify.BeginWatchEvents();
var result = await ProcessConfigs(settings, ct);
await notify.SendNotification(result != ExitStatus.Failed);
return result;
}
private async Task<ExitStatus> ProcessConfigs(ISyncSettings settings, CancellationToken ct)
{
bool failureDetected;
try
@ -63,6 +73,7 @@ public class SyncProcessor(
try
{
using var scope = configScopeFactory.Start<SyncBasedConfigurationScope>(config);
notify.SetInstanceName(config.InstanceName);
console.WriteLine(
$"""

@ -9,5 +9,6 @@ public class CommonAutofacModule : Module
{
base.Load(builder);
builder.RegisterType<RuntimeValidationService>().As<IRuntimeValidationService>();
builder.RegisterType<ValidationLogger>();
}
}

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

@ -1,7 +1,6 @@
using FluentValidation;
using FluentValidation.Results;
using FluentValidation.Validators;
using Serilog.Events;
namespace Recyclarr.Common.FluentValidation;
@ -57,35 +56,4 @@ public static class FluentValidationExtensions
}
}
}
public static LogEventLevel ToLogLevel(this Severity severity)
{
return severity switch
{
Severity.Error => LogEventLevel.Error,
Severity.Warning => LogEventLevel.Warning,
Severity.Info => LogEventLevel.Information,
_ => LogEventLevel.Debug
};
}
public static int LogValidationErrors(
this IReadOnlyCollection<ValidationFailure> errors,
ILogger log,
string errorPrefix)
{
var numErrors = 0;
foreach (var (error, level) in errors.Select(x => (x, x.Severity.ToLogLevel())))
{
if (level == LogEventLevel.Error)
{
++numErrors;
}
log.Write(level, "{ErrorPrefix}: {Msg}", errorPrefix, error.ErrorMessage);
}
return numErrors;
}
}

@ -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
};
}
}

@ -3,7 +3,7 @@ using Recyclarr.Common.FluentValidation;
namespace Recyclarr.Config.Parsing;
[UsedImplicitly]
public class ConfigValidationExecutor(ILogger log, IRuntimeValidationService validationService)
public class ConfigValidationExecutor(ValidationLogger validationLogger, IRuntimeValidationService validationService)
{
public bool Validate(object config, params string[] ruleSets)
{
@ -13,13 +13,6 @@ public class ConfigValidationExecutor(ILogger log, IRuntimeValidationService val
return true;
}
var numErrors = result.Errors.LogValidationErrors(log, "Config Validation");
if (numErrors == 0)
{
return true;
}
log.Error("Config validation failed with {Count} errors", numErrors);
return false;
return !validationLogger.LogValidationErrors(result.Errors, "Config Validation");
}
}

@ -23,7 +23,7 @@ public static class ConfigContextualMessages
return
"Usage of 'reset_unmatched_scores' inside 'quality_profiles' under 'custom_formats' is no " +
"longer supported. Use the root-level 'quality_profiles' instead. " +
"See: https://recyclarr.dev/wiki/upgrade-guide/v5.0/#reset-unmatched-scores";
"See: <https://recyclarr.dev/wiki/upgrade-guide/v5.0/#reset-unmatched-scores>";
}
if (msg.Contains(
@ -31,7 +31,7 @@ public static class ConfigContextualMessages
{
return
"Using true/false with `reset_unmatched_scores` is no longer supported. " +
"See: https://recyclarr.dev/wiki/upgrade-guide/v6.0/#reset-scores";
"See: <https://recyclarr.dev/wiki/upgrade-guide/v6.0/#reset-scores>";
}
if (msg.Contains("Property 'release_profiles' not found on type"))
@ -39,7 +39,7 @@ public static class ConfigContextualMessages
return
"Release profiles and Sonarr v3 in general are no longer supported. All instances of " +
"`release_profiles` in your configuration YAML must be removed. " +
"https://recyclarr.dev/wiki/upgrade-guide/v7.0/#sonarr-v3-removal";
"See: <https://recyclarr.dev/wiki/upgrade-guide/v7.0/#sonarr-v3-removal>";
}
return null;

@ -46,7 +46,7 @@ public class ConfigIncludeProcessor(IFileSystem fs, IAppPaths paths, ILogger log
log.Warning(
"DEPRECATED: Include templates inside the `configs` directory are no longer supported. " +
"These files should be relocated to the new sibling `includes` directory instead. " +
"See: https://recyclarr.dev/wiki/upgrade-guide/v8.0/#include-dir");
"See: <https://recyclarr.dev/wiki/upgrade-guide/v8.0/#include-dir>");
return fsPath;
}

@ -14,7 +14,7 @@ public class CfQualityProfilesDeprecationCheck(ILogger log) : IConfigDeprecation
log.Warning(
"DEPRECATED: The `quality_profiles` element under `custom_formats` nodes was " +
"detected in your config. This has been renamed to `assign_scores_to`. " +
"See: https://recyclarr.dev/wiki/upgrade-guide/v8.0/#assign-scores-to");
"See: <https://recyclarr.dev/wiki/upgrade-guide/v8.0/#assign-scores-to>");
// CustomFormats is checked for null in the CheckIfNeeded() method, which is called first.
var cfs = include.CustomFormats!.Select(x => x with

@ -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>();
}
}

@ -13,6 +13,7 @@
<PackageReference Include="Flurl" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Expressions" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="SuperLinq" />
<PackageReference Include="System.Data.HashFunction.FNV" />

@ -2,5 +2,7 @@ namespace Recyclarr.Settings;
public interface ISettings<out T>
{
bool IsProvided { get; }
T Value { get; }
T? OptionalValue { get; }
}

@ -1,3 +1,5 @@
using System.Collections.ObjectModel;
namespace Recyclarr.Settings;
public record TrashRepository : IRepositorySettings
@ -31,4 +33,25 @@ public record RecyclarrSettings
public bool EnableSslCertificateValidation { get; [UsedImplicitly] init; } = true;
public LogJanitorSettings LogJanitor { get; [UsedImplicitly] init; } = new();
public string? GitPath { get; [UsedImplicitly] init; }
public NotificationSettings? Notifications { get; [UsedImplicitly] init; }
}
public record NotificationSettings
{
public AppriseNotificationSettings? Apprise { get; [UsedImplicitly] init; }
}
public record AppriseNotificationSettings
{
public AppriseMode? Mode { get; [UsedImplicitly] init; }
public Uri? BaseUrl { get; [UsedImplicitly] init; }
public string? Key { get; [UsedImplicitly] init; }
public string? Tags { get; [UsedImplicitly] init; }
public Collection<string> Urls { get; [UsedImplicitly] init; } = [];
}
public enum AppriseMode
{
Stateful,
Stateless
}

@ -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");
}

@ -14,5 +14,6 @@ public class SettingsAutofacModule : Module
builder.RegisterSettings(x => x.LogJanitor);
builder.RegisterSettings(x => x.Repositories.ConfigTemplates);
builder.RegisterSettings(x => x.Repositories.TrashGuides);
builder.RegisterSettings(x => x.Notifications);
}
}

@ -23,7 +23,7 @@ public static class SettingsContextualMessages
return
"Usage of 'repository' setting is no " +
"longer supported. Use 'trash_guides' under 'repositories' instead." +
"See: https://recyclarr.dev/wiki/upgrade-guide/v5.0/#settings-repository-changes";
"See: <https://recyclarr.dev/wiki/upgrade-guide/v5.0/#settings-repository-changes>";
}
return null;

@ -1,5 +1,7 @@
using System.IO.Abstractions;
using FluentValidation;
using Recyclarr.Common.Extensions;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Platform;
using Recyclarr.Yaml;
using YamlDotNet.Core;
@ -16,7 +18,9 @@ public class SettingsLoader(IAppPaths paths, IYamlSerializerFactory serializerFa
{
using var stream = yamlPath.OpenText();
var deserializer = serializerFactory.CreateDeserializer();
return deserializer.Deserialize<RecyclarrSettings?>(stream.ReadToEnd()) ?? new RecyclarrSettings();
var settings = deserializer.Deserialize<RecyclarrSettings?>(stream.ReadToEnd()) ?? new RecyclarrSettings();
ValidateSettings(settings);
return settings;
}
catch (YamlException e)
{
@ -25,6 +29,19 @@ public class SettingsLoader(IAppPaths paths, IYamlSerializerFactory serializerFa
}
}
private static void ValidateSettings(RecyclarrSettings settings)
{
try
{
var validator = new RecyclarrSettingsValidator();
validator.ValidateAndThrow(settings);
}
catch (ValidationException e)
{
throw new ContextualValidationException(e, "Settings", "Settings Validation");
}
}
private IFileInfo CreateDefaultSettingsFile()
{
const string fileData =

@ -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…
Cancel
Save