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>
209 lines
8.3 KiB
C#
209 lines
8.3 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
|
using Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
|
|
|
|
public sealed class LedgerStoreTests : IDisposable
|
|
{
|
|
private readonly GuardFixture _fx = new();
|
|
|
|
public void Dispose() => _fx.Dispose();
|
|
|
|
// 13
|
|
[Fact]
|
|
public void SaveAndLoad_RestoresIdenticalState()
|
|
{
|
|
var series = Guid.NewGuid();
|
|
var movieItem = Guid.NewGuid();
|
|
var episodeItems = new[] { Guid.NewGuid(), Guid.NewGuid() };
|
|
var removedItem = Guid.NewGuid();
|
|
_fx.Reconciler.Register(Media.Movie(movieItem, _fx.Now.AddDays(-30), tmdb: "603", imdb: "tt0133093"));
|
|
_fx.Reconciler.Register(Media.Episode(episodeItems[0], _fx.Now.AddDays(-7), series, 2, 3, seriesTvdb: "393189"));
|
|
_fx.Reconciler.Register(Media.Episode(episodeItems[1], _fx.Now.AddDays(-1), series, 2, 3, seriesTvdb: "393189"));
|
|
var removed = Media.Movie(removedItem, _fx.Now.AddDays(-90), tmdb: "604");
|
|
_fx.Reconciler.Register(removed);
|
|
_fx.Reconciler.OnItemRemoved(removedItem, removed.Keys);
|
|
_fx.Store.MarkSynced(_fx.Now);
|
|
_fx.Store.Flush();
|
|
|
|
using var reloaded = _fx.CreateStore();
|
|
reloaded.Load();
|
|
|
|
Assert.True(reloaded.SeedCompleted);
|
|
Assert.Equal(_fx.Now, reloaded.LastSyncUtc);
|
|
Assert.Equal(_fx.Store.GetStatistics(), reloaded.GetStatistics());
|
|
var expected = _fx.Store.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList();
|
|
var actual = reloaded.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList();
|
|
Assert.Equal(expected.Count, actual.Count);
|
|
for (var i = 0; i < expected.Count; i++)
|
|
{
|
|
Assert.Equal(expected[i].Keys, actual[i].Keys);
|
|
Assert.Equal(expected[i].FirstAdded, actual[i].FirstAdded);
|
|
Assert.Equal(DateTimeKind.Utc, actual[i].FirstAdded.Kind);
|
|
Assert.Equal(expected[i].LastSeen, actual[i].LastSeen);
|
|
Assert.Equal(expected[i].RemovedAt, actual[i].RemovedAt);
|
|
Assert.Equal(expected[i].LiveItemIds.Order(), actual[i].LiveItemIds.Order());
|
|
Assert.Equal(expected[i].DisplayName, actual[i].DisplayName);
|
|
}
|
|
|
|
// Indexes are rebuilt as well.
|
|
Assert.Equal(2, Assert.Single(reloaded.FindByKeys(["ep:tvdb:393189:s2e3"])).LiveItemIds.Count);
|
|
Assert.NotNull(reloaded.FindByItemId(movieItem));
|
|
Assert.Null(reloaded.FindByItemId(removedItem));
|
|
Assert.NotNull(Assert.Single(reloaded.FindByKeys(["movie:tmdb:604"])).RemovedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flush_ReplacesFileAtomically_WithoutLeavingTempFile()
|
|
{
|
|
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603"));
|
|
_fx.Store.Flush();
|
|
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "604"));
|
|
_fx.Store.Flush();
|
|
|
|
Assert.False(File.Exists(_fx.LedgerPath + ".tmp"));
|
|
using var document = JsonDocument.Parse(File.ReadAllBytes(_fx.LedgerPath));
|
|
Assert.Equal(2, document.RootElement.GetProperty("Entries").GetArrayLength());
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_IgnoresLeftoverTempFileOfInterruptedWrite()
|
|
{
|
|
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603"));
|
|
_fx.Store.Flush();
|
|
File.WriteAllText(_fx.LedgerPath + ".tmp", "{ \"Entries\": [ { \"Keys\": [\"half-writ");
|
|
|
|
using var reloaded = _fx.CreateStore();
|
|
reloaded.Load();
|
|
|
|
Assert.Single(reloaded.GetEntries());
|
|
Assert.False(File.Exists(_fx.LedgerPath + ".tmp"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_MovesUnreadableFileAside_AndStartsEmpty()
|
|
{
|
|
Directory.CreateDirectory(_fx.DirectoryPath);
|
|
File.WriteAllText(_fx.LedgerPath, "{ not json");
|
|
|
|
using var store = _fx.CreateStore();
|
|
store.Load();
|
|
|
|
Assert.Empty(store.GetEntries());
|
|
Assert.False(store.SeedCompleted);
|
|
Assert.False(File.Exists(_fx.LedgerPath));
|
|
Assert.Single(Directory.GetFiles(_fx.DirectoryPath, LedgerStore.DefaultFileName + ".corrupt-*"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_WithoutFile_YieldsEmptyLedger()
|
|
{
|
|
using var store = _fx.CreateStore();
|
|
store.Load();
|
|
|
|
Assert.Empty(store.GetEntries());
|
|
Assert.False(store.SeedCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public void Keys_AreMatchedCaseInsensitively()
|
|
{
|
|
_fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, imdb: "tt0133093"));
|
|
|
|
Assert.Single(_fx.Store.FindByKeys(["movie:imdb:TT0133093"]));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MarkDirty_IsDebounced_AndWritesAfterTheDelay()
|
|
{
|
|
var path = Path.Combine(_fx.DirectoryPath, "debounced.json");
|
|
using var store = new LedgerStore(path, NullLogger<LedgerStore>.Instance, TimeProvider.System, TimeSpan.FromMilliseconds(100));
|
|
var reconciler = new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger<Reconciler>.Instance);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
{
|
|
reconciler.Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: i.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
Assert.False(File.Exists(path));
|
|
var stopwatch = Stopwatch.StartNew();
|
|
while (!File.Exists(path) && stopwatch.Elapsed < TimeSpan.FromSeconds(10))
|
|
{
|
|
await Task.Delay(25, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
Assert.True(File.Exists(path));
|
|
using var document = JsonDocument.Parse(File.ReadAllBytes(path));
|
|
Assert.Equal(50, document.RootElement.GetProperty("Entries").GetArrayLength());
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcurrentEvents_KeepLedgerAndIndexesConsistent()
|
|
{
|
|
var itemIds = Enumerable.Range(0, 4000).Select(_ => Guid.NewGuid()).ToArray();
|
|
|
|
Parallel.For(0, itemIds.Length, i =>
|
|
{
|
|
var tmdb = (i % 500).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
var movie = Media.Movie(itemIds[i], _fx.Now.AddMinutes(-i), tmdb: tmdb);
|
|
_fx.Reconciler.OnItemSeen(movie);
|
|
if (i % 3 == 0)
|
|
{
|
|
_fx.Reconciler.OnItemRemoved(itemIds[i], movie.Keys);
|
|
}
|
|
});
|
|
|
|
var entries = _fx.Store.GetEntries();
|
|
Assert.Equal(500, entries.Count);
|
|
var live = itemIds.Where((_, i) => i % 3 != 0).ToArray();
|
|
Assert.Equal(live.Length, entries.Sum(e => e.LiveItemIds.Count));
|
|
Assert.All(live, id => Assert.NotNull(_fx.Store.FindByItemId(id)));
|
|
Assert.All(entries, e => Assert.Equal(e.Keys.Length, e.Keys.Distinct(StringComparer.OrdinalIgnoreCase).Count()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_FlushesPendingChanges()
|
|
{
|
|
var path = Path.Combine(_fx.DirectoryPath, "dispose.json");
|
|
var store = new LedgerStore(path, NullLogger<LedgerStore>.Instance, TimeProvider.System, TimeSpan.FromHours(1));
|
|
new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger<Reconciler>.Instance)
|
|
.Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: "603"));
|
|
|
|
store.Dispose();
|
|
|
|
Assert.True(File.Exists(path));
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_MergesOverlappingEntriesOfADamagedFile()
|
|
{
|
|
var item = Guid.NewGuid();
|
|
Directory.CreateDirectory(_fx.DirectoryPath);
|
|
File.WriteAllText(
|
|
_fx.LedgerPath,
|
|
$$"""
|
|
{"SchemaVersion":1,"SeedCompleted":true,"Entries":[
|
|
{"Keys":["movie:tmdb:603"],"FirstAdded":"2025-05-01T00:00:00Z","LastSeen":"2026-01-01T00:00:00Z","LiveItemIds":["{{item}}"],"DisplayName":"A"},
|
|
{"Keys":["movie:tmdb:603","movie:imdb:tt0133093"],"FirstAdded":"2024-05-01T00:00:00Z","LastSeen":"2025-01-01T00:00:00Z","RemovedAt":"2025-02-01T00:00:00Z","LiveItemIds":[],"DisplayName":"B"}
|
|
]}
|
|
""");
|
|
|
|
using var store = _fx.CreateStore();
|
|
store.Load();
|
|
|
|
var entry = Assert.Single(store.GetEntries());
|
|
Assert.Equal(new DateTime(2024, 5, 1, 0, 0, 0, DateTimeKind.Utc), entry.FirstAdded);
|
|
Assert.Equal(2, entry.Keys.Length);
|
|
Assert.Null(entry.RemovedAt);
|
|
Assert.Same(entry, store.FindByItemId(item));
|
|
}
|
|
}
|