From aeb8ee06f6d5cbd7766d47aa07b6882c3fc04d86 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 6 Jul 2013 14:47:49 -0700 Subject: [PATCH] Episode import uses specs and moves before import now --- .../DownloadDecisionMakerFixture.cs | 2 +- .../MediaFileTests/EpisodeFileMoverFixture.cs | 6 +- .../ImportDecisionMakerFixture.cs | 155 +++++++++ .../NotAlreadyImportedSpecificationFixture.cs | 48 +++ .../NotSampleSpecificationFixture.cs | 122 +++++++ .../UpgradeSpecificationFixture.cs | 159 +++++++++ NzbDrone.Core.Test/NzbDrone.Core.Test.csproj | 5 +- .../ImportFileFixture.cs | 303 ------------------ .../DropFolderImportServiceFixture.cs | 78 +---- NzbDrone.Core/MediaFiles/DiskScanService.cs | 86 +---- .../DownloadedEpisodesImportService.cs | 33 +- .../MediaFiles/EpisodeFileMovingService.cs | 65 ++-- .../IImportDecisionEngineSpecification.cs | 14 + .../EpisodeImport/ImportApprovedEpisodes.cs | 94 ++++++ .../EpisodeImport/ImportDecision.cs | 28 ++ .../EpisodeImport/ImportDecisionMaker.cs | 106 ++++++ .../NotAlreadyImportedSpecification.cs | 34 ++ .../Specifications/NotSampleSpecification.cs | 54 ++++ .../Specifications/UpgradeSpecification.cs | 32 ++ NzbDrone.Core/NzbDrone.Core.csproj | 7 + NzbDrone.Core/Parser/Model/LocalEpisode.cs | 5 +- NzbDrone.Core/Parser/ParsingService.cs | 3 +- 22 files changed, 941 insertions(+), 498 deletions(-) create mode 100644 NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/ImportDecisionMakerFixture.cs create mode 100644 NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotAlreadyImportedSpecificationFixture.cs create mode 100644 NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotSampleSpecificationFixture.cs create mode 100644 NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/UpgradeSpecificationFixture.cs delete mode 100644 NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/ImportFileFixture.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotAlreadyImportedSpecification.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs create mode 100644 NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs diff --git a/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs b/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs index 6b9ecaa02..d0654cdd1 100644 --- a/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs +++ b/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs @@ -199,7 +199,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] - public void should_return_unknown_series_rejection_if_series_is_unknow() + public void should_return_unknown_series_rejection_if_series_is_unknown() { GivenSpecifications(_pass1, _pass2, _pass3); diff --git a/NzbDrone.Core.Test/MediaFileTests/EpisodeFileMoverFixture.cs b/NzbDrone.Core.Test/MediaFileTests/EpisodeFileMoverFixture.cs index 34ca00cb5..fd62b1114 100644 --- a/NzbDrone.Core.Test/MediaFileTests/EpisodeFileMoverFixture.cs +++ b/NzbDrone.Core.Test/MediaFileTests/EpisodeFileMoverFixture.cs @@ -55,7 +55,7 @@ namespace NzbDrone.Core.Test.MediaFileTests .Setup(e => e.BuildFilePath(It.IsAny(), fakeEpisode.First().SeasonNumber, filename, ".avi")) .Returns(fi); - var result = Subject.MoveEpisodeFile(file, false); + var result = Subject.MoveEpisodeFile(file); result.Should().BeNull(); } @@ -106,7 +106,7 @@ namespace NzbDrone.Core.Test.MediaFileTests .Setup(s => s.FileExists(currentFilename)) .Returns(true); - var result = Subject.MoveEpisodeFile(file, true); + var result = Subject.MoveEpisodeFile(file); } @@ -153,7 +153,7 @@ namespace NzbDrone.Core.Test.MediaFileTests .Setup(e => e.BuildFilePath(It.IsAny(), fakeEpisode.First().SeasonNumber, filename, ".mkv")) .Returns(fi); - var result = Subject.MoveEpisodeFile(file, true); + var result = Subject.MoveEpisodeFile(file); result.Should().BeNull(); ExceptionVerification.ExpectedErrors(1); diff --git a/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/ImportDecisionMakerFixture.cs b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/ImportDecisionMakerFixture.cs new file mode 100644 index 000000000..114bd4b15 --- /dev/null +++ b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/ImportDecisionMakerFixture.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFileTests.EpisodeImportTests +{ + [TestFixture] + public class ImportDecisionMakerFixture : CoreTest + { + private List _videoFiles; + private LocalEpisode _localEpisode; + private Series _series; + + private Mock _pass1; + private Mock _pass2; + private Mock _pass3; + + private Mock _fail1; + private Mock _fail2; + private Mock _fail3; + + [SetUp] + public void Setup() + { + _pass1 = new Mock(); + _pass2 = new Mock(); + _pass3 = new Mock(); + + _fail1 = new Mock(); + _fail2 = new Mock(); + _fail3 = new Mock(); + + _pass1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(true); + _pass1.Setup(c => c.RejectionReason).Returns("_pass1"); + + _pass2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(true); + _pass2.Setup(c => c.RejectionReason).Returns("_pass2"); + + _pass3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(true); + _pass3.Setup(c => c.RejectionReason).Returns("_pass3"); + + + _fail1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(false); + _fail1.Setup(c => c.RejectionReason).Returns("_fail1"); + + _fail2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(false); + _fail2.Setup(c => c.RejectionReason).Returns("_fail2"); + + _fail3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(false); + _fail3.Setup(c => c.RejectionReason).Returns("_fail3"); + + _videoFiles = new List { "The.Office.S03E115.DVDRip.XviD-OSiTV" }; + _series = new Series(); + _localEpisode = new LocalEpisode { Series = _series, Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi" }; + + Mocker.GetMock().Setup(c => c.GetEpisodes(It.IsAny(), It.IsAny())) + .Returns(_localEpisode); + + } + + private void GivenSpecifications(params Mock[] mocks) + { + Mocker.SetConstant>(mocks.Select(c => c.Object)); + } + + [Test] + public void should_call_all_specifications() + { + GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); + + Subject.GetImportDecisions(_videoFiles, new Series()); + + _fail1.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + _fail2.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + _fail3.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + _pass1.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + _pass2.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + _pass3.Verify(c => c.IsSatisfiedBy(_localEpisode), Times.Once()); + } + + + [Test] + public void should_return_rejected_if_single_specs_fail() + { + GivenSpecifications(_fail1); + + var result = Subject.GetImportDecisions(_videoFiles, new Series()); + + result.Single().Approved.Should().BeFalse(); + } + + [Test] + public void should_return_rejected_if_one_of_specs_fail() + { + GivenSpecifications(_pass1, _fail1, _pass2, _pass3); + + var result = Subject.GetImportDecisions(_videoFiles, new Series()); + + result.Single().Approved.Should().BeFalse(); + } + + [Test] + public void should_return_pass_if_all_specs_pass() + { + GivenSpecifications(_pass1, _pass2, _pass3); + + var result = Subject.GetImportDecisions(_videoFiles, new Series()); + + result.Single().Approved.Should().BeTrue(); + } + + [Test] + public void should_have_same_number_of_rejections_as_specs_that_failed() + { + GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); + + var result = Subject.GetImportDecisions(_videoFiles, new Series()); + result.Single().Rejections.Should().HaveCount(3); + } + + [Test] + public void failed_parse_shouldnt_blowup_the_process() + { + GivenSpecifications(_pass1); + + Mocker.GetMock().Setup(c => c.GetEpisodes(It.IsAny(), It.IsAny())) + .Throws(); + + _videoFiles = new List + { + "The.Office.S03E115.DVDRip.XviD-OSiTV", + "The.Office.S03E115.DVDRip.XviD-OSiTV", + "The.Office.S03E115.DVDRip.XviD-OSiTV" + }; + + Subject.GetImportDecisions(_videoFiles, new Series()); + + Mocker.GetMock() + .Verify(c => c.GetEpisodes(It.IsAny(), It.IsAny()), Times.Exactly(_videoFiles.Count)); + + ExceptionVerification.ExpectedErrors(3); + } + } + +} \ No newline at end of file diff --git a/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotAlreadyImportedSpecificationFixture.cs b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotAlreadyImportedSpecificationFixture.cs new file mode 100644 index 000000000..ce7dde436 --- /dev/null +++ b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotAlreadyImportedSpecificationFixture.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.MediaFileTests.EpisodeImportTests +{ + [TestFixture] + public class NotAlreadyImportedSpecificationFixture : CoreTest + { + private LocalEpisode _localEpisode; + + [SetUp] + public void Setup() + { + _localEpisode = new LocalEpisode + { + Path = @"C:\Test\30 Rock\30.rock.s01e01.avi" + }; + } + + [Test] + public void should_return_false_if_path_is_already_in_episodeFiles() + { + Mocker.GetMock() + .Setup(s => s.Exists(_localEpisode.Path)) + .Returns(true); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeFalse(); + } + + [Test] + public void should_return_true_if_new_file() + { + Mocker.GetMock() + .Setup(s => s.Exists(_localEpisode.Path)) + .Returns(false); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + } +} diff --git a/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotSampleSpecificationFixture.cs b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotSampleSpecificationFixture.cs new file mode 100644 index 000000000..c55fc69be --- /dev/null +++ b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/NotSampleSpecificationFixture.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Providers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.MediaFileTests.EpisodeImportTests +{ + [TestFixture] + public class NotSampleSpecificationFixture : CoreTest + { + private Series _series; + private LocalEpisode _localEpisode; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew() + .With(s => s.SeriesType = SeriesTypes.Standard) + .Build(); + + var episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.SeasonNumber = 1) + .Build() + .ToList(); + + _localEpisode = new LocalEpisode + { + Path = @"C:\Test\30 Rock\30.rock.s01e01.avi", + Episodes = episodes, + Series = _series + }; + } + + private void WithDailySeries() + { + _series.SeriesType = SeriesTypes.Daily; + } + + private void WithSeasonZero() + { + _localEpisode.Episodes[0].SeasonNumber = 0; + } + + private void WithFileSize(long size) + { + Mocker.GetMock() + .Setup(s => s.GetFileSize(It.IsAny())) + .Returns(size); + } + + private void WithLength(int minutes) + { + Mocker.GetMock() + .Setup(s => s.GetRunTime(It.IsAny())) + .Returns(new TimeSpan(0, 0, minutes, 0)); + } + + [Test] + public void should_return_true_if_series_is_daily() + { + WithDailySeries(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_season_zero() + { + WithSeasonZero(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_false_if_undersize_and_under_length() + { + WithFileSize(10.Megabytes()); + WithLength(1); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeFalse(); + } + + [Test] + public void should_return_true_if_undersize() + { + WithFileSize(10.Megabytes()); + WithLength(10); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_under_length() + { + WithFileSize(100.Megabytes()); + WithLength(1); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_over_size_and_length() + { + WithFileSize(100.Megabytes()); + WithLength(10); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + } +} diff --git a/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/UpgradeSpecificationFixture.cs b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/UpgradeSpecificationFixture.cs new file mode 100644 index 000000000..8ebc05dde --- /dev/null +++ b/NzbDrone.Core.Test/MediaFileTests/EpisodeImportTests/UpgradeSpecificationFixture.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FizzWare.NBuilder; +using FluentAssertions; +using Marr.Data; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Providers; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.MediaFileTests.EpisodeImportTests +{ + [TestFixture] + public class UpgradeSpecificationFixture : CoreTest + { + private Series _series; + private LocalEpisode _localEpisode; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew() + .With(s => s.SeriesType = SeriesTypes.Standard) + .Build(); + + _localEpisode = new LocalEpisode + { + Path = @"C:\Test\30 Rock\30.rock.s01e01.avi", + Quality = new QualityModel(Quality.HDTV720p, false) + }; + } + + [Test] + public void should_return_true_if_no_existing_episodeFile() + { + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.EpisodeFileId = 0) + .With(e => e.EpisodeFile = null) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_no_existing_episodeFile_for_multi_episodes() + { + _localEpisode.Episodes = Builder.CreateListOfSize(2) + .All() + .With(e => e.EpisodeFileId = 0) + .With(e => e.EpisodeFile = null) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_upgrade_for_existing_episodeFile() + { + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Quality = new QualityModel(Quality.SDTV, false) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_upgrade_for_existing_episodeFile_for_multi_episodes() + { + _localEpisode.Episodes = Builder.CreateListOfSize(2) + .All() + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Quality = new QualityModel(Quality.SDTV, false) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeTrue(); + } + + [Test] + public void should_return_false_if_not_an_upgrade_for_existing_episodeFile() + { + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Quality = new QualityModel(Quality.Bluray720p, false) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeFalse(); + } + + [Test] + public void should_return_false_if_not_an_upgrade_for_existing_episodeFile_for_multi_episodes() + { + _localEpisode.Episodes = Builder.CreateListOfSize(2) + .All() + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Quality = new QualityModel(Quality.Bluray720p, false) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeFalse(); + } + + [Test] + public void should_return_false_if_not_an_upgrade_for_one_existing_episodeFile_for_multi_episode() + { + _localEpisode.Episodes = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Quality = new QualityModel(Quality.SDTV, false) + })) + .TheNext(1) + .With(e => e.EpisodeFileId = 2) + .With(e => e.EpisodeFile = new LazyLoaded( + new EpisodeFile + { + Quality = new QualityModel(Quality.Bluray720p, false) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localEpisode).Should().BeFalse(); + } + } +} diff --git a/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 4fde1c4da..ed81e130a 100644 --- a/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -148,6 +148,10 @@ + + + + @@ -200,7 +204,6 @@ - diff --git a/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/ImportFileFixture.cs b/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/ImportFileFixture.cs deleted file mode 100644 index 6efbda31f..000000000 --- a/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/ImportFileFixture.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Providers; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ProviderTests.DiskScanProviderTests -{ - - public class ImportFileFixture : CoreTest - { - - - private long _fileSize = 80.Megabytes(); - private Series _fakeSeries; - private Episode[] _fakeEpisodes; - private Episode _fakeEpisode; - - [SetUp] - public void Setup() - { - _fakeSeries = Builder - .CreateNew() - .Build(); - - _fakeEpisode = Builder - .CreateNew() - .With(c => c.EpisodeFileId = 0) - .Build(); - - - _fakeEpisodes = Builder.CreateListOfSize(2) - .All() - .With(c => c.SeasonNumber = 3) - .With(c => c.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new EpisodeFile()) - .BuildList().ToArray(); - - GivenNewFile(); - - GivenVideoDuration(TimeSpan.FromMinutes(20)); - - GivenFileSize(_fileSize); - - } - - private void GivenFileSize(long size) - { - _fileSize = size; - - Mocker.GetMock() - .Setup(d => d.GetFileSize(It.IsAny())) - .Returns(size); - } - - private void GivenVideoDuration(TimeSpan duration) - { - Mocker.GetMock() - .Setup(d => d.GetRunTime(It.IsAny())) - .Returns(duration); - } - - - private void GivenEpisodes(Episode[] episodes, QualityModel quality) - { - foreach (var episode in episodes) - { - if (episode.EpisodeFile == null) - { - episode.EpisodeFileId = 0; - } - else - { - episode.EpisodeFileId = episode.EpisodeFile.Value.Id; - } - } - - Mocker.GetMock() - .Setup(c => c.GetEpisodes(It.IsAny(), It.IsAny())) - .Returns(new LocalEpisode - { - Episodes = episodes.ToList(), - Quality = quality - }); - } - - private void GivenNewFile() - { - Mocker.GetMock() - .Setup(p => p.Exists(It.IsAny())) - .Returns(false); - } - - [Test] - public void import_new_file_should_succeed() - { - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel()); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - VerifyFileImport(result); - } - - - - [Test] - public void import_new_file_with_same_quality_should_succeed() - { - _fakeEpisode.EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }; - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.SDTV)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - VerifyFileImport(result); - } - - [Test] - public void import_new_file_with_better_quality_should_succeed() - { - _fakeEpisode.EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }; - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - VerifyFileImport(result); - } - - [Test] - public void import_new_file_episode_has_better_quality_should_skip() - { - _fakeEpisode.EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV1080p), Id = 1 }; - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.SDTV)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifySkipImport(result); - } - - - [Test] - public void import_unparsable_file_should_skip() - { - Mocker.GetMock() - .Setup(c => c.GetEpisodes(It.IsAny(), It.IsAny())) - .Returns(null); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - - VerifySkipImport(result); - } - - [Test] - public void import_existing_file_should_skip() - { - Mocker.GetMock() - .Setup(p => p.Exists(It.IsAny())) - .Returns(true); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifySkipImport(result); - } - - [Test] - public void import_file_with_no_episode_in_db_should_skip() - { - GivenEpisodes(new Episode[0], new QualityModel()); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifySkipImport(result); - } - - [Test] - public void import_new_multi_part_file_episode_with_better_quality_than_existing() - { - _fakeEpisodes[0].EpisodeFile = new EpisodeFile(); - _fakeEpisodes[1].EpisodeFile = new EpisodeFile(); - - _fakeEpisodes[0].EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }; - _fakeEpisodes[1].EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }; - - GivenEpisodes(_fakeEpisodes, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - - VerifyFileImport(result); - } - - [Test] - public void skip_import_new_multi_part_file_episode_existing_has_better_quality() - { - _fakeEpisodes[0].EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV1080p), Id = 1 }; - _fakeEpisodes[1].EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV1080p), Id = 1 }; - - GivenEpisodes(_fakeEpisodes, new QualityModel(Quality.SDTV)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - - VerifySkipImport(result); - } - - - [Test] - public void should_skip_if_file_size_is_under_70MB_and_runTime_under_3_minutes() - { - GivenFileSize(50.Megabytes()); - GivenVideoDuration(TimeSpan.FromMinutes(1)); - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifySkipImport(result); - } - - [Test] - public void should_import_if_file_size_is_under_70MB_but_runTime_over_3_minutes() - { - GivenFileSize(50.Megabytes()); - GivenVideoDuration(TimeSpan.FromMinutes(20)); - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifyFileImport(result); - Mocker.GetMock().Verify(p => p.DeleteFile(It.IsAny()), Times.Never()); - } - - [Test] - public void should_import_if_file_size_is_over_70MB_but_runTime_under_3_minutes() - { - GivenFileSize(100.Megabytes()); - GivenVideoDuration(TimeSpan.FromMinutes(1)); - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifyFileImport(result); - } - - [Test] - public void should_import_special_even_if_file_size_is_under_70MB_and_runTime_under_3_minutes() - { - GivenFileSize(10.Megabytes()); - GivenVideoDuration(TimeSpan.FromMinutes(1)); - - _fakeEpisode.SeasonNumber = 0; - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifyFileImport(result); - } - - [Test] - public void should_skip_if_daily_series_with_file_size_is_under_70MB_and_runTime_under_3_minutes() - { - GivenFileSize(10.Megabytes()); - GivenVideoDuration(TimeSpan.FromMinutes(1)); - - _fakeEpisode.SeasonNumber = 0; - _fakeSeries.SeriesType = SeriesTypes.Daily; - - GivenEpisodes(new[] { _fakeEpisode }, new QualityModel(Quality.HDTV1080p)); - - var result = Subject.ImportFile(_fakeSeries, "file.ext"); - - VerifySkipImport(result); - } - - private void VerifyFileImport(EpisodeFile result) - { - result.Should().NotBeNull(); - result.SeriesId.Should().Be(_fakeSeries.Id); - result.Size.Should().Be(_fileSize); - result.DateAdded.Should().HaveDay(DateTime.UtcNow.Day); - - Mocker.GetMock().Verify(c => c.Add(result), Times.Once()); - } - - private void VerifySkipImport(EpisodeFile result) - { - result.Should().BeNull(); - Mocker.GetMock().Verify(p => p.Add(It.IsAny()), Times.Never()); - } - } -} diff --git a/NzbDrone.Core.Test/ProviderTests/PostDownloadProviderTests/DropFolderImportServiceFixture.cs b/NzbDrone.Core.Test/ProviderTests/PostDownloadProviderTests/DropFolderImportServiceFixture.cs index 77e028d69..380311fa6 100644 --- a/NzbDrone.Core.Test/ProviderTests/PostDownloadProviderTests/DropFolderImportServiceFixture.cs +++ b/NzbDrone.Core.Test/ProviderTests/PostDownloadProviderTests/DropFolderImportServiceFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using FizzWare.NBuilder; using Moq; @@ -6,6 +7,7 @@ using NUnit.Framework; using NzbDrone.Common; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Tv; @@ -26,7 +28,6 @@ namespace NzbDrone.Core.Test.ProviderTests.PostDownloadProviderTests { _fakeEpisodeFile = Builder.CreateNew().Build(); - Mocker.GetMock().Setup(c => c.GetVideoFiles(It.IsAny(), It.IsAny())) .Returns(_videoFiles); @@ -37,24 +38,6 @@ namespace NzbDrone.Core.Test.ProviderTests.PostDownloadProviderTests .Returns("c:\\drop\\"); } - private void WithOldWrite() - { - Mocker.GetMock() - .Setup(c => c.GetLastFolderWrite(It.IsAny())) - .Returns(DateTime.Now.AddDays(-5)); - } - - private void WithRecentFolderWrite() - { - Mocker.GetMock() - .Setup(c => c.GetLastFolderWrite(It.IsAny())) - .Returns(DateTime.UtcNow); - - Mocker.GetMock() - .Setup(c => c.GetLastFileWrite(It.IsAny())) - .Returns(DateTime.UtcNow); - } - [Test] public void should_import_file() { @@ -66,65 +49,14 @@ namespace NzbDrone.Core.Test.ProviderTests.PostDownloadProviderTests [Test] public void should_search_for_series_using_folder_name() { - WithOldWrite(); - Subject.ProcessDownloadedEpisodesFolder(); Mocker.GetMock().Verify(c => c.GetSeries("foldername"), Times.Once()); - - } - - [Test] - public void all_imported_files_should_be_moved() - { - Mocker.GetMock().Setup(c => c.ImportFile(It.IsAny(), It.IsAny())) - .Returns(_fakeEpisodeFile); - - Subject.ProcessDownloadedEpisodesFolder(); - - Mocker.GetMock().Verify(c => c.MoveEpisodeFile(_fakeEpisodeFile, true), Times.Once()); - } - - [Test] - public void should_trigger_import_event_on_import() - { - Mocker.GetMock().Setup(c => c.ImportFile(It.IsAny(), It.IsAny())) - .Returns(_fakeEpisodeFile); - - Subject.ProcessDownloadedEpisodesFolder(); - - VerifyEventPublished(); - } [Test] - public void should_not_attempt_move_if_nothing_is_imported() + public void should_skip_if_file_is_in_use_by_another_process() { - Mocker.GetMock().Setup(c => c.ImportFile(It.IsAny(), It.IsAny())) - .Returns(null); - - Subject.ProcessDownloadedEpisodesFolder(); - - Mocker.GetMock().Verify(c => c.MoveEpisodeFile(It.IsAny(), It.IsAny()), Times.Never()); - } - - - [Test] - public void should_not_publish_import_event_if_nothing_is_imported() - { - Mocker.GetMock().Setup(c => c.ImportFile(It.IsAny(), It.IsAny())) - .Returns(null); - - Subject.ProcessDownloadedEpisodesFolder(); - - - VerifyEventNotPublished(); - } - - [Test] - public void should_skip_if_folder_is_in_use_by_another_process() - { - Mocker.GetMock().Setup(c => c.IsFileLocked(It.IsAny())) .Returns(true); @@ -134,13 +66,13 @@ namespace NzbDrone.Core.Test.ProviderTests.PostDownloadProviderTests private void VerifyNoImport() { - Mocker.GetMock().Verify(c => c.ImportFile(It.IsAny(), It.IsAny()), + Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true), Times.Never()); } private void VerifyImport() { - Mocker.GetMock().Verify(c => c.ImportFile(It.IsAny(), It.IsAny()), + Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true), Times.Once()); } } diff --git a/NzbDrone.Core/MediaFiles/DiskScanService.cs b/NzbDrone.Core/MediaFiles/DiskScanService.cs index e6ea286d6..ff8e1003e 100644 --- a/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -6,6 +6,7 @@ using NLog; using NzbDrone.Common; using NzbDrone.Common.Messaging; using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Parser; using NzbDrone.Core.Providers; using NzbDrone.Core.Tv; @@ -15,7 +16,6 @@ namespace NzbDrone.Core.MediaFiles { public interface IDiskScanService { - EpisodeFile ImportFile(Series series, string filePath); string[] GetVideoFiles(string path, bool allDirectories = true); } @@ -25,19 +25,20 @@ namespace NzbDrone.Core.MediaFiles private static readonly string[] MediaExtensions = new[] { ".mkv", ".avi", ".wmv", ".mp4", ".mpg", ".mpeg", ".xvid", ".flv", ".mov", ".rm", ".rmvb", ".divx", ".dvr-ms", ".ts", ".ogm", ".m4v", ".strm" }; private readonly IDiskProvider _diskProvider; private readonly ISeriesService _seriesService; - private readonly IMediaFileService _mediaFileService; - private readonly IVideoFileInfoReader _videoFileInfoReader; - private readonly IParsingService _parsingService; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly IImportApprovedEpisodes _importApprovedEpisodes; private readonly IMessageAggregator _messageAggregator; - public DiskScanService(IDiskProvider diskProvider, ISeriesService seriesService, IMediaFileService mediaFileService, IVideoFileInfoReader videoFileInfoReader, - IParsingService parsingService, IMessageAggregator messageAggregator) + public DiskScanService(IDiskProvider diskProvider, + ISeriesService seriesService, + IMakeImportDecision importDecisionMaker, + IImportApprovedEpisodes importApprovedEpisodes, + IMessageAggregator messageAggregator) { _diskProvider = diskProvider; _seriesService = seriesService; - _mediaFileService = mediaFileService; - _videoFileInfoReader = videoFileInfoReader; - _parsingService = parsingService; + _importDecisionMaker = importDecisionMaker; + _importApprovedEpisodes = importApprovedEpisodes; _messageAggregator = messageAggregator; } @@ -53,71 +54,8 @@ namespace NzbDrone.Core.MediaFiles var mediaFileList = GetVideoFiles(series.Path); - foreach (var filePath in mediaFileList) - { - try - { - ImportFile(series, filePath); - } - catch (Exception e) - { - Logger.ErrorException("Couldn't import file " + filePath, e); - } - } - - //Todo: Find the "best" episode file for all found episodes and import that one - //Todo: Move the episode linking to here, instead of import (or rename import) - } - - public EpisodeFile ImportFile(Series series, string filePath) - { - Logger.Trace("Importing file to database [{0}]", filePath); - - if (_mediaFileService.Exists(filePath)) - { - Logger.Trace("[{0}] already exists in the database. skipping.", filePath); - return null; - } - - var parsedEpisode = _parsingService.GetEpisodes(filePath, series); - - if (parsedEpisode == null || !parsedEpisode.Episodes.Any()) - { - return null; - } - - var size = _diskProvider.GetFileSize(filePath); - - if (series.SeriesType == SeriesTypes.Daily || parsedEpisode.SeasonNumber > 0) - { - var runTime = _videoFileInfoReader.GetRunTime(filePath); - if (size < Constants.IgnoreFileSize && runTime.TotalMinutes < 3) - { - Logger.Trace("[{0}] appears to be a sample. skipping.", filePath); - return null; - } - } - - if (parsedEpisode.Episodes.Any(e => e.EpisodeFileId != 0 && e.EpisodeFile.Value.Quality > parsedEpisode.Quality)) - { - Logger.Trace("This file isn't an upgrade for all episodes. Skipping {0}", filePath); - return null; - } - - var episodeFile = new EpisodeFile(); - episodeFile.DateAdded = DateTime.UtcNow; - episodeFile.SeriesId = series.Id; - episodeFile.Path = filePath.CleanPath(); - episodeFile.Size = size; - episodeFile.Quality = parsedEpisode.Quality; - episodeFile.SeasonNumber = parsedEpisode.SeasonNumber; - episodeFile.SceneName = Path.GetFileNameWithoutExtension(filePath.CleanPath()); - episodeFile.Episodes = parsedEpisode.Episodes; - - //Todo: We shouldn't actually import the file until we confirm its the only one we want. - //Todo: Separate episodeFile creation from importing (pass file to import to import) - _mediaFileService.Add(episodeFile); - return episodeFile; + var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, series); + _importApprovedEpisodes.Import(decisions); } public string[] GetVideoFiles(string path, bool allDirectories = true) diff --git a/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index b015f5a00..9a9d25e03 100644 --- a/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -1,11 +1,12 @@ using System; +using System.Collections.Generic; using System.IO; using NLog; using NzbDrone.Common; using NzbDrone.Common.Messaging; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Parser; using NzbDrone.Core.Tv; @@ -19,7 +20,8 @@ namespace NzbDrone.Core.MediaFiles private readonly IMoveEpisodeFiles _episodeFileMover; private readonly IParsingService _parsingService; private readonly IConfigService _configService; - private readonly IMessageAggregator _messageAggregator; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly IImportApprovedEpisodes _importApprovedEpisodes; private readonly Logger _logger; public DownloadedEpisodesImportService(IDiskProvider diskProvider, @@ -28,7 +30,8 @@ namespace NzbDrone.Core.MediaFiles IMoveEpisodeFiles episodeFileMover, IParsingService parsingService, IConfigService configService, - IMessageAggregator messageAggregator, + IMakeImportDecision importDecisionMaker, + IImportApprovedEpisodes importApprovedEpisodes, Logger logger) { _diskProvider = diskProvider; @@ -37,7 +40,8 @@ namespace NzbDrone.Core.MediaFiles _episodeFileMover = episodeFileMover; _parsingService = parsingService; _configService = configService; - _messageAggregator = messageAggregator; + _importDecisionMaker = importDecisionMaker; + _importApprovedEpisodes = importApprovedEpisodes; _logger = logger; } @@ -92,7 +96,7 @@ namespace NzbDrone.Core.MediaFiles } } - public void ProcessSubFolder(DirectoryInfo subfolderInfo) + private void ProcessSubFolder(DirectoryInfo subfolderInfo) { var series = _parsingService.GetSeries(subfolderInfo.Name); @@ -102,12 +106,9 @@ namespace NzbDrone.Core.MediaFiles return; } - var files = _diskScanService.GetVideoFiles(subfolderInfo.FullName); + var videoFiles = _diskScanService.GetVideoFiles(subfolderInfo.FullName); - foreach (var file in files) - { - ProcessVideoFile(file, series); - } + ProcessFiles(videoFiles, series); } private void ProcessVideoFile(string videoFile, Series series) @@ -118,13 +119,13 @@ namespace NzbDrone.Core.MediaFiles return; } - var episodeFile = _diskScanService.ImportFile(series, videoFile); + ProcessFiles(new [] { videoFile }, series); + } - if (episodeFile != null) - { - _episodeFileMover.MoveEpisodeFile(episodeFile, true); - _messageAggregator.PublishEvent(new EpisodeImportedEvent(episodeFile)); - } + private void ProcessFiles(IEnumerable videoFiles, Series series) + { + var decisions = _importDecisionMaker.GetImportDecisions(videoFiles, series); + _importApprovedEpisodes.Import(decisions, true); } public void Execute(DownloadedEpisodesScanCommand message) diff --git a/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs b/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs index 7cd6921ca..5adf3b2bd 100644 --- a/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs +++ b/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs @@ -6,13 +6,15 @@ using NzbDrone.Common; using NzbDrone.Common.Messaging; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; namespace NzbDrone.Core.MediaFiles { public interface IMoveEpisodeFiles { - EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, bool newDownload = false); + EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile); + EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode); } public class MoveEpisodeFiles : IMoveEpisodeFiles @@ -36,56 +38,69 @@ namespace NzbDrone.Core.MediaFiles _logger = logger; } - public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, bool newDownload = false) + public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile) { if (episodeFile == null) throw new ArgumentNullException("episodeFile"); var series = _seriesRepository.Get(episodeFile.SeriesId); var episodes = _episodeService.GetEpisodesByFileId(episodeFile.Id); - string newFileName = _buildFileNames.BuildFilename(episodes, series, episodeFile); - var newFile = _buildFileNames.BuildFilePath(series, episodes.First().SeasonNumber, newFileName, Path.GetExtension(episodeFile.Path)); + var newFileName = _buildFileNames.BuildFilename(episodes, series, episodeFile); + var destinationFilename = _buildFileNames.BuildFilePath(series, episodes.First().SeasonNumber, newFileName, Path.GetExtension(episodeFile.Path)); - //Only rename if existing and new filenames don't match - if (DiskProvider.PathEquals(episodeFile.Path, newFile)) + episodeFile = MoveFile(episodeFile, destinationFilename); + + _mediaFileService.Update(episodeFile); + + return episodeFile; + } + + public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) + { + var newFileName = _buildFileNames.BuildFilename(localEpisode.Episodes, localEpisode.Series, episodeFile); + var destinationFilename = _buildFileNames.BuildFilePath(localEpisode.Series, localEpisode.SeasonNumber, newFileName, Path.GetExtension(episodeFile.Path)); + episodeFile = MoveFile(episodeFile, destinationFilename); + + //TODO: This just re-parses the source path (which is how we got localEpisode to begin with) + var parsedEpisodeInfo = Parser.Parser.ParsePath(localEpisode.Path); + _messageAggregator.PublishEvent(new EpisodeDownloadedEvent(parsedEpisodeInfo, localEpisode.Series)); + + return episodeFile; + } + + private EpisodeFile MoveFile(EpisodeFile episodeFile, string destinationFilename) + { + if (!_diskProvider.FileExists(episodeFile.Path)) { - _logger.Debug("Skipping file rename, source and destination are the same: {0}", episodeFile.Path); + _logger.Error("Episode file path does not exist, {0}", episodeFile.Path); return null; } - if (!_diskProvider.FileExists(episodeFile.Path)) + //Only rename if existing and new filenames don't match + if (DiskProvider.PathEquals(episodeFile.Path, destinationFilename)) { - _logger.Error("Episode file path does not exist, {0}", episodeFile.Path); + _logger.Debug("Skipping file rename, source and destination are the same: {0}", episodeFile.Path); return null; } - _diskProvider.CreateFolder(new FileInfo(newFile).DirectoryName); + _diskProvider.CreateFolder(new FileInfo(destinationFilename).DirectoryName); - _logger.Debug("Moving [{0}] > [{1}]", episodeFile.Path, newFile); - _diskProvider.MoveFile(episodeFile.Path, newFile); + _logger.Debug("Moving [{0}] > [{1}]", episodeFile.Path, destinationFilename); + _diskProvider.MoveFile(episodeFile.Path, destinationFilename); //Wrapped in Try/Catch to prevent this from causing issues with remote NAS boxes, the move worked, which is more important. try { - _diskProvider.InheritFolderPermissions(newFile); + _diskProvider.InheritFolderPermissions(destinationFilename); } catch (UnauthorizedAccessException ex) { - _logger.Debug("Unable to apply folder permissions to: ", newFile); + _logger.Debug("Unable to apply folder permissions to: ", destinationFilename); _logger.TraceException(ex.Message, ex); } - episodeFile.Path = newFile; - _mediaFileService.Update(episodeFile); - - var parsedEpisodeInfo = Parser.Parser.ParsePath(episodeFile.Path); - parsedEpisodeInfo.Quality = episodeFile.Quality; - - if (newDownload) - { - _messageAggregator.PublishEvent(new EpisodeDownloadedEvent(parsedEpisodeInfo, series)); - } - + episodeFile.Path = destinationFilename; + return episodeFile; } } diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs new file mode 100644 index 000000000..e045826b1 --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public interface IImportDecisionEngineSpecification : IRejectWithReason + { + bool IsSatisfiedBy(LocalEpisode localEpisode); + } +} diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs new file mode 100644 index 000000000..3cccd51dd --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.MediaFiles.Events; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public interface IImportApprovedEpisodes + { + List Import(List decisions, bool newDownloads = false); + } + + public class ImportApprovedEpisodes : IImportApprovedEpisodes + { + private readonly IMoveEpisodeFiles _episodeFileMover; + private readonly MediaFileService _mediaFileService; + private readonly DiskProvider _diskProvider; + private readonly IMessageAggregator _messageAggregator; + private readonly Logger _logger; + + public ImportApprovedEpisodes(IMoveEpisodeFiles episodeFileMover, + MediaFileService mediaFileService, + DiskProvider diskProvider, + IMessageAggregator messageAggregator, + Logger logger) + { + _episodeFileMover = episodeFileMover; + _mediaFileService = mediaFileService; + _diskProvider = diskProvider; + _messageAggregator = messageAggregator; + _logger = logger; + } + + public List Import(List decisions, bool newDownload = false) + { + var qualifiedReports = GetQualifiedReports(decisions); + var imported = new List(); + + foreach (var report in qualifiedReports) + { + var localEpisode = report.LocalEpisode; + + try + { + if (imported.SelectMany(r => r.LocalEpisode.Episodes) + .Select(e => e.Id) + .ToList() + .Intersect(localEpisode.Episodes.Select(e => e.Id)) + .Any()) + { + continue; + } + + var episodeFile = new EpisodeFile(); + episodeFile.DateAdded = DateTime.UtcNow; + episodeFile.SeriesId = localEpisode.Series.Id; + episodeFile.Path = localEpisode.Path.CleanPath(); + episodeFile.Size = _diskProvider.GetFileSize(localEpisode.Path); + episodeFile.Quality = localEpisode.Quality; + episodeFile.SeasonNumber = localEpisode.SeasonNumber; + episodeFile.SceneName = Path.GetFileNameWithoutExtension(localEpisode.Path.CleanPath()); + episodeFile.Episodes = localEpisode.Episodes; + + if (newDownload) + { + episodeFile = _episodeFileMover.MoveEpisodeFile(episodeFile, localEpisode); + } + + _mediaFileService.Add(episodeFile); + _messageAggregator.PublishEvent(new EpisodeImportedEvent(episodeFile)); + + } + catch (Exception e) + { + _logger.WarnException("Couldn't add report to download queue. " + localEpisode, e); + } + } + + return imported; + } + + private List GetQualifiedReports(List decisions) + { + return decisions.Where(c => c.Approved) + .OrderByDescending(c => c.LocalEpisode.Quality) + .ToList(); + } + } +} diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs new file mode 100644 index 000000000..d499a7823 --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public class ImportDecision + { + public LocalEpisode LocalEpisode { get; private set; } + public IEnumerable Rejections { get; private set; } + + public bool Approved + { + get + { + return !Rejections.Any(); + } + } + + public ImportDecision(LocalEpisode localEpisode, params string[] rejections) + { + LocalEpisode = localEpisode; + Rejections = rejections.ToList(); + } + } +} diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs new file mode 100644 index 000000000..e8b0acd8d --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public interface IMakeImportDecision + { + List GetImportDecisions(IEnumerable videoFiles, Series series); + } + + public class ImportDecisionMaker : IMakeImportDecision + { + private readonly IEnumerable _specifications; + private readonly IParsingService _parsingService; + private readonly Logger _logger; + + public ImportDecisionMaker(IEnumerable specifications, IParsingService parsingService, Logger logger) + { + _specifications = specifications; + _parsingService = parsingService; + _logger = logger; + } + + public List GetImportDecisions(IEnumerable videoFiles, Series series) + { + return GetDecisions(videoFiles, series).ToList(); + } + + private IEnumerable GetDecisions(IEnumerable videoFiles, Series series) + { + foreach (var file in videoFiles) + { + ImportDecision decision = null; + + try + { + var parsedEpisode = _parsingService.GetEpisodes(file, series); + + if (parsedEpisode != null) + { + decision = GetDecision(parsedEpisode); + } + + else + { + parsedEpisode = new LocalEpisode(); + parsedEpisode.Path = file; + + decision = new ImportDecision(parsedEpisode, "Unable to parse file"); + } + } + catch (Exception e) + { + _logger.ErrorException("Couldn't process report.", e); + } + + if (decision != null) + { + yield return decision; + } + } + } + + private ImportDecision GetDecision(LocalEpisode localEpisode) + { + var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode)) + .Where(c => !string.IsNullOrWhiteSpace(c)); + + return new ImportDecision(localEpisode, reasons.ToArray()); + } + + private string EvaluateSpec(IRejectWithReason spec, LocalEpisode localEpisode) + { + try + { + if (string.IsNullOrWhiteSpace(spec.RejectionReason)) + { + throw new InvalidOperationException("[Need Rejection Text]"); + } + + var generalSpecification = spec as IImportDecisionEngineSpecification; + if (generalSpecification != null && !generalSpecification.IsSatisfiedBy(localEpisode)) + { + return spec.RejectionReason; + } + } + catch (Exception e) + { + //e.Data.Add("report", remoteEpisode.Report.ToJson()); + //e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson()); + _logger.ErrorException("Couldn't evaluate decision on " + localEpisode.Path, e); + return string.Format("{0}: {1}", spec.GetType().Name, e.Message); + } + + return null; + } + } +} diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotAlreadyImportedSpecification.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotAlreadyImportedSpecification.cs new file mode 100644 index 000000000..beb7232fc --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotAlreadyImportedSpecification.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +{ + public class NotAlreadyImportedSpecification : IImportDecisionEngineSpecification + { + private readonly IMediaFileService _mediaFileService; + private readonly Logger _logger; + + public NotAlreadyImportedSpecification(IMediaFileService mediaFileService, Logger logger) + { + _mediaFileService = mediaFileService; + _logger = logger; + } + + public string RejectionReason { get { return "Is Sample"; } } + + public bool IsSatisfiedBy(LocalEpisode localEpisode) + { + if (_mediaFileService.Exists(localEpisode.Path)) + { + _logger.Trace("[{0}] already exists in the database. skipping.", localEpisode.Path); + return false; + } + + return true; + } + } +} diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs new file mode 100644 index 000000000..871639811 --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Common; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Providers; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +{ + public class NotSampleSpecification : IImportDecisionEngineSpecification + { + private readonly IDiskProvider _diskProvider; + private readonly IVideoFileInfoReader _videoFileInfoReader; + private readonly Logger _logger; + + public NotSampleSpecification(IDiskProvider diskProvider, IVideoFileInfoReader videoFileInfoReader, Logger logger) + { + _diskProvider = diskProvider; + _videoFileInfoReader = videoFileInfoReader; + _logger = logger; + } + + public string RejectionReason { get { return "Sample"; } } + + public bool IsSatisfiedBy(LocalEpisode localEpisode) + { + if (localEpisode.Series.SeriesType == SeriesTypes.Daily) + { + _logger.Trace("Daily Series, skipping sample check"); + return true; + } + + if (localEpisode.SeasonNumber == 0) + { + _logger.Trace("Special, skipping sample check"); + return true; + } + + var size = _diskProvider.GetFileSize(localEpisode.Path); + var runTime = _videoFileInfoReader.GetRunTime(localEpisode.Path); + + if (size < Constants.IgnoreFileSize && runTime.TotalMinutes < 3) + { + _logger.Trace("[{0}] appears to be a sample.", localEpisode.Path); + return false; + } + + return true; + } + } +} diff --git a/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs b/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs new file mode 100644 index 000000000..1cfd4b1f8 --- /dev/null +++ b/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +{ + public class UpgradeSpecification : IImportDecisionEngineSpecification + { + private readonly Logger _logger; + + public UpgradeSpecification(Logger logger) + { + _logger = logger; + } + + public string RejectionReason { get { return "Is Sample"; } } + + public bool IsSatisfiedBy(LocalEpisode localEpisode) + { + if (localEpisode.Episodes.Any(e => e.EpisodeFileId != 0 && e.EpisodeFile.Value.Quality > localEpisode.Quality)) + { + _logger.Trace("This file isn't an upgrade for all episodes. Skipping {0}", localEpisode.Path); + return false; + } + + return true; + } + } +} diff --git a/NzbDrone.Core/NzbDrone.Core.csproj b/NzbDrone.Core/NzbDrone.Core.csproj index 0bd76974b..3e3c5299b 100644 --- a/NzbDrone.Core/NzbDrone.Core.csproj +++ b/NzbDrone.Core/NzbDrone.Core.csproj @@ -274,6 +274,13 @@ + + + + + + + diff --git a/NzbDrone.Core/Parser/Model/LocalEpisode.cs b/NzbDrone.Core/Parser/Model/LocalEpisode.cs index 097945d1e..cf9bc2f75 100644 --- a/NzbDrone.Core/Parser/Model/LocalEpisode.cs +++ b/NzbDrone.Core/Parser/Model/LocalEpisode.cs @@ -6,11 +6,14 @@ namespace NzbDrone.Core.Parser.Model { public class LocalEpisode { - //public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } + public Series Series { get; set; } + public List Episodes { get; set; } public QualityModel Quality { get; set; } + public int SeasonNumber { get { return Episodes.Select(c => c.SeasonNumber).Distinct().Single(); } } + public string Path { get; set; } } } \ No newline at end of file diff --git a/NzbDrone.Core/Parser/ParsingService.cs b/NzbDrone.Core/Parser/ParsingService.cs index 1de0aeb0b..138efe2cc 100644 --- a/NzbDrone.Core/Parser/ParsingService.cs +++ b/NzbDrone.Core/Parser/ParsingService.cs @@ -26,7 +26,6 @@ namespace NzbDrone.Core.Parser _logger = logger; } - public LocalEpisode GetEpisodes(string fileName, Series series) { var parsedEpisodeInfo = Parser.ParseTitle(fileName); @@ -45,8 +44,10 @@ namespace NzbDrone.Core.Parser return new LocalEpisode { + Series = series, Quality = parsedEpisodeInfo.Quality, Episodes = episodes, + Path = fileName }; }