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 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 image URL if (!string.IsNullOrEmpty(serverUrl)) { notification.ImageUrl = $"{serverUrl}/Items/{item.Id}/Images/Primary"; } 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 one if (!string.IsNullOrEmpty(serverUrl) && episode.SeriesId != Guid.Empty) { notification.ImageUrl = $"{serverUrl}/Items/{episode.SeriesId}/Images/Primary"; } } 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.LogDebug("Processing {Count} pending notifications", pendingNotifications.Count); // Group episodes by series var episodesBySeries = pendingNotifications .Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId)) .GroupBy(n => n.SeriesId!) .ToList(); // 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.LogDebug( "Waiting for grouping window for series {SeriesId}, oldest: {Oldest}", seriesGroup.Key, oldestInGroup); continue; } await _discordService.SendGroupedEpisodeNotificationAsync( seriesGroup, CancellationToken.None); var idsToRemove = seriesGroup.Select(n => n.Id).ToList(); _historyService.RemoveNotifications(idsToRemove); } // Process movies var movies = pendingNotifications .Where(n => n.ItemType == "Movie") .ToList(); foreach (var movie in movies) { await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None); _historyService.RemoveNotifications(new[] { movie.Id }); } } catch (Exception ex) { _logger.LogError(ex, "Error processing pending notifications"); } } /// public void Dispose() { if (!_disposed) { _processTimer?.Dispose(); _disposed = true; } } }