using System; using System.IO; using System.Text.Json; using LiteDB; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SmartNotify.Services; /// /// Service for managing the local database of known media items. /// public class ItemHistoryService : IDisposable { private readonly ILogger _logger; private readonly LiteDatabase _database; private readonly ILiteCollection _knownItems; private readonly ILiteCollection _pendingNotifications; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The application paths. /// The logger. public ItemHistoryService( IApplicationPaths applicationPaths, ILogger logger) { _logger = logger; var pluginDataPath = Path.Combine(applicationPaths.PluginConfigurationsPath, "SmartNotify"); Directory.CreateDirectory(pluginDataPath); var dbPath = Path.Combine(pluginDataPath, "smartnotify.db"); _logger.LogInformation("SmartNotify database path: {Path}", dbPath); _database = new LiteDatabase(dbPath); _knownItems = _database.GetCollection("known_items"); _pendingNotifications = _database.GetCollection("pending_notifications"); // Create indexes for efficient queries _knownItems.EnsureIndex(x => x.ContentKey); _knownItems.EnsureIndex(x => x.JellyfinItemId); _pendingNotifications.EnsureIndex(x => x.SeriesId); _pendingNotifications.EnsureIndex(x => x.QueuedAt); } /// /// Generates a content key for uniquely identifying media content (regardless of file). /// /// The base item. /// A unique content key, or null if insufficient metadata. public string? GenerateContentKey(BaseItem item) { if (item is Episode episode) { // For episodes: use series provider IDs + season + episode number var series = episode.Series; if (series == null) { _logger.LogDebug("Episode {Name} has no series, cannot generate content key", episode.Name); return null; } var seriesKey = GetProviderKey(series.ProviderIds); if (string.IsNullOrEmpty(seriesKey)) { // Fallback to series name if no provider IDs seriesKey = series.Name?.ToLowerInvariant().Trim() ?? "unknown"; } var seasonNum = episode.ParentIndexNumber ?? 0; var episodeNum = episode.IndexNumber ?? 0; if (episodeNum == 0) { _logger.LogDebug("Episode {Name} has no episode number", episode.Name); return null; } return $"episode|{seriesKey}|S{seasonNum:D2}E{episodeNum:D3}"; } if (item is Movie movie) { // For movies: use provider IDs var movieKey = GetProviderKey(movie.ProviderIds); if (string.IsNullOrEmpty(movieKey)) { // Fallback to name + year var year = movie.ProductionYear ?? 0; movieKey = $"{movie.Name?.ToLowerInvariant().Trim()}|{year}"; } return $"movie|{movieKey}"; } _logger.LogDebug("Item {Name} is not an Episode or Movie, type: {Type}", item.Name, item.GetType().Name); return null; } /// /// Gets a consistent provider key from provider IDs. /// private string? GetProviderKey(Dictionary? providerIds) { if (providerIds == null || providerIds.Count == 0) { return null; } // Priority order for provider IDs string[] priorityOrder = { "AniDB", "AniList", "Tmdb", "Tvdb", "Imdb" }; foreach (var provider in priorityOrder) { if (providerIds.TryGetValue(provider, out var id) && !string.IsNullOrEmpty(id)) { return $"{provider.ToLowerInvariant()}:{id}"; } } // Fallback: use first available foreach (var kvp in providerIds) { if (!string.IsNullOrEmpty(kvp.Value)) { return $"{kvp.Key.ToLowerInvariant()}:{kvp.Value}"; } } return null; } /// /// Checks if an item is a quality upgrade (content already known). /// /// The item to check. /// True if this is an upgrade of existing content. public bool IsQualityUpgrade(BaseItem item) { var contentKey = GenerateContentKey(item); if (contentKey == null) { _logger.LogDebug("Could not generate content key for {Name}, treating as new", item.Name); return false; } var existing = _knownItems.FindOne(x => x.ContentKey == contentKey); if (existing != null) { _logger.LogInformation( "Item {Name} is a quality upgrade (content key: {Key}, first seen: {FirstSeen})", item.Name, contentKey, existing.FirstSeen); return true; } return false; } /// /// Checks if there's another item with the same content currently in the library. /// /// The item to check. /// The library manager to query current items. /// True if duplicate content exists. public bool HasDuplicateContent(BaseItem item, MediaBrowser.Controller.Library.ILibraryManager libraryManager) { var contentKey = GenerateContentKey(item); if (contentKey == null) { return false; } // Check if we have another item with the same content key but different Jellyfin ID var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != item.Id.ToString()); if (existing != null) { // Verify the other item still exists in the library var otherId = Guid.Parse(existing.JellyfinItemId); var otherItem = libraryManager.GetItemById(otherId); if (otherItem != null) { _logger.LogInformation( "Item {Name} has duplicate content already in library: {OtherName}", item.Name, otherItem.Name); return true; } } return false; } /// /// Records a known item in the database. /// /// The item to record. public void RecordItem(BaseItem item) { var contentKey = GenerateContentKey(item); if (contentKey == null) { return; } var jellyfinId = item.Id.ToString(); // Check if we already have this exact Jellyfin item var existing = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinId); if (existing != null) { // Update the record existing.ContentKey = contentKey; existing.FilePath = item.Path; _knownItems.Update(existing); return; } // Check if we have this content key already (upgrade scenario) var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey); if (byContentKey != null) { // Content exists, this is an upgrade - update the record byContentKey.JellyfinItemId = jellyfinId; byContentKey.FilePath = item.Path; _knownItems.Update(byContentKey); _logger.LogDebug("Updated existing content record for {Key} with new file", contentKey); return; } // New content - create record var record = new KnownMediaItem { JellyfinItemId = jellyfinId, ContentKey = contentKey, ItemType = item.GetType().Name, Name = item.Name, FirstSeen = DateTime.UtcNow, FilePath = item.Path, ProviderIdsJson = System.Text.Json.JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary()) }; if (item is Episode ep) { record.SeriesName = ep.SeriesName; record.SeasonNumber = ep.ParentIndexNumber; record.EpisodeNumber = ep.IndexNumber; record.Year = ep.ProductionYear; } else if (item is Movie movie) { record.Year = movie.ProductionYear; } _knownItems.Insert(record); _logger.LogDebug("Recorded new content: {Key}", contentKey); } /// /// Removes an item from the known items database. /// /// The Jellyfin item ID. public void RemoveItem(Guid itemId) { var jellyfinId = itemId.ToString(); _knownItems.DeleteMany(x => x.JellyfinItemId == jellyfinId); } /// /// Queues a notification for later processing. /// /// The notification to queue. public void QueueNotification(PendingNotification notification) { _pendingNotifications.Insert(notification); } /// /// Gets all pending notifications older than the specified delay. /// /// The cutoff time. /// The pending notifications. public IEnumerable GetPendingNotifications(DateTime olderThan) { return _pendingNotifications.Find(x => x.QueuedAt <= olderThan); } /// /// Removes processed notifications. /// /// The notification IDs to remove. public void RemoveNotifications(IEnumerable ids) { foreach (var id in ids) { _pendingNotifications.Delete(id); } } /// /// Gets pending notifications grouped by series. /// /// The series ID. /// The notifications for the series. public IEnumerable GetNotificationsForSeries(string seriesId) { return _pendingNotifications.Find(x => x.SeriesId == seriesId); } /// public void Dispose() { if (!_disposed) { _database.Dispose(); _disposed = true; } } }