using System; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Timers; using Jellyfin.Data.Enums; 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"); // Pre-populate DB with all existing library items so they're recognized as "known". // This prevents mass notifications on first run or after DB reset. SeedExistingLibraryItems(); // 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; } /// /// Seeds the database with all existing Episodes and Movies from the library. /// Runs once at startup — only records items not yet in the DB. /// private void SeedExistingLibraryItems() { try { var query = new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, IsVirtualItem = false, Recursive = true }; var existingItems = _libraryManager.GetItemList(query); var seeded = 0; foreach (var item in existingItems) { if (!_historyService.IsKnownItem(item.Id)) { _historyService.RecordItem(item); seeded++; } } _logger.LogInformation( "Seeded {Count} existing library items into SmartNotify DB (total in library: {Total})", seeded, existingItems.Count); } catch (Exception ex) { _logger.LogError(ex, "Error seeding existing library items"); } } /// 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; } // Skip virtual items — these are created by "Add Missing Episodes/Seasons" // metadata feature and have no actual file on disk if (item.IsVirtualItem || string.IsNullOrEmpty(item.Path)) { _logger.LogDebug("Skipping virtual item (no file): {Name} (ID: {Id})", item.Name, item.Id); 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 0: Is this exact item (same Jellyfin ID) already known in our DB? if (_historyService.IsKnownItem(item.Id)) { _logger.LogDebug( "Item {Name} (ID: {Id}) is already known in DB, skipping notification", item.Name, item.Id); _historyService.RecordItem(item); 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 provider IDs for external links — episode provider IDs // (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths if (episode.SeriesId != Guid.Empty) { var series = _libraryManager.GetItemById(episode.SeriesId); if (series?.ProviderIds != null && series.ProviderIds.Count > 0) { notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds); } 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); } /// /// Refreshes notification metadata from the library (series info may not be available at queue time). /// private void RefreshNotificationMetadata(PendingNotification notification) { if (!Guid.TryParse(notification.JellyfinItemId, out var itemId) || itemId == Guid.Empty) { return; } var item = _libraryManager.GetItemById(itemId); if (item is not Episode episode) { return; } var changed = false; if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series") { notification.SeriesName = episode.SeriesName; changed = true; } if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString()) { if (episode.SeriesId != Guid.Empty) { notification.SeriesId = episode.SeriesId.ToString(); changed = true; } } if (!notification.SeasonNumber.HasValue && episode.ParentIndexNumber.HasValue) { notification.SeasonNumber = episode.ParentIndexNumber; changed = true; } if (!notification.EpisodeNumber.HasValue && episode.IndexNumber.HasValue) { notification.EpisodeNumber = episode.IndexNumber; changed = true; } // Refresh image if missing if (string.IsNullOrEmpty(notification.ImagePath) && episode.SeriesId != Guid.Empty) { var series = _libraryManager.GetItemById(episode.SeriesId); var seriesImage = series?.GetImagePath(ImageType.Primary, 0); if (!string.IsNullOrEmpty(seriesImage)) { notification.ImagePath = seriesImage; changed = true; } } if (changed) { _historyService.UpdateNotification(notification); _logger.LogInformation( "Refreshed metadata for {Name}: Series={SeriesName}, SeriesId={SeriesId}, S{Season}E{Episode}", notification.Name, notification.SeriesName, notification.SeriesId, notification.SeasonNumber, notification.EpisodeNumber); } } /// /// 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); // Refresh metadata for episodes that were queued before metadata was available foreach (var notification in pendingNotifications.Where(n => n.ItemType == "Episode")) { RefreshNotificationMetadata(notification); } // Late upgrade detection: re-check now that metadata is fully populated. // At queue time, metadata (ProviderIds, Series, Season/Episode) may not have // been available, causing GenerateContentKey() to return null and upgrades // to go undetected. By now (after delay + grouping window), metadata is ready. if (config.SuppressUpgrades) { var suppressedIds = new List(); foreach (var notification in pendingNotifications) { if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId)) { var revalidateItem = _libraryManager.GetItemById(revalidateId); if (_historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem)) { suppressedIds.Add(notification.Id); _logger.LogInformation( "Late suppression: {Name} detected as upgrade at send time", notification.Name); } } } if (suppressedIds.Count > 0) { _historyService.RemoveNotifications(suppressedIds); pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id)); _logger.LogInformation( "Suppressed {Count} upgrade notifications at send time", suppressedIds.Count); } if (pendingNotifications.Count == 0) { return; } } // Group episodes by series var emptyGuid = Guid.Empty.ToString(); var episodesBySeries = pendingNotifications .Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId) && n.SeriesId != emptyGuid) .GroupBy(n => n.SeriesId!) .ToList(); // Handle episodes that still have no series info (send individually as fallback) var orphanEpisodes = pendingNotifications .Where(n => n.ItemType == "Episode" && (string.IsNullOrEmpty(n.SeriesId) || n.SeriesId == emptyGuid)) .ToList(); if (orphanEpisodes.Count > 0) { _logger.LogWarning( "{Count} episode notifications have no series info even after refresh", orphanEpisodes.Count); } // 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 orphan episodes (no series info - send individually) foreach (var orphan in orphanEpisodes) { var oldAge = DateTime.UtcNow.AddMinutes(-groupingWindowMinutes); if (orphan.QueuedAt > oldAge) { continue; } _logger.LogInformation("Sending individual episode notification for: {Name} (no series info)", orphan.Name); var success = await _discordService.SendGroupedEpisodeNotificationAsync( new[] { orphan }, CancellationToken.None); if (success) { _historyService.RemoveNotifications(new[] { orphan.Id }); } else { _logger.LogWarning("Discord send failed for orphan episode {Name}, keeping in queue for retry", orphan.Name); } } // 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; } } }