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>
642 lines
19 KiB
C#
642 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
|
|
|
/// <summary>
|
|
/// Thread-safe, persistent ledger with indexes by alias key and by live item id.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// All state is guarded by <see cref="SyncRoot"/> (re-entrant). Writes are debounced and atomic:
|
|
/// the JSON goes to a temporary file first, which then replaces the ledger file.
|
|
/// </remarks>
|
|
public sealed class LedgerStore : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// The file name of the ledger inside the plugin data folder.
|
|
/// </summary>
|
|
public const string DefaultFileName = "ledger.json";
|
|
|
|
private const int SchemaVersion = 1;
|
|
|
|
private static readonly TimeSpan _defaultSaveDelay = TimeSpan.FromSeconds(5);
|
|
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = false };
|
|
|
|
private readonly Lock _sync = new();
|
|
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
|
private readonly List<LedgerEntry> _entries = [];
|
|
private readonly Dictionary<string, LedgerEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<Guid, LedgerEntry> _byItemId = [];
|
|
private readonly ILogger<LedgerStore> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly TimeSpan _saveDelay;
|
|
private readonly ITimer _saveTimer;
|
|
private bool _seedCompleted;
|
|
private DateTime? _lastSyncUtc;
|
|
private bool _dirty;
|
|
private bool _saveScheduled;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="LedgerStore"/> class.
|
|
/// </summary>
|
|
/// <param name="filePath">The path of the ledger file.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
/// <param name="timeProvider">The time provider; defaults to the system clock.</param>
|
|
/// <param name="saveDelay">How long changes are collected before they are written; defaults to 5 seconds.</param>
|
|
public LedgerStore(string filePath, ILogger<LedgerStore> logger, TimeProvider? timeProvider = null, TimeSpan? saveDelay = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
|
|
FilePath = filePath;
|
|
_logger = logger;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_saveDelay = saveDelay ?? _defaultSaveDelay;
|
|
_saveTimer = _timeProvider.CreateTimer(_ => OnSaveTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the path of the ledger file.
|
|
/// </summary>
|
|
public string FilePath { get; }
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the initial seed has completed.
|
|
/// </summary>
|
|
public bool SeedCompleted
|
|
{
|
|
get
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return _seedCompleted;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the time of the last completed seed or synchronization (UTC).
|
|
/// </summary>
|
|
public DateTime? LastSyncUtc
|
|
{
|
|
get
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return _lastSyncUtc;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the lock guarding the ledger. It is re-entrant, so multi-step operations can wrap store calls.
|
|
/// </summary>
|
|
internal Lock SyncRoot => _sync;
|
|
|
|
/// <summary>
|
|
/// Gets counters for the status page.
|
|
/// </summary>
|
|
/// <returns>The current statistics.</returns>
|
|
public LedgerStatistics GetStatistics()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
var live = 0;
|
|
var removed = 0;
|
|
foreach (var entry in _entries)
|
|
{
|
|
if (entry.LiveItemIds.Count > 0)
|
|
{
|
|
live++;
|
|
}
|
|
else if (entry.RemovedAt is not null)
|
|
{
|
|
removed++;
|
|
}
|
|
}
|
|
|
|
return new LedgerStatistics(_entries.Count, live, removed);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the ledger from disk, replacing the in-memory state. A missing file yields an empty ledger;
|
|
/// an unreadable file is moved aside so the seed runs again.
|
|
/// </summary>
|
|
public void Load()
|
|
{
|
|
TryDelete(TempPath);
|
|
|
|
LedgerDocument? document = null;
|
|
if (File.Exists(FilePath))
|
|
{
|
|
try
|
|
{
|
|
using var stream = File.OpenRead(FilePath);
|
|
document = JsonSerializer.Deserialize<LedgerDocument>(stream, _jsonOptions)
|
|
?? throw new JsonException("The ledger file is empty.");
|
|
}
|
|
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
|
|
{
|
|
var backupPath = FilePath + ".corrupt-"
|
|
+ _timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
|
TryMove(FilePath, backupPath);
|
|
_logger.LogError(ex, "Ledger {Path} ist nicht lesbar und wurde nach {Backup} verschoben, der Seed läuft erneut", FilePath, backupPath);
|
|
document = null;
|
|
}
|
|
}
|
|
|
|
int count;
|
|
lock (_sync)
|
|
{
|
|
_entries.Clear();
|
|
_byKey.Clear();
|
|
_byItemId.Clear();
|
|
_seedCompleted = document?.SeedCompleted ?? false;
|
|
_lastSyncUtc = document?.LastSyncUtc is { } lastSync ? AsUtc(lastSync) : null;
|
|
_dirty = false;
|
|
|
|
foreach (var loaded in document?.Entries ?? [])
|
|
{
|
|
Restore(loaded);
|
|
}
|
|
|
|
count = _entries.Count;
|
|
}
|
|
|
|
_logger.LogInformation("Ledger geladen: {Count} Einträge, Seed abgeschlossen: {SeedCompleted}", count, document?.SeedCompleted ?? false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests a debounced save. Several changes within the save delay result in a single write.
|
|
/// </summary>
|
|
public void MarkDirty()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_dirty = true;
|
|
if (_saveScheduled || _disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_saveScheduled = true;
|
|
}
|
|
|
|
try
|
|
{
|
|
_saveTimer.Change(_saveDelay, Timeout.InfiniteTimeSpan);
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Disposed concurrently; the final flush happens in Dispose.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes pending changes immediately.
|
|
/// </summary>
|
|
public void Flush() => SaveIfDirty();
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
Flush();
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
_logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden: {Path}", FilePath);
|
|
}
|
|
|
|
lock (_sync)
|
|
{
|
|
_disposed = true;
|
|
}
|
|
|
|
_saveTimer.Dispose();
|
|
_writeGate.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds all distinct entries that own at least one of the keys.
|
|
/// </summary>
|
|
/// <param name="keys">The keys to look up.</param>
|
|
/// <returns>The matching entries, in key order.</returns>
|
|
internal IReadOnlyList<LedgerEntry> FindByKeys(IEnumerable<string> keys)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
List<LedgerEntry>? result = null;
|
|
foreach (var key in keys)
|
|
{
|
|
if (_byKey.TryGetValue(key, out var entry) && (result is null || !result.Contains(entry)))
|
|
{
|
|
(result ??= []).Add(entry);
|
|
}
|
|
}
|
|
|
|
return result ?? [];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the entry that currently lists the item as live.
|
|
/// </summary>
|
|
/// <param name="itemId">The item id.</param>
|
|
/// <returns>The entry, or <c>null</c>.</returns>
|
|
internal LedgerEntry? FindByItemId(Guid itemId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return _byItemId.GetValueOrDefault(itemId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new entry without live items.
|
|
/// </summary>
|
|
/// <param name="keys">The keys; keys owned by other entries are skipped.</param>
|
|
/// <param name="firstAdded">The first "date added".</param>
|
|
/// <param name="now">The current time.</param>
|
|
/// <param name="displayName">The display name.</param>
|
|
/// <returns>The new entry.</returns>
|
|
internal LedgerEntry Add(IEnumerable<string> keys, DateTime firstAdded, DateTime now, string displayName)
|
|
{
|
|
var entry = new LedgerEntry
|
|
{
|
|
FirstAdded = AsUtc(firstAdded),
|
|
LastSeen = AsUtc(now),
|
|
DisplayName = displayName
|
|
};
|
|
|
|
lock (_sync)
|
|
{
|
|
_entries.Add(entry);
|
|
AddKeys(entry, keys);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds keys to an entry. Keys that already belong to another entry are skipped,
|
|
/// so callers merge all matching entries first.
|
|
/// </summary>
|
|
/// <param name="entry">The entry.</param>
|
|
/// <param name="keys">The keys.</param>
|
|
internal void AddKeys(LedgerEntry entry, IEnumerable<string> keys)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
List<string>? added = null;
|
|
foreach (var key in keys)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key) || _byKey.ContainsKey(key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_byKey[key] = entry;
|
|
(added ??= []).Add(key);
|
|
}
|
|
|
|
if (added is not null)
|
|
{
|
|
entry.Keys = [.. entry.Keys, .. added];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the item as a live item of the entry, moving it away from any other entry.
|
|
/// </summary>
|
|
/// <param name="entry">The entry.</param>
|
|
/// <param name="itemId">The item id.</param>
|
|
internal void AttachItem(LedgerEntry entry, Guid itemId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_byItemId.TryGetValue(itemId, out var owner) && !ReferenceEquals(owner, entry))
|
|
{
|
|
owner.LiveItemIds.Remove(itemId);
|
|
}
|
|
|
|
entry.LiveItemIds.Add(itemId);
|
|
_byItemId[itemId] = entry;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the item from the live items of the entry.
|
|
/// </summary>
|
|
/// <param name="entry">The entry.</param>
|
|
/// <param name="itemId">The item id.</param>
|
|
internal void DetachItem(LedgerEntry entry, Guid itemId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
entry.LiveItemIds.Remove(itemId);
|
|
if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry))
|
|
{
|
|
_byItemId.Remove(itemId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes an entry and its index entries.
|
|
/// </summary>
|
|
/// <param name="entry">The entry.</param>
|
|
internal void Remove(LedgerEntry entry)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (!_entries.Remove(entry))
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var key in entry.Keys)
|
|
{
|
|
if (_byKey.TryGetValue(key, out var owner) && ReferenceEquals(owner, entry))
|
|
{
|
|
_byKey.Remove(key);
|
|
}
|
|
}
|
|
|
|
foreach (var itemId in entry.LiveItemIds)
|
|
{
|
|
if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry))
|
|
{
|
|
_byItemId.Remove(itemId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves keys and live items of <paramref name="source"/> into <paramref name="target"/> and removes the source.
|
|
/// Dates are left to the caller.
|
|
/// </summary>
|
|
/// <param name="target">The surviving entry.</param>
|
|
/// <param name="source">The entry to merge into the target.</param>
|
|
internal void Absorb(LedgerEntry target, LedgerEntry source)
|
|
{
|
|
if (ReferenceEquals(target, source))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_sync)
|
|
{
|
|
var keys = source.Keys;
|
|
var itemIds = source.LiveItemIds.ToArray();
|
|
Remove(source);
|
|
AddKeys(target, keys);
|
|
foreach (var itemId in itemIds)
|
|
{
|
|
AttachItem(target, itemId);
|
|
}
|
|
|
|
if (source.LastSeen > target.LastSeen)
|
|
{
|
|
target.LastSeen = source.LastSeen;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a snapshot of the entry references for iteration outside the lock.
|
|
/// </summary>
|
|
/// <returns>The entries.</returns>
|
|
internal IReadOnlyList<LedgerEntry> GetEntries()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return [.. _entries];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a completed seed or synchronization.
|
|
/// </summary>
|
|
/// <param name="now">The completion time.</param>
|
|
internal void MarkSynced(DateTime now)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_seedCompleted = true;
|
|
_lastSyncUtc = AsUtc(now);
|
|
}
|
|
|
|
MarkDirty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a timestamp to UTC; unspecified values are taken as UTC.
|
|
/// </summary>
|
|
/// <param name="value">The value.</param>
|
|
/// <returns>The UTC value.</returns>
|
|
internal static DateTime AsUtc(DateTime value) => value.Kind switch
|
|
{
|
|
DateTimeKind.Utc => value,
|
|
DateTimeKind.Local => value.ToUniversalTime(),
|
|
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
|
};
|
|
|
|
private string TempPath => FilePath + ".tmp";
|
|
|
|
private void Restore(LedgerEntry loaded)
|
|
{
|
|
var keys = (loaded.Keys ?? [])
|
|
.Where(key => !string.IsNullOrWhiteSpace(key))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
if (keys.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var itemIds = loaded.LiveItemIds ?? [];
|
|
var firstAdded = AsUtc(loaded.FirstAdded);
|
|
var lastSeen = AsUtc(loaded.LastSeen);
|
|
DateTime? removedAt = loaded.RemovedAt is { } removed ? AsUtc(removed) : null;
|
|
|
|
// Overlapping entries can only come from an older or damaged file: merge them, keeping the earliest date.
|
|
var existing = keys.Select(key => _byKey.GetValueOrDefault(key)).FirstOrDefault(entry => entry is not null);
|
|
if (existing is not null)
|
|
{
|
|
AddKeys(existing, keys);
|
|
foreach (var itemId in itemIds)
|
|
{
|
|
AttachItem(existing, itemId);
|
|
}
|
|
|
|
if (firstAdded < existing.FirstAdded)
|
|
{
|
|
existing.FirstAdded = firstAdded;
|
|
}
|
|
|
|
if (lastSeen > existing.LastSeen)
|
|
{
|
|
existing.LastSeen = lastSeen;
|
|
}
|
|
|
|
existing.RemovedAt = existing.LiveItemIds.Count > 0 ? null : existing.RemovedAt ?? removedAt;
|
|
return;
|
|
}
|
|
|
|
var entry = new LedgerEntry
|
|
{
|
|
FirstAdded = firstAdded,
|
|
LastSeen = lastSeen,
|
|
RemovedAt = itemIds.Count > 0 ? null : removedAt,
|
|
DisplayName = loaded.DisplayName ?? string.Empty
|
|
};
|
|
_entries.Add(entry);
|
|
AddKeys(entry, keys);
|
|
foreach (var itemId in itemIds)
|
|
{
|
|
AttachItem(entry, itemId);
|
|
}
|
|
}
|
|
|
|
#pragma warning disable CA1031 // A timer callback must never throw; failures are logged and retried.
|
|
private void OnSaveTimer()
|
|
{
|
|
try
|
|
{
|
|
SaveIfDirty();
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Shutting down.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Ledger konnte nicht gespeichert werden: {Path}", FilePath);
|
|
MarkDirty();
|
|
}
|
|
}
|
|
#pragma warning restore CA1031
|
|
|
|
private void SaveIfDirty()
|
|
{
|
|
_writeGate.Wait();
|
|
try
|
|
{
|
|
byte[] payload;
|
|
lock (_sync)
|
|
{
|
|
_saveScheduled = false;
|
|
if (!_dirty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
payload = JsonSerializer.SerializeToUtf8Bytes(
|
|
new LedgerDocument
|
|
{
|
|
SchemaVersion = SchemaVersion,
|
|
SeedCompleted = _seedCompleted,
|
|
LastSyncUtc = _lastSyncUtc,
|
|
Entries = _entries
|
|
},
|
|
_jsonOptions);
|
|
_dirty = false;
|
|
}
|
|
|
|
try
|
|
{
|
|
WriteAtomically(payload);
|
|
}
|
|
catch
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_dirty = true;
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_writeGate.Release();
|
|
}
|
|
}
|
|
|
|
private void WriteAtomically(byte[] payload)
|
|
{
|
|
var directory = Path.GetDirectoryName(FilePath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
using (var stream = new FileStream(TempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
|
{
|
|
stream.Write(payload);
|
|
stream.Flush(flushToDisk: true);
|
|
}
|
|
|
|
File.Move(TempPath, FilePath, overwrite: true);
|
|
_logger.LogDebug("Ledger gespeichert: {Path} ({Bytes} Bytes)", FilePath, payload.Length);
|
|
}
|
|
|
|
private void TryMove(string source, string destination)
|
|
{
|
|
try
|
|
{
|
|
File.Move(source, destination, overwrite: true);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
_logger.LogWarning(ex, "Datei {Source} konnte nicht verschoben werden", source);
|
|
}
|
|
}
|
|
|
|
private void TryDelete(string path)
|
|
{
|
|
try
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
_logger.LogDebug(ex, "Temporäre Datei {Path} konnte nicht gelöscht werden", path);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The on-disk format of the ledger.
|
|
/// </summary>
|
|
private sealed class LedgerDocument
|
|
{
|
|
public int SchemaVersion { get; set; }
|
|
|
|
public bool SeedCompleted { get; set; }
|
|
|
|
public DateTime? LastSyncUtc { get; set; }
|
|
|
|
public List<LedgerEntry> Entries { get; set; } = [];
|
|
}
|
|
}
|