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; } = []; } }