Files
Jonas MenzelandClaude Opus 5.5 d431486095
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s
feat: Recently Added Guard plugin for Jellyfin 12.2
Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping
a ledger of the first date added per logical medium (movie or episode,
identified by provider ids and the series) and restoring it on upgrades.

- core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor)
- thread-safe ledger with atomic, debounced persistence
- event processing via channel and background worker, seed on first start
- scheduled task "Ledger synchronisieren", status API, configuration page
- xUnit tests and a Jellyfin 12.2 integration test
- Gitea workflows: build/test, release with jprm and manifest branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:10:02 +02:00

117 lines
3.7 KiB
C#

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