using System.Globalization; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using MassTransit; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.Notifications; public class NotificationPublisher : INotificationPublisher { private readonly ILogger _logger; private readonly IDryRunInterceptor _dryRunInterceptor; private readonly IBus _messageBus; public NotificationPublisher( ILogger logger, IDryRunInterceptor dryRunInterceptor, IBus messageBus) { _logger = logger; _dryRunInterceptor = dryRunInterceptor; _messageBus = messageBus; } public virtual async Task NotifyStrike(StrikeType strikeType, int strikeCount) { // Dead torrent strikes originate from the download cleaner, which has no Arr/queue context // required to build a strike notification. Removal is announced via NotifyDownloadCleaned. if (strikeType is StrikeType.DeadTorrent) { return; } try { var eventType = MapStrikeTypeToEventType(strikeType); var context = BuildStrikeNotificationContext(strikeType, strikeCount, eventType); await SendNotificationAsync(eventType, context); } catch (Exception ex) { _logger.LogError(ex, "failed to notify strike"); } } public virtual async Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason) { try { var context = BuildQueueItemDeletedContext(removeFromClient, reason); await SendNotificationAsync(NotificationEventType.QueueItemDeleted, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify queue item deleted"); } } public virtual async Task NotifyForceImported() { try { NotificationContext context = BuildForceImportedContext(); await SendNotificationAsync(NotificationEventType.ForceImported, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify force imported"); } } public virtual async Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason) { try { var context = BuildDownloadCleanedContext(ratio, seedingTime, categoryName, reason); await SendNotificationAsync(NotificationEventType.DownloadCleaned, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify download cleaned"); } } public virtual async Task NotifyDownloadStopped(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason) { try { NotificationContext context = BuildDownloadStoppedContext(ratio, seedingTime, categoryName, reason); await SendNotificationAsync(NotificationEventType.DownloadStopped, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify download stopped"); } } public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false) { try { var context = BuildCategoryChangedContext(oldCategory, newCategory, isTag); await SendNotificationAsync(NotificationEventType.CategoryChanged, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify category changed"); } } public virtual async Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason) { try { var context = BuildSearchTriggeredContext(itemTitle, searchType, searchReason); await SendNotificationAsync(NotificationEventType.SearchTriggered, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify search triggered"); } } public virtual async Task NotifySearchItemGrabbed(string itemTitle, List grabbedItems, InstanceType instanceType, string instanceUrl) { try { var context = BuildSearchItemGrabbedContext(itemTitle, grabbedItems, instanceType, instanceUrl); await SendNotificationAsync(NotificationEventType.SearchItemGrabbed, context); } catch (Exception ex) { _logger.LogError(ex, "Failed to notify search item grabbed"); } } private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context) { await _dryRunInterceptor.InterceptAsync(() => SendNotificationInternalAsync((eventType, context))); } private async Task SendNotificationInternalAsync((NotificationEventType eventType, NotificationContext context) parameters) { var (eventType, context) = parameters; await _messageBus.Publish(new NotificationMessage(eventType, context)); } /// /// The queue item every arr notification names, pictures and links. /// private (InstanceType InstanceType, Uri InstanceUrl, Uri? ImageUrl, string ItemTitle, string ItemHash) ReadQueueItemContext() { QueueRecord? record = ContextProvider.Get(nameof(QueueRecord)) as QueueRecord; InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); float instanceVersion = (float)ContextProvider.Get(ContextProvider.Keys.Version); Uri instanceUrl = ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl); return ( instanceType, instanceUrl, GetImageFromContext(record, instanceType, instanceVersion), ResolveItemTitle(record), ResolveItemHash(record) ); } private NotificationContext BuildStrikeNotificationContext(StrikeType strikeType, int strikeCount, NotificationEventType eventType) { (InstanceType instanceType, Uri instanceUrl, Uri? imageUrl, string itemTitle, string itemHash) = ReadQueueItemContext(); NotificationContext context = new() { EventType = eventType, Title = $"Strike received with reason: {strikeType}", Description = itemTitle, Severity = EventSeverity.Warning, Image = imageUrl, Data = new Dictionary { ["Strike type"] = strikeType.ToString(), ["Strike count"] = strikeCount.ToString(), ["Hash"] = itemHash, ["Instance type"] = instanceType.ToString(), ["Url"] = instanceUrl.ToString(), } }; if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime) { var rule = ContextProvider.Get(); context.Data.Add("Rule name", rule.Name); } return context; } private NotificationContext BuildQueueItemDeletedContext(bool removeFromClient, DeleteReason reason) { (InstanceType instanceType, Uri instanceUrl, Uri? imageUrl, string itemTitle, string itemHash) = ReadQueueItemContext(); return new NotificationContext { EventType = NotificationEventType.QueueItemDeleted, Title = $"Deleting item from queue with reason: {reason}", Description = itemTitle, Severity = EventSeverity.Important, Image = imageUrl, Data = new Dictionary { ["Reason"] = reason.ToString(), ["Removed from client?"] = removeFromClient.ToString(), ["Hash"] = itemHash, ["Instance type"] = instanceType.ToString(), ["Url"] = instanceUrl.ToString(), } }; } private NotificationContext BuildForceImportedContext() { (InstanceType instanceType, Uri instanceUrl, Uri? imageUrl, string itemTitle, string itemHash) = ReadQueueItemContext(); return new NotificationContext { EventType = NotificationEventType.ForceImported, Title = "Imported a download the arr had blocked", Description = itemTitle, Severity = EventSeverity.Important, Image = imageUrl, Data = new Dictionary { ["Hash"] = itemHash, ["Instance type"] = instanceType.ToString(), ["Url"] = instanceUrl.ToString(), } }; } private static NotificationContext BuildDownloadCleanedContext(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason) { var downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); var hash = ContextProvider.Get(ContextProvider.Keys.Hash); var clientUrl = ContextProvider.Get(ContextProvider.Keys.DownloadClientUrl); return new NotificationContext { EventType = NotificationEventType.DownloadCleaned, Title = $"Cleaned item from download client with reason: {reason}", Description = downloadName, Severity = EventSeverity.Important, Data = new Dictionary { ["Url"] = clientUrl.ToString(), ["Hash"] = hash.ToLowerInvariant(), ["Category"] = categoryName.ToLowerInvariant(), ["Ratio"] = ratio.ToString(CultureInfo.InvariantCulture), ["Seeding hours"] = Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture) } }; } private static NotificationContext BuildDownloadStoppedContext(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason) { string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); string hash = ContextProvider.Get(ContextProvider.Keys.Hash); Uri clientUrl = ContextProvider.Get(ContextProvider.Keys.DownloadClientUrl); return new NotificationContext { EventType = NotificationEventType.DownloadStopped, Title = $"Stopped item in download client with reason: {reason}", Description = $"{downloadName} is no longer seeding. It stays in the download client and its files stay on disk.", Severity = EventSeverity.Important, Data = new Dictionary { ["Url"] = clientUrl.ToString(), ["Hash"] = hash.ToLowerInvariant(), ["Category"] = categoryName.ToLowerInvariant(), ["Ratio"] = ratio.ToString(CultureInfo.InvariantCulture), ["Seeding hours"] = Math.Round(seedingTime.TotalHours, 0).ToString(CultureInfo.InvariantCulture) } }; } private NotificationContext BuildCategoryChangedContext(string oldCategory, string newCategory, bool isTag) { string downloadName = ContextProvider.Get(ContextProvider.Keys.ItemName); Uri clientUrl = ContextProvider.Get(ContextProvider.Keys.DownloadClientUrl); NotificationContext context = new() { EventType = NotificationEventType.CategoryChanged, Title = isTag ? "Tag added" : "Category changed", Description = downloadName, Severity = EventSeverity.Information, Data = new Dictionary { ["Url"] = clientUrl.ToString(), ["hash"] = ContextProvider.Get(ContextProvider.Keys.Hash).ToLowerInvariant(), } }; if (isTag) { context.Data.Add("Tag", newCategory); } else { context.Data.Add("Old category", oldCategory); context.Data.Add("New category", newCategory); } return context; } private static NotificationContext BuildSearchTriggeredContext(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason) { var instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); var instanceUrl = ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl); return new NotificationContext { EventType = NotificationEventType.SearchTriggered, Title = "Search triggered", Description = $"Search triggered for {itemTitle}", Severity = EventSeverity.Information, Data = new Dictionary { ["Instance type"] = instanceType.ToString(), ["Url"] = instanceUrl.ToString(), ["Item"] = itemTitle, ["Search type"] = searchType.ToString(), ["Search reason"] = searchReason.ToString(), } }; } private static NotificationContext BuildSearchItemGrabbedContext(string itemTitle, List grabbedItems, InstanceType instanceType, string instanceUrl) { return new NotificationContext { EventType = NotificationEventType.SearchItemGrabbed, Title = "Download grabbed", Description = itemTitle, Severity = EventSeverity.Information, Data = new Dictionary { ["Item"] = itemTitle, ["Grabbed"] = string.Join(", ", grabbedItems), ["Instance type"] = instanceType.ToString(), ["Url"] = instanceUrl, } }; } private static NotificationEventType MapStrikeTypeToEventType(StrikeType strikeType) { return strikeType switch { StrikeType.Stalled => NotificationEventType.StalledStrike, StrikeType.DownloadingMetadata => NotificationEventType.StalledStrike, StrikeType.FailedImport => NotificationEventType.FailedImportStrike, StrikeType.SlowSpeed => NotificationEventType.SlowSpeedStrike, StrikeType.SlowTime => NotificationEventType.SlowTimeStrike, _ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null) }; } // LazyLibrarian has no QueueRecord, so the record is absent on that path. private static string ResolveItemTitle(QueueRecord? record) => record?.Title ?? ContextProvider.Get(ContextProvider.Keys.ItemName) as string ?? string.Empty; private static string ResolveItemHash(QueueRecord? record) => (record?.DownloadId ?? ContextProvider.Get(ContextProvider.Keys.Hash) as string ?? string.Empty) .ToLowerInvariant(); private Uri? GetImageFromContext(QueueRecord? record, InstanceType instanceType, float version) { if (record is null) { return null; } Uri? image = 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, InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url, InstanceType.Whisparr when version is 2 => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl, InstanceType.Whisparr when version is 3 => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl ?? record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "screenshot")?.RemoteUrl, InstanceType.Sportarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl, InstanceType.LazyLibrarian => null, _ => throw new ArgumentOutOfRangeException(nameof(instanceType)) }; if (image is null) { _logger.LogWarning("No poster found for {title}", record.Title); } return image; } }