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>
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user