Files
Jonas MenzelandClaude Opus 5.5 d431486095
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s
feat: Recently Added Guard plugin for Jellyfin 12.2
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>
2026-10-21 23:10:02 +02:00

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