using System; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Timers; using Jellyfin.Plugin.SmartNotify.Services; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Timer = System.Timers.Timer; namespace Jellyfin.Plugin.SmartNotify.Notifiers; /// /// Background service that monitors library changes and sends smart notifications. /// public class SmartNotifyBackgroundService : IHostedService, IDisposable { private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; private readonly ItemHistoryService _historyService; private readonly DiscordNotificationService _discordService; private Timer? _processTimer; private bool _disposed; /// /// Initializes a new instance of the class. /// public SmartNotifyBackgroundService( ILogger logger, ILibraryManager libraryManager, ItemHistoryService historyService, DiscordNotificationService discordService) { _logger = logger; _libraryManager = libraryManager; _historyService = historyService; _discordService = discordService; } /// public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("SmartNotify background service starting"); // Subscribe to library events _libraryManager.ItemAdded += OnItemAdded; _libraryManager.ItemRemoved += OnItemRemoved; // Start the notification processing timer (runs every minute) _processTimer = new Timer(60_000); _processTimer.Elapsed += ProcessPendingNotifications; _processTimer.AutoReset = true; _processTimer.Start(); _logger.LogInformation("SmartNotify is now monitoring library changes"); return Task.CompletedTask; } /// public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("SmartNotify background service stopping"); _libraryManager.ItemAdded -= OnItemAdded; _libraryManager.ItemRemoved -= OnItemRemoved; _processTimer?.Stop(); return Task.CompletedTask; } /// /// Called when an item is added to the library. /// private void OnItemAdded(object? sender, ItemChangeEventArgs e) { var item = e.Item; // Only process Episodes and Movies if (item is not Episode && item is not Movie) { return; } _logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id); var config = Plugin.Instance?.Configuration; if (config == null) { return; } // Check if this type of notification is enabled if (item is Episode && !config.EnableEpisodeNotifications) { return; } if (item is Movie && !config.EnableMovieNotifications) { return; } try { ProcessNewItem(item); } catch (Exception ex) { _logger.LogError(ex, "Error processing new item {Name}", item.Name); } } /// /// Processes a newly added item. /// private void ProcessNewItem(BaseItem item) { var config = Plugin.Instance?.Configuration; if (config == null) { return; } // Check 1: Is this a quality upgrade? (Same content, different file) var isUpgrade = _historyService.IsQualityUpgrade(item); // Check 2: Is there duplicate content currently in the library? // This catches the case where the new file arrives BEFORE the old one is deleted var hasDuplicate = _historyService.HasDuplicateContent(item, _libraryManager); if (isUpgrade || hasDuplicate) { _logger.LogInformation( "Suppressing notification for {Name} - IsUpgrade: {IsUpgrade}, HasDuplicate: {HasDuplicate}", item.Name, isUpgrade, hasDuplicate); if (config.SuppressUpgrades) { // Just record the item and don't notify _historyService.RecordItem(item); return; } // If not suppressing, we could send an "upgrade" notification instead // For now, just fall through and send as normal } // Record this item in our database _historyService.RecordItem(item); // Create pending notification var notification = CreateNotification(item); if (notification == null) { return; } _historyService.QueueNotification(notification); _logger.LogInformation("Queued notification for {Name}", item.Name); } /// /// Creates a pending notification from a base item. /// private PendingNotification? CreateNotification(BaseItem item) { var config = Plugin.Instance?.Configuration; var serverUrl = config?.ServerUrl?.TrimEnd('/') ?? string.Empty; var notification = new PendingNotification { JellyfinItemId = item.Id.ToString(), ItemType = item.GetType().Name, Name = item.Name, QueuedAt = DateTime.UtcNow, Type = NotificationType.NewContent, ProviderIdsJson = JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary()), Overview = item.Overview }; // Set local image path for attachment-based sending var imagePath = item.GetImagePath(ImageType.Primary, 0); if (!string.IsNullOrEmpty(imagePath)) { notification.ImagePath = imagePath; } if (item is Episode episode) { notification.SeriesName = episode.SeriesName; notification.SeriesId = episode.SeriesId.ToString(); notification.SeasonNumber = episode.ParentIndexNumber; notification.EpisodeNumber = episode.IndexNumber; notification.Year = episode.ProductionYear; // Use series image if episode doesn't have its own if (episode.SeriesId != Guid.Empty) { var series = _libraryManager.GetItemById(episode.SeriesId); var seriesImage = series?.GetImagePath(ImageType.Primary, 0); if (!string.IsNullOrEmpty(seriesImage)) { notification.ImagePath = seriesImage; } } } else if (item is Movie movie) { notification.Year = movie.ProductionYear; } return notification; } /// /// Called when an item is removed from the library. /// private void OnItemRemoved(object? sender, ItemChangeEventArgs e) { // We keep the history record! This is important for detecting upgrades. // When old file is deleted after new one is added, we don't want to // remove our knowledge of this content. _logger.LogDebug("Item removed: {Name} (ID: {Id})", e.Item.Name, e.Item.Id); } /// /// Processes pending notifications (called by timer). /// private async void ProcessPendingNotifications(object? sender, ElapsedEventArgs e) { try { var config = Plugin.Instance?.Configuration; if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl)) { return; } var delayMinutes = config.NotificationDelayMinutes; var groupingWindowMinutes = config.GroupingWindowMinutes; var cutoff = DateTime.UtcNow.AddMinutes(-delayMinutes); var pendingNotifications = _historyService.GetPendingNotifications(cutoff).ToList(); if (pendingNotifications.Count == 0) { return; } _logger.LogInformation( "Processing {Count} pending notifications (delay={Delay}min, grouping={Grouping}min, cutoff={Cutoff})", pendingNotifications.Count, delayMinutes, groupingWindowMinutes, cutoff); // Group episodes by series var episodesBySeries = pendingNotifications .Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId)) .GroupBy(n => n.SeriesId!) .ToList(); // Log unmatched notifications (neither episode with series nor movie) var unmatchedCount = pendingNotifications.Count - pendingNotifications.Count(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId)) - pendingNotifications.Count(n => n.ItemType == "Movie"); if (unmatchedCount > 0) { _logger.LogWarning( "{Count} notifications are neither episodes (with SeriesId) nor movies and will be skipped", unmatchedCount); } // Process each series group foreach (var seriesGroup in episodesBySeries) { var oldestInGroup = seriesGroup.Min(n => n.QueuedAt); var groupingCutoff = DateTime.UtcNow.AddMinutes(-groupingWindowMinutes); // Only process if the oldest notification is outside the grouping window if (oldestInGroup > groupingCutoff) { _logger.LogInformation( "Waiting for grouping window for series {SeriesName} ({SeriesId}), oldest queued: {Oldest}, grouping cutoff: {Cutoff}", seriesGroup.First().SeriesName, seriesGroup.Key, oldestInGroup, groupingCutoff); continue; } _logger.LogInformation( "Sending grouped notification for {SeriesName}: {Count} episodes", seriesGroup.First().SeriesName, seriesGroup.Count()); var success = await _discordService.SendGroupedEpisodeNotificationAsync( seriesGroup, CancellationToken.None); if (success) { var idsToRemove = seriesGroup.Select(n => n.Id).ToList(); _historyService.RemoveNotifications(idsToRemove); _logger.LogInformation("Removed {Count} processed episode notifications from queue", idsToRemove.Count); } else { _logger.LogWarning("Discord send failed for {SeriesName}, keeping notifications in queue for retry", seriesGroup.First().SeriesName); } } // Process movies var movies = pendingNotifications .Where(n => n.ItemType == "Movie") .ToList(); foreach (var movie in movies) { _logger.LogInformation("Sending movie notification for: {Name}", movie.Name); var success = await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None); if (success) { _historyService.RemoveNotifications(new[] { movie.Id }); _logger.LogInformation("Removed processed movie notification from queue: {Name}", movie.Name); } else { _logger.LogWarning("Discord send failed for movie {Name}, keeping in queue for retry", movie.Name); } } } catch (Exception ex) { _logger.LogError(ex, "Error processing pending notifications"); } } /// public void Dispose() { if (!_disposed) { _processTimer?.Dispose(); _disposed = true; } } }