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;
///
/// Thread-safe, persistent ledger with indexes by alias key and by live item id.
///
///
/// All state is guarded by (re-entrant). Writes are debounced and atomic:
/// the JSON goes to a temporary file first, which then replaces the ledger file.
///
public sealed class LedgerStore : IDisposable
{
///
/// The file name of the ledger inside the plugin data folder.
///
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 _entries = [];
private readonly Dictionary _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _byItemId = [];
private readonly ILogger _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;
///
/// Initializes a new instance of the class.
///
/// The path of the ledger file.
/// The logger.
/// The time provider; defaults to the system clock.
/// How long changes are collected before they are written; defaults to 5 seconds.
public LedgerStore(string filePath, ILogger 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);
}
///
/// Gets the path of the ledger file.
///
public string FilePath { get; }
///
/// Gets a value indicating whether the initial seed has completed.
///
public bool SeedCompleted
{
get
{
lock (_sync)
{
return _seedCompleted;
}
}
}
///
/// Gets the time of the last completed seed or synchronization (UTC).
///
public DateTime? LastSyncUtc
{
get
{
lock (_sync)
{
return _lastSyncUtc;
}
}
}
///
/// Gets the lock guarding the ledger. It is re-entrant, so multi-step operations can wrap store calls.
///
internal Lock SyncRoot => _sync;
///
/// Gets counters for the status page.
///
/// The current statistics.
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);
}
}
///
/// 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.
///
public void Load()
{
TryDelete(TempPath);
LedgerDocument? document = null;
if (File.Exists(FilePath))
{
try
{
using var stream = File.OpenRead(FilePath);
document = JsonSerializer.Deserialize(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);
}
///
/// Requests a debounced save. Several changes within the save delay result in a single write.
///
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.
}
}
///
/// Writes pending changes immediately.
///
public void Flush() => SaveIfDirty();
///
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();
}
///
/// Finds all distinct entries that own at least one of the keys.
///
/// The keys to look up.
/// The matching entries, in key order.
internal IReadOnlyList FindByKeys(IEnumerable keys)
{
lock (_sync)
{
List? 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 ?? [];
}
}
///
/// Finds the entry that currently lists the item as live.
///
/// The item id.
/// The entry, or null.
internal LedgerEntry? FindByItemId(Guid itemId)
{
lock (_sync)
{
return _byItemId.GetValueOrDefault(itemId);
}
}
///
/// Adds a new entry without live items.
///
/// The keys; keys owned by other entries are skipped.
/// The first "date added".
/// The current time.
/// The display name.
/// The new entry.
internal LedgerEntry Add(IEnumerable 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;
}
///
/// Adds keys to an entry. Keys that already belong to another entry are skipped,
/// so callers merge all matching entries first.
///
/// The entry.
/// The keys.
internal void AddKeys(LedgerEntry entry, IEnumerable keys)
{
lock (_sync)
{
List? 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];
}
}
}
///
/// Records the item as a live item of the entry, moving it away from any other entry.
///
/// The entry.
/// The item id.
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;
}
}
///
/// Removes the item from the live items of the entry.
///
/// The entry.
/// The item id.
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);
}
}
}
///
/// Removes an entry and its index entries.
///
/// The entry.
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);
}
}
}
}
///
/// Moves keys and live items of into and removes the source.
/// Dates are left to the caller.
///
/// The surviving entry.
/// The entry to merge into the target.
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;
}
}
}
///
/// Returns a snapshot of the entry references for iteration outside the lock.
///
/// The entries.
internal IReadOnlyList GetEntries()
{
lock (_sync)
{
return [.. _entries];
}
}
///
/// Records a completed seed or synchronization.
///
/// The completion time.
internal void MarkSynced(DateTime now)
{
lock (_sync)
{
_seedCompleted = true;
_lastSyncUtc = AsUtc(now);
}
MarkDirty();
}
///
/// Normalizes a timestamp to UTC; unspecified values are taken as UTC.
///
/// The value.
/// The UTC value.
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);
}
}
///
/// The on-disk format of the ledger.
///
private sealed class LedgerDocument
{
public int SchemaVersion { get; set; }
public bool SeedCompleted { get; set; }
public DateTime? LastSyncUtc { get; set; }
public List Entries { get; set; } = [];
}
}