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>
421 lines
17 KiB
C#
421 lines
17 KiB
C#
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);
|
|
}
|