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
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Keeps `dotnet publish` of the solution (jprm) from publishing the tests. -->
|
||||
<IsPublishable>false</IsPublishable>
|
||||
<RootNamespace>Jellyfin.Plugin.RecentlyAddedGuard.Tests</RootNamespace>
|
||||
<!-- Test names use underscores; test classes need no XML docs. -->
|
||||
<NoWarn>$(NoWarn);CA1707;CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.RecentlyAddedGuard\Jellyfin.Plugin.RecentlyAddedGuard.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,208 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
|
||||
|
||||
public sealed class MediaKeysTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForMovie_ContainsOnlyKnownProviderIds()
|
||||
{
|
||||
Assert.Equal(["movie:tmdb:603", "movie:imdb:tt0133093"], MediaKeys.ForMovie(" 603 ", "tt0133093", " "));
|
||||
Assert.Empty(MediaKeys.ForMovie(null, null, null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 5, null, "s1e5")]
|
||||
[InlineData(1, 5, 5, "s1e5")]
|
||||
[InlineData(1, 5, 6, "s1e5-6")]
|
||||
[InlineData(0, 1, null, "s0e1")]
|
||||
[InlineData(null, 5, null, null)]
|
||||
[InlineData(1, null, null, null)]
|
||||
public void EpisodePart_RequiresSeasonAndEpisode(int? season, int? episode, int? episodeEnd, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, MediaKeys.EpisodePart(season, episode, episodeEnd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEpisode_AddsSeriesGuidKeyAlways()
|
||||
{
|
||||
var series = Guid.Parse("0f3c9bd2-38a5-4e47-9d2b-8bd3a1a3e7c4");
|
||||
|
||||
var keys = MediaKeys.ForEpisode("s2e3", "393189", "83867", null, series);
|
||||
|
||||
Assert.Equal(
|
||||
["ep:tvdb:393189:s2e3", "ep:tmdb:83867:s2e3", "ep:series-guid:0f3c9bd238a54e479d2b8bd3a1a3e7c4:s2e3"],
|
||||
keys);
|
||||
Assert.Equal(["ep:series-guid:0f3c9bd238a54e479d2b8bd3a1a3e7c4:s2e3"], MediaKeys.ForEpisode("s2e3", null, null, null, series));
|
||||
Assert.Empty(MediaKeys.ForEpisode("s2e3", null, null, null, Guid.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests;
|
||||
|
||||
public sealed class ReconcilerTests : IDisposable
|
||||
{
|
||||
private static readonly Guid _oldItem = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid _newItem = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
private static readonly Guid _otherItem = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
private static readonly Guid _series = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
|
||||
private readonly GuardFixture _fx = new();
|
||||
|
||||
private static DateTime OriginalDate => GuardFixture.Start.UtcDateTime.AddDays(-300);
|
||||
|
||||
public void Dispose() => _fx.Dispose();
|
||||
|
||||
// 1
|
||||
[Fact]
|
||||
public async Task NewMedium_IsRegistered_AndNotChanged()
|
||||
{
|
||||
var result = await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.Registered, result.Action);
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(_fx.Now, entry.FirstAdded);
|
||||
Assert.Equal([_oldItem], entry.LiveItemIds);
|
||||
Assert.Equal(["movie:tmdb:603"], entry.Keys);
|
||||
}
|
||||
|
||||
// 2
|
||||
[Fact]
|
||||
public async Task Upgrade_WithNewPath_AfterOldItemRemoved_WithinGrace_GetsOriginalDate()
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
|
||||
await SeenAsync(old);
|
||||
_fx.Time.Advance(TimeSpan.FromDays(3));
|
||||
Assert.True(_fx.Processor.ProcessRemoved(_oldItem, old.Keys));
|
||||
Assert.Equal(_fx.Now, _fx.SingleEntry().RemovedAt);
|
||||
|
||||
_fx.Time.Advance(TimeSpan.FromMinutes(2));
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Null(entry.RemovedAt);
|
||||
Assert.Equal([_newItem], entry.LiveItemIds);
|
||||
Assert.Equal(OriginalDate, entry.FirstAdded);
|
||||
|
||||
var correction = Assert.Single(_fx.Corrections.GetRecent());
|
||||
Assert.Equal(_fx.Now, correction.PreviousDate);
|
||||
Assert.Equal(OriginalDate, correction.RestoredDate);
|
||||
Assert.False(correction.DryRun);
|
||||
Assert.Equal(1, _fx.Corrections.CorrectionsSinceStart);
|
||||
}
|
||||
|
||||
// 3
|
||||
[Fact]
|
||||
public async Task Upgrade_BeforeOldItemIsRemoved_BothLive_GetsOriginalDate()
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
|
||||
await SeenAsync(old);
|
||||
_fx.Time.Advance(TimeSpan.FromDays(10));
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
Assert.Equal(2, _fx.SingleEntry().LiveItemIds.Count);
|
||||
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal([_newItem], entry.LiveItemIds);
|
||||
Assert.Null(entry.RemovedAt);
|
||||
}
|
||||
|
||||
// 4
|
||||
[Fact]
|
||||
public async Task SamePath_FileReplaced_DateJumpsOnUpdate_IsReset()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
_fx.Time.Advance(TimeSpan.FromDays(1));
|
||||
|
||||
// Metadata refresh noticed the new file and set DateCreated to its creation time.
|
||||
var result = await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Equal([(_oldItem, OriginalDate)], _fx.Writer.Writes);
|
||||
|
||||
// The ItemUpdated caused by the correction itself is a no-op.
|
||||
var echo = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
Assert.Equal(ReconcileAction.Unchanged, echo.Action);
|
||||
Assert.Single(_fx.Writer.Writes);
|
||||
}
|
||||
|
||||
// 5
|
||||
[Fact]
|
||||
public async Task ReturnAfterGracePeriod_CountsAsNew_WithoutCorrection()
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
_fx.Time.Advance(TimeSpan.FromDays(15));
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.ReturnedAfterGrace, result.Action);
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(_fx.Now, entry.FirstAdded);
|
||||
Assert.Null(entry.RemovedAt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(13, ReconcileAction.UpgradeDetected)]
|
||||
[InlineData(15, ReconcileAction.ReturnedAfterGrace)]
|
||||
public async Task GracePeriod_DecidesBetweenUpgradeAndNew(int daysAway, ReconcileAction expected)
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
_fx.Time.Advance(TimeSpan.FromDays(daysAway));
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(expected, result.Action);
|
||||
}
|
||||
|
||||
// 6
|
||||
[Fact]
|
||||
public async Task OlderDateFromNfo_LowersFirstAdded()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.FirstAddedLowered, result.Action);
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded);
|
||||
|
||||
_fx.Time.Advance(TimeSpan.FromDays(1));
|
||||
await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
}
|
||||
|
||||
// 7
|
||||
[Fact]
|
||||
public async Task MovieWithoutProviderIds_IsIgnoredOnAdd_AndMatchedOnUpdateWithTmdbId()
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
_fx.Time.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
var added = await SeenAsync(Media.Movie(_newItem, _fx.Now));
|
||||
Assert.Equal(ReconcileAction.Ignored, added.Action);
|
||||
Assert.Single(_fx.Store.GetEntries());
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
|
||||
_fx.Time.Advance(TimeSpan.FromMinutes(1));
|
||||
var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now.AddMinutes(-1), tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, updated.Action);
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
}
|
||||
|
||||
// 8
|
||||
[Fact]
|
||||
public async Task Episode_MatchesViaSeriesGuid_WithoutProviderIds()
|
||||
{
|
||||
var old = Media.Episode(_oldItem, OriginalDate, _series, 1, 1);
|
||||
Assert.Equal(["ep:series-guid:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:s1e1"], old.Keys);
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
|
||||
var result = await SeenAsync(Media.Episode(_newItem, _fx.Now, _series, 1, 1));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
|
||||
var otherEpisode = await SeenAsync(Media.Episode(_otherItem, _fx.Now, _series, 1, 2));
|
||||
Assert.Equal(ReconcileAction.Registered, otherEpisode.Action);
|
||||
}
|
||||
|
||||
// 9
|
||||
[Fact]
|
||||
public async Task DoubleEpisode_IsIdentifiedByIndexNumberEnd()
|
||||
{
|
||||
var old = Media.Episode(_oldItem, OriginalDate, _series, 1, 1, episodeEnd: 2, seriesTvdb: "393189");
|
||||
Assert.Contains("ep:tvdb:393189:s1e1-2", old.Keys);
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
|
||||
var upgrade = await SeenAsync(Media.Episode(_newItem, _fx.Now, _series, 1, 1, episodeEnd: 2, seriesTvdb: "393189"));
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, upgrade.Action);
|
||||
|
||||
// A single episode 1 is a different medium than the double episode 1-2.
|
||||
var single = await SeenAsync(Media.Episode(_otherItem, _fx.Now, _series, 1, 1, seriesTvdb: "393189"));
|
||||
Assert.Equal(ReconcileAction.Registered, single.Action);
|
||||
Assert.Single(_fx.Writer.Writes);
|
||||
}
|
||||
|
||||
// 10
|
||||
[Fact]
|
||||
public async Task AliasKeys_ImdbOnlyEntry_IsMatchedByUpgradeWithTmdbAndImdb_AndKeysAreMerged()
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093");
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(["movie:imdb:tt0133093", "movie:tmdb:603"], entry.Keys.Order());
|
||||
Assert.Same(entry, Assert.Single(_fx.Store.FindByKeys(["movie:tmdb:603"])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AliasKeys_SeparateEntriesAreMerged_WhenAnItemBridgesThem()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093"));
|
||||
_fx.Time.Advance(TimeSpan.FromDays(2));
|
||||
|
||||
// Added with the TMDb id from the folder name only: no overlap yet, so it looks new.
|
||||
var added = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
Assert.Equal(ReconcileAction.Registered, added.Action);
|
||||
Assert.Equal(2, _fx.Store.GetEntries().Count);
|
||||
|
||||
// The metadata refresh brings the IMDb id and bridges both entries.
|
||||
var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, updated.Action);
|
||||
Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(OriginalDate, entry.FirstAdded);
|
||||
Assert.Equal(2, entry.LiveItemIds.Count);
|
||||
Assert.Equal(2, entry.Keys.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AliasKeys_EntryPastGracePeriod_OnlyContributesKeysWhenMerged()
|
||||
{
|
||||
var old = Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093");
|
||||
await SeenAsync(old);
|
||||
_fx.Processor.ProcessRemoved(_oldItem, old.Keys);
|
||||
_fx.Time.Advance(TimeSpan.FromDays(20));
|
||||
|
||||
await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093"));
|
||||
|
||||
Assert.Equal(ReconcileAction.Unchanged, updated.Action);
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(_fx.Now, entry.FirstAdded);
|
||||
Assert.Null(entry.RemovedAt);
|
||||
}
|
||||
|
||||
// 11
|
||||
[Fact]
|
||||
public async Task DryRun_DetectsUpgrade_ButChangesNothing()
|
||||
{
|
||||
_fx.Options = GuardOptions.Default with { DryRun = true };
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
_fx.Time.Advance(TimeSpan.FromDays(1));
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
Assert.Equal(0, _fx.Corrections.CorrectionsSinceStart);
|
||||
Assert.Equal(1, _fx.Corrections.DryRunDetectionsSinceStart);
|
||||
Assert.True(Assert.Single(_fx.Corrections.GetRecent()).DryRun);
|
||||
Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded);
|
||||
}
|
||||
|
||||
// 12
|
||||
[Fact]
|
||||
public async Task Prune_RemovesEntriesRemovedLongerThanGracePlusOneDay()
|
||||
{
|
||||
var gone = Media.Movie(_oldItem, OriginalDate, tmdb: "603");
|
||||
await SeenAsync(gone);
|
||||
await SeenAsync(Media.Movie(_otherItem, OriginalDate, tmdb: "604"));
|
||||
_fx.Processor.ProcessRemoved(_oldItem, gone.Keys);
|
||||
|
||||
_fx.Time.Advance(TimeSpan.FromDays(14) + TimeSpan.FromHours(23));
|
||||
Assert.Equal(0, _fx.Reconciler.Prune());
|
||||
|
||||
_fx.Time.Advance(TimeSpan.FromHours(2));
|
||||
Assert.Equal(1, _fx.Reconciler.Prune());
|
||||
|
||||
var remaining = _fx.SingleEntry();
|
||||
Assert.Equal(["movie:tmdb:604"], remaining.Keys);
|
||||
Assert.Empty(_fx.Store.FindByKeys(gone.Keys));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RepeatedCorrectionOfTheSameUpgrade_IsAppliedAgain_ButCountedOnce()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
var upgrade = Media.Movie(_newItem, _fx.Now, tmdb: "603", name: "The Matrix (1999) {tmdb-603}");
|
||||
await SeenAsync(upgrade);
|
||||
|
||||
// A metadata refresh that was already running saves the item with its new date once more.
|
||||
await SeenAsync(upgrade with { DisplayName = "Matrix (1999)" });
|
||||
|
||||
Assert.Equal([(_newItem, OriginalDate), (_newItem, OriginalDate)], _fx.Writer.Writes);
|
||||
Assert.Equal(1, _fx.Corrections.CorrectionsSinceStart);
|
||||
Assert.Equal("Matrix (1999)", Assert.Single(_fx.Corrections.GetRecent()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeWithinTolerance_IsNoUpgrade()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, OriginalDate.AddSeconds(45), tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.Unchanged, result.Action);
|
||||
Assert.Empty(_fx.Writer.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingDateCreated_IsTreatedAsNow()
|
||||
{
|
||||
var result = await SeenAsync(Media.Movie(_oldItem, DateTime.MinValue, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.Registered, result.Action);
|
||||
Assert.Equal(_fx.Now, _fx.SingleEntry().FirstAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpgradeOfDeletedItem_IsNotRecorded()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
_fx.Writer.ItemExists = false;
|
||||
|
||||
var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.UpgradeDetected, result.Action);
|
||||
Assert.Empty(_fx.Corrections.GetRecent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReidentifiedItem_MovesToEntryOfItsNewKeys()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "1"));
|
||||
|
||||
// "Identify" corrected a wrong match: same item, other film.
|
||||
var result = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.Registered, result.Action);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(["movie:tmdb:603"], entry.Keys);
|
||||
Assert.Equal(OriginalDate, entry.FirstAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removal_FallsBackToKeys_WhenItemWasNeverRegistered()
|
||||
{
|
||||
await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
|
||||
Assert.True(_fx.Processor.ProcessRemoved(_otherItem, MediaKeys.ForMovie("603", null, null)));
|
||||
Assert.Null(_fx.SingleEntry().RemovedAt);
|
||||
Assert.False(_fx.Processor.ProcessRemoved(_otherItem, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_TakesMinimumDate_RegardlessOfOrder_AndNeverReportsUpgrades()
|
||||
{
|
||||
var later = _fx.Reconciler.Register(Media.Movie(_newItem, _fx.Now, tmdb: "603"));
|
||||
var earlier = _fx.Reconciler.Register(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
var latest = _fx.Reconciler.Register(Media.Movie(_otherItem, _fx.Now.AddDays(1), tmdb: "603"));
|
||||
|
||||
Assert.Equal(ReconcileAction.Registered, later.Action);
|
||||
Assert.Equal(ReconcileAction.FirstAddedLowered, earlier.Action);
|
||||
Assert.Equal(ReconcileAction.Unchanged, latest.Action);
|
||||
var entry = _fx.SingleEntry();
|
||||
Assert.Equal(OriginalDate, entry.FirstAdded);
|
||||
Assert.Equal(3, entry.LiveItemIds.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sync_DetachesItemsThatNoLongerExist()
|
||||
{
|
||||
_fx.Reconciler.Register(Media.Movie(_oldItem, OriginalDate, tmdb: "603"));
|
||||
_fx.Reconciler.Register(Media.Movie(_newItem, OriginalDate, tmdb: "604"));
|
||||
|
||||
var unseen = _fx.Reconciler.GetUnseenLiveItemIds(new[] { _oldItem }.ToHashSet());
|
||||
Assert.Equal([_newItem], unseen);
|
||||
|
||||
Assert.Equal(1, _fx.Reconciler.RemoveStaleItems(unseen));
|
||||
var removed = Assert.Single(_fx.Store.FindByKeys(["movie:tmdb:604"]));
|
||||
Assert.Empty(removed.LiveItemIds);
|
||||
Assert.Equal(_fx.Now, removed.RemovedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalDate_OnlyLowersFirstAdded()
|
||||
{
|
||||
var movie = Media.Movie(_oldItem, _fx.Now, tmdb: "603");
|
||||
_fx.Reconciler.Register(movie);
|
||||
|
||||
Assert.False(_fx.Reconciler.ApplyExternalFirstAdded(movie.Keys, _fx.Now.AddDays(1)));
|
||||
Assert.True(_fx.Reconciler.ApplyExternalFirstAdded(movie.Keys, OriginalDate));
|
||||
Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded);
|
||||
}
|
||||
|
||||
private Task<ReconcileResult> SeenAsync(MediaIdentity identity)
|
||||
=> _fx.Processor.ProcessSeenAsync(identity, CancellationToken.None);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
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;
|
||||
|
||||
internal sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
public ManualTimeProvider(DateTimeOffset start)
|
||||
{
|
||||
Now = start;
|
||||
}
|
||||
|
||||
public DateTimeOffset Now { get; set; }
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
|
||||
public void Advance(TimeSpan by) => Now += by;
|
||||
}
|
||||
|
||||
internal sealed class RecordingDateWriter : IItemDateWriter
|
||||
{
|
||||
public List<(Guid ItemId, DateTime Date)> Writes { get; } = [];
|
||||
|
||||
public bool ItemExists { get; set; } = true;
|
||||
|
||||
public Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ItemExists)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
Writes.Add((itemId, dateCreated));
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires the core classes against a temporary ledger file and a manual clock.
|
||||
/// </summary>
|
||||
internal sealed class GuardFixture : IDisposable
|
||||
{
|
||||
public static readonly DateTimeOffset Start = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public GuardFixture(GuardOptions? options = null)
|
||||
{
|
||||
DirectoryPath = Path.Combine(Path.GetTempPath(), "rag-tests", Guid.NewGuid().ToString("N"));
|
||||
Time = new ManualTimeProvider(Start);
|
||||
Options = options ?? GuardOptions.Default;
|
||||
Store = CreateStore();
|
||||
Reconciler = new Reconciler(Store, () => Options, Time, NullLogger<Reconciler>.Instance);
|
||||
Writer = new RecordingDateWriter();
|
||||
Corrections = new CorrectionLog();
|
||||
Processor = new GuardProcessor(Reconciler, Writer, Corrections, () => Options, Time, NullLogger<GuardProcessor>.Instance);
|
||||
}
|
||||
|
||||
public string DirectoryPath { get; }
|
||||
|
||||
public string LedgerPath => Path.Combine(DirectoryPath, LedgerStore.DefaultFileName);
|
||||
|
||||
public ManualTimeProvider Time { get; }
|
||||
|
||||
public GuardOptions Options { get; set; }
|
||||
|
||||
public LedgerStore Store { get; }
|
||||
|
||||
public Reconciler Reconciler { get; }
|
||||
|
||||
public RecordingDateWriter Writer { get; }
|
||||
|
||||
public CorrectionLog Corrections { get; }
|
||||
|
||||
public GuardProcessor Processor { get; }
|
||||
|
||||
public DateTime Now => Time.GetUtcNow().UtcDateTime;
|
||||
|
||||
/// <summary>
|
||||
/// Creates another store on the same file. The save delay is long, so tests control writes via Flush.
|
||||
/// </summary>
|
||||
public LedgerStore CreateStore(TimeSpan? saveDelay = null)
|
||||
=> new(LedgerPath, NullLogger<LedgerStore>.Instance, Time, saveDelay ?? TimeSpan.FromHours(1));
|
||||
|
||||
public LedgerEntry SingleEntry()
|
||||
{
|
||||
var entries = Store.GetEntries();
|
||||
Assert.Single(entries);
|
||||
return entries[0];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Store.Dispose();
|
||||
try
|
||||
{
|
||||
Directory.Delete(DirectoryPath, recursive: true);
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Media
|
||||
{
|
||||
public static MediaIdentity Movie(
|
||||
Guid itemId,
|
||||
DateTime dateCreated,
|
||||
string? tmdb = null,
|
||||
string? imdb = null,
|
||||
string? tvdb = null,
|
||||
string name = "The Matrix (1999)")
|
||||
=> new(itemId, MediaKind.Movie, dateCreated, MediaKeys.ForMovie(tmdb, imdb, tvdb), name);
|
||||
|
||||
public static MediaIdentity Episode(
|
||||
Guid itemId,
|
||||
DateTime dateCreated,
|
||||
Guid seriesId,
|
||||
int season,
|
||||
int episode,
|
||||
int? episodeEnd = null,
|
||||
string? seriesTvdb = null,
|
||||
string name = "Andor S01E01")
|
||||
=> new(
|
||||
itemId,
|
||||
MediaKind.Episode,
|
||||
dateCreated,
|
||||
MediaKeys.ForEpisode(MediaKeys.EpisodePart(season, episode, episodeEnd)!, seriesTvdb, null, null, seriesId),
|
||||
name);
|
||||
}
|
||||
Reference in new issue
Block a user