From 0707517bb8bf5f09df54b2e95ebfb7b0e7d8b78a Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 7 Apr 2024 12:51:12 -0500 Subject: [PATCH] feat: Notifications support through Apprise --- CHANGELOG.md | 4 + docker/.gitignore | 1 + docker/debugging/docker-compose.yml | 18 +++ schemas/settings/notifications.json | 22 ++- src/Recyclarr.Cli/CompositionRoot.cs | 6 +- .../Console/Setup/LoggerSetupTask.cs | 1 - src/Recyclarr.Cli/Logging/LoggerFactory.cs | 2 - .../CustomFormatTransactionLogger.cs | 5 +- .../PipelinePhases/MediaNamingLogPhase.cs | 5 +- .../PipelinePhases/QualityProfileLogPhase.cs | 18 +-- .../PipelinePhases/QualitySizeLogPhase.cs | 5 +- .../ErrorHandling/ConsoleExceptionHandler.cs | 11 +- .../Processors/Sync/SyncProcessor.cs | 13 +- .../Common/CommonAutofacModule.cs | 1 + .../ContextualValidationException.cs | 20 +++ .../FluentValidationExtensions.cs | 32 ----- .../FluentValidation/ValidationLogger.cs | 47 +++++++ .../Parsing/ConfigValidationExecutor.cs | 11 +- .../ErrorHandling/ConfigContextualMessages.cs | 6 +- .../ConfigMerging/ConfigIncludeProcessor.cs | 2 +- .../CfQualityProfilesDeprecationCheck.cs | 2 +- .../Apprise/AppriseRequestBuilder.cs | 59 ++++++++ .../AppriseStatefulNotificationApiService.cs | 25 ++++ .../AppriseStatelessNotificationApiService.cs | 25 ++++ .../Apprise/Dto/AppriseMessageFormat.cs | 8 ++ .../Apprise/Dto/AppriseMessageType.cs | 9 ++ .../Apprise/Dto/AppriseNotification.cs | 21 +++ .../Apprise/IAppriseNotificationApiService.cs | 11 ++ .../Apprise/IAppriseRequestBuilder.cs | 8 ++ .../Notifications/Events/ErrorEvent.cs | 7 + .../Events/IPresentableNotification.cs | 7 + .../Notifications/Events/InformationEvent.cs | 9 ++ .../Notifications/Events/WarningEvent.cs | 7 + .../Notifications/NotificationEmitter.cs | 35 +++++ .../Notifications/NotificationLogSink.cs | 33 +++++ .../NotificationLogSinkConfigurator.cs | 27 ++++ .../Notifications/NotificationService.cs | 131 ++++++++++++++++++ .../NotificationsAutofacModule.cs | 27 ++++ src/Recyclarr.Core/Recyclarr.Core.csproj | 1 + src/Recyclarr.Core/Settings/ISettings.cs | 2 + .../Settings/RecyclarrSettings.cs | 23 +++ src/Recyclarr.Core/Settings/Settings.cs | 6 +- .../Settings/SettingsAutofacModule.cs | 1 + .../Settings/SettingsContextualMessages.cs | 2 +- src/Recyclarr.Core/Settings/SettingsLoader.cs | 19 ++- .../Settings/SettingsValuesValidators.cs | 42 ++++++ 46 files changed, 707 insertions(+), 70 deletions(-) create mode 100644 src/Recyclarr.Core/Common/FluentValidation/ContextualValidationException.cs create mode 100644 src/Recyclarr.Core/Common/FluentValidation/ValidationLogger.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/AppriseRequestBuilder.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/AppriseStatefulNotificationApiService.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/AppriseStatelessNotificationApiService.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageFormat.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageType.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseNotification.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/IAppriseNotificationApiService.cs create mode 100644 src/Recyclarr.Core/Notifications/Apprise/IAppriseRequestBuilder.cs create mode 100644 src/Recyclarr.Core/Notifications/Events/ErrorEvent.cs create mode 100644 src/Recyclarr.Core/Notifications/Events/IPresentableNotification.cs create mode 100644 src/Recyclarr.Core/Notifications/Events/InformationEvent.cs create mode 100644 src/Recyclarr.Core/Notifications/Events/WarningEvent.cs create mode 100644 src/Recyclarr.Core/Notifications/NotificationEmitter.cs create mode 100644 src/Recyclarr.Core/Notifications/NotificationLogSink.cs create mode 100644 src/Recyclarr.Core/Notifications/NotificationLogSinkConfigurator.cs create mode 100644 src/Recyclarr.Core/Notifications/NotificationService.cs create mode 100644 src/Recyclarr.Core/Notifications/NotificationsAutofacModule.cs create mode 100644 src/Recyclarr.Core/Settings/SettingsValuesValidators.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b12002cd..c94d790c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docker/.gitignore b/docker/.gitignore index 96e9fc04..a10d0948 100644 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -1,2 +1,3 @@ /config/ /artifacts/ +/debugging/apprise/ diff --git a/docker/debugging/docker-compose.yml b/docker/debugging/docker-compose.yml index 84b21f22..3c39ee8f 100644 --- a/docker/debugging/docker-compose.yml +++ b/docker/debugging/docker-compose.yml @@ -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 diff --git a/schemas/settings/notifications.json b/schemas/settings/notifications.json index 26cf7e27..d4085fa8 100644 --- a/schemas/settings/notifications.json +++ b/schemas/settings/notifications.json @@ -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" + } } } } diff --git a/src/Recyclarr.Cli/CompositionRoot.cs b/src/Recyclarr.Cli/CompositionRoot.cs index 5bf54740..4817ad79 100644 --- a/src/Recyclarr.Cli/CompositionRoot.cs +++ b/src/Recyclarr.Cli/CompositionRoot.cs @@ -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(); builder.RegisterModule(); builder.RegisterModule(); + builder.RegisterModule(); builder.RegisterType().As(); builder.Register(_ => new ResourceDataReader(thisAssembly)).As(); @@ -93,9 +95,7 @@ public static class CompositionRoot private static void RegisterLogger(ContainerBuilder builder) { // Log Configurators - builder.RegisterTypes( - typeof(FileLogSinkConfigurator)) - .As(); + builder.RegisterType().As(); builder.RegisterType().SingleInstance(); builder.RegisterType().SingleInstance(); diff --git a/src/Recyclarr.Cli/Console/Setup/LoggerSetupTask.cs b/src/Recyclarr.Cli/Console/Setup/LoggerSetupTask.cs index 8475b170..76b101f2 100644 --- a/src/Recyclarr.Cli/Console/Setup/LoggerSetupTask.cs +++ b/src/Recyclarr.Cli/Console/Setup/LoggerSetupTask.cs @@ -25,6 +25,5 @@ public class LoggerSetupTask( public void OnFinish() { - throw new NotImplementedException(); } } diff --git a/src/Recyclarr.Cli/Logging/LoggerFactory.cs b/src/Recyclarr.Cli/Logging/LoggerFactory.cs index 7291ddae..ee9b1dc6 100644 --- a/src/Recyclarr.Cli/Logging/LoggerFactory.cs +++ b/src/Recyclarr.Cli/Logging/LoggerFactory.cs @@ -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); diff --git a/src/Recyclarr.Cli/Pipelines/CustomFormat/CustomFormatTransactionLogger.cs b/src/Recyclarr.Cli/Pipelines/CustomFormat/CustomFormatTransactionLogger.cs index 013e6d3b..789268d1 100644 --- a/src/Recyclarr.Cli/Pipelines/CustomFormat/CustomFormatTransactionLogger.cs +++ b/src/Recyclarr.Cli/Pipelines/CustomFormat/CustomFormatTransactionLogger.cs @@ -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 { diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingLogPhase.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingLogPhase.cs index 1c102f34..adaaa62b 100644 --- a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingLogPhase.cs +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingLogPhase.cs @@ -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 +public class MediaNamingLogPhase(ILogger log, NotificationEmitter notificationEmitter) + : ILogPipelinePhase { // Returning 'true' means to exit. 'false' means to proceed. public bool LogConfigPhaseAndExitIfNeeded(MediaNamingPipelineContext context) @@ -53,6 +55,7 @@ public class MediaNamingLogPhase(ILogger log) : ILogPipelinePhase +public class QualityProfileLogPhase( + ILogger log, + ValidationLogger validationLogger, + NotificationEmitter notificationEmitter) + : ILogPipelinePhase { public bool LogConfigPhaseAndExitIfNeeded(QualityProfilePipelineContext context) { @@ -36,17 +41,12 @@ public class QualityProfileLogPhase(ILogger log) : ILogPipelinePhase 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 +public class QualitySizeLogPhase(ILogger log, NotificationEmitter notificationEmitter) + : ILogPipelinePhase { public bool LogConfigPhaseAndExitIfNeeded(QualitySizePipelineContext context) { @@ -35,6 +37,7 @@ public class QualitySizeLogPhase(ILogger log) : ILogPipelinePhase"); 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; } diff --git a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs index 5ed9da72..133bbeb7 100644 --- a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs @@ -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 Process(ISyncSettings settings, CancellationToken ct) + { + notify.BeginWatchEvents(); + var result = await ProcessConfigs(settings, ct); + await notify.SendNotification(result != ExitStatus.Failed); + return result; + } + + private async Task ProcessConfigs(ISyncSettings settings, CancellationToken ct) { bool failureDetected; try @@ -63,6 +73,7 @@ public class SyncProcessor( try { using var scope = configScopeFactory.Start(config); + notify.SetInstanceName(config.InstanceName); console.WriteLine( $""" diff --git a/src/Recyclarr.Core/Common/CommonAutofacModule.cs b/src/Recyclarr.Core/Common/CommonAutofacModule.cs index ace46949..71bd5557 100644 --- a/src/Recyclarr.Core/Common/CommonAutofacModule.cs +++ b/src/Recyclarr.Core/Common/CommonAutofacModule.cs @@ -9,5 +9,6 @@ public class CommonAutofacModule : Module { base.Load(builder); builder.RegisterType().As(); + builder.RegisterType(); } } diff --git a/src/Recyclarr.Core/Common/FluentValidation/ContextualValidationException.cs b/src/Recyclarr.Core/Common/FluentValidation/ContextualValidationException.cs new file mode 100644 index 00000000..69e35e1e --- /dev/null +++ b/src/Recyclarr.Core/Common/FluentValidation/ContextualValidationException.cs @@ -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); + } +} diff --git a/src/Recyclarr.Core/Common/FluentValidation/FluentValidationExtensions.cs b/src/Recyclarr.Core/Common/FluentValidation/FluentValidationExtensions.cs index 60ff678c..ce2816fc 100644 --- a/src/Recyclarr.Core/Common/FluentValidation/FluentValidationExtensions.cs +++ b/src/Recyclarr.Core/Common/FluentValidation/FluentValidationExtensions.cs @@ -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 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; - } } diff --git a/src/Recyclarr.Core/Common/FluentValidation/ValidationLogger.cs b/src/Recyclarr.Core/Common/FluentValidation/ValidationLogger.cs new file mode 100644 index 00000000..f95d16e6 --- /dev/null +++ b/src/Recyclarr.Core/Common/FluentValidation/ValidationLogger.cs @@ -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 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 + }; + } +} diff --git a/src/Recyclarr.Core/Config/Parsing/ConfigValidationExecutor.cs b/src/Recyclarr.Core/Config/Parsing/ConfigValidationExecutor.cs index 24513134..993da803 100644 --- a/src/Recyclarr.Core/Config/Parsing/ConfigValidationExecutor.cs +++ b/src/Recyclarr.Core/Config/Parsing/ConfigValidationExecutor.cs @@ -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"); } } diff --git a/src/Recyclarr.Core/Config/Parsing/ErrorHandling/ConfigContextualMessages.cs b/src/Recyclarr.Core/Config/Parsing/ErrorHandling/ConfigContextualMessages.cs index dac4330a..90d84a63 100644 --- a/src/Recyclarr.Core/Config/Parsing/ErrorHandling/ConfigContextualMessages.cs +++ b/src/Recyclarr.Core/Config/Parsing/ErrorHandling/ConfigContextualMessages.cs @@ -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: "; } 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: "; } 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: "; } return null; diff --git a/src/Recyclarr.Core/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs b/src/Recyclarr.Core/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs index 57716803..725c75c9 100644 --- a/src/Recyclarr.Core/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs +++ b/src/Recyclarr.Core/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs @@ -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: "); return fsPath; } diff --git a/src/Recyclarr.Core/Config/Parsing/PostProcessing/Deprecations/CfQualityProfilesDeprecationCheck.cs b/src/Recyclarr.Core/Config/Parsing/PostProcessing/Deprecations/CfQualityProfilesDeprecationCheck.cs index 24ee7fb3..2a071a34 100644 --- a/src/Recyclarr.Core/Config/Parsing/PostProcessing/Deprecations/CfQualityProfilesDeprecationCheck.cs +++ b/src/Recyclarr.Core/Config/Parsing/PostProcessing/Deprecations/CfQualityProfilesDeprecationCheck.cs @@ -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: "); // CustomFormats is checked for null in the CheckIfNeeded() method, which is called first. var cfs = include.CustomFormats!.Select(x => x with diff --git a/src/Recyclarr.Core/Notifications/Apprise/AppriseRequestBuilder.cs b/src/Recyclarr.Core/Notifications/Apprise/AppriseRequestBuilder.cs new file mode 100644 index 00000000..7742fda5 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/AppriseRequestBuilder.cs @@ -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 settings, + IEnumerable eventHandlers) + : IAppriseRequestBuilder +{ + private readonly Lazy _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) + } + }); + }); + } +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/AppriseStatefulNotificationApiService.cs b/src/Recyclarr.Core/Notifications/Apprise/AppriseStatefulNotificationApiService.cs new file mode 100644 index 00000000..f1c1f36b --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/AppriseStatefulNotificationApiService.cs @@ -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 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); + } +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/AppriseStatelessNotificationApiService.cs b/src/Recyclarr.Core/Notifications/Apprise/AppriseStatelessNotificationApiService.cs new file mode 100644 index 00000000..aa9b6cba --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/AppriseStatelessNotificationApiService.cs @@ -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 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); + } +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageFormat.cs b/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageFormat.cs new file mode 100644 index 00000000..e9378025 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageFormat.cs @@ -0,0 +1,8 @@ +namespace Recyclarr.Notifications.Apprise.Dto; + +public enum AppriseMessageFormat +{ + Text, + Markdown, + Html +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageType.cs b/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageType.cs new file mode 100644 index 00000000..b32dd50d --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseMessageType.cs @@ -0,0 +1,9 @@ +namespace Recyclarr.Notifications.Apprise.Dto; + +public enum AppriseMessageType +{ + Info, + Success, + Warning, + Failure +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseNotification.cs b/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseNotification.cs new file mode 100644 index 00000000..e7f9f391 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/Dto/AppriseNotification.cs @@ -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 Urls { get; init; } = []; +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/IAppriseNotificationApiService.cs b/src/Recyclarr.Core/Notifications/Apprise/IAppriseNotificationApiService.cs new file mode 100644 index 00000000..95dc5613 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/IAppriseNotificationApiService.cs @@ -0,0 +1,11 @@ +using Recyclarr.Notifications.Apprise.Dto; +using Recyclarr.Settings; + +namespace Recyclarr.Notifications.Apprise; + +public interface IAppriseNotificationApiService +{ + Task Notify( + AppriseNotificationSettings settings, + Func notificationBuilder); +} diff --git a/src/Recyclarr.Core/Notifications/Apprise/IAppriseRequestBuilder.cs b/src/Recyclarr.Core/Notifications/Apprise/IAppriseRequestBuilder.cs new file mode 100644 index 00000000..0e13ea5f --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Apprise/IAppriseRequestBuilder.cs @@ -0,0 +1,8 @@ +using Flurl.Http; + +namespace Recyclarr.Notifications.Apprise; + +public interface IAppriseRequestBuilder +{ + IFlurlRequest Request(params object[] path); +} diff --git a/src/Recyclarr.Core/Notifications/Events/ErrorEvent.cs b/src/Recyclarr.Core/Notifications/Events/ErrorEvent.cs new file mode 100644 index 00000000..e0a51409 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Events/ErrorEvent.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Notifications.Events; + +public record ErrorEvent(string Message) : IPresentableNotification +{ + public string Category => "Errors"; + public string Render() => $"- {Message}"; +} diff --git a/src/Recyclarr.Core/Notifications/Events/IPresentableNotification.cs b/src/Recyclarr.Core/Notifications/Events/IPresentableNotification.cs new file mode 100644 index 00000000..105371cf --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Events/IPresentableNotification.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Notifications.Events; + +public interface IPresentableNotification +{ + public string Category { get; } + public string Render(); +} diff --git a/src/Recyclarr.Core/Notifications/Events/InformationEvent.cs b/src/Recyclarr.Core/Notifications/Events/InformationEvent.cs new file mode 100644 index 00000000..affed1be --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Events/InformationEvent.cs @@ -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}")}"; +} diff --git a/src/Recyclarr.Core/Notifications/Events/WarningEvent.cs b/src/Recyclarr.Core/Notifications/Events/WarningEvent.cs new file mode 100644 index 00000000..d13a3323 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/Events/WarningEvent.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Notifications.Events; + +public record WarningEvent(string Message) : IPresentableNotification +{ + public string Category => "Warnings"; + public string Render() => $"- {Message}"; +} diff --git a/src/Recyclarr.Core/Notifications/NotificationEmitter.cs b/src/Recyclarr.Core/Notifications/NotificationEmitter.cs new file mode 100644 index 00000000..019e5040 --- /dev/null +++ b/src/Recyclarr.Core/Notifications/NotificationEmitter.cs @@ -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 _notifications = new(); + + public IObservable OnNotification => _notifications.AsObservable(); + + public void SendStatistic(string description) + { + _notifications.OnNext(new InformationEvent(description)); + } + + public void SendStatistic(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)); + } +} diff --git a/src/Recyclarr.Core/Notifications/NotificationLogSink.cs b/src/Recyclarr.Core/Notifications/NotificationLogSink.cs new file mode 100644 index 00000000..8e37113c --- /dev/null +++ b/src/Recyclarr.Core/Notifications/NotificationLogSink.cs @@ -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(); + } +} diff --git a/src/Recyclarr.Core/Notifications/NotificationLogSinkConfigurator.cs b/src/Recyclarr.Core/Notifications/NotificationLogSinkConfigurator.cs new file mode 100644 index 00000000..ad19633e --- /dev/null +++ b/src/Recyclarr.Core/Notifications/NotificationLogSinkConfigurator.cs @@ -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 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); + } +} diff --git a/src/Recyclarr.Core/Notifications/NotificationService.cs b/src/Recyclarr.Core/Notifications/NotificationService.cs new file mode 100644 index 00000000..87ec303a --- /dev/null +++ b/src/Recyclarr.Core/Notifications/NotificationService.cs @@ -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 apiFactory, + ISettings settings, + NotificationEmitter notificationEmitter) + : IDisposable +{ + private const string NoInstance = "[no instance]"; + + private readonly Dictionary> _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 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()))} + + """); + } + } +} diff --git a/src/Recyclarr.Core/Notifications/NotificationsAutofacModule.cs b/src/Recyclarr.Core/Notifications/NotificationsAutofacModule.cs new file mode 100644 index 00000000..094bf0fa --- /dev/null +++ b/src/Recyclarr.Core/Notifications/NotificationsAutofacModule.cs @@ -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().As(); + builder.RegisterType().SingleInstance(); + builder.RegisterType().SingleInstance(); + + // Apprise + builder.RegisterType() + .Keyed(AppriseMode.Stateful); + + builder.RegisterType() + .Keyed(AppriseMode.Stateless); + + builder.RegisterType().As(); + } +} diff --git a/src/Recyclarr.Core/Recyclarr.Core.csproj b/src/Recyclarr.Core/Recyclarr.Core.csproj index 0ea2019f..ba9c2465 100644 --- a/src/Recyclarr.Core/Recyclarr.Core.csproj +++ b/src/Recyclarr.Core/Recyclarr.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Recyclarr.Core/Settings/ISettings.cs b/src/Recyclarr.Core/Settings/ISettings.cs index 51ea23f2..ef79b235 100644 --- a/src/Recyclarr.Core/Settings/ISettings.cs +++ b/src/Recyclarr.Core/Settings/ISettings.cs @@ -2,5 +2,7 @@ namespace Recyclarr.Settings; public interface ISettings { + bool IsProvided { get; } T Value { get; } + T? OptionalValue { get; } } diff --git a/src/Recyclarr.Core/Settings/RecyclarrSettings.cs b/src/Recyclarr.Core/Settings/RecyclarrSettings.cs index 39d3ca30..f4da9b25 100644 --- a/src/Recyclarr.Core/Settings/RecyclarrSettings.cs +++ b/src/Recyclarr.Core/Settings/RecyclarrSettings.cs @@ -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 Urls { get; [UsedImplicitly] init; } = []; +} + +public enum AppriseMode +{ + Stateful, + Stateless } diff --git a/src/Recyclarr.Core/Settings/Settings.cs b/src/Recyclarr.Core/Settings/Settings.cs index 13542e90..9eaf1163 100644 --- a/src/Recyclarr.Core/Settings/Settings.cs +++ b/src/Recyclarr.Core/Settings/Settings.cs @@ -1,3 +1,7 @@ namespace Recyclarr.Settings; -internal record Settings(T Value) : ISettings; +internal record Settings(T? OptionalValue) : ISettings +{ + public bool IsProvided => OptionalValue is not null; + public T Value => OptionalValue ?? throw new InvalidOperationException("Settings value is not provided"); +} diff --git a/src/Recyclarr.Core/Settings/SettingsAutofacModule.cs b/src/Recyclarr.Core/Settings/SettingsAutofacModule.cs index e4616cbc..b1014cfc 100644 --- a/src/Recyclarr.Core/Settings/SettingsAutofacModule.cs +++ b/src/Recyclarr.Core/Settings/SettingsAutofacModule.cs @@ -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); } } diff --git a/src/Recyclarr.Core/Settings/SettingsContextualMessages.cs b/src/Recyclarr.Core/Settings/SettingsContextualMessages.cs index 0e77e59c..5d7e5d88 100644 --- a/src/Recyclarr.Core/Settings/SettingsContextualMessages.cs +++ b/src/Recyclarr.Core/Settings/SettingsContextualMessages.cs @@ -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: "; } return null; diff --git a/src/Recyclarr.Core/Settings/SettingsLoader.cs b/src/Recyclarr.Core/Settings/SettingsLoader.cs index 6b7de0a2..d7ce21ef 100644 --- a/src/Recyclarr.Core/Settings/SettingsLoader.cs +++ b/src/Recyclarr.Core/Settings/SettingsLoader.cs @@ -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(stream.ReadToEnd()) ?? new RecyclarrSettings(); + var settings = deserializer.Deserialize(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 = diff --git a/src/Recyclarr.Core/Settings/SettingsValuesValidators.cs b/src/Recyclarr.Core/Settings/SettingsValuesValidators.cs new file mode 100644 index 00000000..d54b802a --- /dev/null +++ b/src/Recyclarr.Core/Settings/SettingsValuesValidators.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using Recyclarr.Common.FluentValidation; + +namespace Recyclarr.Settings; + +public class RecyclarrSettingsValidator : AbstractValidator +{ + public RecyclarrSettingsValidator() + { + RuleFor(x => x.Notifications).SetNonNullableValidator(new NotificationSettingsValidator()); + } +} + +public class NotificationSettingsValidator : AbstractValidator +{ + public NotificationSettingsValidator() + { + RuleFor(x => x.Apprise).SetNonNullableValidator(new AppriseNotificationSettingsValidator()); + } +} + +public class AppriseNotificationSettingsValidator : AbstractValidator +{ + 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"); + } +}