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 SeenAsync(MediaIdentity identity) => _fx.Processor.ProcessSeenAsync(identity, CancellationToken.None); }