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>
This commit is contained in:
commit
d431486095
38 files changed
+4396
No files matched your search
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// The first "date added" of one logical medium (a movie or an episode), independent of its files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Keys"/> and <see cref="LiveItemIds"/> are indexed by the <see cref="LedgerStore"/>;
|
||||
/// change them only through the store so the indexes stay consistent.
|
||||
/// </remarks>
|
||||
public sealed class LedgerEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets all alias keys of the medium (e.g. <c>movie:tmdb:603</c>).
|
||||
/// </summary>
|
||||
#pragma warning disable CA1819 // Plain persistence model; the store owns all mutations.
|
||||
public string[] Keys { get; set; } = [];
|
||||
#pragma warning restore CA1819
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected first "date added" (UTC).
|
||||
/// </summary>
|
||||
public DateTime FirstAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when an item of this medium was last seen (UTC).
|
||||
/// </summary>
|
||||
public DateTime LastSeen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the last live item of this medium disappeared (UTC); <c>null</c> while items exist.
|
||||
/// </summary>
|
||||
public DateTime? RemovedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ids of the Jellyfin items currently representing this medium.
|
||||
/// </summary>
|
||||
#pragma warning disable CA2227 // Plain persistence model; the store owns all mutations.
|
||||
public HashSet<Guid> LiveItemIds { get; set; } = [];
|
||||
#pragma warning restore CA2227
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a display name for logs and the configuration page.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Counters describing the ledger.
|
||||
/// </summary>
|
||||
/// <param name="Entries">All entries.</param>
|
||||
/// <param name="LiveEntries">Entries with at least one live item.</param>
|
||||
/// <param name="RemovedEntries">Entries whose items are gone (kept for the grace period).</param>
|
||||
public sealed record LedgerStatistics(int Entries, int LiveEntries, int RemovedEntries);
|
||||
@@ -0,0 +1,641 @@
|
||||
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; } = [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user