diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AlreadyImportedSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AlreadyImportedSpecificationFixture.cs new file mode 100644 index 000000000..1821563ef --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AlreadyImportedSpecificationFixture.cs @@ -0,0 +1,163 @@ +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.History; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class AlreadyImportedSpecificationFixture : CoreTest + { + private const int FIRST_EPISODE_ID = 1; + private const string TITLE = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; + + private Series _series; + private QualityModel _hdtv720p; + private QualityModel _hdtv1080p; + private RemoteEpisode _remoteEpisode; + private List _history; + + [SetUp] + public void Setup() + { + var singleEpisodeList = new List + { + new Episode + { + Id = FIRST_EPISODE_ID, + SeasonNumber = 12, + EpisodeNumber = 3, + EpisodeFileId = 1 + } + }; + + _series = Builder.CreateNew() + .Build(); + + _hdtv720p = new QualityModel(Quality.HDTV720p, new Revision(version: 1)); + _hdtv1080p = new QualityModel(Quality.HDTV1080p, new Revision(version: 1)); + + _remoteEpisode = new RemoteEpisode + { + Series = _series, + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = _hdtv720p }, + Episodes = singleEpisodeList, + Release = Builder.CreateNew() + .Build() + }; + + _history = new List(); + + Mocker.GetMock() + .SetupGet(s => s.EnableCompletedDownloadHandling) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.FindByEpisodeId(It.IsAny())) + .Returns(_history); + } + + private void GivenCdhDisabled() + { + Mocker.GetMock() + .SetupGet(s => s.EnableCompletedDownloadHandling) + .Returns(false); + } + + private void GivenHistoryItem(string downloadId, string sourceTitle, QualityModel quality, HistoryEventType eventType) + { + _history.Add(new History.History + { + DownloadId = downloadId, + SourceTitle = sourceTitle, + Quality = quality, + Date = DateTime.UtcNow, + EventType = eventType + }); + } + + [Test] + public void should_be_accepted_if_CDH_is_disabled() + { + GivenCdhDisabled(); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_episode_does_not_have_a_file() + { + _remoteEpisode.Episodes.First().EpisodeFileId = 0; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_episode_does_not_have_grabbed_event() + { + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_episode_does_not_have_imported_event() + { + GivenHistoryItem(Guid.NewGuid().ToString().ToUpper(), TITLE, _hdtv720p, HistoryEventType.Grabbed); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_grabbed_and_imported_quality_is_the_same() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _hdtv720p, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _hdtv720p, HistoryEventType.DownloadFolderImported); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_rejected_if_grabbed_download_id_matches_release_torrent_hash() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _hdtv720p, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _hdtv1080p, HistoryEventType.DownloadFolderImported); + + _remoteEpisode.Release = Builder.CreateNew() + .With(t => t.DownloadProtocol = DownloadProtocol.Torrent) + .With(t => t.InfoHash = downloadId) + .Build(); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_rejected_if_release_title_matches_grabbed_event_source_title() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _hdtv720p, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _hdtv1080p, HistoryEventType.DownloadFolderImported); + + _remoteEpisode.Release = Builder.CreateNew() + .With(t => t.DownloadProtocol = DownloadProtocol.Torrent) + .With(t => t.InfoHash = downloadId) + .Build(); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/HistorySpecificationFixture.cs similarity index 99% rename from src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs rename to src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/HistorySpecificationFixture.cs index 25a10b498..e781daab9 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/HistorySpecificationFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; @@ -16,7 +16,7 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Test.Framework; -namespace NzbDrone.Core.Test.DecisionEngineTests +namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync { [TestFixture] public class HistorySpecificationFixture : CoreTest diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualityFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualityFixture.cs deleted file mode 100644 index ba7d0e1d2..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualityFixture.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Collections.Generic; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Download; -using NzbDrone.Core.History; -using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications -{ - [TestFixture] - public class GrabbedReleaseQualityFixture : CoreTest - { - private LocalEpisode _localEpisode; - private DownloadClientItem _downloadClientItem; - - [SetUp] - public void Setup() - { - _localEpisode = Builder.CreateNew() - .With(l => l.Quality = new QualityModel(Quality.Bluray720p)) - .Build(); - - _downloadClientItem = Builder.CreateNew() - .Build(); - } - - private void GivenHistory(List history) - { - Mocker.GetMock() - .Setup(s => s.FindByDownloadId(It.IsAny())) - .Returns(history); - } - - [Test] - public void should_be_accepted_when_downloadClientItem_is_null() - { - Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_no_history_for_downloadId() - { - GivenHistory(new List()); - - Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_no_grabbed_history_for_downloadId() - { - var history = Builder.CreateListOfSize(1) - .All() - .With(h => h.EventType = HistoryEventType.Unknown) - .BuildList(); - - GivenHistory(history); - - Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_grabbed_history_is_for_a_season_pack() - { - var history = Builder.CreateListOfSize(1) - .All() - .With(h => h.EventType = HistoryEventType.Grabbed) - .With(h => h.Quality = _localEpisode.Quality) - .With(h => h.SourceTitle = "Series.Title.S01.720p.HDTV.x264-RlsGroup") - .BuildList(); - - GivenHistory(history); - - Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_grabbed_history_quality_is_unknown() - { - var history = Builder.CreateListOfSize(1) - .All() - .With(h => h.EventType = HistoryEventType.Grabbed) - .With(h => h.Quality = new QualityModel(Quality.Unknown)) - .BuildList(); - - GivenHistory(history); - - Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_grabbed_history_quality_matches() - { - var history = Builder.CreateListOfSize(1) - .All() - .With(h => h.EventType = HistoryEventType.Grabbed) - .With(h => h.Quality = _localEpisode.Quality) - .BuildList(); - - GivenHistory(history); - - Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_rejected_if_grabbed_history_quality_does_not_match() - { - var history = Builder.CreateListOfSize(1) - .All() - .With(h => h.EventType = HistoryEventType.Grabbed) - .With(h => h.Quality = new QualityModel(Quality.HDTV720p)) - .BuildList(); - - GivenHistory(history); - - Subject.IsSatisfiedBy(_localEpisode, _downloadClientItem).Accepted.Should().BeFalse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index ab85a93a4..401a909da 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -166,7 +166,8 @@ - + + @@ -316,7 +317,6 @@ - diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AlreadyImportedSpecification.cs new file mode 100644 index 000000000..14d7cb989 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AlreadyImportedSpecification.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.History; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class AlreadyImportedSpecification : IDecisionEngineSpecification + { + private readonly IHistoryService _historyService; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public AlreadyImportedSpecification(IHistoryService historyService, + IConfigService configService, + Logger logger) + { + _historyService = historyService; + _configService = configService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Database; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + var cdhEnabled = _configService.EnableCompletedDownloadHandling; + + if (!cdhEnabled) + { + _logger.Debug("Skipping already imported check because CDH is disabled"); + return Decision.Accept(); + } + + _logger.Debug("Performing alerady imported check on report"); + foreach (var episode in subject.Episodes) + { + if (!episode.HasFile) + { + _logger.Debug("Skipping already imported check for episode without file"); + continue; + } + + var historyForEpisode = _historyService.FindByEpisodeId(episode.Id); + var lastGrabbed = historyForEpisode.FirstOrDefault(h => h.EventType == HistoryEventType.Grabbed); + + if (lastGrabbed == null) + { + continue; + } + + var imported = historyForEpisode.FirstOrDefault(h => + h.EventType == HistoryEventType.DownloadFolderImported && + h.DownloadId == lastGrabbed.DownloadId); + + if (imported == null) + { + continue; + } + + // This is really only a guard against redownloading the same release over + // and over when the grabbed and imported qualities do not match, if they do + // match skip this check. + if (lastGrabbed.Quality.Equals(imported.Quality)) + { + continue; + } + + var release = subject.Release; + + if (release.DownloadProtocol == DownloadProtocol.Torrent) + { + var torrentInfo = release as TorrentInfo; + + if (torrentInfo != null && torrentInfo.InfoHash.ToUpper() == lastGrabbed.DownloadId) + { + _logger.Debug("Has same torrent hash as a grabbed and imported release"); + return Decision.Reject("Has same torrent hash as a grabbed and imported release"); + } + } + + // Only based on title because a release with the same title on another indexer/released at + // a different time very likely has the exact same content and we don't need to also try it. + + if (release.Title.Equals(lastGrabbed.SourceTitle, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Has same release name as a grabbed and imported release"); + return Decision.Reject("Has same release name as a grabbed and imported release"); + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/History/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs index bfc1aeb85..067af8923 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.History { List GetBestQualityInHistory(int episodeId); History MostRecentForEpisode(int episodeId); + List FindByEpisodeId(int episodeId); History MostRecentForDownloadId(string downloadId); List FindByDownloadId(string downloadId); List FindDownloadHistory(int idSeriesId, QualityModel quality); @@ -43,6 +44,13 @@ namespace NzbDrone.Core.History .FirstOrDefault(); } + public List FindByEpisodeId(int episodeId) + { + return Query.Where(h => h.EpisodeId == episodeId) + .OrderByDescending(h => h.Date) + .ToList(); + } + public History MostRecentForDownloadId(string downloadId) { return Query.Where(h => h.DownloadId == downloadId) diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 0f835b5a4..d01fa2ba8 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.History QualityModel GetBestQualityInHistory(Profile profile, int episodeId); PagingSpec Paged(PagingSpec pagingSpec); History MostRecentForEpisode(int episodeId); + List FindByEpisodeId(int episodeId); History MostRecentForDownloadId(string downloadId); History Get(int historyId); List Find(string downloadId, HistoryEventType eventType); @@ -55,6 +56,11 @@ namespace NzbDrone.Core.History return _historyRepository.MostRecentForEpisode(episodeId); } + public List FindByEpisodeId(int episodeId) + { + return _historyRepository.FindByEpisodeId(episodeId); + } + public History MostRecentForDownloadId(string downloadId) { return _historyRepository.MostRecentForDownloadId(downloadId); diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualitySpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualitySpecification.cs deleted file mode 100644 index 1ea418d74..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/GrabbedReleaseQualitySpecification.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.History; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class GrabbedReleaseQualitySpecification : IImportDecisionEngineSpecification - { - private readonly IHistoryService _historyService; - private readonly Logger _logger; - - public GrabbedReleaseQualitySpecification(IHistoryService historyService, Logger logger) - { - _historyService = historyService; - _logger = logger; - } - public Decision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) - { - if (downloadClientItem == null) - { - _logger.Debug("No download client item provided, skipping."); - return Decision.Accept(); - } - - var grabbedHistory = _historyService.FindByDownloadId(downloadClientItem.DownloadId) - .Where(h => h.EventType == HistoryEventType.Grabbed) - .ToList(); - - if (grabbedHistory.Empty()) - { - _logger.Debug("No grabbed history for this download client item"); - return Decision.Accept(); - } - - var parsedReleaseName = Parser.Parser.ParseTitle(grabbedHistory.First().SourceTitle); - - if (parsedReleaseName != null && parsedReleaseName.FullSeason) - { - _logger.Debug("File is part of a season pack, skipping."); - return Decision.Accept(); - } - - foreach (var item in grabbedHistory) - { - if (item.Quality.Quality != Quality.Unknown && item.Quality != localEpisode.Quality) - { - _logger.Debug("Quality for grabbed release ({0}) does not match the quality of the file ({1})", item.Quality, localEpisode.Quality); - return Decision.Reject("File quality does not match quality of the grabbed release"); - } - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 49383c194..10b84d1bb 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -143,6 +143,7 @@ + @@ -817,7 +818,6 @@ -