using System.Text.Json; using System.Text.Json.Serialization; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Events; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Events; /// /// Service for publishing events to database and SignalR hub /// public class EventPublisher : IEventPublisher { private readonly EventsContext _context; private readonly IHubContext _appHubContext; private readonly ILogger _logger; private readonly INotificationPublisher _notificationPublisher; private readonly IDryRunInterceptor _dryRunInterceptor; public EventPublisher( EventsContext context, IHubContext appHubContext, ILogger logger, INotificationPublisher notificationPublisher, IDryRunInterceptor dryRunInterceptor) { _context = context; _appHubContext = appHubContext; _logger = logger; _notificationPublisher = notificationPublisher; _dryRunInterceptor = dryRunInterceptor; } /// /// Generic method for publishing events to database and SignalR clients /// public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null) { AppEvent eventEntity = new() { EventType = eventType, Message = message, Severity = severity, Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }) : null, TrackingId = trackingId }; // Save to database with dry run interception await _dryRunInterceptor.InterceptAsync(SaveEventToDatabase, eventEntity); // Always send to SignalR clients (not affected by dry run) await NotifyClientsAsync(eventEntity); _logger.LogTrace("Published event: {eventType}", eventType); } public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null) { ManualEvent eventEntity = new() { Message = message, Severity = severity, Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }) : null, }; // Save to database with dry run interception await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity); // Always send to SignalR clients (not affected by dry run) await NotifyClientsAsync(eventEntity); _logger.LogTrace("Published manual event: {message}", message); } /// /// Publishes a strike event with context data and notifications /// public async Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName) { // Determine the appropriate EventType based on StrikeType EventType eventType = strikeType switch { StrikeType.Stalled => EventType.StalledStrike, StrikeType.DownloadingMetadata => EventType.DownloadingMetadataStrike, StrikeType.FailedImport => EventType.FailedImportStrike, StrikeType.SlowSpeed => EventType.SlowSpeedStrike, StrikeType.SlowTime => EventType.SlowTimeStrike, _ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null) }; dynamic data; if (strikeType is StrikeType.FailedImport) { QueueRecord record = ContextProvider.Get(nameof(QueueRecord)); data = new { hash, itemName, strikeCount, strikeType, failedImportReasons = record.StatusMessages ?? [], }; } else { data = new { hash, itemName, strikeCount, strikeType, }; } // Publish the event await PublishAsync( eventType, $"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'", EventSeverity.Important, data: data); // Send notification (uses ContextProvider internally) await _notificationPublisher.NotifyStrike(strikeType, strikeCount); } /// /// Publishes a queue item deleted event with context data and notifications /// public async Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason) { // Get context data for the event string downloadName = ContextProvider.Get("downloadName") ?? "Unknown"; string hash = ContextProvider.Get("hash") ?? "Unknown"; // Publish the event await PublishAsync( EventType.QueueItemDeleted, $"Deleting item from queue with reason: {deleteReason}", EventSeverity.Important, data: new { downloadName, hash, removeFromClient, deleteReason }); // Send notification (uses ContextProvider internally) await _notificationPublisher.NotifyQueueItemDeleted(removeFromClient, deleteReason); } /// /// Publishes a download cleaned event with context data and notifications /// public async Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason) { // Get context data for the event string downloadName = ContextProvider.Get("downloadName"); string hash = ContextProvider.Get("hash"); // Publish the event await PublishAsync( EventType.DownloadCleaned, $"Cleaned item from download client with reason: {reason}", EventSeverity.Important, data: new { downloadName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason }); // Send notification (uses ContextProvider internally) await _notificationPublisher.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason); } /// /// Publishes a category changed event with context data and notifications /// public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false) { // Get context data for the event string downloadName = ContextProvider.Get("downloadName"); string hash = ContextProvider.Get("hash"); // Publish the event await PublishAsync( EventType.CategoryChanged, isTag ? $"Tag '{newCategory}' added to download" : $"Category changed from '{oldCategory}' to '{newCategory}'", EventSeverity.Information, data: new { downloadName, hash, oldCategory, newCategory, isTag }); // Send notification (uses ContextProvider internally) await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag); } /// /// Publishes an event alerting that an item keeps coming back /// public async Task PublishRecurringItem(string hash, string itemName, int strikeCount) { var instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); var instanceUrl = ContextProvider.Get(nameof(ArrInstance) + nameof(ArrInstance.Url)); // Publish the event await PublishManualAsync( "Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/", EventSeverity.Important, data: new { itemName, hash, strikeCount, instanceType, instanceUrl } ); } /// /// Publishes an event alerting that search was not triggered for an item /// public async Task PublishSearchNotTriggered(string hash, string itemName) { var instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); var instanceUrl = ContextProvider.Get(nameof(ArrInstance) + nameof(ArrInstance.Url)); await PublishManualAsync( "Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed", EventSeverity.Warning, data: new { itemName, hash, instanceType, instanceUrl } ); } private async Task SaveEventToDatabase(AppEvent eventEntity) { _context.Events.Add(eventEntity); await _context.SaveChangesAsync(); } private async Task SaveManualEventToDatabase(ManualEvent eventEntity) { _context.ManualEvents.Add(eventEntity); await _context.SaveChangesAsync(); } private async Task NotifyClientsAsync(AppEvent appEventEntity) { try { // Send to all connected clients via the unified AppHub await _appHubContext.Clients.All.SendAsync("EventReceived", appEventEntity); } catch (Exception ex) { _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id); } } private async Task NotifyClientsAsync(ManualEvent appEventEntity) { try { // Send to all connected clients via the unified AppHub await _appHubContext.Clients.All.SendAsync("ManualEventReceived", appEventEntity); } catch (Exception ex) { _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id); } } }