wip: Notifications support through Apprise

Robert Dailey 4 weeks ago
parent bd3b398e38
commit eb4102fa22

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

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

@ -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<JsonAutofacModule>();
builder.RegisterModule<PlatformAutofacModule>();
builder.RegisterModule<CommonAutofacModule>();
builder.RegisterModule<NotificationsAutofacModule>();
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.Register(_ => new ResourceDataReader(thisAssembly)).As<IResourceDataReader>();

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

@ -1,8 +1,10 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Notifications;
namespace Recyclarr.Cli.Pipelines.CustomFormat.PipelinePhases;
public class CustomFormatLogPhase(ILogger log) : ILogPipelinePhase<CustomFormatPipelineContext>
public class CustomFormatLogPhase(ILogger log, NotificationEmitter notify)
: ILogPipelinePhase<CustomFormatPipelineContext>
{
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(CustomFormatPipelineContext context)
@ -11,6 +13,7 @@ public class CustomFormatLogPhase(ILogger log) : ILogPipelinePhase<CustomFormatP
{
log.Warning("These Custom Formats do not exist in the guide and will be skipped: {Cfs}",
context.InvalidFormats);
notify.NotifyError($"Invalid Custom Formats: {context.InvalidFormats}");
}
if (context.ConfigOutput.Count == 0)
@ -29,5 +32,11 @@ public class CustomFormatLogPhase(ILogger log) : ILogPipelinePhase<CustomFormatP
public void LogPersistenceResults(CustomFormatPipelineContext context)
{
context.LogTransactions(log);
var totalCount = context.TransactionOutput.TotalCustomFormatChanges;
if (totalCount > 0)
{
notify.NotifyStatistic("Total custom formats synced", totalCount.ToString());
}
}
}

@ -4,5 +4,5 @@ namespace Recyclarr.Cli.Processors.Sync;
public interface ISyncProcessor
{
Task<ExitStatus> ProcessConfigs(ISyncSettings settings);
Task<ExitStatus> Process(ISyncSettings settings);
}

@ -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<ISyncPipeline> pipelines,
IEnumerable<IPipelineCache> caches)
IEnumerable<IPipelineCache> 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;
}
}
}

@ -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<ExitStatus> ProcessConfigs(ISyncSettings settings)
public async Task<ExitStatus> Process(ISyncSettings settings)
{
notify.BeginWatchEvents();
var result = await ProcessConfigs(settings);
await notify.SendNotification(result != ExitStatus.Failed);
return result;
}
private async Task<ExitStatus> 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)
{

@ -34,6 +34,7 @@
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
<ProjectReference Include="..\Recyclarr.Compatibility\Recyclarr.Compatibility.csproj" />
<ProjectReference Include="..\Recyclarr.Config\Recyclarr.Config.csproj" />
<ProjectReference Include="..\Recyclarr.Notifications\Recyclarr.Notifications.csproj" />
<ProjectReference Include="..\Recyclarr.TrashGuide\Recyclarr.TrashGuide.csproj" />
</ItemGroup>

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

@ -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<FlurlSpecificEventHandler> eventHandlers)
: IAppriseRequestBuilder
{
private readonly Lazy<Uri> _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)
}
});
});
}
}

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

@ -0,0 +1,8 @@
using Recyclarr.Notifications.Apprise.Dto;
namespace Recyclarr.Notifications.Apprise;
public interface IAppriseNotificationApiService
{
Task Notify(string key, AppriseNotification notification);
}

@ -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 Error) : INotificationEvent
{
public string Category => "Errors";
public string Render() => $"- {Error}";
}

@ -0,0 +1,7 @@
namespace Recyclarr.Notifications.Events;
public interface INotificationEvent
{
public string Category { get; }
public string Render();
}

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

@ -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<INotificationEvent> _notifications = new();
public IObservable<INotificationEvent> 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));
}
}

@ -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<string, List<INotificationEvent>> _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<INotificationEvent> 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()))}
""");
}
}
}

@ -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<NotificationService>().InstancePerLifetimeScope();
builder.RegisterType<NotificationEmitter>().InstancePerLifetimeScope();
// Apprise
builder.RegisterType<AppriseNotificationApiService>().As<IAppriseNotificationApiService>();
builder.RegisterType<AppriseRequestBuilder>().As<IAppriseRequestBuilder>();
}
}

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Flurl.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Http\Recyclarr.Http.csproj" />
<ProjectReference Include="..\Recyclarr.Settings\Recyclarr.Settings.csproj" />
</ItemGroup>
</Project>

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

Loading…
Cancel
Save