diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs
index 0fcc7057..0b6009ec 100644
--- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs
+++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs
@@ -25,4 +25,9 @@ public sealed record SearchableMovie
public DateTimeOffset? PhysicalRelease { get; init; }
public DateTimeOffset? InCinemas { get; init; }
+
+ ///
+ /// Set when a search reached an indexer; RSS grabs leave it alone.
+ ///
+ public DateTimeOffset? LastSearchTime { get; init; }
}
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs
index f2349dc7..65371519 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs
@@ -80,7 +80,8 @@ public class SeekerUnitEvaluatorTests : IDisposable
bool hasFile = false,
int qualityProfileId = 1,
DateTimeOffset? digitalRelease = null,
- DateTimeOffset? added = null)
+ DateTimeOffset? added = null,
+ DateTimeOffset? lastSearchTime = null)
{
return new SearchableMovie
{
@@ -93,6 +94,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
QualityProfileId = qualityProfileId,
DigitalRelease = digitalRelease,
Added = added,
+ LastSearchTime = lastSearchTime,
};
}
@@ -133,7 +135,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
};
}
- private SearchableEpisode Episode(long id, long seriesId, int season = 1, int number = 1, DateTimeOffset? airDate = null, bool noAirDate = false)
+ private SearchableEpisode Episode(long id, long seriesId, int season = 1, int number = 1, DateTimeOffset? airDate = null, bool noAirDate = false, DateTimeOffset? lastSearchTime = null)
{
return new SearchableEpisode
{
@@ -143,6 +145,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
EpisodeNumber = number,
Monitored = true,
AirDateUtc = noAirDate ? null : airDate ?? _timeProvider.GetUtcNow().AddDays(-30),
+ LastSearchTime = lastSearchTime,
};
}
@@ -1042,6 +1045,97 @@ public class SeekerUnitEvaluatorTests : IDisposable
candidates[1].SeasonNumber.ShouldBe(2);
}
+ [Fact]
+ public async Task SelectNext_OldestSearchFirst_OrdersMoviesByTheArrLastSearchTime()
+ {
+ // Arrange
+ DateTimeOffset now = _timeProvider.GetUtcNow();
+ SetupRadarr(
+ [
+ Movie(1, lastSearchTime: now.AddDays(-1)),
+ Movie(2),
+ Movie(3, lastSearchTime: now.AddDays(-5)),
+ ]);
+ SeekerEvaluation evaluation = await EvaluateRadarrAsync();
+
+ // Act
+ List candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 3);
+
+ // Assert
+ candidates.Select(c => c.ItemId).ShouldBe([2L, 3L, 1L]);
+ }
+
+ [Fact]
+ public async Task SelectNext_OldestSearchFirst_RanksSeriesByTheirLatestEpisodeSearch()
+ {
+ // Arrange
+ DateTimeOffset now = _timeProvider.GetUtcNow();
+ SetupSonarr([Series(1), Series(2)],
+ [
+ Episode(10, 1, season: 1, lastSearchTime: now.AddDays(-10)),
+ Episode(11, 1, season: 2, lastSearchTime: now.AddDays(-1)),
+ Episode(20, 2, season: 1, lastSearchTime: now.AddDays(-5)),
+ ]);
+ SeekerEvaluation evaluation = await EvaluateSonarrAsync();
+
+ // Act
+ List candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 1);
+
+ // Assert
+ candidates.ShouldHaveSingleItem().ItemId.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task SelectNext_OldestSearchFirst_CycleSearchNewerThanTheArrTimeWins()
+ {
+ // Arrange
+ DateTimeOffset now = _timeProvider.GetUtcNow();
+ SetupSonarr([Series(1), Series(2)],
+ [
+ Episode(10, 1, season: 1, lastSearchTime: now.AddDays(-10)),
+ Episode(11, 1, season: 2, lastSearchTime: now.AddDays(-10)),
+ Episode(20, 2, season: 1, lastSearchTime: now.AddDays(-5)),
+ ]);
+ SeekerEvaluation evaluation = await EvaluateSonarrAsync(
+ history: [History(_sonarr, 1, now.AddDays(-1), seasonNumber: 1)]);
+
+ // Act
+ List candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 1);
+
+ // Assert
+ candidates.ShouldHaveSingleItem().ItemId.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_Sonarr_CustomFormatOnlyEpisode_TakesTheCachedLastSearchTime()
+ {
+ // Arrange
+ DateTimeOffset lastSearchTime = _timeProvider.GetUtcNow().AddDays(-3);
+ SetupSonarr([Series(1)], []);
+ _sonarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]);
+ _eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
+ {
+ ArrInstanceId = _sonarr.Id,
+ ExternalItemId = 1,
+ EpisodeId = 20,
+ Title = "Series 1 S01E01",
+ ItemType = InstanceType.Sonarr,
+ SeasonNumber = 1,
+ AirDateUtc = _timeProvider.GetUtcNow().AddDays(-30),
+ LastSearchTime = lastSearchTime,
+ IsMonitored = true,
+ CurrentScore = 5,
+ CutoffScore = 10,
+ });
+ await _eventsContext.SaveChangesAsync();
+
+ // Act
+ SeekerEvaluation evaluation = await EvaluateSonarrAsync(InstanceConfig(_sonarr, c => c.UseCustomFormatScore = true));
+
+ // Assert
+ evaluation.Units.ShouldHaveSingleItem().LastSearchTime.ShouldBe(lastSearchTime);
+ }
+
#endregion
#region WithoutCycleHistory
@@ -1087,6 +1181,22 @@ public class SeekerUnitEvaluatorTests : IDisposable
fresh.LastSearchedByItem.ShouldBeEmpty();
}
+ [Fact]
+ public async Task WithoutCycleHistory_KeepsTheArrLastSearchTime()
+ {
+ // Arrange
+ DateTimeOffset lastSearchTime = _timeProvider.GetUtcNow().AddDays(-4);
+ SetupRadarr([Movie(1, lastSearchTime: lastSearchTime)]);
+ SeekerEvaluation evaluation = await EvaluateRadarrAsync(
+ history: [History(_radarr, 1, _timeProvider.GetUtcNow().AddDays(-1))]);
+
+ // Act
+ SeekerEvaluation fresh = evaluation.WithoutCycleHistory();
+
+ // Assert
+ fresh.Units.ShouldHaveSingleItem().LastSearchTime.ShouldBe(lastSearchTime);
+ }
+
[Fact]
public async Task WithoutCycleHistory_KeepsQueueBlocks()
{
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs
index ae22847b..adad89b1 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchUnit.cs
@@ -41,6 +41,11 @@ public sealed record SeekerSearchUnit
///
public DateTimeOffset? Added { get; init; }
+ ///
+ /// Latest search the arr recorded for the movie or any episode the unit covers.
+ ///
+ public DateTimeOffset? LastSearchTime { get; init; }
+
///
/// Whether the current cycle already searched this unit.
///
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs
index 1163e8d9..d2b63c54 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs
@@ -118,9 +118,17 @@ public sealed class SeekerUnitEvaluator
.GroupBy(u => u.ItemId)
.ToDictionary(g => g.Key, g => g.ToList());
+ Dictionary arrSearchedByItem = evaluation.Units
+ .GroupBy(u => u.ItemId)
+ .ToDictionary(g => g.Key, g => g.Max(u => u.LastSearchTime));
+
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> selectionCandidates = remainingByItem
- .Select(p => (p.Key, p.Value[0].Added,
- LastSearched: evaluation.LastSearchedByItem.TryGetValue(p.Key, out DateTimeOffset at) ? (DateTimeOffset?)at : null))
+ .Select(p =>
+ {
+ DateTimeOffset? cycleSearched = evaluation.LastSearchedByItem.TryGetValue(p.Key, out DateTimeOffset at) ? at : null;
+ DateTimeOffset? lastSearched = new[] { arrSearchedByItem[p.Key], cycleSearched }.Max();
+ return (p.Key, p.Value[0].Added, lastSearched);
+ })
.ToList();
return ItemSelectorFactory.Create(strategy)
@@ -294,6 +302,7 @@ public sealed class SeekerUnitEvaluator
Name = movie.Title,
Reasons = reasons,
Added = movie.Added,
+ LastSearchTime = movie.LastSearchTime,
Searched = false,
BlockedBy = blockedBy,
});
@@ -413,6 +422,7 @@ public sealed class SeekerUnitEvaluator
EpisodeId = e.Id,
EpisodeNumber = e.EpisodeNumber,
AirDateUtc = e.AirDateUtc,
+ LastSearchTime = e.LastSearchTime,
Monitored = e.Monitored,
Reason = reason,
};
@@ -475,6 +485,7 @@ public sealed class SeekerUnitEvaluator
EpisodeId = entry.EpisodeId,
Title = entry.Title,
AirDateUtc = entry.AirDateUtc,
+ LastSearchTime = entry.LastSearchTime,
Monitored = entry.IsMonitored,
Reason = SeekerSearchReason.CustomFormatScoreBelowCutoff,
});
@@ -593,6 +604,7 @@ public sealed class SeekerUnitEvaluator
ContentKind = kind,
Reasons = season.Select(w => w.Reason).ToList(),
Added = series.Added,
+ LastSearchTime = season.Max(w => w.LastSearchTime),
Searched = seasonSearched,
});
continue;
@@ -615,6 +627,7 @@ public sealed class SeekerUnitEvaluator
Reasons = episode.Select(w => w.Reason).ToList(),
AirDateUtc = first.AirDateUtc,
Added = series.Added,
+ LastSearchTime = episode.Max(w => w.LastSearchTime),
Searched = seasonSearched || searchedEpisodeIds.Contains(episode.Key),
});
}
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs
index 130e24b5..9dfa70e5 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SonarrWantedEpisode.cs
@@ -25,6 +25,8 @@ internal sealed record SonarrWantedEpisode
public DateTimeOffset? AirDateUtc { get; init; }
+ public DateTimeOffset? LastSearchTime { get; init; }
+
public required bool Monitored { get; init; }
public required SeekerSearchReason Reason { get; init; }