using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; /// /// Matches items against the ledger and decides whether an item is new, known or an upgrade. /// /// /// Pure logic without Jellyfin types: it only updates the ledger and reports what it found. /// Changing items is up to the caller (see ). /// public sealed class Reconciler { private readonly LedgerStore _store; private readonly Func _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The ledger. /// Provides the current options. /// The clock. /// The logger. public Reconciler(LedgerStore store, Func options, TimeProvider timeProvider, ILogger logger) { _store = store ?? throw new ArgumentNullException(nameof(store)); _options = options ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Handles an added or updated item: registers it and detects upgrades. /// /// The item. /// The outcome; asks the caller to restore . public ReconcileResult OnItemSeen(MediaIdentity identity) => Apply(identity, detectUpgrades: true); /// /// Registers an item during seed or synchronization. The ledger keeps the minimum date; upgrades are not reported. /// /// The item. /// The outcome. public ReconcileResult Register(MediaIdentity identity) => Apply(identity, detectUpgrades: false); /// /// Handles a removed item. The entry is found by item id, or by key if the item was never registered. /// /// The removed item. /// The keys the item still resolves to; may be empty. /// true if a ledger entry was affected. public bool OnItemRemoved(Guid itemId, IReadOnlyList keys) { ArgumentNullException.ThrowIfNull(keys); var now = UtcNow(); lock (_store.SyncRoot) { var entry = _store.FindByItemId(itemId); if (entry is not null) { _store.DetachItem(entry, itemId); } else { var matches = keys.Count > 0 ? _store.FindByKeys(keys) : []; if (matches.Count == 0) { return false; } entry = matches[0]; } if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null) { entry.RemovedAt = now; _logger.LogDebug("Letztes Item von {Name} entfernt, Frist läuft ab {RemovedAt:u}", entry.DisplayName, now); } _store.MarkDirty(); return true; } } /// /// Lists live item ids the ledger knows but a synchronization did not see. /// /// The items seen by the synchronization. /// Candidates for ; the caller checks whether they still exist. public IReadOnlyList GetUnseenLiveItemIds(IReadOnlySet seenItemIds) { ArgumentNullException.ThrowIfNull(seenItemIds); lock (_store.SyncRoot) { return _store.GetEntries() .SelectMany(entry => entry.LiveItemIds) .Where(itemId => !seenItemIds.Contains(itemId)) .ToList(); } } /// /// Removes items that no longer exist from their entries and starts the grace period for emptied entries. /// /// The items that no longer exist. /// The number of detached items. public int RemoveStaleItems(IEnumerable itemIds) { ArgumentNullException.ThrowIfNull(itemIds); var now = UtcNow(); var detached = 0; lock (_store.SyncRoot) { foreach (var itemId in itemIds) { var entry = _store.FindByItemId(itemId); if (entry is null) { continue; } _store.DetachItem(entry, itemId); detached++; if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null) { entry.RemovedAt = now; } } if (detached > 0) { _store.MarkDirty(); } } return detached; } /// /// Deletes entries removed longer than the grace period plus one day; a return after that counts as new anyway. /// /// The number of deleted entries. public int Prune() { var now = UtcNow(); var retention = _options().GracePeriod + TimeSpan.FromDays(1); var pruned = 0; var changed = false; lock (_store.SyncRoot) { foreach (var entry in _store.GetEntries()) { if (entry.LiveItemIds.Count > 0) { continue; } if (entry.RemovedAt is not { } removedAt) { // Lost its last item without a removal event; start the grace period now. entry.RemovedAt = now; changed = true; } else if (now - removedAt > retention) { _store.Remove(entry); pruned++; } } if (pruned > 0 || changed) { _store.MarkDirty(); } } return pruned; } /// /// Lowers the first "date added" of a known medium from an external source (e.g. the import history /// of Radarr/Sonarr). Never raises it and never changes items. /// /// The keys of the medium. /// The date reported by the source. /// true if the ledger was lowered. public bool ApplyExternalFirstAdded(IReadOnlyList keys, DateTime firstAdded) { ArgumentNullException.ThrowIfNull(keys); var date = LedgerStore.AsUtc(firstAdded); lock (_store.SyncRoot) { var entry = _store.FindByKeys(keys).MinBy(candidate => candidate.FirstAdded); if (entry is null || date >= entry.FirstAdded) { return false; } entry.FirstAdded = date; _store.MarkDirty(); return true; } } private ReconcileResult Apply(MediaIdentity identity, bool detectUpgrades) { ArgumentNullException.ThrowIfNull(identity); var now = UtcNow(); var itemDate = identity.DateCreated == DateTime.MinValue ? now : LedgerStore.AsUtc(identity.DateCreated); if (identity.Keys.Count == 0) { return new ReconcileResult(ReconcileAction.Ignored, identity, itemDate, itemDate); } var options = _options(); lock (_store.SyncRoot) { var candidates = _store.FindByKeys(identity.Keys); ReleaseFromOutdatedEntry(identity.ItemId, candidates); var entry = candidates.Count switch { 0 => null, 1 => candidates[0], _ => Merge(candidates, now, options) }; if (entry is null) { entry = _store.Add(identity.Keys, itemDate, now, identity.DisplayName); _store.AttachItem(entry, identity.ItemId); _store.MarkDirty(); return new ReconcileResult(ReconcileAction.Registered, identity, itemDate, entry.FirstAdded); } var wasLive = entry.LiveItemIds.Contains(identity.ItemId); _store.AddKeys(entry, identity.Keys); _store.AttachItem(entry, identity.ItemId); entry.LastSeen = now; entry.DisplayName = identity.DisplayName; _store.MarkDirty(); if (entry.RemovedAt is { } removedAt && !wasLive && now - removedAt > options.GracePeriod) { // Deleted on purpose and fetched again later: a genuinely new addition. entry.RemovedAt = null; entry.FirstAdded = itemDate; return new ReconcileResult(ReconcileAction.ReturnedAfterGrace, identity, itemDate, itemDate); } entry.RemovedAt = null; // Seed and sync take the exact minimum, so the order of items does not matter. var tolerance = detectUpgrades ? options.Tolerance : TimeSpan.Zero; if (entry.FirstAdded - itemDate > tolerance) { entry.FirstAdded = itemDate; return new ReconcileResult(ReconcileAction.FirstAddedLowered, identity, itemDate, itemDate); } if (detectUpgrades && itemDate - entry.FirstAdded > tolerance) { return new ReconcileResult(ReconcileAction.UpgradeDetected, identity, itemDate, entry.FirstAdded); } return new ReconcileResult(ReconcileAction.Unchanged, identity, itemDate, entry.FirstAdded); } } /// /// Detaches the item from an entry its keys no longer match (e.g. after "Identify"). /// The orphaned entry is dropped if the item was its only one, as the medium lives on under the new keys. /// private void ReleaseFromOutdatedEntry(Guid itemId, IReadOnlyList candidates) { var previous = _store.FindByItemId(itemId); if (previous is null || candidates.Contains(previous)) { return; } _store.DetachItem(previous, itemId); if (previous.LiveItemIds.Count == 0) { _store.Remove(previous); } _logger.LogDebug("Item {ItemId} passt nicht mehr zu {Name} und wurde neu zugeordnet", itemId, previous.DisplayName); } /// /// Merges several entries that turned out to be the same medium. Entries past their grace period /// only contribute keys, so a medium that returned as new does not inherit an old date. /// private LedgerEntry Merge(IReadOnlyList candidates, DateTime now, GuardOptions options) { var active = candidates.Where(entry => !IsExpired(entry, now, options)).ToList(); var pool = active.Count > 0 ? active : candidates; var target = pool.MinBy(entry => entry.FirstAdded)!; var latestRemoval = pool.Max(entry => entry.RemovedAt); foreach (var other in candidates) { _store.Absorb(target, other); } target.RemovedAt = target.LiveItemIds.Count > 0 ? null : latestRemoval; _logger.LogDebug("{Count} Ledger-Einträge zu {Name} zusammengeführt", candidates.Count, target.DisplayName); return target; } private static bool IsExpired(LedgerEntry entry, DateTime now, GuardOptions options) => entry.LiveItemIds.Count == 0 && entry.RemovedAt is { } removedAt && now - removedAt > options.GracePeriod; private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime; }