feat: Recently Added Guard plugin for Jellyfin 12.2
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s

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>
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-21 23:10:02 +02:00
commit d431486095
38 files changed
+4396

No files matched your search

@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// One restored (or, in dry-run mode, detected) upgrade.
/// </summary>
/// <param name="ItemId">The corrected item.</param>
/// <param name="Name">The display name.</param>
/// <param name="PreviousDate">The "date added" the upgrade brought along (UTC).</param>
/// <param name="RestoredDate">The first "date added" from the ledger (UTC).</param>
/// <param name="CorrectedAt">When the correction happened (UTC).</param>
/// <param name="DryRun"><c>true</c> if only detected, not applied.</param>
public sealed record CorrectionRecord(
Guid ItemId,
string Name,
DateTime PreviousDate,
DateTime RestoredDate,
DateTime CorrectedAt,
bool DryRun);
/// <summary>
/// In-memory ring buffer of the most recent corrections, for the configuration page.
/// </summary>
public sealed class CorrectionLog
{
/// <summary>
/// The default number of kept corrections.
/// </summary>
public const int DefaultCapacity = 100;
private readonly Lock _sync = new();
private readonly List<CorrectionRecord> _records;
private readonly int _capacity;
private long _corrections;
private long _dryRunDetections;
/// <summary>
/// Initializes a new instance of the <see cref="CorrectionLog"/> class.
/// </summary>
/// <param name="capacity">The number of kept corrections.</param>
public CorrectionLog(int capacity = DefaultCapacity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
_capacity = capacity;
_records = new List<CorrectionRecord>(capacity);
}
/// <summary>
/// Gets the number of applied corrections since the server started.
/// </summary>
public long CorrectionsSinceStart => Interlocked.Read(ref _corrections);
/// <summary>
/// Gets the number of upgrades detected in dry-run mode since the server started.
/// </summary>
public long DryRunDetectionsSinceStart => Interlocked.Read(ref _dryRunDetections);
/// <summary>
/// Records a correction, dropping the oldest one when full. Restoring the same date of the same item again
/// (Jellyfin saved the item with its new date once more, e.g. from a metadata refresh that was already running)
/// replaces the earlier record and is not counted twice.
/// </summary>
/// <param name="record">The correction.</param>
/// <returns><c>true</c> for a new correction, <c>false</c> for a repeated one.</returns>
public bool Add(CorrectionRecord record)
{
ArgumentNullException.ThrowIfNull(record);
lock (_sync)
{
var existing = _records.FindIndex(r => r.ItemId == record.ItemId
&& r.RestoredDate == record.RestoredDate
&& r.DryRun == record.DryRun);
if (existing >= 0)
{
_records.RemoveAt(existing);
_records.Add(record);
return false;
}
if (_records.Count == _capacity)
{
_records.RemoveAt(0);
}
_records.Add(record);
}
if (record.DryRun)
{
Interlocked.Increment(ref _dryRunDetections);
}
else
{
Interlocked.Increment(ref _corrections);
}
return true;
}
/// <summary>
/// Returns the kept corrections, newest first.
/// </summary>
/// <returns>The corrections.</returns>
public IReadOnlyList<CorrectionRecord> GetRecent()
{
lock (_sync)
{
return _records.AsEnumerable().Reverse().ToList();
}
}
}
@@ -0,0 +1,27 @@
using System;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// The settings the core logic depends on, decoupled from the Jellyfin plugin configuration.
/// </summary>
/// <param name="GraceDays">Days within which a returning medium counts as an upgrade.</param>
/// <param name="ToleranceSeconds">Date differences up to this many seconds are ignored.</param>
/// <param name="DryRun">Only log detected upgrades, never change items.</param>
public sealed record GuardOptions(int GraceDays, int ToleranceSeconds, bool DryRun)
{
/// <summary>
/// Gets the defaults (14 days, 60 seconds, no dry run).
/// </summary>
public static GuardOptions Default { get; } = new(14, 60, false);
/// <summary>
/// Gets the grace period.
/// </summary>
public TimeSpan GracePeriod => TimeSpan.FromDays(Math.Max(0, GraceDays));
/// <summary>
/// Gets the tolerance for date comparisons.
/// </summary>
public TimeSpan Tolerance => TimeSpan.FromSeconds(Math.Max(0, ToleranceSeconds));
}
@@ -0,0 +1,137 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Turns reconciler decisions into actions: restores the date of upgrades (or only logs them in dry-run mode)
/// and records corrections.
/// </summary>
public sealed class GuardProcessor
{
private readonly Reconciler _reconciler;
private readonly IItemDateWriter _writer;
private readonly CorrectionLog _corrections;
private readonly Func<GuardOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<GuardProcessor> _logger;
private readonly ConcurrentDictionary<Guid, DateTime> _dryRunReported = new();
/// <summary>
/// Initializes a new instance of the <see cref="GuardProcessor"/> class.
/// </summary>
/// <param name="reconciler">The reconciler.</param>
/// <param name="writer">Writes item dates.</param>
/// <param name="corrections">The correction log.</param>
/// <param name="options">Provides the current options.</param>
/// <param name="timeProvider">The clock.</param>
/// <param name="logger">The logger.</param>
public GuardProcessor(
Reconciler reconciler,
IItemDateWriter writer,
CorrectionLog corrections,
Func<GuardOptions> options,
TimeProvider timeProvider,
ILogger<GuardProcessor> logger)
{
_reconciler = reconciler ?? throw new ArgumentNullException(nameof(reconciler));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_corrections = corrections ?? throw new ArgumentNullException(nameof(corrections));
_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.
/// </summary>
/// <param name="identity">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The reconciler's outcome.</returns>
public async Task<ReconcileResult> ProcessSeenAsync(MediaIdentity identity, CancellationToken cancellationToken)
{
var result = _reconciler.OnItemSeen(identity);
switch (result.Action)
{
case ReconcileAction.UpgradeDetected:
await RestoreDateAsync(result, cancellationToken).ConfigureAwait(false);
break;
case ReconcileAction.ReturnedAfterGrace:
_logger.LogInformation(
"{Name} ist nach Ablauf der Frist zurückgekehrt und gilt als neu (DateAdded {Date:u})",
identity.DisplayName,
result.ItemDate);
break;
case ReconcileAction.Registered:
_logger.LogDebug("Neu registriert: {Name} (DateAdded {Date:u})", identity.DisplayName, result.ItemDate);
break;
case ReconcileAction.FirstAddedLowered:
_logger.LogDebug("Erstes Hinzufügedatum von {Name} auf {Date:u} vorverlegt", identity.DisplayName, result.ItemDate);
break;
default:
break;
}
return result;
}
/// <summary>
/// Handles a removed item.
/// </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 ProcessRemoved(Guid itemId, IReadOnlyList<string> keys) => _reconciler.OnItemRemoved(itemId, keys);
private async Task RestoreDateAsync(ReconcileResult result, CancellationToken cancellationToken)
{
var identity = result.Identity;
if (_options().DryRun)
{
// Every metadata refresh of the item reports the upgrade again; log it once per date.
if (_dryRunReported.TryGetValue(identity.ItemId, out var reported) && reported == result.ItemDate)
{
return;
}
_dryRunReported[identity.ItemId] = result.ItemDate;
_logger.LogInformation(
"[DryRun] Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u} (nicht geändert)",
identity.DisplayName,
result.ItemDate,
result.FirstAdded);
_corrections.Add(new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: true));
return;
}
if (!await _writer.TrySetDateCreatedAsync(identity.ItemId, result.FirstAdded, cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug("Item {ItemId} existiert nicht mehr, keine Korrektur", identity.ItemId);
return;
}
_dryRunReported.TryRemove(identity.ItemId, out _);
var record = new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: false);
if (_corrections.Add(record))
{
_logger.LogInformation(
"Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u}",
identity.DisplayName,
result.ItemDate,
result.FirstAdded);
}
else
{
_logger.LogDebug(
"DateAdded von {Name} wurde von Jellyfin erneut überschrieben und wieder auf {Original:u} gesetzt",
identity.DisplayName,
result.FirstAdded);
}
}
private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime;
}
@@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// An external source for the first "date added" of a medium, e.g. the import history of Radarr or Sonarr.
/// </summary>
/// <remarks>
/// Extension point for v2: implementations registered in the DI container are consulted by the
/// ledger synchronization, and earlier dates lower the ledger via <see cref="Reconciler.ApplyExternalFirstAdded"/>.
/// v1 ships no implementation.
/// </remarks>
public interface IExternalDateSource
{
/// <summary>
/// Gets the name of the source for logs.
/// </summary>
string Name { get; }
/// <summary>
/// Looks up when the medium was first imported.
/// </summary>
/// <param name="identity">The medium.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The first import date (UTC), or <c>null</c> if unknown.</returns>
Task<DateTime?> GetFirstAddedAsync(MediaIdentity identity, CancellationToken cancellationToken);
}
@@ -0,0 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Writes the "date added" of a library item.
/// </summary>
public interface IItemDateWriter
{
/// <summary>
/// Sets the item's "date added" and saves it.
/// </summary>
/// <param name="itemId">The item.</param>
/// <param name="dateCreated">The new "date added" (UTC).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>false</c> if the item no longer exists.</returns>
Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken);
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Maps Jellyfin items to <see cref="MediaIdentity"/> snapshots for the core logic.
/// </summary>
public sealed class IdentityResolver
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<IdentityResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="IdentityResolver"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
public IdentityResolver(ILibraryManager libraryManager, ILogger<IdentityResolver> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>
/// Checks whether the item is a real movie or episode: no missing (virtual) episode, no extra.
/// </summary>
/// <param name="item">The item.</param>
/// <returns><c>true</c> if the plugin tracks items of this kind.</returns>
public static bool IsTracked(BaseItem item)
=> item is Movie or Episode && !item.IsVirtualItem && item.ExtraType is null;
/// <summary>
/// Resolves the identity of a tracked item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="seriesLookup">Optional cached series lookup (used by the synchronization).</param>
/// <returns>The identity (its keys may be empty if provider ids are still missing),
/// or <c>null</c> if the item is not tracked or its kind is disabled.</returns>
public MediaIdentity? Resolve(BaseItem item, Func<Guid, Series?>? seriesLookup = null)
{
ArgumentNullException.ThrowIfNull(item);
if (!IsTracked(item))
{
return null;
}
var configuration = Plugin.CurrentConfiguration;
switch (item)
{
case Movie movie when configuration.EnableMovies:
return new MediaIdentity(movie.Id, MediaKind.Movie, movie.DateCreated, MovieKeys(movie), MovieName(movie));
case Episode episode when configuration.EnableEpisodes:
var seriesId = FindSeriesId(episode);
var series = FindSeries(seriesId, seriesLookup);
return new MediaIdentity(
episode.Id,
MediaKind.Episode,
episode.DateCreated,
EpisodeKeys(episode, seriesId, series),
EpisodeName(episode, series));
default:
return null;
}
}
/// <summary>
/// Resolves the keys of a removed item. The provider ids are still attached to the object,
/// but the series may already be gone, so every lookup is defensive.
/// </summary>
/// <param name="item">The removed item.</param>
/// <returns>The keys; empty if they cannot be determined.</returns>
public IReadOnlyList<string> ResolveKeysForRemoval(BaseItem item)
{
ArgumentNullException.ThrowIfNull(item);
switch (item)
{
case Movie movie:
return MovieKeys(movie);
case Episode episode:
var seriesId = FindSeriesId(episode);
return EpisodeKeys(episode, seriesId, FindSeries(seriesId, null));
default:
return [];
}
}
private static IReadOnlyList<string> MovieKeys(Movie movie)
=> MediaKeys.ForMovie(
movie.GetProviderId(MetadataProvider.Tmdb),
movie.GetProviderId(MetadataProvider.Imdb),
movie.GetProviderId(MetadataProvider.Tvdb));
private static IReadOnlyList<string> EpisodeKeys(Episode episode, Guid seriesId, Series? series)
{
var part = MediaKeys.EpisodePart(episode.ParentIndexNumber, episode.IndexNumber, episode.IndexNumberEnd);
if (part is null)
{
return [];
}
return MediaKeys.ForEpisode(
part,
series?.GetProviderId(MetadataProvider.Tvdb),
series?.GetProviderId(MetadataProvider.Tmdb),
series?.GetProviderId(MetadataProvider.Imdb),
seriesId);
}
private static string MovieName(Movie movie)
{
var name = string.IsNullOrWhiteSpace(movie.Name) ? movie.Id.ToString("N", CultureInfo.InvariantCulture) : movie.Name;
return movie.ProductionYear is { } year
? string.Create(CultureInfo.InvariantCulture, $"{name} ({year})")
: name;
}
private static string EpisodeName(Episode episode, Series? series)
{
var seriesName = episode.SeriesName ?? series?.Name ?? "?";
var number = string.Create(CultureInfo.InvariantCulture, $"S{episode.ParentIndexNumber ?? 0:00}E{episode.IndexNumber ?? 0:00}");
if (episode.IndexNumberEnd is { } end && end > (episode.IndexNumber ?? 0))
{
number += string.Create(CultureInfo.InvariantCulture, $"-E{end:00}");
}
return string.IsNullOrWhiteSpace(episode.Name)
? $"{seriesName} {number}"
: $"{seriesName} {number} – {episode.Name}";
}
#pragma warning disable CA1031 // Lookups on half-deleted items may fail in many ways; a missing key is acceptable.
private Guid FindSeriesId(Episode episode)
{
if (episode.SeriesId != Guid.Empty)
{
return episode.SeriesId;
}
try
{
return episode.FindSeriesId();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Serie von Episode {ItemId} nicht ermittelbar", episode.Id);
return Guid.Empty;
}
}
private Series? FindSeries(Guid seriesId, Func<Guid, Series?>? seriesLookup)
{
if (seriesId == Guid.Empty)
{
return null;
}
try
{
return seriesLookup is not null ? seriesLookup(seriesId) : _libraryManager.GetItemById(seriesId) as Series;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Serie {SeriesId} nicht auffindbar", seriesId);
return null;
}
}
#pragma warning restore CA1031
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Kind of logical medium tracked by the ledger.
/// </summary>
public enum MediaKind
{
/// <summary>A movie.</summary>
Movie,
/// <summary>A TV episode (or a multi-episode file).</summary>
Episode
}
/// <summary>
/// Jellyfin-independent snapshot of a library item, as consumed by the <see cref="Reconciler"/>.
/// </summary>
/// <param name="ItemId">The Jellyfin item id.</param>
/// <param name="Kind">The kind of medium.</param>
/// <param name="DateCreated">The item's current "date added" (UTC).</param>
/// <param name="Keys">All identity keys of the logical medium; empty if the item cannot be identified yet.</param>
/// <param name="DisplayName">Human readable name for logs and the configuration page.</param>
public sealed record MediaIdentity(
Guid ItemId,
MediaKind Kind,
DateTime DateCreated,
IReadOnlyList<string> Keys,
string DisplayName);
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// Builds the alias keys that identify a logical medium independent of its file path.
/// </summary>
/// <remarks>
/// Keys are compared case-insensitively by the ledger, so provider ids are only trimmed.
/// </remarks>
public static class MediaKeys
{
/// <summary>
/// Builds the keys of a movie from its provider ids.
/// </summary>
/// <param name="tmdbId">The TMDb id, if known.</param>
/// <param name="imdbId">The IMDb id, if known.</param>
/// <param name="tvdbId">The TVDb id, if known.</param>
/// <returns>The keys; empty if the movie has no provider id yet.</returns>
public static IReadOnlyList<string> ForMovie(string? tmdbId, string? imdbId, string? tvdbId)
{
var keys = new List<string>(3);
AddIfPresent(keys, "movie:tmdb:", tmdbId, string.Empty);
AddIfPresent(keys, "movie:imdb:", imdbId, string.Empty);
AddIfPresent(keys, "movie:tvdb:", tvdbId, string.Empty);
return keys;
}
/// <summary>
/// Builds the episode part of an episode key, e.g. <c>s1e5</c> or <c>s1e5-6</c> for a double episode.
/// </summary>
/// <param name="seasonNumber">The season number (<c>ParentIndexNumber</c>).</param>
/// <param name="episodeNumber">The episode number (<c>IndexNumber</c>).</param>
/// <param name="episodeNumberEnd">The last episode number of a multi-episode file (<c>IndexNumberEnd</c>).</param>
/// <returns>The episode part, or <c>null</c> if season or episode number is missing.</returns>
public static string? EpisodePart(int? seasonNumber, int? episodeNumber, int? episodeNumberEnd)
{
if (seasonNumber is not { } season || episodeNumber is not { } episode)
{
return null;
}
return episodeNumberEnd is { } end && end > episode
? string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}-{end}")
: string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}");
}
/// <summary>
/// Builds the keys of an episode.
/// </summary>
/// <param name="episodePart">The episode part from <see cref="EpisodePart"/>.</param>
/// <param name="seriesTvdbId">The series' TVDb id, if known.</param>
/// <param name="seriesTmdbId">The series' TMDb id, if known.</param>
/// <param name="seriesImdbId">The series' IMDb id, if known.</param>
/// <param name="seriesId">The Jellyfin id of the series; <see cref="Guid.Empty"/> if unknown.</param>
/// <returns>The keys; contains the series-guid key whenever the series id is known.</returns>
public static IReadOnlyList<string> ForEpisode(
string episodePart,
string? seriesTvdbId,
string? seriesTmdbId,
string? seriesImdbId,
Guid seriesId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(episodePart);
var suffix = ":" + episodePart;
var keys = new List<string>(4);
AddIfPresent(keys, "ep:tvdb:", seriesTvdbId, suffix);
AddIfPresent(keys, "ep:tmdb:", seriesTmdbId, suffix);
AddIfPresent(keys, "ep:imdb:", seriesImdbId, suffix);
if (seriesId != Guid.Empty)
{
keys.Add("ep:series-guid:" + seriesId.ToString("N", CultureInfo.InvariantCulture) + suffix);
}
return keys;
}
private static void AddIfPresent(List<string> keys, string prefix, string? id, string suffix)
{
if (!string.IsNullOrWhiteSpace(id))
{
keys.Add(prefix + id.Trim() + suffix);
}
}
}
@@ -0,0 +1,36 @@
using System;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
/// <summary>
/// What the <see cref="Reconciler"/> concluded for an item.
/// </summary>
public enum ReconcileAction
{
/// <summary>The item has no identity keys (yet) and was skipped.</summary>
Ignored,
/// <summary>A new medium; a ledger entry was created with the item's date.</summary>
Registered,
/// <summary>A known medium whose date matches the ledger.</summary>
Unchanged,
/// <summary>The item is older than the ledger (e.g. a date from an NFO); the ledger took the earlier date.</summary>
FirstAddedLowered,
/// <summary>The medium returned after the grace period; it counts as new and the ledger took its date.</summary>
ReturnedAfterGrace,
/// <summary>The item is newer than the ledger: an upgrade whose date has to be restored.</summary>
UpgradeDetected
}
/// <summary>
/// The outcome of reconciling one item with the ledger.
/// </summary>
/// <param name="Action">The conclusion.</param>
/// <param name="Identity">The item.</param>
/// <param name="ItemDate">The item's "date added" as evaluated (UTC).</param>
/// <param name="FirstAdded">The ledger's first "date added" after reconciling (UTC).</param>
public sealed record ReconcileResult(ReconcileAction Action, MediaIdentity Identity, DateTime ItemDate, DateTime FirstAdded);
@@ -0,0 +1,329 @@
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;
}