diff --git a/Recyclarr.sln b/Recyclarr.sln index 7fb387c7..84c2318a 100644 --- a/Recyclarr.sln +++ b/Recyclarr.sln @@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Cli.Tests", "test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Http", "src\Recyclarr.Http\Recyclarr.Http.csproj", "{9B90CD1F-5D31-42A4-B0DD-A12BB7DC68CD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Notifications", "src\Recyclarr.Notifications\Recyclarr.Notifications.csproj", "{C097406E-C00A-4F59-A00E-57F99B248063}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -143,6 +145,10 @@ Global {9B90CD1F-5D31-42A4-B0DD-A12BB7DC68CD}.Debug|Any CPU.Build.0 = Debug|Any CPU {9B90CD1F-5D31-42A4-B0DD-A12BB7DC68CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B90CD1F-5D31-42A4-B0DD-A12BB7DC68CD}.Release|Any CPU.Build.0 = Release|Any CPU + {C097406E-C00A-4F59-A00E-57F99B248063}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C097406E-C00A-4F59-A00E-57F99B248063}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C097406E-C00A-4F59-A00E-57F99B248063}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C097406E-C00A-4F59-A00E-57F99B248063}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docker/debugging/docker-compose.yml b/docker/debugging/docker-compose.yml index 5e7ff951..3b7ea1f0 100644 --- a/docker/debugging/docker-compose.yml +++ b/docker/debugging/docker-compose.yml @@ -7,6 +7,7 @@ volumes: radarr_develop: sonarr_stable: sonarr_develop: + apprise: services: radarr_stable: @@ -54,3 +55,16 @@ services: environment: - TZ=America/Chicago - SONARR__API_KEY=testkey + + # http://localhost:8000 + apprise: + image: caronc/apprise + container_name: apprise + networks: [recyclarr] + ports: [8000:8000] + volumes: + - apprise:/config + environment: + - TZ=America/Chicago + - APPRISE_DEFAULT_THEM=dark + - DEBUG=yes diff --git a/src/Recyclarr.Cli/CompositionRoot.cs b/src/Recyclarr.Cli/CompositionRoot.cs index dfc6e520..a1cf1422 100644 --- a/src/Recyclarr.Cli/CompositionRoot.cs +++ b/src/Recyclarr.Cli/CompositionRoot.cs @@ -19,6 +19,7 @@ using Recyclarr.Compatibility; using Recyclarr.Config; using Recyclarr.Http; using Recyclarr.Json; +using Recyclarr.Notifications; using Recyclarr.Platform; using Recyclarr.Repo; using Recyclarr.ServarrApi; @@ -57,6 +58,7 @@ public static class CompositionRoot builder.RegisterModule(); builder.RegisterModule(); builder.RegisterModule(); + builder.RegisterModule(); builder.RegisterType().As(); builder.Register(_ => new ResourceDataReader(thisAssembly)).As(); diff --git a/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs b/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs index 6ecc2d5b..ae5bc2a2 100644 --- a/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs @@ -53,6 +53,6 @@ public class SyncCommand(IMigrationExecutor migration, IMultiRepoUpdater repoUpd await repoUpdater.UpdateAllRepositories(settings.CancellationToken); - return (int) await syncProcessor.ProcessConfigs(settings); + return (int) await syncProcessor.Process(settings); } } diff --git a/src/Recyclarr.Cli/Pipelines/CustomFormat/PipelinePhases/CustomFormatLogPhase.cs b/src/Recyclarr.Cli/Pipelines/CustomFormat/PipelinePhases/CustomFormatLogPhase.cs index ff91e6ee..a5e9231b 100644 --- a/src/Recyclarr.Cli/Pipelines/CustomFormat/PipelinePhases/CustomFormatLogPhase.cs +++ b/src/Recyclarr.Cli/Pipelines/CustomFormat/PipelinePhases/CustomFormatLogPhase.cs @@ -1,8 +1,10 @@ using Recyclarr.Cli.Pipelines.Generic; +using Recyclarr.Notifications; namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases; -public class CustomFormatLogPhase(ILogger log) : ILogPipelinePhase +public class CustomFormatLogPhase(ILogger log, NotificationEmitter notify) + : ILogPipelinePhase { // Returning 'true' means to exit. 'false' means to proceed. public bool LogConfigPhaseAndExitIfNeeded(CustomFormatPipelineContext context) @@ -11,6 +13,7 @@ public class CustomFormatLogPhase(ILogger log) : ILogPipelinePhase 0) + { + notify.NotifyStatistic("Total custom formats synced", totalCount.ToString()); + } } } diff --git a/src/Recyclarr.Cli/Processors/Sync/ISyncProcessor.cs b/src/Recyclarr.Cli/Processors/Sync/ISyncProcessor.cs index 82a4e3a8..752ab48d 100644 --- a/src/Recyclarr.Cli/Processors/Sync/ISyncProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Sync/ISyncProcessor.cs @@ -4,5 +4,5 @@ namespace Recyclarr.Cli.Processors.Sync; public interface ISyncProcessor { - Task ProcessConfigs(ISyncSettings settings); + Task Process(ISyncSettings settings); } diff --git a/src/Recyclarr.Cli/Processors/Sync/SyncPipelineExecutor.cs b/src/Recyclarr.Cli/Processors/Sync/SyncPipelineExecutor.cs index 86c75c9c..ff86b456 100644 --- a/src/Recyclarr.Cli/Processors/Sync/SyncPipelineExecutor.cs +++ b/src/Recyclarr.Cli/Processors/Sync/SyncPipelineExecutor.cs @@ -1,25 +1,42 @@ using Recyclarr.Cli.Console.Settings; using Recyclarr.Cli.Pipelines; using Recyclarr.Config.Models; +using Recyclarr.Notifications; namespace Recyclarr.Cli.Processors.Sync; public class SyncPipelineExecutor( ILogger log, IOrderedEnumerable pipelines, - IEnumerable caches) + IEnumerable caches, + NotificationEmitter emitter) { public async Task Process(ISyncSettings settings, IServiceConfiguration config) { - foreach (var cache in caches) + try { - cache.Clear(); - } + emitter.NotifyStatistic("Test statistic", "10"); + emitter.NotifyStatistic("Test statistic 2", "1060"); + emitter.NotifyError("Failure occurred"); + emitter.NotifyError("Another failure occurred"); + emitter.NotifyError("Another failure occurred 2"); + foreach (var cache in caches) + { + cache.Clear(); + } + + foreach (var pipeline in pipelines) + { + log.Debug("Executing Pipeline: {Pipeline}", pipeline.GetType().Name); + await pipeline.Execute(settings, config); + } - foreach (var pipeline in pipelines) + log.Information("Completed at {Date}", DateTime.Now); + } + catch (Exception e) { - log.Debug("Executing Pipeline: {Pipeline}", pipeline.GetType().Name); - await pipeline.Execute(settings, config); + emitter.NotifyError($"Exception: {e.Message}"); + throw; } } } diff --git a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs index 5de9a354..35486890 100644 --- a/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Sync/SyncProcessor.cs @@ -4,7 +4,7 @@ using Recyclarr.Cli.Processors.ErrorHandling; using Recyclarr.Compatibility; using Recyclarr.Config; using Recyclarr.Config.Models; -using Recyclarr.TrashGuide; +using Recyclarr.Notifications; using Spectre.Console; namespace Recyclarr.Cli.Processors.Sync; @@ -14,12 +14,21 @@ public class SyncProcessor( IAnsiConsole console, ILogger log, IConfigurationRegistry configRegistry, - SyncPipelineExecutor pipelines, + SyncPipelineExecutor pipelineExecutor, ServiceAgnosticCapabilityEnforcer capabilityEnforcer, - ConsoleExceptionHandler exceptionHandler) + ConsoleExceptionHandler exceptionHandler, + NotificationService notify) : ISyncProcessor { - public async Task ProcessConfigs(ISyncSettings settings) + public async Task Process(ISyncSettings settings) + { + notify.BeginWatchEvents(); + var result = await ProcessConfigs(settings); + await notify.SendNotification(result != ExitStatus.Failed); + return result; + } + + private async Task ProcessConfigs(ISyncSettings settings) { bool failureDetected; try @@ -55,10 +64,10 @@ public class SyncProcessor( { try { + notify.SetInstanceName(config.InstanceName); PrintProcessingHeader(config.ServiceType, config); await capabilityEnforcer.Check(config); - await pipelines.Process(settings, config); - log.Information("Completed at {Date}", DateTime.Now); + await pipelineExecutor.Process(settings, config); } catch (Exception e) { diff --git a/src/Recyclarr.Cli/Recyclarr.Cli.csproj b/src/Recyclarr.Cli/Recyclarr.Cli.csproj index 5b875b68..86ed0057 100644 --- a/src/Recyclarr.Cli/Recyclarr.Cli.csproj +++ b/src/Recyclarr.Cli/Recyclarr.Cli.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Recyclarr.Notifications/Apprise/AppriseNotificationApiService.cs b/src/Recyclarr.Notifications/Apprise/AppriseNotificationApiService.cs new file mode 100644 index 00000000..eb59ebc5 --- /dev/null +++ b/src/Recyclarr.Notifications/Apprise/AppriseNotificationApiService.cs @@ -0,0 +1,21 @@ +using Flurl.Http; +using Recyclarr.Notifications.Apprise.Dto; +using Recyclarr.Settings; + +namespace Recyclarr.Notifications.Apprise; + +public class AppriseNotificationApiService(IAppriseRequestBuilder api, ISettingsProvider settingsProvider) + : IAppriseNotificationApiService +{ + public async Task Notify(string key, AppriseNotification notification) + { + var settings = settingsProvider.Settings.Notifications?.Apprise; + if (settings?.Key is null) + { + throw new ArgumentException("No apprise notification settings have been defined"); + } + + await api.Request("notify", settings.Key) + .PostJsonAsync(notification); + } +} diff --git a/src/Recyclarr.Notifications/Apprise/AppriseRequestBuilder.cs b/src/Recyclarr.Notifications/Apprise/AppriseRequestBuilder.cs new file mode 100644 index 00000000..c4912a6a --- /dev/null +++ b/src/Recyclarr.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, + ISettingsProvider settingsProvider, + IEnumerable eventHandlers) + : IAppriseRequestBuilder +{ + private readonly Lazy _baseUrl = new(() => + { + var settings = settingsProvider.Settings.Notifications?.Apprise; + if (settings is null) + { + throw new ArgumentException("No apprise notification settings have been defined"); + } + + if (settings.BaseUrl is null) + { + throw new ArgumentException("Apprise `base_url` setting is not present or empty"); + } + + return settings.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.Notifications/Apprise/Dto/AppriseMessageFormat.cs b/src/Recyclarr.Notifications/Apprise/Dto/AppriseMessageFormat.cs new file mode 100644 index 00000000..e9378025 --- /dev/null +++ b/src/Recyclarr.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.Notifications/Apprise/Dto/AppriseMessageType.cs b/src/Recyclarr.Notifications/Apprise/Dto/AppriseMessageType.cs new file mode 100644 index 00000000..b32dd50d --- /dev/null +++ b/src/Recyclarr.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.Notifications/Apprise/Dto/AppriseNotification.cs b/src/Recyclarr.Notifications/Apprise/Dto/AppriseNotification.cs new file mode 100644 index 00000000..e6a244b1 --- /dev/null +++ b/src/Recyclarr.Notifications/Apprise/Dto/AppriseNotification.cs @@ -0,0 +1,10 @@ +namespace Recyclarr.Notifications.Apprise.Dto; + +public record AppriseNotification +{ + public required string Body { get; init; } + public string? Title { get; init; } + public AppriseMessageType? Type { get; init; } + public AppriseMessageFormat? Format { get; init; } + public string? Tag { get; init; } +} diff --git a/src/Recyclarr.Notifications/Apprise/IAppriseNotificationApiService.cs b/src/Recyclarr.Notifications/Apprise/IAppriseNotificationApiService.cs new file mode 100644 index 00000000..5a99cd41 --- /dev/null +++ b/src/Recyclarr.Notifications/Apprise/IAppriseNotificationApiService.cs @@ -0,0 +1,8 @@ +using Recyclarr.Notifications.Apprise.Dto; + +namespace Recyclarr.Notifications.Apprise; + +public interface IAppriseNotificationApiService +{ + Task Notify(string key, AppriseNotification notification); +} diff --git a/src/Recyclarr.Notifications/Apprise/IAppriseRequestBuilder.cs b/src/Recyclarr.Notifications/Apprise/IAppriseRequestBuilder.cs new file mode 100644 index 00000000..0e13ea5f --- /dev/null +++ b/src/Recyclarr.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.Notifications/Events/ErrorEvent.cs b/src/Recyclarr.Notifications/Events/ErrorEvent.cs new file mode 100644 index 00000000..8f7f6504 --- /dev/null +++ b/src/Recyclarr.Notifications/Events/ErrorEvent.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Notifications.Events; + +public record ErrorEvent(string Error) : INotificationEvent +{ + public string Category => "Errors"; + public string Render() => $"- {Error}"; +} diff --git a/src/Recyclarr.Notifications/Events/INotificationEvent.cs b/src/Recyclarr.Notifications/Events/INotificationEvent.cs new file mode 100644 index 00000000..4d6f9c73 --- /dev/null +++ b/src/Recyclarr.Notifications/Events/INotificationEvent.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Notifications.Events; + +public interface INotificationEvent +{ + public string Category { get; } + public string Render(); +} diff --git a/src/Recyclarr.Notifications/Events/StatisticEvent.cs b/src/Recyclarr.Notifications/Events/StatisticEvent.cs new file mode 100644 index 00000000..10f4e842 --- /dev/null +++ b/src/Recyclarr.Notifications/Events/StatisticEvent.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.Notifications.Events; + +public record StatisticEvent(string Description, string Statistic) : INotificationEvent +{ + public string Category => "Statistics"; + public string Render() => $"- {Description}: {Statistic}"; +} \ No newline at end of file diff --git a/src/Recyclarr.Notifications/NotificationEmitter.cs b/src/Recyclarr.Notifications/NotificationEmitter.cs new file mode 100644 index 00000000..2fe91d00 --- /dev/null +++ b/src/Recyclarr.Notifications/NotificationEmitter.cs @@ -0,0 +1,22 @@ +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 NotifyStatistic(string description, string stat) + { + _notifications.OnNext(new StatisticEvent(description, stat)); + } + + public void NotifyError(string error) + { + _notifications.OnNext(new ErrorEvent(error)); + } +} diff --git a/src/Recyclarr.Notifications/NotificationService.cs b/src/Recyclarr.Notifications/NotificationService.cs new file mode 100644 index 00000000..ee8e9970 --- /dev/null +++ b/src/Recyclarr.Notifications/NotificationService.cs @@ -0,0 +1,107 @@ +using System.Reactive.Disposables; +using System.Text; +using Flurl.Http; +using Recyclarr.Common.Extensions; +using Recyclarr.Http; +using Recyclarr.Notifications.Apprise; +using Recyclarr.Notifications.Apprise.Dto; +using Recyclarr.Notifications.Events; +using Recyclarr.Settings; +using Serilog; + +namespace Recyclarr.Notifications; + +public sealed class NotificationService( + ILogger log, + IAppriseNotificationApiService apprise, + ISettingsProvider settingsProvider, + NotificationEmitter notificationEmitter) : IDisposable +{ + private readonly Dictionary> _events = new(); + private readonly CompositeDisposable _eventConnection = new(); + 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 => + { + ArgumentNullException.ThrowIfNull(_activeInstanceName); + _events.GetOrCreate(_activeInstanceName).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 (settingsProvider.Settings.Notifications is null) + { + log.Debug("Notification settings are not present, so this notification will not be sent"); + return; + } + + var body = new StringBuilder(); + + foreach (var (instanceName, notifications) in _events) + { + RenderInstanceEvents(body, instanceName, notifications); + } + + var messageType = AppriseMessageType.Success; + if (!succeeded) + { + messageType = AppriseMessageType.Failure; + } + + try + { + await apprise.Notify("apprise", new AppriseNotification + { + Title = $"Recyclarr Sync {(succeeded ? "Completed" : "Failed")}", + Body = body.ToString(), + Type = messageType, + Format = AppriseMessageFormat.Markdown + }); + } + catch (FlurlHttpException e) + { + log.Error("Failed to send notification: {Msg}", e.SanitizedExceptionMessage()); + } + } + + private static void RenderInstanceEvents( + StringBuilder body, + string instanceName, + IEnumerable notifications) + { + body.AppendLine($"### Instance: `{instanceName}`"); + + 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.Notifications/NotificationsAutofacModule.cs b/src/Recyclarr.Notifications/NotificationsAutofacModule.cs new file mode 100644 index 00000000..bacc3bf0 --- /dev/null +++ b/src/Recyclarr.Notifications/NotificationsAutofacModule.cs @@ -0,0 +1,19 @@ +using Autofac; +using Recyclarr.Notifications.Apprise; + +namespace Recyclarr.Notifications; + +public class NotificationsAutofacModule : Module +{ + protected override void Load(ContainerBuilder builder) + { + base.Load(builder); + + builder.RegisterType().InstancePerLifetimeScope(); + builder.RegisterType().InstancePerLifetimeScope(); + + // Apprise + builder.RegisterType().As(); + builder.RegisterType().As(); + } +} diff --git a/src/Recyclarr.Notifications/Recyclarr.Notifications.csproj b/src/Recyclarr.Notifications/Recyclarr.Notifications.csproj new file mode 100644 index 00000000..31de8c7b --- /dev/null +++ b/src/Recyclarr.Notifications/Recyclarr.Notifications.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Recyclarr.Settings/SettingsValues.cs b/src/Recyclarr.Settings/SettingsValues.cs index e69125ef..34425377 100644 --- a/src/Recyclarr.Settings/SettingsValues.cs +++ b/src/Recyclarr.Settings/SettingsValues.cs @@ -33,4 +33,17 @@ public record SettingsValues public bool EnableSslCertificateValidation { get; [UsedImplicitly] init; } = true; public LogJanitorSettings LogJanitor { get; [UsedImplicitly] init; } = new(); public string? GitPath { get; [UsedImplicitly] init; } + public NotificationSettings? Notifications { get; init; } +} + +public record NotificationSettings +{ + public AppriseNotificationSettings? Apprise { get; init; } +} + +public record AppriseNotificationSettings +{ + public Uri? BaseUrl { get; init; } + public string? Key { get; init; } + public string? Tags { get; init; } }