Files
jellyfin-plugin-recently-ad…/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardProcessor.cs
T
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

138 lines
5.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}