Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping a ledger of the first date added per logical medium (movie or episode, identified by provider ids and the series) and restoring it on upgrades. - core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor) - thread-safe ledger with atomic, debounced persistence - event processing via channel and background worker, seed on first start - scheduled task "Ledger synchronisieren", status API, configuration page - xUnit tests and a Jellyfin 12.2 integration test - Gitea workflows: build/test, release with jprm and manifest branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
330 lines
12 KiB
C#
330 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
|
|
|
/// <summary>
|
|
/// Matches items against the ledger and decides whether an item is new, known or an upgrade.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Pure logic without Jellyfin types: it only updates the ledger and reports what it found.
|
|
/// Changing items is up to the caller (see <see cref="GuardProcessor"/>).
|
|
/// </remarks>
|
|
public sealed class Reconciler
|
|
{
|
|
private readonly LedgerStore _store;
|
|
private readonly Func<GuardOptions> _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<Reconciler> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="Reconciler"/> class.
|
|
/// </summary>
|
|
/// <param name="store">The ledger.</param>
|
|
/// <param name="options">Provides the current options.</param>
|
|
/// <param name="timeProvider">The clock.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
public Reconciler(LedgerStore store, Func<GuardOptions> options, TimeProvider timeProvider, ILogger<Reconciler> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles an added or updated item: registers it and detects upgrades.
|
|
/// </summary>
|
|
/// <param name="identity">The item.</param>
|
|
/// <returns>The outcome; <see cref="ReconcileAction.UpgradeDetected"/> asks the caller to restore <see cref="ReconcileResult.FirstAdded"/>.</returns>
|
|
public ReconcileResult OnItemSeen(MediaIdentity identity) => Apply(identity, detectUpgrades: true);
|
|
|
|
/// <summary>
|
|
/// Registers an item during seed or synchronization. The ledger keeps the minimum date; upgrades are not reported.
|
|
/// </summary>
|
|
/// <param name="identity">The item.</param>
|
|
/// <returns>The outcome.</returns>
|
|
public ReconcileResult Register(MediaIdentity identity) => Apply(identity, detectUpgrades: false);
|
|
|
|
/// <summary>
|
|
/// Handles a removed item. The entry is found by item id, or by key if the item was never registered.
|
|
/// </summary>
|
|
/// <param name="itemId">The removed item.</param>
|
|
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
|
|
/// <returns><c>true</c> if a ledger entry was affected.</returns>
|
|
public bool OnItemRemoved(Guid itemId, IReadOnlyList<string> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists live item ids the ledger knows but a synchronization did not see.
|
|
/// </summary>
|
|
/// <param name="seenItemIds">The items seen by the synchronization.</param>
|
|
/// <returns>Candidates for <see cref="RemoveStaleItems"/>; the caller checks whether they still exist.</returns>
|
|
public IReadOnlyList<Guid> GetUnseenLiveItemIds(IReadOnlySet<Guid> seenItemIds)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(seenItemIds);
|
|
|
|
lock (_store.SyncRoot)
|
|
{
|
|
return _store.GetEntries()
|
|
.SelectMany(entry => entry.LiveItemIds)
|
|
.Where(itemId => !seenItemIds.Contains(itemId))
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes items that no longer exist from their entries and starts the grace period for emptied entries.
|
|
/// </summary>
|
|
/// <param name="itemIds">The items that no longer exist.</param>
|
|
/// <returns>The number of detached items.</returns>
|
|
public int RemoveStaleItems(IEnumerable<Guid> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes entries removed longer than the grace period plus one day; a return after that counts as new anyway.
|
|
/// </summary>
|
|
/// <returns>The number of deleted entries.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="keys">The keys of the medium.</param>
|
|
/// <param name="firstAdded">The date reported by the source.</param>
|
|
/// <returns><c>true</c> if the ledger was lowered.</returns>
|
|
public bool ApplyExternalFirstAdded(IReadOnlyList<string> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void ReleaseFromOutdatedEntry(Guid itemId, IReadOnlyList<LedgerEntry> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private LedgerEntry Merge(IReadOnlyList<LedgerEntry> 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;
|
|
}
|