Refactor history checks and add tests for decision logic

Streamlined history checks by replacing `MostRecentForEpisodeInEventCollection` with more granular methods to improve clarity and maintainability. Added comprehensive unit tests to validate the updated decision-making logic for episode downloads. Removed unused methods and imports to enhance code consistency.
This commit is contained in:
skydel0 2025-03-22 08:56:22 +01:00
parent 02e455b2da
commit d894035922
5 changed files with 417 additions and 88 deletions

View file

@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.DecisionEngine.Specifications.RssSync;
using NzbDrone.Core.History;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Test.Qualities;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.HistoryTests
{
[TestFixture]
public class HistorySpecificationFixture : CoreTest<HistorySpecification>
{
private RemoteEpisode _remoteEpisode;
private Series _series;
private Episode _episode;
private QualityModel _quality;
private QualityProfile _profile;
[SetUp]
public void Setup()
{
_series = Builder<Series>.CreateNew()
.With(s => s.Id = 1)
.Build();
_episode = Builder<Episode>.CreateNew()
.With(e => e.Id = 1)
.With(e => e.SeriesId = _series.Id)
.Build();
_quality = new QualityModel(Quality.HDTV720p);
_series.QualityProfile = _profile;
_remoteEpisode = Builder<RemoteEpisode>.CreateNew()
.With(r => r.Series = _series)
.With(r => r.Episodes = new List<Episode> { _episode })
.With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = _quality })
.With(r => r.CustomFormats = new List<CustomFormat>())
.With(r => r.CustomFormatScore = 0)
.Build();
_profile = new QualityProfile
{
Cutoff = Quality.HDTV720p.Id,
Items = QualityFixture.GetDefaultQualities(),
Name = "Test"
};
}
private void SetupHistoryServiceMock(List<EpisodeHistory> history)
{
// Setup mock for IHistoryService
Mocker.GetMock<IHistoryService>()
.Setup(s => s.FindByEpisodeId(It.IsAny<int>()))
.Returns(history);
}
private void SetupCdh(bool cdhEnabled)
{
// Setup mock for IHistoryService
Mocker.GetMock<IConfigService>()
.Setup(s => s.EnableCompletedDownloadHandling)
.Returns(cdhEnabled);
}
private List<EpisodeHistory> GivenFileHistory(DateTime date, QualityModel quality, EpisodeHistoryEventType eventType = EpisodeHistoryEventType.SeriesFolderImported)
{
return new List<EpisodeHistory>
{
new()
{
Date = date,
Quality = quality,
EventType = eventType
}
};
}
private RemoteEpisode MockUpOrDownGrade(int customFormatScoreHistory, int customFormatScoreNew, QualityModel newQuality, int cutoff, bool hasFile = false)
{
_profile.FormatItems = new List<ProfileFormatItem>
{
new()
{
Format = new CustomFormat(),
Score = customFormatScoreHistory
}
};
_profile.Cutoff = cutoff;
var episode = Builder<Episode>.CreateNew()
.With(e => e.Id = 1)
.With(e => e.EpisodeFileId = hasFile ? 1 : 0)
.Build();
var remoteEpisode = new RemoteEpisode
{
Episodes = new List<Episode> { episode },
Series = new Series
{
QualityProfile = _profile
},
CustomFormatScore = customFormatScoreNew,
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = newQuality },
};
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeHistory>(), It.IsAny<Series>()))
.Returns(new List<CustomFormat>()
{
_profile.FormatItems.First().Format
});
return remoteEpisode;
}
[Test]
public void should_accept_if_no_history()
{
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue();
}
[Test]
public void should_reject_when_grabbed_history_is_old_and_cdh_enabled_when_no_quality_update()
{
// Arrange
var newQuality = new QualityModel(Quality.HDTV720p);
var remoteEpisode = MockUpOrDownGrade(3000, 3000, newQuality, Quality.HDTV720p.Id, true);
var history = GivenFileHistory(DateTime.UtcNow.AddHours(-13), _quality, EpisodeHistoryEventType.Grabbed);
history.AddRange(GivenFileHistory(DateTime.UtcNow.AddHours(-13), _quality));
SetupHistoryServiceMock(history);
SetupCdh(true);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeFalse();
}
[Test]
public void should_accept_when_grabbed_history_is_old_and_cdh_enabled_when_quality_update()
{
// Arrange
var newQuality = new QualityModel(Quality.Bluray1080p);
var remoteEpisode = MockUpOrDownGrade(3000, 3000, newQuality, Quality.WEBRip2160p.Id);
var history = GivenFileHistory(DateTime.UtcNow.AddHours(-13), _quality, EpisodeHistoryEventType.Grabbed);
SetupHistoryServiceMock(history);
SetupCdh(true);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeTrue();
}
[Test]
public void should_accept_when_grabbed_history_is_old_and_cdh_enabled_when_custom_format_score_update()
{
// Arrange
var newQuality = new QualityModel(Quality.Bluray1080p);
var remoteEpisode = MockUpOrDownGrade(3000, 8000, newQuality, Quality.WEBRip1080p.Id);
var history = GivenFileHistory(DateTime.UtcNow.AddHours(-13), _quality, EpisodeHistoryEventType.Grabbed);
history.AddRange(GivenFileHistory(DateTime.UtcNow.AddHours(-13), _quality));
SetupHistoryServiceMock(history);
SetupCdh(true);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeTrue();
}
[Test]
public void should_reject_when_grabbed_history_meets_cutoff_and_is_recent()
{
// Arrange
var betterQuality = new QualityModel(Quality.Bluray1080p);
var history = GivenFileHistory(DateTime.UtcNow.AddHours(-1), betterQuality, EpisodeHistoryEventType.Grabbed);
var newQuality = new QualityModel(Quality.Bluray1080p);
var remoteEpisode = MockUpOrDownGrade(3000, 8000, newQuality, Quality.Bluray1080p.Id);
SetupHistoryServiceMock(history);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeFalse();
decision.Reason.Should().Be(DownloadRejectionReason.HistoryRecentCutoffMet);
}
[Test]
public void should_reject_when_grabbed_history_meets_cutoff_and_cdh_disabled()
{
// Arrange
var newQuality = new QualityModel(Quality.WEBDL720p);
var remoteEpisode = MockUpOrDownGrade(3000, 8000, newQuality, Quality.Bluray1080p.Id);
var betterQuality = new QualityModel(Quality.Bluray1080p);
var history = GivenFileHistory(DateTime.UtcNow.AddHours(-13), betterQuality, EpisodeHistoryEventType.Grabbed);
SetupHistoryServiceMock(history);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeFalse();
decision.Reason.Should().Be(DownloadRejectionReason.HistoryCdhDisabledCutoffMet);
}
[Test]
public void should_accept_when_file_history_has_lower_quality_in_custom_format_score()
{
// Arrange
var newQuality = new QualityModel(Quality.SDTV);
var remoteEpisode = MockUpOrDownGrade(3000, 8000, newQuality, Quality.WEBDL720p.Id, true);
var history = GivenFileHistory(DateTime.UtcNow.AddDays(-5), new QualityModel(Quality.SDTV));
SetupHistoryServiceMock(history);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeTrue();
}
[Test]
public void should_reject_when_file_history_has_higher_quality_in_custom_format_score()
{
// Arrange
var newQuality = new QualityModel(Quality.SDTV);
var remoteEpisode = MockUpOrDownGrade(8000, 3000, newQuality, Quality.WEBDL720p.Id, true);
var history = GivenFileHistory(DateTime.UtcNow.AddDays(-5), new QualityModel(Quality.SDTV));
SetupHistoryServiceMock(history);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeFalse();
}
[Test]
public void should_accept_when_file_history_has_lower_quality_in_quality_profile()
{
// Arrange
var newQuality = new QualityModel(Quality.WEBDL720p);
var remoteEpisode = MockUpOrDownGrade(3000, 3000, newQuality, Quality.WEBDL720p.Id, true);
var history = GivenFileHistory(DateTime.UtcNow.AddDays(-5), new QualityModel(Quality.SDTV));
SetupHistoryServiceMock(history);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeTrue();
}
[Test]
public void should_reject_when_file_history_has_higher_quality_in_quality_profile()
{
// Arrange
var newQuality = new QualityModel(Quality.SDTV);
var remoteEpisode = MockUpOrDownGrade(3000, 3000, newQuality, Quality.WEBDL720p.Id, true);
var history = GivenFileHistory(DateTime.UtcNow.AddDays(-5), new QualityModel(Quality.WEBDL720p));
SetupHistoryServiceMock(history);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeFalse();
}
[Test]
public void should_reject_when_grabbed_history_has_better_custom_format()
{
// Arrange
var date = DateTime.UtcNow.AddMinutes(-10);
var grabHistory = GivenFileHistory(date, new QualityModel(Quality.HDTV720p), EpisodeHistoryEventType.Grabbed);
var remoteEpisode = MockUpOrDownGrade(5, 2, new QualityModel(Quality.HDTV720p), 0);
SetupHistoryServiceMock(grabHistory);
// Act
var decision = Subject.IsSatisfiedBy(remoteEpisode, null);
// Assert
decision.Accepted.Should().BeFalse();
decision.Reason.Should().Be(DownloadRejectionReason.HistoryRecentCutoffMet);
}
}
}

