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;
}
}
}