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