All checks were successful
Create Release PR / Create Release PR (push) Successful in 18s
481 lines
17 KiB
C#
481 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Service for managing the local database of known media items.
|
|
/// </summary>
|
|
public class ItemHistoryService : IDisposable
|
|
{
|
|
private readonly ILogger<ItemHistoryService> _logger;
|
|
private readonly LiteDatabase _database;
|
|
private readonly ILiteCollection<KnownMediaItem> _knownItems;
|
|
private readonly ILiteCollection<PendingNotification> _pendingNotifications;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ItemHistoryService"/> class.
|
|
/// </summary>
|
|
/// <param name="applicationPaths">The application paths.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public ItemHistoryService(
|
|
IApplicationPaths applicationPaths,
|
|
ILogger<ItemHistoryService> 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);
|
|
|
|
// Use UTC for all DateTime to avoid container timezone vs Jellyfin time mismatches
|
|
var mapper = new BsonMapper { SerializeNullValues = false };
|
|
mapper.RegisterType<DateTime>(
|
|
serialize: value => new BsonValue(value.ToUniversalTime()),
|
|
deserialize: bson => bson.AsDateTime.ToUniversalTime());
|
|
|
|
_database = new LiteDatabase(dbPath, mapper);
|
|
_knownItems = _database.GetCollection<KnownMediaItem>("known_items");
|
|
_pendingNotifications = _database.GetCollection<PendingNotification>("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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a content key for uniquely identifying media content (regardless of file).
|
|
/// </summary>
|
|
/// <param name="item">The base item.</param>
|
|
/// <returns>A unique content key, or null if insufficient metadata.</returns>
|
|
public string? GenerateContentKey(BaseItem item)
|
|
{
|
|
if (item is Episode episode)
|
|
{
|
|
// Prefer episode's own provider ID (e.g. AniDB episode ID).
|
|
// This is stable even when seasons are reorganized in Jellyfin.
|
|
var episodeKey = GetProviderKey(episode.ProviderIds);
|
|
if (!string.IsNullOrEmpty(episodeKey))
|
|
{
|
|
return $"episode|{episodeKey}";
|
|
}
|
|
|
|
// Fallback: series provider key + season + episode number
|
|
var series = episode.Series;
|
|
if (series == null)
|
|
{
|
|
_logger.LogDebug("Episode {Name} has no series and no provider IDs, cannot generate content key", episode.Name);
|
|
return null;
|
|
}
|
|
|
|
var seriesKey = GetProviderKey(series.ProviderIds);
|
|
if (string.IsNullOrEmpty(seriesKey))
|
|
{
|
|
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 and no provider IDs", 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a consistent provider key from provider IDs.
|
|
/// </summary>
|
|
private string? GetProviderKey(Dictionary<string, string>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if an item is a quality upgrade (content already known).
|
|
/// </summary>
|
|
/// <param name="item">The item to check.</param>
|
|
/// <returns>True if this is an upgrade of existing content.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if there's another item with the same content currently in the library.
|
|
/// </summary>
|
|
/// <param name="item">The item to check.</param>
|
|
/// <param name="libraryManager">The library manager to query current items.</param>
|
|
/// <returns>True if duplicate content exists.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if an item is already tracked in the database.
|
|
/// Checks both by Jellyfin ID and by content key (provider IDs),
|
|
/// so moved/rescanned files with new Jellyfin IDs are still recognized.
|
|
/// </summary>
|
|
/// <param name="itemId">The Jellyfin item ID.</param>
|
|
/// <param name="item">The item to check (used for content key lookup).</param>
|
|
/// <returns>True if the item is already known.</returns>
|
|
public bool IsKnownItem(Guid itemId, BaseItem? item = null)
|
|
{
|
|
var jellyfinId = itemId.ToString();
|
|
if (_knownItems.Exists(x => x.JellyfinItemId == jellyfinId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Fallback: check by content key (provider IDs) so moved files are still recognized
|
|
if (item != null)
|
|
{
|
|
var contentKey = GenerateContentKey(item);
|
|
if (contentKey != null && _knownItems.Exists(x => x.ContentKey == contentKey))
|
|
{
|
|
_logger.LogInformation(
|
|
"Item {Name} recognized as known by content key {Key} (new Jellyfin ID: {Id})",
|
|
item.Name,
|
|
contentKey,
|
|
jellyfinId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a known item in the database.
|
|
/// Always records the JellyfinItemId even if metadata is not yet available,
|
|
/// using a placeholder ContentKey that gets updated later.
|
|
/// </summary>
|
|
/// <param name="item">The item to record.</param>
|
|
public void RecordItem(BaseItem item)
|
|
{
|
|
var contentKey = GenerateContentKey(item);
|
|
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 — only update ContentKey if we have a real one
|
|
if (contentKey != null)
|
|
{
|
|
existing.ContentKey = contentKey;
|
|
}
|
|
|
|
existing.FilePath = item.Path;
|
|
existing.Name = item.Name;
|
|
_knownItems.Update(existing);
|
|
return;
|
|
}
|
|
|
|
// Check if we have this content key already (upgrade scenario)
|
|
if (contentKey != null)
|
|
{
|
|
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 (use placeholder key if metadata not yet available)
|
|
var record = new KnownMediaItem
|
|
{
|
|
JellyfinItemId = jellyfinId,
|
|
ContentKey = contentKey ?? $"unresolved|{jellyfinId}",
|
|
ItemType = item.GetType().Name,
|
|
Name = item.Name,
|
|
FirstSeen = DateTime.UtcNow,
|
|
FilePath = item.Path,
|
|
ProviderIdsJson = System.Text.Json.JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>())
|
|
};
|
|
|
|
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}", record.ContentKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of revalidating a pending notification at send time.
|
|
/// </summary>
|
|
public enum RevalidationResult
|
|
{
|
|
/// <summary>Item is genuinely new content.</summary>
|
|
New,
|
|
|
|
/// <summary>Item is a known item that was reorganized (path/metadata change, old ID gone).</summary>
|
|
Reorganized,
|
|
|
|
/// <summary>Item is a quality upgrade (same content, old file still exists).</summary>
|
|
Upgrade
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-checks if a pending notification is actually a known item (reorganized or upgraded).
|
|
/// Called at send time when metadata is fully populated.
|
|
/// At queue time, items often have empty ProviderIds and no season/episode numbers,
|
|
/// so the content key couldn't be generated. Now that metadata is available, we can
|
|
/// properly identify known items.
|
|
/// </summary>
|
|
/// <param name="jellyfinItemId">The Jellyfin item ID string.</param>
|
|
/// <param name="item">The resolved library item (may have updated metadata).</param>
|
|
/// <param name="libraryManager">The library manager to check if old items still exist.</param>
|
|
/// <returns>The revalidation result indicating if this is new, reorganized, or an upgrade.</returns>
|
|
public RevalidationResult RevalidatePendingItem(string jellyfinItemId, BaseItem? item, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
|
|
{
|
|
if (item == null)
|
|
{
|
|
return RevalidationResult.New;
|
|
}
|
|
|
|
var contentKey = GenerateContentKey(item);
|
|
if (contentKey == null)
|
|
{
|
|
return RevalidationResult.New;
|
|
}
|
|
|
|
// Update unresolved content key on the item's own record
|
|
var ownRecord = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId);
|
|
if (ownRecord != null && ownRecord.ContentKey.StartsWith("unresolved|", StringComparison.Ordinal))
|
|
{
|
|
ownRecord.ContentKey = contentKey;
|
|
_knownItems.Update(ownRecord);
|
|
_logger.LogDebug("Resolved content key for {Name}: {Key}", item.Name, contentKey);
|
|
}
|
|
|
|
// Check if this content was already known under a different item ID
|
|
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
|
|
if (existing != null)
|
|
{
|
|
// Determine if the old item still exists in the library
|
|
var oldItemExists = false;
|
|
if (Guid.TryParse(existing.JellyfinItemId, out var oldId))
|
|
{
|
|
oldItemExists = libraryManager.GetItemById(oldId) != null;
|
|
}
|
|
|
|
var resultType = oldItemExists
|
|
? RevalidationResult.Upgrade
|
|
: RevalidationResult.Reorganized;
|
|
|
|
_logger.LogInformation(
|
|
"Late detection for {Name}: content key {Key} already known (first seen: {FirstSeen}, result: {Result})",
|
|
item.Name,
|
|
contentKey,
|
|
existing.FirstSeen,
|
|
resultType);
|
|
|
|
// Update the existing record to point to the new item
|
|
existing.JellyfinItemId = jellyfinItemId;
|
|
existing.FilePath = item.Path;
|
|
_knownItems.Update(existing);
|
|
|
|
// Clean up any duplicate record that was created for this item
|
|
var duplicate = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId && x.Id != existing.Id);
|
|
if (duplicate != null)
|
|
{
|
|
_knownItems.Delete(duplicate.Id);
|
|
}
|
|
|
|
return resultType;
|
|
}
|
|
|
|
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
|
|
RecordItem(item);
|
|
return RevalidationResult.New;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes an item from the known items database.
|
|
/// </summary>
|
|
/// <param name="itemId">The Jellyfin item ID.</param>
|
|
public void RemoveItem(Guid itemId)
|
|
{
|
|
var jellyfinId = itemId.ToString();
|
|
_knownItems.DeleteMany(x => x.JellyfinItemId == jellyfinId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues a notification for later processing.
|
|
/// </summary>
|
|
/// <param name="notification">The notification to queue.</param>
|
|
public void QueueNotification(PendingNotification notification)
|
|
{
|
|
_pendingNotifications.Insert(notification);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all pending notifications older than the specified delay.
|
|
/// </summary>
|
|
/// <param name="olderThan">The cutoff time.</param>
|
|
/// <returns>The pending notifications.</returns>
|
|
public IEnumerable<PendingNotification> GetPendingNotifications(DateTime olderThan)
|
|
{
|
|
return _pendingNotifications.Find(x => x.QueuedAt <= olderThan);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing notification in the queue (e.g. after metadata refresh).
|
|
/// </summary>
|
|
/// <param name="notification">The notification to update.</param>
|
|
public void UpdateNotification(PendingNotification notification)
|
|
{
|
|
_pendingNotifications.Update(notification);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes processed notifications.
|
|
/// </summary>
|
|
/// <param name="ids">The notification IDs to remove.</param>
|
|
public void RemoveNotifications(IEnumerable<int> ids)
|
|
{
|
|
foreach (var id in ids)
|
|
{
|
|
_pendingNotifications.Delete(id);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets pending notifications grouped by series.
|
|
/// </summary>
|
|
/// <param name="seriesId">The series ID.</param>
|
|
/// <returns>The notifications for the series.</returns>
|
|
public IEnumerable<PendingNotification> GetNotificationsForSeries(string seriesId)
|
|
{
|
|
return _pendingNotifications.Find(x => x.SeriesId == seriesId);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (!_disposed)
|
|
{
|
|
_database.Dispose();
|
|
_disposed = true;
|
|
}
|
|
}
|
|
}
|