From 19b367570144ac8158f7a983323d40346289da6f Mon Sep 17 00:00:00 2001 From: Marius Nechifor Date: Sun, 2 Feb 2025 20:45:50 +0200 Subject: [PATCH] Add Notifiarr support (#52) --- README.md | 20 ++++ .../Notification/NotificationConfig.cs | 19 ++++ code/Domain/Enums/DeleteReason.cs | 8 ++ code/Domain/Models/Arr/Queue/Image.cs | 8 ++ code/Domain/Models/Arr/Queue/LidarrImage.cs | 8 ++ code/Domain/Models/Arr/Queue/QueueAlbum.cs | 6 ++ code/Domain/Models/Arr/Queue/QueueMovie.cs | 6 ++ code/Domain/Models/Arr/Queue/QueueRecord.cs | 6 ++ code/Domain/Models/Arr/Queue/QueueSeries.cs | 6 ++ .../DependencyInjection/LoggingDI.cs | 1 + code/Executable/DependencyInjection/MainDI.cs | 24 ++++- .../DependencyInjection/NotificationsDI.cs | 16 +++ code/Executable/appsettings.Development.json | 7 ++ code/Executable/appsettings.json | 7 ++ code/Infrastructure/Infrastructure.csproj | 2 + .../Infrastructure/Verticals/Arr/ArrClient.cs | 4 +- .../Verticals/Arr/SonarrClient.cs | 3 +- .../ContentBlocker/ContentBlocker.cs | 17 +++- .../Verticals/Context/ContextProvider.cs | 24 +++++ .../DownloadClient/Deluge/DelugeService.cs | 17 +++- .../DownloadClient/DownloadServiceBase.cs | 4 +- .../DownloadClient/QBittorrent/QBitService.cs | 14 ++- .../Verticals/DownloadClient/StalledResult.cs | 6 +- .../Transmission/TransmissionService.cs | 17 +++- .../Verticals/ItemStriker/Striker.cs | 13 ++- .../Verticals/Jobs/GenericHandler.cs | 6 +- .../Consumers/NotificationConsumer.cs | 45 +++++++++ .../Notifications/INotificationFactory.cs | 10 ++ .../Notifications/INotificationProvider.cs | 17 ++++ .../Models/FailedImportStrikeNotification.cs | 5 + .../Notifications/Models/Notification.cs | 20 ++++ .../Notifications/Models/NotificationField.cs | 8 ++ .../Models/QueueItemDeleteNotification.cs | 5 + .../Models/StalledStrikeNotification.cs | 5 + .../Notifiarr/INotifiarrProxy.cs | 6 ++ .../Notifiarr/NotifiarrConfig.cs | 30 ++++++ .../Notifiarr/NotifiarrException.cs | 12 +++ .../Notifiarr/NotifiarrPayload.cs | 57 +++++++++++ .../Notifiarr/NotifiarrProvider.cs | 75 ++++++++++++++ .../Notifications/Notifiarr/NotifiarrProxy.cs | 55 +++++++++++ .../Notifications/NotificationFactory.cs | 32 ++++++ .../Notifications/NotificationProvider.cs | 23 +++++ .../Notifications/NotificationPublisher.cs | 99 +++++++++++++++++++ .../Notifications/NotificationService.cs | 61 ++++++++++++ .../Verticals/QueueCleaner/QueueCleaner.cs | 19 +++- code/test/docker-compose.yml | 6 ++ 46 files changed, 834 insertions(+), 25 deletions(-) create mode 100644 code/Common/Configuration/Notification/NotificationConfig.cs create mode 100644 code/Domain/Enums/DeleteReason.cs create mode 100644 code/Domain/Models/Arr/Queue/Image.cs create mode 100644 code/Domain/Models/Arr/Queue/LidarrImage.cs create mode 100644 code/Domain/Models/Arr/Queue/QueueAlbum.cs create mode 100644 code/Domain/Models/Arr/Queue/QueueMovie.cs create mode 100644 code/Domain/Models/Arr/Queue/QueueSeries.cs create mode 100644 code/Executable/DependencyInjection/NotificationsDI.cs create mode 100644 code/Infrastructure/Verticals/Context/ContextProvider.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Consumers/NotificationConsumer.cs create mode 100644 code/Infrastructure/Verticals/Notifications/INotificationFactory.cs create mode 100644 code/Infrastructure/Verticals/Notifications/INotificationProvider.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Models/Notification.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Models/NotificationField.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Models/QueueItemDeleteNotification.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrConfig.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrException.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrPayload.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs create mode 100644 code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProxy.cs create mode 100644 code/Infrastructure/Verticals/Notifications/NotificationFactory.cs create mode 100644 code/Infrastructure/Verticals/Notifications/NotificationProvider.cs create mode 100644 code/Infrastructure/Verticals/Notifications/NotificationPublisher.cs create mode 100644 code/Infrastructure/Verticals/Notifications/NotificationService.cs diff --git a/README.md b/README.md index c5bbe45d..2ce667ed 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,12 @@ services: - LIDARR__INSTANCES__0__APIKEY=secret5 - LIDARR__INSTANCES__1__URL=http://radarr:8687 - LIDARR__INSTANCES__1__APIKEY=secret6 + + # - NOTIFIARR__ON_IMPORT_FAILED_STRIKE=false + # - NOTIFIARR__ON_STALLED_STRIKE=false + # - NOTIFIARR__ON_QUEUE_ITEM_DELETE=false + # - NOTIFIARR__API_KEY=notifiarr_secret + # - NOTIFIARR__CHANNEL_ID=discord_channel_id ``` ## Environment variables @@ -257,6 +263,20 @@ services: | LIDARR__INSTANCES__0__APIKEY | No | First LIDARR instance API key | empty | +### Notifications variables +
+ Click here + +| Variable | Required | Description | Default value | +|---|---|---|---| +| NOTIFIARR__ON_IMPORT_FAILED_STRIKE | No | Notify on failed import strike. | false | +| NOTIFIARR__ON_STALLED_STRIKE | No | Notify on stalled download strike. | false | +| NOTIFIARR__ON_QUEUE_ITEM_DELETE | No | Notify on deleting a queue item. | false | +| NOTIFIARR__API_KEY | No | Notifiarr API key.
Requires Notifiarr's `Passthrough` integration to work. | empty | +| NOTIFIARR__CHANNEL_ID | No | Discord channel id for notifications. | empty | +
+ + ### Advanced variables
Click here diff --git a/code/Common/Configuration/Notification/NotificationConfig.cs b/code/Common/Configuration/Notification/NotificationConfig.cs new file mode 100644 index 00000000..2fc4574b --- /dev/null +++ b/code/Common/Configuration/Notification/NotificationConfig.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; + +namespace Common.Configuration.Notification; + +public abstract record NotificationConfig +{ + [ConfigurationKeyName("ON_IMPORT_FAILED_STRIKE")] + public bool OnImportFailedStrike { get; init; } + + [ConfigurationKeyName("ON_STALLED_STRIKE")] + public bool OnStalledStrike { get; init; } + + [ConfigurationKeyName("ON_QUEUE_ITEM_DELETE")] + public bool OnQueueItemDelete { get; init; } + + public bool IsEnabled => OnImportFailedStrike || OnStalledStrike || OnQueueItemDelete; + + public abstract bool IsValid(); +} \ No newline at end of file diff --git a/code/Domain/Enums/DeleteReason.cs b/code/Domain/Enums/DeleteReason.cs new file mode 100644 index 00000000..e46cdd6d --- /dev/null +++ b/code/Domain/Enums/DeleteReason.cs @@ -0,0 +1,8 @@ +namespace Domain.Enums; + +public enum DeleteReason +{ + Stalled, + ImportFailed, + AllFilesBlocked +} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Queue/Image.cs b/code/Domain/Models/Arr/Queue/Image.cs new file mode 100644 index 00000000..5f7b3300 --- /dev/null +++ b/code/Domain/Models/Arr/Queue/Image.cs @@ -0,0 +1,8 @@ +namespace Domain.Models.Arr.Queue; + +public record Image +{ + public required string CoverType { get; init; } + + public required Uri RemoteUrl { get; init; } +} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Queue/LidarrImage.cs b/code/Domain/Models/Arr/Queue/LidarrImage.cs new file mode 100644 index 00000000..b317d7e1 --- /dev/null +++ b/code/Domain/Models/Arr/Queue/LidarrImage.cs @@ -0,0 +1,8 @@ +namespace Domain.Models.Arr.Queue; + +public record LidarrImage +{ + public required string CoverType { get; init; } + + public required Uri Url { get; init; } +} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Queue/QueueAlbum.cs b/code/Domain/Models/Arr/Queue/QueueAlbum.cs new file mode 100644 index 00000000..f006b172 --- /dev/null +++ b/code/Domain/Models/Arr/Queue/QueueAlbum.cs @@ -0,0 +1,6 @@ +namespace Domain.Models.Arr.Queue; + +public sealed record QueueAlbum +{ + public List Images { get; init; } = []; +} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Queue/QueueMovie.cs b/code/Domain/Models/Arr/Queue/QueueMovie.cs new file mode 100644 index 00000000..990d2e91 --- /dev/null +++ b/code/Domain/Models/Arr/Queue/QueueMovie.cs @@ -0,0 +1,6 @@ +namespace Domain.Models.Arr.Queue; + +public sealed record QueueMovie +{ + public List Images { get; init; } = []; +} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Queue/QueueRecord.cs b/code/Domain/Models/Arr/Queue/QueueRecord.cs index c66f5980..0d98f930 100644 --- a/code/Domain/Models/Arr/Queue/QueueRecord.cs +++ b/code/Domain/Models/Arr/Queue/QueueRecord.cs @@ -7,14 +7,20 @@ public sealed record QueueRecord public long EpisodeId { get; init; } public long SeasonNumber { get; init; } + public QueueSeries? Series { get; init; } + // Radarr public long MovieId { get; init; } + public QueueSeries? Movie { get; init; } + // Lidarr public long ArtistId { get; init; } public long AlbumId { get; init; } + public QueueAlbum? Album { get; init; } + // common public required string Title { get; init; } public string Status { get; init; } diff --git a/code/Domain/Models/Arr/Queue/QueueSeries.cs b/code/Domain/Models/Arr/Queue/QueueSeries.cs new file mode 100644 index 00000000..544a0a98 --- /dev/null +++ b/code/Domain/Models/Arr/Queue/QueueSeries.cs @@ -0,0 +1,6 @@ +namespace Domain.Models.Arr.Queue; + +public sealed record QueueSeries +{ + public List Images { get; init; } = []; +} \ No newline at end of file diff --git a/code/Executable/DependencyInjection/LoggingDI.cs b/code/Executable/DependencyInjection/LoggingDI.cs index 9af11687..dc33821c 100644 --- a/code/Executable/DependencyInjection/LoggingDI.cs +++ b/code/Executable/DependencyInjection/LoggingDI.cs @@ -63,6 +63,7 @@ public static class LoggingDI Log.Logger = logConfig .MinimumLevel.Is(level) + .MinimumLevel.Override("MassTransit", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) .MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning) .MinimumLevel.Override("Quartz", LogEventLevel.Warning) diff --git a/code/Executable/DependencyInjection/MainDI.cs b/code/Executable/DependencyInjection/MainDI.cs index cdfe8496..7e6d531f 100644 --- a/code/Executable/DependencyInjection/MainDI.cs +++ b/code/Executable/DependencyInjection/MainDI.cs @@ -2,6 +2,9 @@ using Common.Configuration.General; using Common.Helpers; using Infrastructure.Verticals.DownloadClient.Deluge; +using Infrastructure.Verticals.Notifications.Consumers; +using Infrastructure.Verticals.Notifications.Models; +using MassTransit; using Polly; using Polly.Extensions.Http; @@ -16,7 +19,26 @@ public static class MainDI .AddConfiguration(configuration) .AddMemoryCache() .AddServices() - .AddQuartzServices(configuration); + .AddQuartzServices(configuration) + .AddNotifications(configuration) + .AddMassTransit(config => + { + config.AddConsumer>(); + config.AddConsumer>(); + config.AddConsumer>(); + + config.UsingInMemory((context, cfg) => + { + cfg.ReceiveEndpoint("notification-queue", e => + { + e.ConfigureConsumer>(context); + e.ConfigureConsumer>(context); + e.ConfigureConsumer>(context); + e.ConcurrentMessageLimit = 1; + e.PrefetchCount = 1; + }); + }); + }); private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration) { diff --git a/code/Executable/DependencyInjection/NotificationsDI.cs b/code/Executable/DependencyInjection/NotificationsDI.cs new file mode 100644 index 00000000..87bf3852 --- /dev/null +++ b/code/Executable/DependencyInjection/NotificationsDI.cs @@ -0,0 +1,16 @@ +using Infrastructure.Verticals.Notifications; +using Infrastructure.Verticals.Notifications.Notifiarr; + +namespace Executable.DependencyInjection; + +public static class NotificationsDI +{ + public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) => + services + .Configure(configuration.GetSection(NotifiarrConfig.SectionName)) + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient(); +} \ No newline at end of file diff --git a/code/Executable/appsettings.Development.json b/code/Executable/appsettings.Development.json index 6f1eceb7..65f88e6f 100644 --- a/code/Executable/appsettings.Development.json +++ b/code/Executable/appsettings.Development.json @@ -86,5 +86,12 @@ "ApiKey": "7f677cfdc074414397af53dd633860c5" } ] + }, + "Notifiarr": { + "ON_IMPORT_FAILED_STRIKE": true, + "ON_STALLED_STRIKE": true, + "ON_QUEUE_ITEM_DELETE": true, + "API_KEY": "", + "CHANNEL_ID": "" } } diff --git a/code/Executable/appsettings.json b/code/Executable/appsettings.json index cd83efdd..18bfe546 100644 --- a/code/Executable/appsettings.json +++ b/code/Executable/appsettings.json @@ -83,5 +83,12 @@ "ApiKey": "" } ] + }, + "Notifiarr": { + "ON_IMPORT_FAILED_STRIKE": false, + "ON_STALLED_STRIKE": false, + "ON_QUEUE_ITEM_DELETE": false, + "API_KEY": "", + "CHANNEL_ID": "" } } diff --git a/code/Infrastructure/Infrastructure.csproj b/code/Infrastructure/Infrastructure.csproj index e6f34500..ed985e29 100644 --- a/code/Infrastructure/Infrastructure.csproj +++ b/code/Infrastructure/Infrastructure.csproj @@ -14,6 +14,8 @@ + + diff --git a/code/Infrastructure/Verticals/Arr/ArrClient.cs b/code/Infrastructure/Verticals/Arr/ArrClient.cs index 65f400dd..7baa2fe5 100644 --- a/code/Infrastructure/Verticals/Arr/ArrClient.cs +++ b/code/Infrastructure/Verticals/Arr/ArrClient.cs @@ -66,7 +66,7 @@ public abstract class ArrClient return queueResponse; } - public virtual bool ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload) + public virtual async Task ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload) { if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload) { @@ -96,7 +96,7 @@ public abstract class ArrClient return false; } - return _striker.StrikeAndCheckLimit( + return await _striker.StrikeAndCheckLimit( record.DownloadId, record.Title, _queueCleanerConfig.ImportFailedMaxStrikes, diff --git a/code/Infrastructure/Verticals/Arr/SonarrClient.cs b/code/Infrastructure/Verticals/Arr/SonarrClient.cs index ddd0295b..20ad6513 100644 --- a/code/Infrastructure/Verticals/Arr/SonarrClient.cs +++ b/code/Infrastructure/Verticals/Arr/SonarrClient.cs @@ -9,6 +9,7 @@ using Infrastructure.Verticals.ItemStriker; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; +using Series = Domain.Models.Sonarr.Series; namespace Infrastructure.Verticals.Arr; @@ -26,7 +27,7 @@ public sealed class SonarrClient : ArrClient protected override string GetQueueUrlPath(int page) { - return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true"; + return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true"; } protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) diff --git a/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs b/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs index f4a9f0f0..d70f9062 100644 --- a/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs +++ b/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs @@ -7,8 +7,10 @@ using Domain.Enums; using Domain.Models.Arr; using Domain.Models.Arr.Queue; using Infrastructure.Verticals.Arr; +using Infrastructure.Verticals.Context; using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.Jobs; +using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Serilog.Context; @@ -32,12 +34,15 @@ public sealed class ContentBlocker : GenericHandler LidarrClient lidarrClient, ArrQueueIterator arrArrQueueIterator, BlocklistProvider blocklistProvider, - DownloadServiceFactory downloadServiceFactory + DownloadServiceFactory downloadServiceFactory, + NotificationPublisher notifier + ) : base( logger, downloadClientConfig, sonarrConfig, radarrConfig, lidarrConfig, sonarrClient, radarrClient, lidarrClient, - arrArrQueueIterator, downloadServiceFactory + arrArrQueueIterator, downloadServiceFactory, + notifier ) { _config = config.Value; @@ -75,6 +80,10 @@ public sealed class ContentBlocker : GenericHandler BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); + + // push to context + ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url); + ContextProvider.Set(nameof(InstanceType), instanceType); await _arrArrQueueIterator.Iterate(arrClient, instance, async items => { @@ -97,6 +106,9 @@ public sealed class ContentBlocker : GenericHandler continue; } + // push record to context + ContextProvider.Set(nameof(QueueRecord), record); + _logger.LogDebug("searching unwanted files for {title}", record.Title); BlockFilesResult result = await _downloadService @@ -119,6 +131,7 @@ public sealed class ContentBlocker : GenericHandler } await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); + await _notifier.NotifyQueueItemDelete(removeFromClient, DeleteReason.AllFilesBlocked); } }); diff --git a/code/Infrastructure/Verticals/Context/ContextProvider.cs b/code/Infrastructure/Verticals/Context/ContextProvider.cs new file mode 100644 index 00000000..071b91e0 --- /dev/null +++ b/code/Infrastructure/Verticals/Context/ContextProvider.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; + +namespace Infrastructure.Verticals.Context; + +public static class ContextProvider +{ + private static readonly AsyncLocal> _asyncLocalDict = new(); + + public static void Set(string key, object value) + { + ImmutableDictionary currentDict = _asyncLocalDict.Value ?? ImmutableDictionary.Empty; + _asyncLocalDict.Value = currentDict.SetItem(key, value); + } + + public static object? Get(string key) + { + return _asyncLocalDict.Value?.TryGetValue(key, out object? value) is true ? value : null; + } + + public static T? Get(string key) where T : class + { + return Get(key) as T; + } +} diff --git a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs index b64e467a..067b2b70 100644 --- a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadClient; using Common.Configuration.QueueCleaner; +using Domain.Enums; using Domain.Models.Deluge.Response; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ItemStriker; @@ -71,8 +72,18 @@ public sealed class DelugeService : DownloadServiceBase } }); - result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(status); + if (shouldRemove) + { + result.DeleteReason = DeleteReason.AllFilesBlocked; + } + + result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(status); result.IsPrivate = status.Private; + + if (!shouldRemove && result.ShouldRemove) + { + result.DeleteReason = DeleteReason.Stalled; + } return result; } @@ -180,7 +191,7 @@ public sealed class DelugeService : DownloadServiceBase await _client.DeleteTorrent(hash); } - private bool IsItemStuckAndShouldRemove(TorrentStatus status) + private async Task IsItemStuckAndShouldRemove(TorrentStatus status) { if (_queueCleanerConfig.StalledMaxStrikes is 0) { @@ -206,7 +217,7 @@ public sealed class DelugeService : DownloadServiceBase ResetStrikesOnProgress(status.Hash!, status.TotalDone); - return StrikeAndCheckLimit(status.Hash!, status.Name!); + return await StrikeAndCheckLimit(status.Hash!, status.Name!); } private async Task GetTorrentStatus(string hash) diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadServiceBase.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadServiceBase.cs index 91f37c2a..1d896cb3 100644 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadServiceBase.cs +++ b/code/Infrastructure/Verticals/DownloadClient/DownloadServiceBase.cs @@ -83,8 +83,8 @@ public abstract class DownloadServiceBase : IDownloadService /// The torrent hash. /// The name or title of the item. /// True if the limit has been reached; otherwise, false. - protected bool StrikeAndCheckLimit(string hash, string itemName) + protected async Task StrikeAndCheckLimit(string hash, string itemName) { - return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled); + return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled); } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs index 19bf99e4..d082856f 100644 --- a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs @@ -4,6 +4,7 @@ using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadClient; using Common.Configuration.QueueCleaner; using Common.Helpers; +using Domain.Enums; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ItemStriker; using Microsoft.Extensions.Caching.Memory; @@ -73,6 +74,7 @@ public sealed class QBitService : DownloadServiceBase if (torrent is { CompletionOn: not null, Downloaded: null or 0 }) { result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AllFilesBlocked; return result; } @@ -82,10 +84,16 @@ public sealed class QBitService : DownloadServiceBase if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) { result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AllFilesBlocked; return result; } - result.ShouldRemove = IsItemStuckAndShouldRemove(torrent, result.IsPrivate); + result.ShouldRemove = await IsItemStuckAndShouldRemove(torrent, result.IsPrivate); + + if (result.ShouldRemove) + { + result.DeleteReason = DeleteReason.Stalled; + } return result; } @@ -197,7 +205,7 @@ public sealed class QBitService : DownloadServiceBase _client.Dispose(); } - private bool IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate) + private async Task IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate) { if (_queueCleanerConfig.StalledMaxStrikes is 0) { @@ -220,6 +228,6 @@ public sealed class QBitService : DownloadServiceBase ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0); - return StrikeAndCheckLimit(torrent.Hash, torrent.Name); + return await StrikeAndCheckLimit(torrent.Hash, torrent.Name); } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/StalledResult.cs b/code/Infrastructure/Verticals/DownloadClient/StalledResult.cs index ead7572b..a1f35f81 100644 --- a/code/Infrastructure/Verticals/DownloadClient/StalledResult.cs +++ b/code/Infrastructure/Verticals/DownloadClient/StalledResult.cs @@ -1,4 +1,6 @@ -namespace Infrastructure.Verticals.DownloadClient; +using Domain.Enums; + +namespace Infrastructure.Verticals.DownloadClient; public sealed record StalledResult { @@ -7,6 +9,8 @@ public sealed record StalledResult /// public bool ShouldRemove { get; set; } + public DeleteReason DeleteReason { get; set; } + /// /// True if the download is private; otherwise false. /// diff --git a/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs b/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs index b53a0ba1..92c88a7a 100644 --- a/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs @@ -4,6 +4,7 @@ using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadClient; using Common.Configuration.QueueCleaner; using Common.Helpers; +using Domain.Enums; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ItemStriker; using Microsoft.Extensions.Caching.Memory; @@ -76,9 +77,19 @@ public sealed class TransmissionService : DownloadServiceBase shouldRemove = false; } } + + if (shouldRemove) + { + result.DeleteReason = DeleteReason.AllFilesBlocked; + } // remove if all files are unwanted or download is stuck - result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(torrent); + result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(torrent); + + if (!shouldRemove && result.ShouldRemove) + { + result.DeleteReason = DeleteReason.Stalled; + } return result; } @@ -178,7 +189,7 @@ public sealed class TransmissionService : DownloadServiceBase { } - private bool IsItemStuckAndShouldRemove(TorrentInfo torrent) + private async Task IsItemStuckAndShouldRemove(TorrentInfo torrent) { if (_queueCleanerConfig.StalledMaxStrikes is 0) { @@ -205,7 +216,7 @@ public sealed class TransmissionService : DownloadServiceBase ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0); - return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!); + return await StrikeAndCheckLimit(torrent.HashString!, torrent.Name!); } private async Task GetTorrentAsync(string hash) diff --git a/code/Infrastructure/Verticals/ItemStriker/Striker.cs b/code/Infrastructure/Verticals/ItemStriker/Striker.cs index dcd7308b..c1ce8ec7 100644 --- a/code/Infrastructure/Verticals/ItemStriker/Striker.cs +++ b/code/Infrastructure/Verticals/ItemStriker/Striker.cs @@ -1,6 +1,8 @@ using Common.Helpers; using Domain.Enums; using Infrastructure.Helpers; +using Infrastructure.Verticals.Context; +using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -11,16 +13,18 @@ public class Striker private readonly ILogger _logger; private readonly IMemoryCache _cache; private readonly MemoryCacheEntryOptions _cacheOptions; + private readonly NotificationPublisher _notifier; - public Striker(ILogger logger, IMemoryCache cache) + public Striker(ILogger logger, IMemoryCache cache, NotificationPublisher notifier) { _logger = logger; _cache = cache; + _notifier = notifier; _cacheOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); } - public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType) + public async Task StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType) { if (maxStrikes is 0) { @@ -29,7 +33,7 @@ public class Striker string key = CacheKeys.Strike(strikeType, hash); - if (!_cache.TryGetValue(key, out int? strikeCount)) + if (!_cache.TryGetValue(key, out int strikeCount)) { strikeCount = 1; } @@ -39,6 +43,9 @@ public class Striker } _logger.LogInformation("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName); + + await _notifier.NotifyStrike(strikeType, strikeCount); + _cache.Set(key, strikeCount, _cacheOptions); if (strikeCount < maxStrikes) diff --git a/code/Infrastructure/Verticals/Jobs/GenericHandler.cs b/code/Infrastructure/Verticals/Jobs/GenericHandler.cs index 83e4dc54..dc40f0ee 100644 --- a/code/Infrastructure/Verticals/Jobs/GenericHandler.cs +++ b/code/Infrastructure/Verticals/Jobs/GenericHandler.cs @@ -5,6 +5,7 @@ using Domain.Models.Arr; using Domain.Models.Arr.Queue; using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -22,6 +23,7 @@ public abstract class GenericHandler : IDisposable protected readonly LidarrClient _lidarrClient; protected readonly ArrQueueIterator _arrArrQueueIterator; protected readonly IDownloadService _downloadService; + protected readonly NotificationPublisher _notifier; protected GenericHandler( ILogger logger, @@ -33,7 +35,8 @@ public abstract class GenericHandler : IDisposable RadarrClient radarrClient, LidarrClient lidarrClient, ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory + DownloadServiceFactory downloadServiceFactory, + NotificationPublisher notifier ) { _logger = logger; @@ -46,6 +49,7 @@ public abstract class GenericHandler : IDisposable _lidarrClient = lidarrClient; _arrArrQueueIterator = arrArrQueueIterator; _downloadService = downloadServiceFactory.CreateDownloadClient(); + _notifier = notifier; } public virtual async Task ExecuteAsync() diff --git a/code/Infrastructure/Verticals/Notifications/Consumers/NotificationConsumer.cs b/code/Infrastructure/Verticals/Notifications/Consumers/NotificationConsumer.cs new file mode 100644 index 00000000..166155ee --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Consumers/NotificationConsumer.cs @@ -0,0 +1,45 @@ +using Infrastructure.Verticals.Notifications.Models; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Verticals.Notifications.Consumers; + +public sealed class NotificationConsumer : IConsumer where T : Notification +{ + private readonly ILogger> _logger; + private readonly NotificationService _notificationService; + + public NotificationConsumer(ILogger> logger, NotificationService notificationService) + { + _logger = logger; + _notificationService = notificationService; + } + + public async Task Consume(ConsumeContext context) + { + try + { + switch (context.Message) + { + case FailedImportStrikeNotification failedMessage: + await _notificationService.Notify(failedMessage); + break; + case StalledStrikeNotification stalledMessage: + await _notificationService.Notify(stalledMessage); + break; + case QueueItemDeleteNotification queueItemDeleteMessage: + await _notificationService.Notify(queueItemDeleteMessage); + break; + default: + throw new NotImplementedException(); + } + + // prevent spamming + await Task.Delay(1000); + } + catch (Exception exception) + { + _logger.LogError(exception, "error while processing notifications"); + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/INotificationFactory.cs b/code/Infrastructure/Verticals/Notifications/INotificationFactory.cs new file mode 100644 index 00000000..bfb26355 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/INotificationFactory.cs @@ -0,0 +1,10 @@ +namespace Infrastructure.Verticals.Notifications; + +public interface INotificationFactory +{ + List OnFailedImportStrikeEnabled(); + + List OnStalledStrikeEnabled(); + + List OnQueueItemDeleteEnabled(); +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/INotificationProvider.cs b/code/Infrastructure/Verticals/Notifications/INotificationProvider.cs new file mode 100644 index 00000000..1dc00b64 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/INotificationProvider.cs @@ -0,0 +1,17 @@ +using Common.Configuration.Notification; +using Infrastructure.Verticals.Notifications.Models; + +namespace Infrastructure.Verticals.Notifications; + +public interface INotificationProvider +{ + NotificationConfig Config { get; } + + string Name { get; } + + Task OnFailedImportStrike(FailedImportStrikeNotification notification); + + Task OnStalledStrike(StalledStrikeNotification notification); + + Task OnQueueItemDelete(QueueItemDeleteNotification notification); +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs new file mode 100644 index 00000000..b8ac5fa5 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs @@ -0,0 +1,5 @@ +namespace Infrastructure.Verticals.Notifications.Models; + +public sealed record FailedImportStrikeNotification : Notification +{ +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/Notification.cs b/code/Infrastructure/Verticals/Notifications/Models/Notification.cs new file mode 100644 index 00000000..910a6107 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Models/Notification.cs @@ -0,0 +1,20 @@ +using Domain.Enums; + +namespace Infrastructure.Verticals.Notifications.Models; + +public record Notification +{ + public required InstanceType InstanceType { get; init; } + + public required Uri InstanceUrl { get; init; } + + public required string Hash { get; init; } + + public required string Title { get; init; } + + public required string Description { get; init; } + + public Uri? Image { get; init; } + + public List? Fields { get; init; } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/NotificationField.cs b/code/Infrastructure/Verticals/Notifications/Models/NotificationField.cs new file mode 100644 index 00000000..1d8621f1 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Models/NotificationField.cs @@ -0,0 +1,8 @@ +namespace Infrastructure.Verticals.Notifications.Models; + +public sealed record NotificationField +{ + public required string Title { get; init; } + + public required string Text { get; init; } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/QueueItemDeleteNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/QueueItemDeleteNotification.cs new file mode 100644 index 00000000..0a5b2cab --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Models/QueueItemDeleteNotification.cs @@ -0,0 +1,5 @@ +namespace Infrastructure.Verticals.Notifications.Models; + +public sealed record QueueItemDeleteNotification : Notification +{ +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs new file mode 100644 index 00000000..74f17ba8 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs @@ -0,0 +1,5 @@ +namespace Infrastructure.Verticals.Notifications.Models; + +public sealed record StalledStrikeNotification : Notification +{ +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs new file mode 100644 index 00000000..d54c8faa --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs @@ -0,0 +1,6 @@ +namespace Infrastructure.Verticals.Notifications.Notifiarr; + +public interface INotifiarrProxy +{ + Task SendNotification(NotifiarrPayload payload, NotifiarrConfig config); +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrConfig.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrConfig.cs new file mode 100644 index 00000000..489e62a3 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrConfig.cs @@ -0,0 +1,30 @@ +using Common.Configuration.Notification; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Verticals.Notifications.Notifiarr; + +public sealed record NotifiarrConfig : NotificationConfig +{ + public const string SectionName = "Notifiarr"; + + [ConfigurationKeyName("API_KEY")] + public string? ApiKey { get; init; } + + [ConfigurationKeyName("CHANNEL_ID")] + public string? ChannelId { get; init; } + + public override bool IsValid() + { + if (string.IsNullOrEmpty(ApiKey?.Trim())) + { + return false; + } + + if (string.IsNullOrEmpty(ChannelId?.Trim())) + { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrException.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrException.cs new file mode 100644 index 00000000..10ca5908 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrException.cs @@ -0,0 +1,12 @@ +namespace Infrastructure.Verticals.Notifications.Notifiarr; + +public class NotifiarrException : Exception +{ + public NotifiarrException(string message) : base(message) + { + } + + public NotifiarrException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrPayload.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrPayload.cs new file mode 100644 index 00000000..24e5da9b --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrPayload.cs @@ -0,0 +1,57 @@ +namespace Infrastructure.Verticals.Notifications.Notifiarr; + +public class NotifiarrPayload +{ + public NotifiarrNotification Notification { get; set; } = new NotifiarrNotification(); + public Discord Discord { get; set; } +} + +public class NotifiarrNotification +{ + public bool Update { get; set; } + public string Name => "Cleanuperr"; + public int? Event { get; set; } +} + +public class Discord +{ + public string Color { get; set; } = string.Empty; + public Ping Ping { get; set; } + public Images Images { get; set; } + public Text Text { get; set; } + public Ids Ids { get; set; } +} + +public class Ping +{ + public string PingUser { get; set; } + public string PingRole { get; set; } +} + +public class Images +{ + public Uri? Thumbnail { get; set; } + public Uri? Image { get; set; } +} + +public class Text +{ + public string Title { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public List Fields { get; set; } = new List(); + public string Footer { get; set; } = string.Empty; +} + +public class Field +{ + public string Title { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public bool Inline { get; set; } +} + +public class Ids +{ + public string Channel { get; set; } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs new file mode 100644 index 00000000..dd189fb9 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs @@ -0,0 +1,75 @@ +using Domain.Enums; +using Infrastructure.Verticals.Notifications.Models; +using Mapster; +using Microsoft.Extensions.Options; + +namespace Infrastructure.Verticals.Notifications.Notifiarr; + +public class NotifiarrProvider : NotificationProvider +{ + private readonly NotifiarrConfig _config; + private readonly INotifiarrProxy _proxy; + + private const string WarningColor = "f0ad4e"; + private const string ImportantColor = "bb2124"; + + public NotifiarrProvider(IOptions config, INotifiarrProxy proxy) + : base(config) + { + _config = config.Value; + _proxy = proxy; + } + + public override string Name => "Notifiarr"; + + public override async Task OnFailedImportStrike(FailedImportStrikeNotification notification) + { + await _proxy.SendNotification(BuildPayload(notification, WarningColor), _config); + } + + public override async Task OnStalledStrike(StalledStrikeNotification notification) + { + await _proxy.SendNotification(BuildPayload(notification, WarningColor), _config); + } + + public override async Task OnQueueItemDelete(QueueItemDeleteNotification notification) + { + await _proxy.SendNotification(BuildPayload(notification, ImportantColor), _config); + } + + private NotifiarrPayload BuildPayload(Notification notification, string color) + { + NotifiarrPayload payload = new() + { + Discord = new() + { + Color = color, + Text = new() + { + Title = notification.Title, + Icon = "https://github.com/flmorg/cleanuperr/blob/main/Logo/48.png?raw=true", + Description = notification.Description, + Fields = new() + { + new() { Title = "Instance type", Text = notification.InstanceType.ToString() }, + new() { Title = "Url", Text = notification.InstanceUrl.ToString() }, + new() { Title = "Download hash", Text = notification.Hash } + } + }, + Ids = new Ids + { + Channel = _config.ChannelId + }, + Images = new() + { + Thumbnail = new Uri("https://github.com/flmorg/cleanuperr/blob/main/Logo/48.png?raw=true"), + Image = notification.Image + } + } + }; + + payload.Discord.Text.Fields.AddRange(notification.Fields?.Adapt>() ?? []); + + return payload; + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProxy.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProxy.cs new file mode 100644 index 00000000..edfc01a1 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProxy.cs @@ -0,0 +1,55 @@ +using System.Text; +using Common.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Infrastructure.Verticals.Notifications.Notifiarr; + +public class NotifiarrProxy : INotifiarrProxy +{ + private readonly HttpClient _httpClient; + + private const string Url = "https://notifiarr.com/api/v1/notification/passthrough/"; + + public NotifiarrProxy(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); + } + + public async Task SendNotification(NotifiarrPayload payload, NotifiarrConfig config) + { + try + { + string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }); + + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{Url}{config.ApiKey}"); + request.Method = HttpMethod.Post; + request.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + using HttpResponseMessage response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException exception) + { + if (exception.StatusCode is null) + { + throw new NotifiarrException("unable to send notification", exception); + } + + switch ((int)exception.StatusCode) + { + case 401: + throw new NotifiarrException("unable to send notification | API key is invalid"); + case 502: + case 503: + case 504: + throw new NotifiarrException("unable to send notification | service unavailable", exception); + default: + throw new NotifiarrException("unable to send notification", exception); + } + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/NotificationFactory.cs b/code/Infrastructure/Verticals/Notifications/NotificationFactory.cs new file mode 100644 index 00000000..bc942527 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/NotificationFactory.cs @@ -0,0 +1,32 @@ +namespace Infrastructure.Verticals.Notifications; + +public class NotificationFactory : INotificationFactory +{ + private readonly IEnumerable _providers; + + public NotificationFactory(IEnumerable providers) + { + _providers = providers; + } + + protected List ActiveProviders() => + _providers + .Where(x => x.Config.IsValid()) + .Where(provider => provider.Config.IsEnabled) + .ToList(); + + public List OnFailedImportStrikeEnabled() => + ActiveProviders() + .Where(n => n.Config.OnImportFailedStrike) + .ToList(); + + public List OnStalledStrikeEnabled() => + ActiveProviders() + .Where(n => n.Config.OnStalledStrike) + .ToList(); + + public List OnQueueItemDeleteEnabled() => + ActiveProviders() + .Where(n => n.Config.OnQueueItemDelete) + .ToList(); +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs b/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs new file mode 100644 index 00000000..749ba4d2 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs @@ -0,0 +1,23 @@ +using Common.Configuration.Notification; +using Infrastructure.Verticals.Notifications.Models; +using Microsoft.Extensions.Options; + +namespace Infrastructure.Verticals.Notifications; + +public abstract class NotificationProvider : INotificationProvider +{ + protected NotificationProvider(IOptions config) + { + Config = config.Value; + } + + public abstract string Name { get; } + + public NotificationConfig Config { get; } + + public abstract Task OnFailedImportStrike(FailedImportStrikeNotification notification); + + public abstract Task OnStalledStrike(StalledStrikeNotification notification); + + public abstract Task OnQueueItemDelete(QueueItemDeleteNotification notification); +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/NotificationPublisher.cs b/code/Infrastructure/Verticals/Notifications/NotificationPublisher.cs new file mode 100644 index 00000000..b8b486b6 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/NotificationPublisher.cs @@ -0,0 +1,99 @@ +using Common.Configuration.Arr; +using Domain.Enums; +using Domain.Models.Arr.Queue; +using Infrastructure.Verticals.Context; +using Infrastructure.Verticals.Notifications.Models; +using Mapster; +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Verticals.Notifications; + +public sealed class NotificationPublisher +{ + private readonly ILogger _logger; + private readonly IBus _messageBus; + + public NotificationPublisher(ILogger logger, IBus messageBus) + { + _logger = logger; + _messageBus = messageBus; + } + + public async Task NotifyStrike(StrikeType strikeType, int strikeCount) + { + try + { + QueueRecord record = GetRecordFromContext(); + InstanceType instanceType = GetInstanceTypeFromContext(); + Uri instanceUrl = GetInstanceUrlFromContext(); + Uri? imageUrl = GetImageFromContext(record, instanceType); + + Notification notification = new() + { + InstanceType = instanceType, + InstanceUrl = instanceUrl, + Hash = record.DownloadId.ToLowerInvariant(), + Title = $"Strike received with reason: {strikeType}", + Description = record.Title, + Image = imageUrl, + Fields = [new() { Title = "Strike count", Text = strikeCount.ToString() }] + }; + + switch (strikeType) + { + case StrikeType.Stalled: + await _messageBus.Publish(notification.Adapt()); + break; + case StrikeType.ImportFailed: + await _messageBus.Publish(notification.Adapt()); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "failed to notify strike"); + } + } + + public async Task NotifyQueueItemDelete(bool removeFromClient, DeleteReason reason) + { + QueueRecord record = GetRecordFromContext(); + InstanceType instanceType = GetInstanceTypeFromContext(); + Uri instanceUrl = GetInstanceUrlFromContext(); + Uri? imageUrl = GetImageFromContext(record, instanceType); + + Notification notification = new() + { + InstanceType = instanceType, + InstanceUrl = instanceUrl, + Hash = record.DownloadId.ToLowerInvariant(), + Title = $"Deleting item from queue with reason: {reason}", + Description = record.Title, + Image = imageUrl, + Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }] + }; + + await _messageBus.Publish(notification.Adapt()); + } + + private static QueueRecord GetRecordFromContext() => + ContextProvider.Get(nameof(QueueRecord)) ?? throw new Exception("failed to get record from context"); + + private static InstanceType GetInstanceTypeFromContext() => + (InstanceType)(ContextProvider.Get(nameof(InstanceType)) ?? + throw new Exception("failed to get instance type from context")); + + private static Uri GetInstanceUrlFromContext() => + ContextProvider.Get(nameof(ArrInstance) + nameof(ArrInstance.Url)) ?? + throw new Exception("failed to get instance url from context"); + + private static Uri GetImageFromContext(QueueRecord record, InstanceType instanceType) => + instanceType switch + { + InstanceType.Sonarr => record.Series!.Images.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl, + InstanceType.Radarr => record.Movie!.Images.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl, + InstanceType.Lidarr => record.Album!.Images.FirstOrDefault(x => x.CoverType == "cover")?.Url, + _ => throw new ArgumentOutOfRangeException(nameof(instanceType)) + } ?? throw new Exception("failed to get image url from context"); +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/NotificationService.cs b/code/Infrastructure/Verticals/Notifications/NotificationService.cs new file mode 100644 index 00000000..a6c11596 --- /dev/null +++ b/code/Infrastructure/Verticals/Notifications/NotificationService.cs @@ -0,0 +1,61 @@ +using Infrastructure.Verticals.Notifications.Models; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Verticals.Notifications; + +public class NotificationService +{ + private readonly ILogger _logger; + private readonly INotificationFactory _notificationFactory; + + public NotificationService(ILogger logger, INotificationFactory notificationFactory) + { + _logger = logger; + _notificationFactory = notificationFactory; + } + + public async Task Notify(FailedImportStrikeNotification notification) + { + foreach (INotificationProvider provider in _notificationFactory.OnFailedImportStrikeEnabled()) + { + try + { + await provider.OnFailedImportStrike(notification); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name); + } + } + } + + public async Task Notify(StalledStrikeNotification notification) + { + foreach (INotificationProvider provider in _notificationFactory.OnStalledStrikeEnabled()) + { + try + { + await provider.OnStalledStrike(notification); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name); + } + } + } + + public async Task Notify(QueueItemDeleteNotification notification) + { + foreach (INotificationProvider provider in _notificationFactory.OnQueueItemDeleteEnabled()) + { + try + { + await provider.OnQueueItemDelete(notification); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name); + } + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs b/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs index 3098a51f..35d01bab 100644 --- a/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs +++ b/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs @@ -5,8 +5,10 @@ using Domain.Enums; using Domain.Models.Arr; using Domain.Models.Arr.Queue; using Infrastructure.Verticals.Arr; +using Infrastructure.Verticals.Context; using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.Jobs; +using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Serilog.Context; @@ -28,12 +30,14 @@ public sealed class QueueCleaner : GenericHandler RadarrClient radarrClient, LidarrClient lidarrClient, ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory + DownloadServiceFactory downloadServiceFactory, + NotificationPublisher notifier ) : base( logger, downloadClientConfig, sonarrConfig, radarrConfig, lidarrConfig, sonarrClient, radarrClient, lidarrClient, - arrArrQueueIterator, downloadServiceFactory + arrArrQueueIterator, downloadServiceFactory, + notifier ) { _config = config.Value; @@ -45,6 +49,10 @@ public sealed class QueueCleaner : GenericHandler HashSet itemsToBeRefreshed = []; ArrClient arrClient = GetClient(instanceType); + + // push to context + ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url); + ContextProvider.Set(nameof(InstanceType), instanceType); await _arrArrQueueIterator.Iterate(arrClient, instance, async items => { @@ -65,6 +73,9 @@ public sealed class QueueCleaner : GenericHandler { continue; } + + // push record to context + ContextProvider.Set(nameof(QueueRecord), record); StalledResult stalledCheckResult = new(); @@ -75,7 +86,8 @@ public sealed class QueueCleaner : GenericHandler } // failed import check - bool shouldRemoveFromArr = arrClient.ShouldRemoveFromQueue(instanceType, record, stalledCheckResult.IsPrivate); + bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, stalledCheckResult.IsPrivate); + DeleteReason deleteReason = stalledCheckResult.ShouldRemove ? stalledCheckResult.DeleteReason : DeleteReason.ImportFailed; if (!shouldRemoveFromArr && !stalledCheckResult.ShouldRemove) { @@ -101,6 +113,7 @@ public sealed class QueueCleaner : GenericHandler } await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); + await _notifier.NotifyQueueItemDelete(removeFromClient, deleteReason); } }); diff --git a/code/test/docker-compose.yml b/code/test/docker-compose.yml index d0ac73ae..d7cc0a09 100644 --- a/code/test/docker-compose.yml +++ b/code/test/docker-compose.yml @@ -228,6 +228,12 @@ services: - LIDARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist # TODO - LIDARR__INSTANCES__0__URL=http://lidarr:8686 - LIDARR__INSTANCES__0__APIKEY=7f677cfdc074414397af53dd633860c5 + + # - NOTIFIARR__ON_IMPORT_FAILED_STRIKE=true + # - NOTIFIARR__ON_STALLED_STRIKE=true + # - NOTIFIARR__ON_QUEUE_ITEM_DELETE=true + # - NOTIFIARR__API_KEY=notifiarr_secret + # - NOTIFIARR__CHANNEL_ID=discord_channel_id volumes: - ./data/cleanuperr/logs:/var/logs restart: unless-stopped