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,228 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
/// <summary>
/// Listens to library events and processes them on a background worker; runs the seed on first start.
/// </summary>
public sealed class GuardHostedService : IHostedService, IDisposable
{
private static readonly TimeSpan _debounceDelay = TimeSpan.FromSeconds(2);
private readonly ILibraryManager _libraryManager;
private readonly IdentityResolver _resolver;
private readonly GuardProcessor _processor;
private readonly LibraryItemDateWriter _writer;
private readonly LedgerStore _store;
private readonly LedgerSynchronizer _synchronizer;
private readonly ILogger<GuardHostedService> _logger;
private readonly Channel<GuardEvent> _events = Channel.CreateUnbounded<GuardEvent>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
private readonly CancellationTokenSource _stopping = new();
private Task _seed = Task.CompletedTask;
private Task _worker = Task.CompletedTask;
/// <summary>
/// Initializes a new instance of the <see cref="GuardHostedService"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="resolver">The identity resolver.</param>
/// <param name="processor">The processor.</param>
/// <param name="writer">The date writer.</param>
/// <param name="store">The ledger.</param>
/// <param name="synchronizer">The synchronizer.</param>
/// <param name="logger">The logger.</param>
public GuardHostedService(
ILibraryManager libraryManager,
IdentityResolver resolver,
GuardProcessor processor,
LibraryItemDateWriter writer,
LedgerStore store,
LedgerSynchronizer synchronizer,
ILogger<GuardHostedService> logger)
{
_libraryManager = libraryManager;
_resolver = resolver;
_processor = processor;
_writer = writer;
_store = store;
_synchronizer = synchronizer;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_store.Load();
_libraryManager.ItemAdded += OnItemAddedOrUpdated;
_libraryManager.ItemUpdated += OnItemAddedOrUpdated;
_libraryManager.ItemRemoved += OnItemRemoved;
if (!_store.SeedCompleted)
{
_seed = Task.Run(() => RunSeedAsync(_stopping.Token), CancellationToken.None);
}
_worker = Task.Run(() => RunWorkerAsync(_stopping.Token), CancellationToken.None);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_libraryManager.ItemAdded -= OnItemAddedOrUpdated;
_libraryManager.ItemUpdated -= OnItemAddedOrUpdated;
_libraryManager.ItemRemoved -= OnItemRemoved;
_events.Writer.TryComplete();
// An unfinished seed starts over on the next start; pending events are still processed.
if (!_seed.IsCompleted)
{
await _stopping.CancelAsync().ConfigureAwait(false);
}
try
{
await Task.WhenAll(_seed, _worker).WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await _stopping.CancelAsync().ConfigureAwait(false);
}
try
{
_store.Flush();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden");
}
}
/// <inheritdoc />
public void Dispose() => _stopping.Dispose();
private void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e)
{
var item = e.Item;
if (item is null || !IdentityResolver.IsTracked(item) || _writer.IsWriting(item.Id))
{
return;
}
_events.Writer.TryWrite(new GuardEvent(item.Id, null));
}
private void OnItemRemoved(object? sender, ItemChangeEventArgs e)
{
var item = e.Item;
if (item is null || !IdentityResolver.IsTracked(item))
{
return;
}
// Keep the object: its provider ids are needed later, the database row is already gone.
_events.Writer.TryWrite(new GuardEvent(item.Id, item));
}
#pragma warning disable CA1031 // Background loops log failures and keep running.
private async Task RunSeedAsync(CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Ledger wurde noch nicht befüllt, Seed läuft im Hintergrund");
await _synchronizer.RunAsync(new Progress<double>(), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Ledger-Seed abgebrochen, er läuft beim nächsten Start erneut");
}
catch (Exception ex)
{
_logger.LogError(ex, "Ledger-Seed fehlgeschlagen; der Scheduled Task \"Ledger synchronisieren\" holt ihn nach");
}
}
private async Task RunWorkerAsync(CancellationToken cancellationToken)
{
try
{
// Events raised during the seed wait in the channel, so they are matched against a complete ledger.
await _seed.ConfigureAwait(false);
var reader = _events.Reader;
var pending = new OrderedDictionary<Guid, GuardEvent>();
while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
// A scan raises several events per item within moments; collect the burst, keep the latest per item.
await Task.Delay(_debounceDelay, cancellationToken).ConfigureAwait(false);
while (reader.TryRead(out var guardEvent))
{
pending[guardEvent.ItemId] = guardEvent;
}
foreach (var guardEvent in pending.Values)
{
await ProcessAsync(guardEvent, cancellationToken).ConfigureAwait(false);
}
pending.Clear();
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutting down.
}
catch (Exception ex)
{
_logger.LogError(ex, "Event-Verarbeitung von Recently Added Guard wurde unerwartet beendet");
}
}
private async Task ProcessAsync(GuardEvent guardEvent, CancellationToken cancellationToken)
{
try
{
if (guardEvent.RemovedItem is { } removed)
{
_processor.ProcessRemoved(guardEvent.ItemId, _resolver.ResolveKeysForRemoval(removed));
return;
}
var item = _libraryManager.GetItemById(guardEvent.ItemId);
var identity = item is null ? null : _resolver.Resolve(item);
if (identity is not null)
{
await _processor.ProcessSeenAsync(identity, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Item {ItemId} konnte nicht verarbeitet werden", guardEvent.ItemId);
}
}
#pragma warning restore CA1031
/// <summary>
/// A queued library event. <see cref="RemovedItem"/> is set for removals.
/// </summary>
private sealed record GuardEvent(Guid ItemId, BaseItem? RemovedItem);
}
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
/// <summary>
/// Seeds and synchronizes the ledger with the library. Only registers items; never changes them.
/// </summary>
public sealed class LedgerSynchronizer : IDisposable
{
private const int BatchSize = 500;
private static readonly BaseItemKind[] _trackedKinds = [BaseItemKind.Movie, BaseItemKind.Episode];
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ILibraryManager _libraryManager;
private readonly IdentityResolver _resolver;
private readonly Reconciler _reconciler;
private readonly LedgerStore _store;
private readonly IExternalDateSource[] _externalSources;
private readonly ILogger<LedgerSynchronizer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LedgerSynchronizer"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="resolver">The identity resolver.</param>
/// <param name="reconciler">The reconciler.</param>
/// <param name="store">The ledger.</param>
/// <param name="externalSources">External date sources (none in v1).</param>
/// <param name="logger">The logger.</param>
public LedgerSynchronizer(
ILibraryManager libraryManager,
IdentityResolver resolver,
Reconciler reconciler,
LedgerStore store,
IEnumerable<IExternalDateSource> externalSources,
ILogger<LedgerSynchronizer> logger)
{
_libraryManager = libraryManager;
_resolver = resolver;
_reconciler = reconciler;
_store = store;
_externalSources = externalSources.ToArray();
_logger = logger;
}
/// <summary>
/// Registers all movies and episodes, detaches items that no longer exist and prunes old entries.
/// The first run on an empty ledger is the seed. Concurrent calls wait for each other.
/// </summary>
/// <param name="progress">Reports progress from 0 to 100.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that completes when the ledger is synchronized and saved.</returns>
public async Task RunAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(progress);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await RunCoreAsync(progress, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public void Dispose() => _gate.Dispose();
private async Task RunCoreAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var isSeed = !_store.SeedCompleted;
var stopwatch = Stopwatch.StartNew();
progress.Report(0);
// Ids first, then the items in batches: keeps memory flat for libraries with tens of thousands of episodes.
var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
IncludeItemTypes = _trackedKinds,
Recursive = true,
IsVirtualItem = false
});
var seen = new HashSet<Guid>(itemIds.Count);
var seriesCache = new Dictionary<Guid, Series?>();
int registered = 0, lowered = 0, ignored = 0, external = 0;
for (var offset = 0; offset < itemIds.Count; offset += BatchSize)
{
cancellationToken.ThrowIfCancellationRequested();
var batch = itemIds.Skip(offset).Take(BatchSize).ToArray();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = batch,
IncludeItemTypes = _trackedKinds,
Recursive = true,
IsVirtualItem = false
});
foreach (var item in items)
{
seen.Add(item.Id);
var identity = _resolver.Resolve(item, seriesId => LookupSeries(seriesId, seriesCache));
if (identity is null || identity.Keys.Count == 0)
{
ignored++;
continue;
}
switch (_reconciler.Register(identity).Action)
{
case ReconcileAction.Registered:
registered++;
break;
case ReconcileAction.FirstAddedLowered:
lowered++;
break;
default:
break;
}
if (_externalSources.Length > 0 && await ApplyExternalSourcesAsync(identity, cancellationToken).ConfigureAwait(false))
{
external++;
}
}
progress.Report(90.0 * Math.Min(offset + BatchSize, itemIds.Count) / itemIds.Count);
}
// Live items the query did not return may be alternate versions or added meanwhile; only drop the ones that are gone.
var stale = _reconciler.GetUnseenLiveItemIds(seen)
.Where(itemId => _libraryManager.GetItemById(itemId) is null)
.ToList();
var detached = _reconciler.RemoveStaleItems(stale);
var pruned = _reconciler.Prune();
_store.MarkSynced(DateTime.UtcNow);
_store.Flush();
progress.Report(100);
var statistics = _store.GetStatistics();
_logger.LogInformation(
"{Kind} abgeschlossen in {Elapsed:g}: {Items} Items geprüft, {Registered} neu registriert, {Lowered} Datum vorverlegt, "
+ "{External} aus externen Quellen, {Ignored} ohne Kennung übersprungen, {Detached} entfernte Items ausgetragen, {Pruned} Einträge bereinigt. "
+ "Ledger: {Entries} Einträge ({Live} mit Items, {Removed} in der Frist)",
isSeed ? "Ledger-Seed" : "Ledger-Synchronisation",
stopwatch.Elapsed,
itemIds.Count,
registered,
lowered,
external,
ignored,
detached,
pruned,
statistics.Entries,
statistics.LiveEntries,
statistics.RemovedEntries);
}
private Series? LookupSeries(Guid seriesId, Dictionary<Guid, Series?> cache)
{
if (!cache.TryGetValue(seriesId, out var series))
{
series = _libraryManager.GetItemById(seriesId) as Series;
cache[seriesId] = series;
}
return series;
}
#pragma warning disable CA1031 // A failing external source must not abort the synchronization.
private async Task<bool> ApplyExternalSourcesAsync(MediaIdentity identity, CancellationToken cancellationToken)
{
var lowered = false;
foreach (var source in _externalSources)
{
try
{
if (await source.GetFirstAddedAsync(identity, cancellationToken).ConfigureAwait(false) is { } firstAdded)
{
lowered |= _reconciler.ApplyExternalFirstAdded(identity.Keys, firstAdded);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Externe Datumsquelle {Source} ist für {Name} fehlgeschlagen", source.Name, identity.DisplayName);
}
}
return lowered;
}
#pragma warning restore CA1031
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
using MediaBrowser.Controller.Library;
namespace Jellyfin.Plugin.RecentlyAddedGuard.Services;
/// <summary>
/// Writes "date added" through the library manager and remembers which items it is currently saving,
/// so the resulting <c>ItemUpdated</c> events can be ignored.
/// </summary>
public sealed class LibraryItemDateWriter : IItemDateWriter
{
private readonly ILibraryManager _libraryManager;
private readonly ConcurrentDictionary<Guid, byte> _inFlight = new();
/// <summary>
/// Initializes a new instance of the <see cref="LibraryItemDateWriter"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
public LibraryItemDateWriter(ILibraryManager libraryManager)
{
_libraryManager = libraryManager;
}
/// <summary>
/// Checks whether the item is being saved by this writer right now.
/// </summary>
/// <param name="itemId">The item.</param>
/// <returns><c>true</c> while a correction of the item is in progress.</returns>
public bool IsWriting(Guid itemId) => _inFlight.ContainsKey(itemId);
/// <inheritdoc />
public async Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
{
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
return false;
}
_inFlight.TryAdd(itemId, 0);
try
{
item.DateCreated = dateCreated;
// MetadataEdit also lets the NFO saver (if enabled) write the restored <dateadded>.
await _libraryManager.UpdateItemAsync(item, item.GetParent(), ItemUpdateType.MetadataEdit, cancellationToken)
.ConfigureAwait(false);
return true;
}
finally
{
_inFlight.TryRemove(itemId, out _);
}
}
}