View file

@ -73,5 +73,6 @@ public enum DownloadRejectionReason
DiskCustomFormatCutoffMet,
DiskCustomFormatScore,
DiskCustomFormatScoreIncrement,
DiskUpgradesNotAllowed
DiskUpgradesNotAllowed,
BetterQuality
}

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
@ -9,6 +8,8 @@ using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.History;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync
{
@ -52,61 +53,83 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync
foreach (var episode in subject.Episodes)
{
_logger.Debug("Checking current status of episode [{0}] in history", episode.Id);
var mostRecent = _historyService.MostRecentForEpisode(episode.Id);
var episodeHistories = _historyService.FindByEpisodeId(episode.Id);
if (mostRecent == null)
if (episodeHistories == null || !episodeHistories.Any())
{
continue;
}
if (mostRecent.EventType == EpisodeHistoryEventType.Grabbed)
// Check for grabbed events first
var rejectionDecision = CheckGrabbedEvents(episodeHistories, subject, qualityProfile, cdhEnabled);
if (!rejectionDecision.Accepted)
{
var recent = mostRecent.Date.After(DateTime.UtcNow.AddHours(-12));
return rejectionDecision;
}
if (!recent && cdhEnabled)
// Then check for file events if episode has a file
if (episode.HasFile)
{
rejectionDecision = CheckLastImportedFile(episodeHistories, subject, qualityProfile);
if (!rejectionDecision.Accepted)
{
continue;
return rejectionDecision;
}
}
}
return DownloadSpecDecision.Accept();
}
private DownloadSpecDecision CheckGrabbedEvents(List<EpisodeHistory> histories, RemoteEpisode subject, QualityProfile qualityProfile, bool cdhEnabled)
{
foreach (var history in histories.Where(h => h.EventType == EpisodeHistoryEventType.Grabbed))
{
var customFormats = _formatService.ParseCustomFormat(history, subject.Series);
var recent = history.Date.After(DateTime.UtcNow.AddHours(-12));
if (!recent && cdhEnabled)
{
continue;
}
var cutoffUnmet = _upgradableSpecification.CutoffNotMet(
qualityProfile,
history.Quality,
customFormats,
subject.ParsedEpisodeInfo.Quality);
var upgradeableRejectReason = _upgradableSpecification.IsUpgradable(
qualityProfile,
history.Quality,
customFormats,
subject.ParsedEpisodeInfo.Quality,
subject.CustomFormats);
if (!cutoffUnmet)
{
if (recent)
{
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryRecentCutoffMet,
"Recent grab event in history already meets cutoff: {0}",
history.Quality);
}
var customFormats = _formatService.ParseCustomFormat(mostRecent, subject.Series);
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryCdhDisabledCutoffMet, "CDH is disabled and grab event in history already meets cutoff: {0}", history.Quality);
}
// The series will be the same as the one in history since it's the same episode.
// Instead of fetching the series from the DB reuse the known series.
var cutoffUnmet = _upgradableSpecification.CutoffNotMet(
subject.Series.QualityProfile,
mostRecent.Quality,
customFormats,
subject.ParsedEpisodeInfo.Quality);
var rejectionSubject = recent ? "Recent" : "CDH is disabled and";
var upgradeableRejectReason = _upgradableSpecification.IsUpgradable(
subject.Series.QualityProfile,
mostRecent.Quality,
customFormats,
subject.ParsedEpisodeInfo.Quality,
subject.CustomFormats);
if (!cutoffUnmet)
{
if (recent)
{
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryRecentCutoffMet, "Recent grab event in history already meets cutoff: {0}", mostRecent.Quality);
}
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryCdhDisabledCutoffMet, "CDH is disabled and grab event in history already meets cutoff: {0}", mostRecent.Quality);
}
var rejectionSubject = recent ? "Recent" : "CDH is disabled and";
switch (upgradeableRejectReason)
switch (upgradeableRejectReason)
{
case UpgradeableRejectReason.None:
continue;
case UpgradeableRejectReason.BetterQuality:
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryHigherPreference, "{0} grab event in history is of equal or higher preference: {1}", rejectionSubject, mostRecent.Quality);
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryHigherPreference, "{0} grab event in history is of equal or higher preference: {1}", rejectionSubject, history.Quality);
case UpgradeableRejectReason.BetterRevision:
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryHigherRevision, "{0} grab event in history is of equal or higher revision: {1}", rejectionSubject, mostRecent.Quality.Revision);
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryHigherRevision, "{0} grab event in history is of equal or higher revision: {1}", rejectionSubject, history.Quality.Revision);
case UpgradeableRejectReason.QualityCutoff:
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryCutoffMet, "{0} grab event in history meets quality cutoff: {1}", rejectionSubject, qualityProfile.Items[qualityProfile.GetIndex(qualityProfile.Cutoff).Index]);
@ -123,45 +146,58 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync
case UpgradeableRejectReason.UpgradesNotAllowed:
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryUpgradesNotAllowed, "{0} grab event in history and Quality Profile '{1}' does not allow upgrades", rejectionSubject, qualityProfile.Name);
}
}
if (episode is { HasFile: true })
{
EpisodeHistory availableUsableEpisodeHistoryForCustomFormatScore;
var episodeHistoryEventTypeForHistoryComparison = new List<EpisodeHistoryEventType>
{
EpisodeHistoryEventType.Grabbed,
EpisodeHistoryEventType.SeriesFolderImported,
};
if (episodeHistoryEventTypeForHistoryComparison.Contains(mostRecent.EventType))
{
availableUsableEpisodeHistoryForCustomFormatScore = mostRecent;
}
else
{
availableUsableEpisodeHistoryForCustomFormatScore = _historyService.MostRecentForEpisodeInEventCollection(
episode.Id,
new ReadOnlyCollection<EpisodeHistoryEventType>(episodeHistoryEventTypeForHistoryComparison));
}
if (availableUsableEpisodeHistoryForCustomFormatScore == null)
{
continue;
}
var mostRecentCustomFormat = _formatService.ParseCustomFormat(availableUsableEpisodeHistoryForCustomFormatScore, subject.Series);
var mostRecentCustomFormatScore = qualityProfile.CalculateCustomFormatScore(mostRecentCustomFormat);
if (mostRecentCustomFormatScore > subject.CustomFormatScore)
{
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryCustomFormatScore, "Quality Profile '{0}' has a higher Custom Format score than the report: {1}", qualityProfile.Name, subject.CustomFormatScore);
}
}
}
return DownloadSpecDecision.Accept();
}
private DownloadSpecDecision CheckLastImportedFile(List<EpisodeHistory> histories, RemoteEpisode subject, QualityProfile qualityProfile)
{
var newQuality = subject.ParsedEpisodeInfo.Quality;
var relevantHistorie = histories.FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.SeriesFolderImported);
if (relevantHistorie == null)
{
return DownloadSpecDecision.Accept();
}
var qualityComparer = new QualityModelComparer(qualityProfile);
var qualityCompare = qualityComparer.Compare(newQuality, relevantHistorie.Quality);
var historyCustomFormats = _formatService.ParseCustomFormat(relevantHistorie, subject.Series);
var historyCustomFormatScore = qualityProfile.CalculateCustomFormatScore(historyCustomFormats);
// New release has better quality - always accept
if (qualityCompare > 0)
{
return DownloadSpecDecision.Accept();
}
// New release has worse quality - reject
if (qualityCompare < 0)
{
var reject = DownloadSpecDecision.Reject(DownloadRejectionReason.BetterQuality, "Existing item has better quality, skipping. Existing: {0}. New: {1}", relevantHistorie.Quality, newQuality);
_logger.Debug(reject.Message);
return reject;
}
// Quality is the same, check custom format score
if (subject.CustomFormatScore > historyCustomFormatScore)
{
// New release has better custom format score
return DownloadSpecDecision.Accept();
}
else
{
// New release has same or worse custom format score
var reject = DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryCustomFormatScore,
"New item's custom formats [{0}] ({1}) do not improve on [{2}] ({3}), skipping",
subject.CustomFormats.ConcatToString(),
subject.CustomFormatScore,
historyCustomFormats?.ConcatToString(),
historyCustomFormatScore);
_logger.Debug(reject.Message);
return reject;
}
}
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
@ -21,7 +20,6 @@ namespace NzbDrone.Core.History
void DeleteForSeries(List<int> seriesIds);
List<EpisodeHistory> Since(DateTime date, EpisodeHistoryEventType? eventType);
PagingSpec<EpisodeHistory> GetPaged(PagingSpec<EpisodeHistory> pagingSpec, int[] languages, int[] qualities);
EpisodeHistory MostRecentForEpisodeInEventCollection(int id, ReadOnlyCollection<EpisodeHistoryEventType> episodeHistoryEventTypes);
}
public class HistoryRepository : BasicRepository<EpisodeHistory>, IHistoryRepository
@ -134,11 +132,6 @@ namespace NzbDrone.Core.History
return pagingSpec;
}
public EpisodeHistory MostRecentForEpisodeInEventCollection(int id, ReadOnlyCollection<EpisodeHistoryEventType> episodeHistoryEventTypes)
{
return Query(x => x.EpisodeId == id).Where(x => episodeHistoryEventTypes.Contains(x.EventType)).MaxBy(h => h.Date);
}
private SqlBuilder PagedBuilder(int[] languages, int[] qualities)
{
var builder = Builder()

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using NLog;
@ -28,7 +27,6 @@ namespace NzbDrone.Core.History
List<EpisodeHistory> FindByDownloadId(string downloadId);
string FindDownloadId(EpisodeImportedEvent trackedDownload);
List<EpisodeHistory> Since(DateTime date, EpisodeHistoryEventType? eventType);
EpisodeHistory MostRecentForEpisodeInEventCollection(int id, ReadOnlyCollection<EpisodeHistoryEventType> episodeHistoryEventTypes);
}
public class HistoryService : IHistoryService,
@ -367,10 +365,5 @@ namespace NzbDrone.Core.History
{
return _historyRepository.Since(date, eventType);
}
public EpisodeHistory MostRecentForEpisodeInEventCollection(int id, ReadOnlyCollection<EpisodeHistoryEventType> episodeHistoryEventTypes)
{
return _historyRepository.MostRecentForEpisodeInEventCollection(id, episodeHistoryEventTypes);
}
}
}