From 37a1398338bbcd5395362cd84f1f10a486ff630c Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 23 Nov 2014 16:07:46 -0800 Subject: [PATCH 1/4] Delay Profiles New: Select preferred protocol (usenet/torrent) New: Option to delay grabs from usenet/torrents independently --- src/NzbDrone.Api/NzbDrone.Api.csproj | 4 + .../Profiles/Delay/DelayProfileModule.cs | 62 +++++++ .../Profiles/Delay/DelayProfileResource.cs | 15 ++ src/NzbDrone.Api/Profiles/ProfileModule.cs | 2 - src/NzbDrone.Api/Profiles/ProfileResource.cs | 3 - .../REST/MethodNotAllowedException.cs | 13 ++ .../Validation/EmptyCollectionValidator.cs | 23 +++ .../Validation/RuleBuilderExtensions.cs | 10 +- .../PrioritizeDownloadDecisionFixture.cs | 55 +++++- ...ReleaseRestrictionsSpecificationFixture.cs | 33 +++- .../RssSync/DelaySpecificationFixture.cs | 141 +++------------ .../PendingReleaseServiceTests/AddFixture.cs | 1 - .../RemoveGrabbedFixture.cs | 1 - .../RemoveRejectedFixture.cs | 1 - .../Datastore/Migration/070_delay_profile.cs | 163 ++++++++++++++++++ src/NzbDrone.Core/Datastore/TableMapping.cs | 3 + .../DownloadDecisionPriorizationService.cs | 51 ++++-- .../RssSync/DelaySpecification.cs | 77 ++++----- .../Download/Pending/PendingReleaseService.cs | 26 ++- src/NzbDrone.Core/NzbDrone.Core.csproj | 8 +- .../Profiles/Delay/DelayProfile.cs | 25 +++ .../Profiles/Delay/DelayProfileRepository.cs | 18 ++ .../Profiles/Delay/DelayProfileService.cs | 76 ++++++++ .../Delay/DelayProfileTagInUseValidator.cs | 34 ++++ src/NzbDrone.Core/Profiles/GrabDelayMode.cs | 9 - src/NzbDrone.Core/Profiles/Profile.cs | 10 +- src/NzbDrone.sln.DotSettings | 2 + src/UI/Mixins/AsEditModalView.js | 8 +- src/UI/Mixins/AsSortedCollectionView.js | 32 ++++ src/UI/Series/Details/SeasonCollectionView.js | 32 +--- src/UI/Series/Details/SeasonLayout.js | 42 ++--- .../RemotePathMappingItemViewTemplate.hbs | 22 +-- .../RestrictionItemViewTemplate.hbs | 16 +- .../Profile/Delay/DelayProfileCollection.js | 12 ++ .../Delay/DelayProfileCollectionView.js | 17 ++ .../Profile/Delay/DelayProfileItemView.js | 27 +++ .../Delay/DelayProfileItemViewTemplate.hbs | 39 +++++ .../Profile/Delay/DelayProfileLayout.js | 109 ++++++++++++ .../Delay/DelayProfileLayoutTemplate.hbs | 24 +++ .../Profile/Delay/DelayProfileModel.js | 8 + .../Delay/Delete/DelayProfileDeleteView.js | 25 +++ .../Delete/DelayProfileDeleteViewTemplate.hbs | 13 ++ .../Delay/Edit/DelayProfileEditView.js | 48 ++++++ .../Edit/DelayProfileEditViewTemplate.hbs | 76 ++++++++ .../Settings/Profile/Edit/EditProfileView.js | 29 +--- .../Profile/Edit/EditProfileViewTemplate.hbs | 28 --- src/UI/Settings/Profile/ProfileLayout.js | 11 +- .../Profile/ProfileLayoutTemplate.hbs | 2 + .../Settings/Profile/ProfileViewTemplate.hbs | 5 +- src/UI/Settings/Profile/profile.less | 12 ++ src/UI/Settings/settings.less | 3 +- 51 files changed, 1164 insertions(+), 342 deletions(-) create mode 100644 src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs create mode 100644 src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs create mode 100644 src/NzbDrone.Api/REST/MethodNotAllowedException.cs create mode 100644 src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs create mode 100644 src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs create mode 100644 src/NzbDrone.Core/Profiles/Delay/DelayProfileRepository.cs create mode 100644 src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs create mode 100644 src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs delete mode 100644 src/NzbDrone.Core/Profiles/GrabDelayMode.cs create mode 100644 src/UI/Mixins/AsSortedCollectionView.js create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileCollection.js create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileItemView.js create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileLayout.js create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs create mode 100644 src/UI/Settings/Profile/Delay/DelayProfileModel.js create mode 100644 src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js create mode 100644 src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs create mode 100644 src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js create mode 100644 src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 62f2c41ff..92ff378f7 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -98,6 +98,8 @@ + + @@ -200,6 +202,7 @@ + @@ -221,6 +224,7 @@ + diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs new file mode 100644 index 000000000..6cea9b4f7 --- /dev/null +++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Api.Mapping; +using NzbDrone.Api.REST; +using NzbDrone.Api.Validation; +using NzbDrone.Core.Profiles.Delay; + +namespace NzbDrone.Api.Profiles.Delay +{ + public class DelayProfileModule : NzbDroneRestModule + { + private readonly IDelayProfileService _delayProfileService; + + public DelayProfileModule(IDelayProfileService delayProfileService, DelayProfileTagInUseValidator tagInUseValidator) + { + _delayProfileService = delayProfileService; + + GetResourceAll = GetAll; + GetResourceById = GetById; + UpdateResource = Update; + CreateResource = Create; + DeleteResource = DeleteProfile; + + SharedValidator.RuleFor(d => d.Tags).NotEmpty().When(d => d.Id != 1); + SharedValidator.RuleFor(d => d.Tags).EmptyCollection().When(d => d.Id == 1); + SharedValidator.RuleFor(d => d.Tags).SetValidator(tagInUseValidator); + } + + private int Create(DelayProfileResource resource) + { + var model = resource.InjectTo(); + model = _delayProfileService.Add(model); + + return model.Id; + } + + private void DeleteProfile(int id) + { + if (id == 1) + { + throw new MethodNotAllowedException("Cannot delete global delay profile"); + } + + _delayProfileService.Delete(id); + } + + private void Update(DelayProfileResource resource) + { + GetNewId(_delayProfileService.Update, resource); + } + + private DelayProfileResource GetById(int id) + { + return _delayProfileService.Get(id).InjectTo(); + } + + private List GetAll() + { + return _delayProfileService.All().InjectTo>(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs new file mode 100644 index 000000000..00e5c8e44 --- /dev/null +++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Api.REST; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Api.Profiles.Delay +{ + public class DelayProfileResource : RestResource + { + public DownloadProtocol PreferredProtocol { get; set; } + public int UsenetDelay { get; set; } + public int TorrentDelay { get; set; } + public int Order { get; set; } + public HashSet Tags { get; set; } + } +} diff --git a/src/NzbDrone.Api/Profiles/ProfileModule.cs b/src/NzbDrone.Api/Profiles/ProfileModule.cs index 413d99281..ca1276dad 100644 --- a/src/NzbDrone.Api/Profiles/ProfileModule.cs +++ b/src/NzbDrone.Api/Profiles/ProfileModule.cs @@ -46,8 +46,6 @@ private void Update(ProfileResource resource) model.Cutoff = (Quality)resource.Cutoff.Id; model.Items = resource.Items.InjectTo>(); model.Language = resource.Language; - model.GrabDelay = resource.GrabDelay; - model.GrabDelayMode = resource.GrabDelayMode; _profileService.Update(model); } diff --git a/src/NzbDrone.Api/Profiles/ProfileResource.cs b/src/NzbDrone.Api/Profiles/ProfileResource.cs index 432569460..9adb4ca70 100644 --- a/src/NzbDrone.Api/Profiles/ProfileResource.cs +++ b/src/NzbDrone.Api/Profiles/ProfileResource.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using NzbDrone.Api.REST; using NzbDrone.Core.Parser; -using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; namespace NzbDrone.Api.Profiles @@ -13,8 +12,6 @@ public class ProfileResource : RestResource public Quality Cutoff { get; set; } public List Items { get; set; } public Language Language { get; set; } - public Int32 GrabDelay { get; set; } - public GrabDelayMode GrabDelayMode { get; set; } } public class ProfileQualityItemResource : RestResource diff --git a/src/NzbDrone.Api/REST/MethodNotAllowedException.cs b/src/NzbDrone.Api/REST/MethodNotAllowedException.cs new file mode 100644 index 000000000..44d2065c6 --- /dev/null +++ b/src/NzbDrone.Api/REST/MethodNotAllowedException.cs @@ -0,0 +1,13 @@ +using Nancy; +using NzbDrone.Api.ErrorManagement; + +namespace NzbDrone.Api.REST +{ + public class MethodNotAllowedException : ApiException + { + public MethodNotAllowedException(object content = null) + : base(HttpStatusCode.MethodNotAllowed, content) + { + } + } +} diff --git a/src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs b/src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs new file mode 100644 index 000000000..432eb1ed9 --- /dev/null +++ b/src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Api.Validation +{ + public class EmptyCollectionValidator : PropertyValidator + { + public EmptyCollectionValidator() + : base("Collection Must Be Empty") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + var collection = context.PropertyValue as IEnumerable; + + return collection != null && collection.Empty(); + } + } +} diff --git a/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs b/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs index e88607b65..45cd0e1c6 100644 --- a/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs +++ b/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Collections.Generic; +using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Validators; @@ -25,5 +26,10 @@ public static IRuleBuilderOptions NotBlank(this IRuleBuilder> EmptyCollection(this IRuleBuilder> ruleBuilder) + { + return ruleBuilder.SetValidator(new EmptyCollectionValidator()); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 08c66f2ef..47e3ed127 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using Moq; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Tv; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.DecisionEngine.Specifications; using NUnit.Framework; using FluentAssertions; using FizzWare.NBuilder; @@ -17,6 +19,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestFixture] public class PrioritizeDownloadDecisionFixture : CoreTest { + [SetUp] + public void Setup() + { + GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); + } + private Episode GivenEpisode(int id) { return Builder.CreateNew() @@ -25,7 +33,7 @@ private Episode GivenEpisode(int id) .Build(); } - private RemoteEpisode GivenRemoteEpisode(List episodes, QualityModel quality, int age = 0, long size = 0) + private RemoteEpisode GivenRemoteEpisode(List episodes, QualityModel quality, int age = 0, long size = 0, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet) { var remoteEpisode = new RemoteEpisode(); remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); @@ -37,6 +45,7 @@ private RemoteEpisode GivenRemoteEpisode(List episodes, QualityModel qu remoteEpisode.Release = new ReleaseInfo(); remoteEpisode.Release.PublishDate = DateTime.Now.AddDays(-age); remoteEpisode.Release.Size = size; + remoteEpisode.Release.DownloadProtocol = downloadProtocol; remoteEpisode.Series = Builder.CreateNew() .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) @@ -45,6 +54,16 @@ private RemoteEpisode GivenRemoteEpisode(List episodes, QualityModel qu return remoteEpisode; } + private void GivenPreferredDownloadProtocol(DownloadProtocol downloadProtocol) + { + Mocker.GetMock() + .Setup(s => s.BestForTags(It.IsAny>())) + .Returns(new DelayProfile + { + PreferredProtocol = downloadProtocol + }); + } + [Test] public void should_put_propers_before_non_propers() { @@ -148,5 +167,37 @@ public void should_not_throw_if_no_episodes_are_found() Subject.PrioritizeDecisions(decisions); } + + [Test] + public void should_put_usenet_above_torrent_when_usenet_is_preferred() + { + GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); + + var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Torrent); + var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Usenet); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisode1)); + decisions.Add(new DownloadDecision(remoteEpisode2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteEpisode.Release.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); + } + + [Test] + public void should_put_torrent_above_usenet_when_torrent_is_preferred() + { + GivenPreferredDownloadProtocol(DownloadProtocol.Torrent); + + var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Torrent); + var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Usenet); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisode1)); + decisions.Add(new DownloadDecision(remoteEpisode2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteEpisode.Release.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs index 0beb24e40..2c2083036 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs @@ -15,12 +15,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestFixture] public class ReleaseRestrictionsSpecificationFixture : CoreTest { - private RemoteEpisode _parseResult; + private RemoteEpisode _remoteEpisode; [SetUp] public void Setup() { - _parseResult = new RemoteEpisode + _remoteEpisode = new RemoteEpisode { Series = new Series { @@ -54,7 +54,7 @@ public void should_be_true_when_restrictions_are_empty() .Setup(s => s.AllForTags(It.IsAny>())) .Returns(new List()); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -62,7 +62,7 @@ public void should_be_true_when_title_contains_one_required_term() { GivenRestictions("WEBRip", null); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -70,7 +70,7 @@ public void should_be_false_when_title_does_not_contain_any_required_terms() { GivenRestictions("doesnt,exist", null); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -78,7 +78,7 @@ public void should_be_true_when_title_does_not_contain_any_ignored_terms() { GivenRestictions(null, "ignored"); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -86,7 +86,7 @@ public void should_be_false_when_title_contains_one_anded_ignored_terms() { GivenRestictions(null, "edited"); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } [TestCase("EdiTED")] @@ -97,7 +97,7 @@ public void should_ignore_case_when_matching_required(String required) { GivenRestictions(required, null); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [TestCase("EdiTED")] @@ -108,7 +108,22 @@ public void should_ignore_case_when_matching_ignored(String ignored) { GivenRestictions(null, ignored); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_false_when_release_contains_one_restricted_word_and_one_required_word() + { + _remoteEpisode.Release.Title = "[ www.Speed.cd ] -Whose.Line.is.it.Anyway.US.S10E24.720p.HDTV.x264-BAJSKORV"; + + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(new List + { + new Restriction { Required = "x264", Ignored = "www.Speed.cd" } + }); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs index 536c29660..df8c9939f 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs @@ -9,14 +9,15 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications.RssSync; using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync { @@ -24,6 +25,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync public class DelaySpecificationFixture : CoreTest { private Profile _profile; + private DelayProfile _delayProfile; private RemoteEpisode _remoteEpisode; [SetUp] @@ -32,6 +34,10 @@ public void Setup() _profile = Builder.CreateNew() .Build(); + _delayProfile = Builder.CreateNew() + .With(d => d.PreferredProtocol = DownloadProtocol.Usenet) + .Build(); + var series = Builder.CreateNew() .With(s => s.Profile = _profile) .Build(); @@ -46,13 +52,21 @@ public void Setup() _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.Bluray720p }); _profile.Cutoff = Quality.WEBDL720p; - _profile.GrabDelayMode = GrabDelayMode.Always; _remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); _remoteEpisode.Release = new ReleaseInfo(); + _remoteEpisode.Release.DownloadProtocol = DownloadProtocol.Usenet; _remoteEpisode.Episodes = Builder.CreateListOfSize(1).Build().ToList(); _remoteEpisode.Episodes.First().EpisodeFileId = 0; + + Mocker.GetMock() + .Setup(s => s.BestForTags(It.IsAny>())) + .Returns(_delayProfile); + + Mocker.GetMock() + .Setup(s => s.GetPendingRemoteEpisodes(It.IsAny())) + .Returns(new List()); } private void GivenExistingFile(QualityModel quality) @@ -81,7 +95,7 @@ public void should_be_true_when_search() [Test] public void should_be_true_when_profile_does_not_have_a_delay() { - _profile.GrabDelay = 0; + _delayProfile.UsenetDelay = 0; Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } @@ -99,8 +113,8 @@ public void should_be_true_when_release_is_older_than_delay() { _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddHours(-10); - - _profile.GrabDelay = 1; + + _delayProfile.UsenetDelay = 1; Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } @@ -111,7 +125,7 @@ public void should_be_false_when_release_is_younger_than_delay() _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.SDTV); _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - _profile.GrabDelay = 12; + _delayProfile.UsenetDelay = 12; Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } @@ -129,7 +143,7 @@ public void should_be_true_when_release_is_a_proper_for_existing_episode() .Setup(s => s.IsRevisionUpgrade(It.IsAny(), It.IsAny())) .Returns(true); - _profile.GrabDelay = 12; + _delayProfile.UsenetDelay = 12; Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } @@ -147,47 +161,11 @@ public void should_be_true_when_release_is_a_real_for_existing_episode() .Setup(s => s.IsRevisionUpgrade(It.IsAny(), It.IsAny())) .Returns(true); - _profile.GrabDelay = 12; + _delayProfile.UsenetDelay = 12; Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } - [Test] - public void should_be_true_when_release_meets_cutoff_and_mode_is_cutoff() - { - _profile.GrabDelayMode = GrabDelayMode.Cutoff; - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - - _profile.GrabDelay = 12; - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_true_when_release_exceeds_cutoff_and_mode_is_cutoff() - { - _profile.GrabDelayMode = GrabDelayMode.Cutoff; - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.Bluray720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - - _profile.GrabDelay = 12; - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_false_when_release_is_below_cutoff_and_mode_is_cutoff() - { - _profile.GrabDelayMode = GrabDelayMode.Cutoff; - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - - _profile.GrabDelay = 12; - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - [Test] public void should_be_false_when_release_is_proper_for_existing_episode_of_different_quality() { @@ -196,82 +174,9 @@ public void should_be_false_when_release_is_proper_for_existing_episode_of_diffe GivenExistingFile(new QualityModel(Quality.SDTV)); - _profile.GrabDelay = 12; + _delayProfile.UsenetDelay = 12; Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } - - [Test] - public void should_be_false_when_release_is_first_detected_and_mode_is_first() - { - _profile.GrabDelayMode = GrabDelayMode.First; - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - - _profile.GrabDelay = 12; - - Mocker.GetMock() - .Setup(s => s.GetPendingRemoteEpisodes(It.IsAny())) - .Returns(new List()); - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - - [Test] - public void should_be_false_when_release_is_not_first_but_oldest_has_not_expired_and_type_is_first() - { - _profile.GrabDelayMode = GrabDelayMode.First; - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - - _profile.GrabDelay = 12; - - Mocker.GetMock() - .Setup(s => s.GetPendingRemoteEpisodes(It.IsAny())) - .Returns(new List { _remoteEpisode.JsonClone() }); - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - - [Test] - public void should_be_true_when_existing_pending_release_expired_and_mode_is_first() - { - _profile.GrabDelayMode = GrabDelayMode.First; - - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - _profile.GrabDelay = 12; - - var pendingRemoteEpisode = _remoteEpisode.JsonClone(); - pendingRemoteEpisode.Release.PublishDate = DateTime.UtcNow.AddHours(-15); - - Mocker.GetMock() - .Setup(s => s.GetPendingRemoteEpisodes(It.IsAny())) - .Returns(new List { pendingRemoteEpisode }); - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_true_when_one_existing_pending_release_is_expired_and_mode_is_first() - { - _profile.GrabDelayMode = GrabDelayMode.First; - - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; - _profile.GrabDelay = 12; - - var pendingRemoteEpisode1 = _remoteEpisode.JsonClone(); - pendingRemoteEpisode1.Release.PublishDate = DateTime.UtcNow.AddHours(-15); - - var pendingRemoteEpisode2 = _remoteEpisode.JsonClone(); - pendingRemoteEpisode2.Release.PublishDate = DateTime.UtcNow.AddHours(5); - - Mocker.GetMock() - .Setup(s => s.GetPendingRemoteEpisodes(It.IsAny())) - .Returns(new List { pendingRemoteEpisode1, pendingRemoteEpisode2 }); - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } } } diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs index e7b95035d..6dac469ab 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs @@ -40,7 +40,6 @@ public void Setup() { Name = "Test", Cutoff = Quality.HDTV720p, - GrabDelay = 1, Items = new List { new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }, diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs index 424f61241..800f9a720 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs @@ -40,7 +40,6 @@ public void Setup() { Name = "Test", Cutoff = Quality.HDTV720p, - GrabDelay = 1, Items = new List { new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }, diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs index fa7560422..c80780c4a 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs @@ -40,7 +40,6 @@ public void Setup() { Name = "Test", Cutoff = Quality.HDTV720p, - GrabDelay = 1, Items = new List { new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }, diff --git a/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs b/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs new file mode 100644 index 000000000..a8add7616 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using FluentMigrator; +using NzbDrone.Common; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(70)] + public class delay_profile : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("DelayProfiles") + .WithColumn("PreferredProtocol").AsInt32().NotNullable() + .WithColumn("UsenetDelay").AsInt32().NotNullable() + .WithColumn("TorrentDelay").AsInt32().NotNullable() + .WithColumn("Order").AsInt32().NotNullable() + .WithColumn("Tags").AsString().NotNullable(); + + + Insert.IntoTable("DelayProfiles").Row(new + { + PreferredProtocol = 1, + UsenetDelay = 0, + TorrentDelay = 0, + Order = Int32.MaxValue, + Tags = "[]" + }); + + Execute.WithConnection(ConvertProfile); + + Delete.Column("GrabDelay").FromTable("Profiles"); + Delete.Column("GrabDelayMode").FromTable("Profiles"); + } + + private void ConvertProfile(IDbConnection conn, IDbTransaction tran) + { + var profiles = GetProfiles(conn, tran); + var order = 1; + + foreach (var profileClosure in profiles.DistinctBy(p => p.GrabDelay)) + { + var profile = profileClosure; + if (profile.GrabDelay == 0) continue; + + var tag = String.Format("delay-{0}", profile.GrabDelay); + var tagId = InsertTag(conn, tran, tag); + var tags = String.Format("[{0}]", tagId); + + using (IDbCommand insertDelayProfileCmd = conn.CreateCommand()) + { + insertDelayProfileCmd.Transaction = tran; + insertDelayProfileCmd.CommandText = "INSERT INTO DelayProfiles (PreferredProtocol, TorrentDelay, UsenetDelay, [Order], Tags) VALUES (1, 0, ?, ?, ?)"; + insertDelayProfileCmd.AddParameter(profile.GrabDelay); + insertDelayProfileCmd.AddParameter(order); + insertDelayProfileCmd.AddParameter(tags); + + insertDelayProfileCmd.ExecuteNonQuery(); + } + + var matchingProfileIds = profiles.Where(p => p.GrabDelay == profile.GrabDelay) + .Select(p => p.Id); + + UpdateSeries(conn, tran, matchingProfileIds, tagId); + + order++; + } + } + + private List GetProfiles(IDbConnection conn, IDbTransaction tran) + { + var profiles = new List(); + + using (IDbCommand getProfilesCmd = conn.CreateCommand()) + { + getProfilesCmd.Transaction = tran; + getProfilesCmd.CommandText = @"SELECT Id, GrabDelay FROM Profiles"; + + using (IDataReader profileReader = getProfilesCmd.ExecuteReader()) + { + while (profileReader.Read()) + { + var id = profileReader.GetInt32(0); + var delay = profileReader.GetInt32(1); + + profiles.Add(new Profile70 + { + Id = id, + GrabDelay = delay * 60 + }); + } + } + } + + return profiles; + } + + private Int32 InsertTag(IDbConnection conn, IDbTransaction tran, string tagLabel) + { + using (IDbCommand insertCmd = conn.CreateCommand()) + { + insertCmd.Transaction = tran; + insertCmd.CommandText = @"INSERT INTO Tags (Label) VALUES (?); SELECT last_insert_rowid()"; + insertCmd.AddParameter(tagLabel); + + var id = insertCmd.ExecuteScalar(); + + return Convert.ToInt32(id); + } + } + + private void UpdateSeries(IDbConnection conn, IDbTransaction tran, IEnumerable profileIds, int tagId) + { + using (IDbCommand getSeriesCmd = conn.CreateCommand()) + { + getSeriesCmd.Transaction = tran; + getSeriesCmd.CommandText = "SELECT Id, Tags FROM Series WHERE ProfileId IN (?)"; + getSeriesCmd.AddParameter(String.Join(",", profileIds)); + + using (IDataReader seriesReader = getSeriesCmd.ExecuteReader()) + { + while (seriesReader.Read()) + { + var id = seriesReader.GetInt32(0); + var tagString = seriesReader.GetString(1); + + var tags = Json.Deserialize>(tagString); + tags.Add(tagId); + + using (IDbCommand updateSeriesCmd = conn.CreateCommand()) + { + updateSeriesCmd.Transaction = tran; + updateSeriesCmd.CommandText = "UPDATE Series SET Tags = ? WHERE Id = ?"; + updateSeriesCmd.AddParameter(tags.ToJson()); + updateSeriesCmd.AddParameter(id); + + updateSeriesCmd.ExecuteNonQuery(); + } + } + } + + getSeriesCmd.ExecuteNonQuery(); + } + } + + private class Profile70 + { + public int Id { get; set; } + public int GrabDelay { get; set; } + } + + private class Series70 + { + public int Id { get; set; } + public HashSet Tags { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index f6f90609f..4fa9a8bad 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -16,6 +16,7 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Metadata; using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Notifications; using NzbDrone.Core.Organizer; @@ -95,6 +96,8 @@ public static void Map() Mapper.Entity().RegisterModel("RemotePathMappings"); Mapper.Entity().RegisterModel("Tags"); Mapper.Entity().RegisterModel("Restrictions"); + + Mapper.Entity().RegisterModel("DelayProfiles"); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs index 191e60d3e..3bf52ae29 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs @@ -1,8 +1,11 @@ using System; using System.Linq; using System.Collections.Generic; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.DecisionEngine { @@ -13,20 +16,44 @@ public interface IPrioritizeDownloadDecision public class DownloadDecisionPriorizationService : IPrioritizeDownloadDecision { + private readonly IDelayProfileService _delayProfileService; + + public DownloadDecisionPriorizationService(IDelayProfileService delayProfileService) + { + _delayProfileService = delayProfileService; + } + public List PrioritizeDecisions(List decisions) { - return decisions - .Where(c => c.RemoteEpisode.Series != null) - .GroupBy(c => c.RemoteEpisode.Series.Id, (i, s) => s - .OrderByDescending(c => c.RemoteEpisode.ParsedEpisodeInfo.Quality, new QualityModelComparer(s.First().RemoteEpisode.Series.Profile)) - .ThenBy(c => c.RemoteEpisode.Episodes.Select(e => e.EpisodeNumber).MinOrDefault()) - .ThenBy(c => c.RemoteEpisode.Release.DownloadProtocol) - .ThenBy(c => c.RemoteEpisode.Release.Size.Round(200.Megabytes()) / Math.Max(1, c.RemoteEpisode.Episodes.Count)) - .ThenByDescending(c => TorrentInfo.GetSeeders(c.RemoteEpisode.Release)) - .ThenBy(c => c.RemoteEpisode.Release.Age)) - .SelectMany(c => c) - .Union(decisions.Where(c => c.RemoteEpisode.Series == null)) - .ToList(); + return decisions.Where(c => c.RemoteEpisode.Series != null) + .GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, d) => + { + var downloadDecisions = d.ToList(); + var series = downloadDecisions.First().RemoteEpisode.Series; + + return downloadDecisions + .OrderByDescending(c => c.RemoteEpisode.ParsedEpisodeInfo.Quality, new QualityModelComparer(series.Profile)) + .ThenBy(c => c.RemoteEpisode.Episodes.Select(e => e.EpisodeNumber).MinOrDefault()) + .ThenBy(c => PrioritizeDownloadProtocol(series, c.RemoteEpisode.Release.DownloadProtocol)) + .ThenBy(c => c.RemoteEpisode.Release.Size.Round(200.Megabytes()) / Math.Max(1, c.RemoteEpisode.Episodes.Count)) + .ThenByDescending(c => TorrentInfo.GetSeeders(c.RemoteEpisode.Release)) + .ThenBy(c => c.RemoteEpisode.Release.Age); + }) + .SelectMany(c => c) + .Union(decisions.Where(c => c.RemoteEpisode.Series == null)) + .ToList(); + } + + private int PrioritizeDownloadProtocol(Series series, DownloadProtocol downloadProtocol) + { + var delayProfile = _delayProfileService.BestForTags(series.Tags); + + if (downloadProtocol == delayProfile.PreferredProtocol) + { + return 0; + } + + return 1; } } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs index 3c18d0d4d..5b4e23608 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs @@ -3,7 +3,7 @@ using NzbDrone.Core.Download.Pending; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync @@ -12,12 +12,17 @@ public class DelaySpecification : IDecisionEngineSpecification { private readonly IPendingReleaseService _pendingReleaseService; private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly IDelayProfileService _delayProfileService; private readonly Logger _logger; - public DelaySpecification(IPendingReleaseService pendingReleaseService, IQualityUpgradableSpecification qualityUpgradableSpecification, Logger logger) + public DelaySpecification(IPendingReleaseService pendingReleaseService, + IQualityUpgradableSpecification qualityUpgradableSpecification, + IDelayProfileService delayProfileService, + Logger logger) { _pendingReleaseService = pendingReleaseService; _qualityUpgradableSpecification = qualityUpgradableSpecification; + _delayProfileService = delayProfileService; _logger = logger; } @@ -35,71 +40,59 @@ public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase } var profile = subject.Series.Profile.Value; + var delayProfile = _delayProfileService.BestForTags(subject.Series.Tags); + var delay = delayProfile.GetProtocolDelay(subject.Release.DownloadProtocol); + var isPreferredProtocol = subject.Release.DownloadProtocol == delayProfile.PreferredProtocol; - if (profile.GrabDelay == 0) + if (delay == 0) { - _logger.Debug("Profile does not delay before download"); + _logger.Debug("Profile does not require a waiting period before download for {0}.", subject.Release.DownloadProtocol); return Decision.Accept(); } var comparer = new QualityModelComparer(profile); - foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) + if (isPreferredProtocol) { - var upgradable = _qualityUpgradableSpecification.IsUpgradable(profile, file.Quality, subject.ParsedEpisodeInfo.Quality); - - if (upgradable) + foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) { - var revisionUpgrade = _qualityUpgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedEpisodeInfo.Quality); + var upgradable = _qualityUpgradableSpecification.IsUpgradable(profile, file.Quality, subject.ParsedEpisodeInfo.Quality); - if (revisionUpgrade) + if (upgradable) { - _logger.Debug("New quality is a better revision for existing quality, skipping delay"); - return Decision.Accept(); + var revisionUpgrade = _qualityUpgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedEpisodeInfo.Quality); + + if (revisionUpgrade) + { + _logger.Debug("New quality is a better revision for existing quality, skipping delay"); + return Decision.Accept(); + } } } } //If quality meets or exceeds the best allowed quality in the profile accept it immediately - var bestQualityInProfile = new QualityModel(profile.Items.Last(q => q.Allowed).Quality); - var bestCompare = comparer.Compare(subject.ParsedEpisodeInfo.Quality, bestQualityInProfile); + var bestQualityInProfile = new QualityModel(profile.LastAllowedQuality()); + var isBestInProfile = comparer.Compare(subject.ParsedEpisodeInfo.Quality, bestQualityInProfile) >= 0; - if (bestCompare >= 0) + if (isBestInProfile && isPreferredProtocol) { - _logger.Debug("Quality is highest in profile, will not delay"); + _logger.Debug("Quality is highest in profile for preferred protocol, will not delay"); return Decision.Accept(); } - if (profile.GrabDelayMode == GrabDelayMode.Cutoff) - { - var cutoff = new QualityModel(profile.Cutoff); - var cutoffCompare = comparer.Compare(subject.ParsedEpisodeInfo.Quality, cutoff); + var episodeIds = subject.Episodes.Select(e => e.Id); - if (cutoffCompare >= 0) - { - _logger.Debug("Quality meets or exceeds the cutoff, will not delay"); - return Decision.Accept(); - } + var oldest = _pendingReleaseService.OldestPendingRelease(subject.Series.Id, episodeIds); + + if (oldest != null && oldest.Release.AgeHours > delay) + { + return Decision.Accept(); } - if (profile.GrabDelayMode == GrabDelayMode.First) + if (subject.Release.AgeHours < delay) { - var episodeIds = subject.Episodes.Select(e => e.Id); - - var oldest = _pendingReleaseService.GetPendingRemoteEpisodes(subject.Series.Id) - .Where(r => r.Episodes.Select(e => e.Id).Intersect(episodeIds).Any()) - .OrderByDescending(p => p.Release.AgeHours) - .FirstOrDefault(); - - if (oldest != null && oldest.Release.AgeHours > profile.GrabDelay) - { - return Decision.Accept(); - } - } - - if (subject.Release.AgeHours < profile.GrabDelay) - { - _logger.Debug("Age ({0}) is less than delay {1}, delaying", subject.Release.AgeHours, profile.GrabDelay); + _logger.Debug("Waiting for better quality release, There is a {0} hour delay on {1}", delay, subject.Release.DownloadProtocol); return Decision.Reject("Waiting for better quality release"); } diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index 0d9fd5a78..d58c506fb 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -5,9 +5,11 @@ using NzbDrone.Common; using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; using NzbDrone.Core.Tv.Events; @@ -20,8 +22,9 @@ public interface IPendingReleaseService void RemoveGrabbed(List grabbed); void RemoveRejected(List rejected); List GetPending(); - List GetPendingRemoteEpisodes(Int32 seriesId); + List GetPendingRemoteEpisodes(int seriesId); List GetPendingQueue(); + RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable episodeIds); } public class PendingReleaseService : IPendingReleaseService, IHandle @@ -29,18 +32,21 @@ public class PendingReleaseService : IPendingReleaseService, IHandle GetPendingRemoteEpisodes(int seriesId) { foreach (var episode in pendingRelease.RemoteEpisode.Episodes) { - var ect = pendingRelease.Release.PublishDate.AddHours( - pendingRelease.RemoteEpisode.Series.Profile.Value.GrabDelay); + var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode)); var queue = new Queue.Queue { @@ -162,6 +167,14 @@ public List GetPendingRemoteEpisodes(int seriesId) return queued; } + public RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable episodeIds) + { + return GetPendingRemoteEpisodes(seriesId) + .Where(r => r.Episodes.Select(e => e.Id).Intersect(episodeIds).Any()) + .OrderByDescending(p => p.Release.AgeHours) + .FirstOrDefault(); + } + private List GetPendingReleases() { var result = new List(); @@ -225,6 +238,13 @@ private Func MatchingReleasePredicate(DownloadDecision dec p.Release.Indexer == decision.RemoteEpisode.Release.Indexer; } + private int GetDelay(RemoteEpisode remoteEpisode) + { + var delayProfile = _delayProfileService.AllForTags(remoteEpisode.Series.Tags).OrderBy(d => d.Order).First(); + + return delayProfile.GetProtocolDelay(remoteEpisode.Release.DownloadProtocol); + } + public void Handle(SeriesDeletedEvent message) { _repository.DeleteBySeriesId(message.Series.Id); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index e4e2230c4..876f10c94 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -230,6 +230,7 @@ + @@ -623,6 +624,10 @@ + + + + @@ -738,11 +743,10 @@ - - + diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs new file mode 100644 index 000000000..65f403b5b --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Profiles.Delay +{ + public class DelayProfile : ModelBase + { + public DownloadProtocol PreferredProtocol { get; set; } + public int UsenetDelay { get; set; } + public int TorrentDelay { get; set; } + public int Order { get; set; } + public HashSet Tags { get; set; } + + public DelayProfile() + { + Tags = new HashSet(); + } + + public int GetProtocolDelay(DownloadProtocol protocol) + { + return protocol == DownloadProtocol.Torrent ? TorrentDelay : UsenetDelay; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfileRepository.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfileRepository.cs new file mode 100644 index 000000000..a6198015d --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfileRepository.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Delay +{ + public interface IDelayProfileRepository : IBasicRepository + { + + } + + public class DelayProfileRepository : BasicRepository, IDelayProfileRepository + { + public DelayProfileRepository(IDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs new file mode 100644 index 000000000..d6ff9a1d2 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Profiles.Delay +{ + public interface IDelayProfileService + { + DelayProfile Add(DelayProfile profile); + DelayProfile Update(DelayProfile profile); + void Delete(int id); + List All(); + DelayProfile Get(int id); + List AllForTags(HashSet tagIds); + DelayProfile BestForTags(HashSet tagIds); + } + + public class DelayProfileService : IDelayProfileService + { + private readonly IDelayProfileRepository _repo; + + public DelayProfileService(IDelayProfileRepository repo) + { + _repo = repo; + } + + public DelayProfile Add(DelayProfile profile) + { + return _repo.Insert(profile); + } + + public DelayProfile Update(DelayProfile profile) + { + return _repo.Update(profile); + } + + public void Delete(int id) + { + _repo.Delete(id); + + var all = All().OrderBy(d => d.Order).ToList(); + + for (int i = 0; i < all.Count; i++) + { + if (all[i].Id == 1) continue; + + all[i].Order = i + 1; + } + + _repo.UpdateMany(all); + } + + public List All() + { + return _repo.All().ToList(); + } + + public DelayProfile Get(int id) + { + return _repo.Get(id); + } + + public List AllForTags(HashSet tagIds) + { + return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); + } + + public DelayProfile BestForTags(HashSet tagIds) + { + return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()) + .OrderBy(d => d.Order).First(); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs new file mode 100644 index 000000000..80cb89294 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Validators; +using NzbDrone.Common; +using NzbDrone.Common.Extensions; +using Omu.ValueInjecter; + +namespace NzbDrone.Core.Profiles.Delay +{ + public class DelayProfileTagInUseValidator : PropertyValidator + { + private readonly IDelayProfileService _delayProfileService; + + public DelayProfileTagInUseValidator(IDelayProfileService delayProfileService) + : base("One or more tags is used in another profile") + { + _delayProfileService = delayProfileService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + var delayProfile = new DelayProfile(); + delayProfile.InjectFrom(context.ParentContext.InstanceToValidate); + + var collection = context.PropertyValue as HashSet; + + if (collection == null || collection.Empty()) return true; + + return _delayProfileService.All().None(d => d.Id != delayProfile.Id && d.Tags.Intersect(collection).Any()); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/GrabDelayMode.cs b/src/NzbDrone.Core/Profiles/GrabDelayMode.cs deleted file mode 100644 index 146e68894..000000000 --- a/src/NzbDrone.Core/Profiles/GrabDelayMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Profiles -{ - public enum GrabDelayMode - { - First = 0, - Cutoff = 1, - Always = 2 - } -} diff --git a/src/NzbDrone.Core/Profiles/Profile.cs b/src/NzbDrone.Core/Profiles/Profile.cs index 914f49815..55a3a302b 100644 --- a/src/NzbDrone.Core/Profiles/Profile.cs +++ b/src/NzbDrone.Core/Profiles/Profile.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using NzbDrone.Core.Datastore; using NzbDrone.Core.Parser; using NzbDrone.Core.Qualities; @@ -12,7 +13,10 @@ public class Profile : ModelBase public Quality Cutoff { get; set; } public List Items { get; set; } public Language Language { get; set; } - public Int32 GrabDelay { get; set; } - public GrabDelayMode GrabDelayMode { get; set; } + + public Quality LastAllowedQuality() + { + return Items.Last(q => q.Allowed).Quality; + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.sln.DotSettings b/src/NzbDrone.sln.DotSettings index 088eea7f9..cbb36a9fe 100644 --- a/src/NzbDrone.sln.DotSettings +++ b/src/NzbDrone.sln.DotSettings @@ -9,6 +9,8 @@ ERROR HINT WARNING + WARNING + WARNING WARNING HINT True diff --git a/src/UI/Mixins/AsEditModalView.js b/src/UI/Mixins/AsEditModalView.js index bda5d10f1..c33df00eb 100644 --- a/src/UI/Mixins/AsEditModalView.js +++ b/src/UI/Mixins/AsEditModalView.js @@ -26,7 +26,7 @@ define( }); promise.done(function () { - self.originalModelData = self.model.toJSON(); + self.originalModelData = JSON.stringify(self.model.toJSON()); }); return promise; @@ -38,7 +38,7 @@ define( throw 'View has no model'; } - this.originalModelData = this.model.toJSON(); + this.originalModelData = JSON.stringify(this.model.toJSON()); this.events = this.events || {}; this.events['click .x-save'] = '_save'; @@ -63,8 +63,6 @@ define( if (self._onAfterSave) { self._onAfterSave.call(self); } - - self.originalModelData = self.model.toJSON(); }); }; @@ -96,7 +94,7 @@ define( }; this.prototype.onBeforeClose = function () { - this.model.set(this.originalModelData); + this.model.set(JSON.parse(this.originalModelData)); if (originalOnBeforeClose) { originalOnBeforeClose.call(this); diff --git a/src/UI/Mixins/AsSortedCollectionView.js b/src/UI/Mixins/AsSortedCollectionView.js new file mode 100644 index 000000000..c96d9538d --- /dev/null +++ b/src/UI/Mixins/AsSortedCollectionView.js @@ -0,0 +1,32 @@ +'use strict'; + +define( + function () { + + return function () { + + this.prototype.appendHtml = function(collectionView, itemView, index) { + var childrenContainer = collectionView.itemViewContainer ? collectionView.$(collectionView.itemViewContainer) : collectionView.$el; + var collection = collectionView.collection; + + // If the index of the model is at the end of the collection append, else insert at proper index + if (index >= collection.size() - 1) { + childrenContainer.append(itemView.el); + } else { + var previousModel = collection.at(index + 1); + var previousView = this.children.findByModel(previousModel); + + if (previousView) { + previousView.$el.before(itemView.$el); + } + + else { + childrenContainer.append(itemView.el); + } + } + }; + + return this; + }; + } +); diff --git a/src/UI/Series/Details/SeasonCollectionView.js b/src/UI/Series/Details/SeasonCollectionView.js index ab165d6de..70707912c 100644 --- a/src/UI/Series/Details/SeasonCollectionView.js +++ b/src/UI/Series/Details/SeasonCollectionView.js @@ -1,11 +1,12 @@ 'use strict'; define( [ + 'underscore', 'marionette', 'Series/Details/SeasonLayout', - 'underscore' - ], function (Marionette, SeasonLayout, _) { - return Marionette.CollectionView.extend({ + 'Mixins/AsSortedCollectionView' + ], function (_, Marionette, SeasonLayout, AsSortedCollectionView) { + var view = Marionette.CollectionView.extend({ itemView: SeasonLayout, @@ -19,27 +20,6 @@ define( this.series = options.series; }, - appendHtml: function(collectionView, itemView, index) { - var childrenContainer = collectionView.itemViewContainer ? collectionView.$(collectionView.itemViewContainer) : collectionView.$el; - var collection = collectionView.collection; - - // If the index of the model is at the end of the collection append, else insert at proper index - if (index >= collection.size() - 1) { - childrenContainer.append(itemView.el); - } else { - var previousModel = collection.at(index + 1); - var previousView = this.children.findByModel(previousModel); - - if (previousView) { - previousView.$el.before(itemView.$el); - } - - else { - childrenContainer.append(itemView.el); - } - } - }, - itemViewOptions: function () { return { episodeCollection: this.episodeCollection, @@ -62,4 +42,8 @@ define( this.render(); } }); + + AsSortedCollectionView.call(view); + + return view; }); diff --git a/src/UI/Series/Details/SeasonLayout.js b/src/UI/Series/Details/SeasonLayout.js index 6aea66c09..8d0b229fd 100644 --- a/src/UI/Series/Details/SeasonLayout.js +++ b/src/UI/Series/Details/SeasonLayout.js @@ -99,6 +99,27 @@ define( } ], + templateHelpers: function () { + + var episodeCount = this.episodeCollection.filter(function (episode) { + return episode.get('hasFile') || (episode.get('monitored') && moment(episode.get('airDateUtc')).isBefore(moment())); + }).length; + + var episodeFileCount = this.episodeCollection.where({ hasFile: true }).length; + var percentOfEpisodes = 100; + + if (episodeCount > 0) { + percentOfEpisodes = episodeFileCount / episodeCount * 100; + } + + return { + showingEpisodes : this.showingEpisodes, + episodeCount : episodeCount, + episodeFileCount : episodeFileCount, + percentOfEpisodes: percentOfEpisodes + }; + }, + initialize: function (options) { if (!options.episodeCollection) { @@ -229,27 +250,6 @@ define( }); }, - templateHelpers: function () { - - var episodeCount = this.episodeCollection.filter(function (episode) { - return episode.get('hasFile') || (episode.get('monitored') && moment(episode.get('airDateUtc')).isBefore(moment())); - }).length; - - var episodeFileCount = this.episodeCollection.where({ hasFile: true }).length; - var percentOfEpisodes = 100; - - if (episodeCount > 0) { - percentOfEpisodes = episodeFileCount / episodeCount * 100; - } - - return { - showingEpisodes : this.showingEpisodes, - episodeCount : episodeCount, - episodeFileCount : episodeFileCount, - percentOfEpisodes: percentOfEpisodes - }; - }, - _showHideEpisodes: function () { if (this.showingEpisodes) { this.showingEpisodes = false; diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs index 2aecc5417..2796de5b1 100644 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs +++ b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs @@ -1,12 +1,12 @@ - -
{{host}}
-
- -
{{remotePath}}
-
- -
{{localPath}}
-
- +
+ {{host}} +
+
+ {{remotePath}} +
+
+ {{localPath}} +
+
- \ No newline at end of file +
\ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs b/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs index 55c414af1..0933f49d1 100644 --- a/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs +++ b/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs @@ -1,12 +1,12 @@ - +
{{genericTagDisplay required 'label label-success'}} - - +
+
{{genericTagDisplay ignored 'label label-danger'}} - - +
+
{{tagDisplay tags}} - - +
+
- \ No newline at end of file +
\ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileCollection.js b/src/UI/Settings/Profile/Delay/DelayProfileCollection.js new file mode 100644 index 000000000..b41d1dc7b --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileCollection.js @@ -0,0 +1,12 @@ +'use strict'; +define( + [ + 'backbone', + 'Settings/Profile/Delay/DelayProfileModel' + ], function (Backbone, DelayProfileModel) { + + return Backbone.Collection.extend({ + model: DelayProfileModel, + url : window.NzbDrone.ApiRoot + '/delayprofile' + }); + }); diff --git a/src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js b/src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js new file mode 100644 index 000000000..8dcd477a5 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js @@ -0,0 +1,17 @@ +'use strict'; +define([ + 'backbone.collectionview', + 'Settings/Profile/Delay/DelayProfileItemView' +], function (BackboneSortableCollectionView, DelayProfileItemView) { + + return BackboneSortableCollectionView.extend({ + className : 'delay-profiles', + modelView : DelayProfileItemView, + + events: { + 'click li, td' : '_listItem_onMousedown', + 'dblclick li, td' : '_listItem_onDoubleClick', + 'keydown' : '_onKeydown' + } + }); +}); diff --git a/src/UI/Settings/Profile/Delay/DelayProfileItemView.js b/src/UI/Settings/Profile/Delay/DelayProfileItemView.js new file mode 100644 index 000000000..ce9ea22db --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileItemView.js @@ -0,0 +1,27 @@ +'use strict'; + +define([ + 'jquery', + 'AppLayout', + 'marionette', + 'Settings/Profile/Delay/Edit/DelayProfileEditView' +], function ($, AppLayout, Marionette, EditView) { + + return Marionette.ItemView.extend({ + template : 'Settings/Profile/Delay/DelayProfileItemViewTemplate', + className : 'row', + + events: { + 'click .x-edit' : '_edit' + }, + + initialize: function () { + this.listenTo(this.model, 'sync', this.render); + }, + + _edit: function() { + var view = new EditView({ model: this.model, targetCollection: this.model.collection}); + AppLayout.modalRegion.show(view); + } + }); +}); diff --git a/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs b/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs new file mode 100644 index 000000000..2022ee3a1 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs @@ -0,0 +1,39 @@ +
+ {{TitleCase preferredProtocol}} +
+
+ + {{#if_eq usenetDelay compare="0"}} + No delay + {{else}} + {{#if_eq usenetDelay compare="1"}} + 1 minute + {{else}} + {{usenetDelay}} minutes + {{/if_eq}} + {{/if_eq}} + +
+
+ {{#if_eq torrentDelay compare="0"}} + No delay + {{else}} + {{#if_eq torrentDelay compare="1"}} + 1 minute + {{else}} + {{torrentDelay}} minutes + {{/if_eq}} + {{/if_eq}} +
+
+ {{tagDisplay tags}} +
+
+
+ {{#unless_eq id compare="1"}} + + {{/unless_eq}} + + +
+
\ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileLayout.js b/src/UI/Settings/Profile/Delay/DelayProfileLayout.js new file mode 100644 index 000000000..be643f080 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileLayout.js @@ -0,0 +1,109 @@ +'use strict'; +define( + [ + 'jquery', + 'underscore', + 'vent', + 'AppLayout', + 'marionette', + 'backbone', + 'Settings/Profile/Delay/DelayProfileCollectionView', + 'Settings/Profile/Delay/Edit/DelayProfileEditView', + 'Settings/Profile/Delay/DelayProfileModel' + ], function ($, + _, + vent, + AppLayout, + Marionette, + Backbone, + DelayProfileCollectionView, + EditView, + Model) { + + return Marionette.Layout.extend({ + template: 'Settings/Profile/Delay/DelayProfileLayoutTemplate', + + regions: { + delayProfiles : '.x-rows' + }, + + events: { + 'click .x-add' : '_add' + }, + + initialize: function (options) { + this.collection = options.collection; + + this._updateOrderedCollection(); + + this.listenTo(this.collection, 'sync', this._updateOrderedCollection); + this.listenTo(this.collection, 'add', this._updateOrderedCollection); + this.listenTo(this.collection, 'remove', function () { + this.collection.fetch(); + }); + }, + + onRender: function () { + + this.sortableListView = new DelayProfileCollectionView({ + sortable : true, + collection : this.orderedCollection, + + sortableOptions : { + handle: '.x-drag-handle' + }, + + sortableModelsFilter : function( model ) { + return model.get('id') !== 1; + } + }); + + this.delayProfiles.show(this.sortableListView); + + this.listenTo(this.sortableListView, 'sortStop', this._updateOrder); + }, + + _updateOrder: function() { + var self = this; + + this.collection.forEach(function (model) { + if (model.get('id') === 1) { + return; + } + + var orderedModel = self.orderedCollection.get(model); + var order = self.orderedCollection.indexOf(orderedModel) + 1; + + if (model.get('order') !== order) { + model.set('order', order); + model.save(); + } + }); + }, + + _add: function() { + var model = new Model({ + preferredProtocol : 1, + usenetDelay : 0, + torrentDelay : 0, + order : this.collection.length, + tags : [] + }); + + model.collection = this.collection; + + var view = new EditView({ model: model, targetCollection: this.collection}); + AppLayout.modalRegion.show(view); + }, + + _updateOrderedCollection: function () { + if (!this.orderedCollection) { + this.orderedCollection = new Backbone.Collection(); + } + + this.orderedCollection.reset(_.sortBy(this.collection.models, function (model) { + return model.get('order'); + })); + } + }); + }); diff --git a/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs b/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs new file mode 100644 index 000000000..2edaa4a88 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs @@ -0,0 +1,24 @@ +
+ Delay Profiles + +
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileModel.js b/src/UI/Settings/Profile/Delay/DelayProfileModel.js new file mode 100644 index 000000000..5ff78c9fd --- /dev/null +++ b/src/UI/Settings/Profile/Delay/DelayProfileModel.js @@ -0,0 +1,8 @@ +'use strict'; +define( + [ + 'backbone' + ], function (Backbone) { + return Backbone.Model.extend({ + }); + }); diff --git a/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js b/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js new file mode 100644 index 000000000..3d4093120 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js @@ -0,0 +1,25 @@ +'use strict'; + +define([ + 'vent', + 'marionette' +], function (vent, Marionette) { + return Marionette.ItemView.extend({ + template: 'Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate', + + events: { + 'click .x-confirm-delete': '_delete' + }, + + _delete: function () { + var collection = this.model.collection; + + this.model.destroy({ + wait : true, + success: function () { + vent.trigger(vent.Commands.CloseModalCommand); + } + }); + } + }); +}); diff --git a/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs b/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs new file mode 100644 index 000000000..fbd6cad88 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs @@ -0,0 +1,13 @@ + diff --git a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js new file mode 100644 index 000000000..2161a18cb --- /dev/null +++ b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js @@ -0,0 +1,48 @@ + 'use strict'; + +define([ + 'vent', + 'AppLayout', + 'marionette', + 'Settings/Profile/Delay/Delete/DelayProfileDeleteView', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView', + 'Mixins/AsEditModalView', + 'Mixins/TagInput', + 'bootstrap' +], function (vent, AppLayout, Marionette, DeleteView, AsModelBoundView, AsValidatedView, AsEditModalView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate', + + _deleteView: DeleteView, + + ui: { + tags : '.x-tags' + }, + + initialize: function (options) { + this.targetCollection = options.targetCollection; + }, + + onRender: function () { + if (this.model.id !== 1) { + this.ui.tags.tagInput({ + model : this.model, + property : 'tags' + }); + } + }, + + _onAfterSave: function () { + this.targetCollection.add(this.model, { merge: true }); + vent.trigger(vent.Commands.CloseModalCommand); + } + }); + + AsModelBoundView.call(view); + AsValidatedView.call(view); + AsEditModalView.call(view); + + return view; +}); diff --git a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs new file mode 100644 index 000000000..136533d06 --- /dev/null +++ b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs @@ -0,0 +1,76 @@ + diff --git a/src/UI/Settings/Profile/Edit/EditProfileView.js b/src/UI/Settings/Profile/Edit/EditProfileView.js index 046534358..5e2e9e07f 100644 --- a/src/UI/Settings/Profile/Edit/EditProfileView.js +++ b/src/UI/Settings/Profile/Edit/EditProfileView.js @@ -13,14 +13,7 @@ define( template: 'Settings/Profile/Edit/EditProfileViewTemplate', ui: { - cutoff : '.x-cutoff', - delay : '.x-delay', - delayMode : '.x-delay-mode' - }, - - events: { - 'change .x-delay': 'toggleDelayMode', - 'keyup .x-delay': 'toggleDelayMode' + cutoff : '.x-cutoff' }, templateHelpers: function () { @@ -29,30 +22,10 @@ define( }; }, - onShow: function () { - this.toggleDelayMode(); - }, - getCutoff: function () { var self = this; return _.findWhere(_.pluck(this.model.get('items'), 'quality'), { id: parseInt(self.ui.cutoff.val(), 10)}); - }, - - toggleDelayMode: function () { - var delay = parseInt(this.ui.delay.val(), 10); - - if (isNaN(delay)) { - return; - } - - if (delay > 0 && Config.getValueBoolean(Config.Keys.AdvancedSettings)) { - this.ui.delayMode.show(); - } - - else { - this.ui.delayMode.hide(); - } } }); diff --git a/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs b/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs index 9fe9e490e..6e2911962 100644 --- a/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs +++ b/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs @@ -24,34 +24,6 @@ -
- - -
- -
- -
- -
-
- -
- - -
- -
- -
- -
-
-
diff --git a/src/UI/Settings/Profile/ProfileLayout.js b/src/UI/Settings/Profile/ProfileLayout.js index 16822f4f9..47cd217f1 100644 --- a/src/UI/Settings/Profile/ProfileLayout.js +++ b/src/UI/Settings/Profile/ProfileLayout.js @@ -5,22 +5,29 @@ define( 'marionette', 'Profile/ProfileCollection', 'Settings/Profile/ProfileCollectionView', + 'Settings/Profile/Delay/DelayProfileLayout', + 'Settings/Profile/Delay/DelayProfileCollection', 'Settings/Profile/Language/LanguageCollection' - ], function (Marionette, ProfileCollection, ProfileCollectionView, LanguageCollection) { + ], function (Marionette, ProfileCollection, ProfileCollectionView, DelayProfileLayout, DelayProfileCollection, LanguageCollection) { return Marionette.Layout.extend({ template: 'Settings/Profile/ProfileLayoutTemplate', regions: { - profile : '#profile' + profile : '#profile', + delayProfile : '#delay-profile' }, initialize: function (options) { this.settings = options.settings; ProfileCollection.fetch(); + + this.delayProfileCollection = new DelayProfileCollection(); + this.delayProfileCollection.fetch(); }, onShow: function () { this.profile.show(new ProfileCollectionView({collection: ProfileCollection})); + this.delayProfile.show(new DelayProfileLayout({collection: this.delayProfileCollection})); } }); }); diff --git a/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs b/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs index 1e812f24b..65ea7a26f 100644 --- a/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs +++ b/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs @@ -1,3 +1,5 @@ 
+ +
diff --git a/src/UI/Settings/Profile/ProfileViewTemplate.hbs b/src/UI/Settings/Profile/ProfileViewTemplate.hbs index f664c0caa..fdb0e0526 100644 --- a/src/UI/Settings/Profile/ProfileViewTemplate.hbs +++ b/src/UI/Settings/Profile/ProfileViewTemplate.hbs @@ -5,11 +5,8 @@
{{languageLabel}} - - {{#if_gt grabDelay compare="0"}} - - {{/if_gt}}
+
    {{allowedLabeler}}
diff --git a/src/UI/Settings/Profile/profile.less b/src/UI/Settings/Profile/profile.less index 845954bc6..df217a398 100644 --- a/src/UI/Settings/Profile/profile.less +++ b/src/UI/Settings/Profile/profile.less @@ -29,3 +29,15 @@ margin-bottom: 3px; } } + +.delay-profile-region { + margin-top : 30px; +} + +.delay-profiles { + padding-left : 0px; + + li { + list-style-type : none; + } +} \ No newline at end of file diff --git a/src/UI/Settings/settings.less b/src/UI/Settings/settings.less index 1207014cb..ec6bd2a1c 100644 --- a/src/UI/Settings/settings.less +++ b/src/UI/Settings/settings.less @@ -154,7 +154,8 @@ li.save-and-add:hover { padding : 5px; i { - cursor : pointer; + cursor : pointer; + margin-left : 5px; } } } From c7ed76f6d3f926c009022f27aa2dcbfc6c6c2e23 Mon Sep 17 00:00:00 2001 From: Keivan Beigi Date: Tue, 2 Dec 2014 11:21:43 -0800 Subject: [PATCH 2/4] Added before migration hook, this can be used to insert "old" data into test Db so data migration could be tested. --- src/NzbDrone.Core.Test/Framework/DbTest.cs | 7 ++--- src/NzbDrone.Core/Datastore/DbFactory.cs | 6 ++-- .../Migration/Framework/MigrationContext.cs | 15 ++++++++-- .../Framework/MigrationController.cs | 15 ++++------ .../Framework/NzbDroneMigrationBase.cs | 28 +++++++++++++++++-- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/NzbDrone.Core.Test/Framework/DbTest.cs b/src/NzbDrone.Core.Test/Framework/DbTest.cs index be9ffed86..436368db3 100644 --- a/src/NzbDrone.Core.Test/Framework/DbTest.cs +++ b/src/NzbDrone.Core.Test/Framework/DbTest.cs @@ -89,7 +89,6 @@ private void WithTestDb() { WithTempAsAppPath(); - Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); @@ -97,9 +96,9 @@ private void WithTestDb() MapRepository.Instance.EnableTraceLogging = true; var factory = Mocker.Resolve(); - var _database = factory.Create(MigrationType); - _db = new TestDatabase(_database); - Mocker.SetConstant(_database); + var database = factory.Create(MigrationType); + _db = new TestDatabase(database); + Mocker.SetConstant(database); } [SetUp] diff --git a/src/NzbDrone.Core/Datastore/DbFactory.cs b/src/NzbDrone.Core/Datastore/DbFactory.cs index df7889d83..e4fffed2f 100644 --- a/src/NzbDrone.Core/Datastore/DbFactory.cs +++ b/src/NzbDrone.Core/Datastore/DbFactory.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Datastore { public interface IDbFactory { - IDatabase Create(MigrationType migrationType = MigrationType.Main); + IDatabase Create(MigrationType migrationType = MigrationType.Main, Action beforeMigration = null); } public class DbFactory : IDbFactory @@ -43,7 +43,7 @@ public DbFactory(IMigrationController migrationController, IConnectionStringFact _connectionStringFactory = connectionStringFactory; } - public IDatabase Create(MigrationType migrationType = MigrationType.Main) + public IDatabase Create(MigrationType migrationType = MigrationType.Main, Action beforeMigration = null) { string connectionString; @@ -66,7 +66,7 @@ public IDatabase Create(MigrationType migrationType = MigrationType.Main) } } - _migrationController.MigrateToLatest(connectionString, migrationType); + _migrationController.MigrateToLatest(connectionString, migrationType, beforeMigration); var db = new Database(() => { diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationContext.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationContext.cs index e1bb47bcf..0d49450af 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationContext.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationContext.cs @@ -1,7 +1,18 @@ -namespace NzbDrone.Core.Datastore.Migration.Framework +using System; + +namespace NzbDrone.Core.Datastore.Migration.Framework { public class MigrationContext { - public MigrationType MigrationType { get; set; } + public MigrationType MigrationType { get; private set; } + + public Action BeforeMigration { get; private set; } + + public MigrationContext(MigrationType migrationType, Action beforeAction) + { + MigrationType = migrationType; + + BeforeMigration = beforeAction; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs index 4c55762b8..a505078fe 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs @@ -1,14 +1,14 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; using System.Reflection; using FluentMigrator.Runner; using FluentMigrator.Runner.Initialization; -using FluentMigrator.Runner.Processors.SQLite; namespace NzbDrone.Core.Datastore.Migration.Framework { public interface IMigrationController { - void MigrateToLatest(string connectionString, MigrationType migrationType); + void MigrateToLatest(string connectionString, MigrationType migrationType, Action beforeMigration); } public class MigrationController : IMigrationController @@ -20,7 +20,7 @@ public MigrationController(IAnnouncer announcer) _announcer = announcer; } - public void MigrateToLatest(string connectionString, MigrationType migrationType) + public void MigrateToLatest(string connectionString, MigrationType migrationType, Action beforeMigration) { var sw = Stopwatch.StartNew(); @@ -31,10 +31,7 @@ public void MigrateToLatest(string connectionString, MigrationType migrationType var migrationContext = new RunnerContext(_announcer) { Namespace = "NzbDrone.Core.Datastore.Migration", - ApplicationContext = new MigrationContext - { - MigrationType = migrationType - } + ApplicationContext = new MigrationContext(migrationType, beforeMigration) }; var options = new MigrationOptions { PreviewOnly = false, Timeout = 60 }; @@ -45,7 +42,7 @@ public void MigrateToLatest(string connectionString, MigrationType migrationType sw.Stop(); - _announcer.ElapsedTime(sw.Elapsed); + _announcer.ElapsedTime(sw.Elapsed); } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs index c2fca697a..2838adac4 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs @@ -1,4 +1,5 @@ using System; +using FluentMigrator; using NLog; using NzbDrone.Common.Instrumentation; @@ -7,10 +8,12 @@ namespace NzbDrone.Core.Datastore.Migration.Framework public abstract class NzbDroneMigrationBase : FluentMigrator.Migration { protected readonly Logger _logger; + private readonly MigrationContext _migrationContext; protected NzbDroneMigrationBase() { _logger = NzbDroneLogger.GetLogger(this); + _migrationContext = (MigrationContext)ApplicationContext; } protected virtual void MainDbUpgrade() @@ -21,11 +24,32 @@ protected virtual void LogDbUpgrade() { } + public int Version + { + get + { + var migrationAttribute = (MigrationAttribute)Attribute.GetCustomAttribute(GetType(), typeof(MigrationAttribute)); + return (int)migrationAttribute.Version; + } + } + + public MigrationContext Context + { + get + { + return _migrationContext; + } + } + public override void Up() { - var context = (MigrationContext)ApplicationContext; - switch (context.MigrationType) + if (Context.BeforeMigration != null) + { + Context.BeforeMigration(this); + } + + switch (Context.MigrationType) { case MigrationType.Main: MainDbUpgrade(); From be81bf322a48e5edfcfd2b2d06997f9cde5d2b37 Mon Sep 17 00:00:00 2001 From: Keivan Beigi Date: Tue, 2 Dec 2014 12:08:37 -0800 Subject: [PATCH 3/4] Our first data migration test :D --- .../Migration/070_delay_profileFixture.cs | 52 +++++++++++++++++++ src/NzbDrone.Core.Test/Framework/DbTest.cs | 26 ++++++---- .../Framework/MigrationTest.cs | 36 +++++++++++++ .../NzbDrone.Core.Test.csproj | 2 + .../Framework/NzbDroneMigrationBase.cs | 7 ++- 5 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs create mode 100644 src/NzbDrone.Core.Test/Framework/MigrationTest.cs diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs new file mode 100644 index 000000000..7ade7e918 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using FluentAssertions; +using FluentMigrator; +using NUnit.Framework; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class delay_profileFixture : MigrationTest + { + [TestCase] + public void should_migrate_old_delays() + { + WithTestDb(c => + { + c.Insert.IntoTable("Profiles").Row(new + { + GrabDelay = 1, + Name = "OneHour", + Cutoff = "{}", + Items = "{}" + }); + c.Insert.IntoTable("Profiles").Row(new + { + GrabDelay = 2, + Name = "TwoHours", + Cutoff = "{}", + Items = "[]" + }); + }); + + + var allProfiles = Mocker.Resolve().All().ToList(); + + + allProfiles.Should().HaveCount(3); + allProfiles.Should().OnlyContain(c => c.PreferredProtocol == DownloadProtocol.Usenet); + allProfiles.Should().OnlyContain(c => c.TorrentDelay == 0); + allProfiles.Should().Contain(c => c.UsenetDelay == 60); + allProfiles.Should().Contain(c => c.UsenetDelay == 120); + + } + } + +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Framework/DbTest.cs b/src/NzbDrone.Core.Test/Framework/DbTest.cs index 436368db3..3e81b68ad 100644 --- a/src/NzbDrone.Core.Test/Framework/DbTest.cs +++ b/src/NzbDrone.Core.Test/Framework/DbTest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using FluentMigrator; using FluentMigrator.Runner; using Marr.Data; using Moq; @@ -13,7 +14,6 @@ namespace NzbDrone.Core.Test.Framework { - public abstract class DbTest : DbTest where TSubject : class where TModel : ModelBase, new() @@ -85,7 +85,19 @@ protected ITestDatabase Db } } - private void WithTestDb() + protected virtual TestDatabase WithTestDb(Action beforeMigration) + { + var factory = Mocker.Resolve(); + var database = factory.Create(MigrationType); + Mocker.SetConstant(database); + + var testDb = new TestDatabase(database); + + return testDb; + } + + + protected void SetupContainer() { WithTempAsAppPath(); @@ -94,17 +106,13 @@ private void WithTestDb() Mocker.SetConstant(Mocker.Resolve()); MapRepository.Instance.EnableTraceLogging = true; - - var factory = Mocker.Resolve(); - var database = factory.Create(MigrationType); - _db = new TestDatabase(database); - Mocker.SetConstant(database); } [SetUp] - public void SetupReadDb() + public virtual void SetupDb() { - WithTestDb(); + SetupContainer(); + _db = WithTestDb(null); } [TearDown] diff --git a/src/NzbDrone.Core.Test/Framework/MigrationTest.cs b/src/NzbDrone.Core.Test/Framework/MigrationTest.cs new file mode 100644 index 000000000..137f41983 --- /dev/null +++ b/src/NzbDrone.Core.Test/Framework/MigrationTest.cs @@ -0,0 +1,36 @@ +using System; +using FluentMigrator; +using NUnit.Framework; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Test.Framework +{ + [Category("DbMigrationTest")] + [Category("DbTest")] + public abstract class MigrationTest : DbTest where TMigration : MigrationBase + { + protected override TestDatabase WithTestDb(Action beforeMigration) + { + var factory = Mocker.Resolve(); + + var database = factory.Create(MigrationType, m => + { + if (m.GetType() == typeof(TMigration)) + { + beforeMigration(m); + } + }); + + var testDb = new TestDatabase(database); + Mocker.SetConstant(database); + + return testDb; + } + + [SetUp] + public override void SetupDb() + { + SetupContainer(); + } + } +} \ 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 6f4bb2718..221b61979 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -116,6 +116,7 @@ + @@ -157,6 +158,7 @@ + diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs index 2838adac4..a2144f0ec 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs @@ -8,12 +8,11 @@ namespace NzbDrone.Core.Datastore.Migration.Framework public abstract class NzbDroneMigrationBase : FluentMigrator.Migration { protected readonly Logger _logger; - private readonly MigrationContext _migrationContext; + private MigrationContext _migrationContext; protected NzbDroneMigrationBase() { _logger = NzbDroneLogger.GetLogger(this); - _migrationContext = (MigrationContext)ApplicationContext; } protected virtual void MainDbUpgrade() @@ -37,6 +36,10 @@ public MigrationContext Context { get { + if (_migrationContext == null) + { + _migrationContext = (MigrationContext)ApplicationContext; + } return _migrationContext; } } From cd1d34a0f2bb258a5a00f8e1d0c18a645b1127e0 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 2 Dec 2014 17:18:17 -0800 Subject: [PATCH 4/4] Fixed: Add tag when text box loses focus --- src/NzbDrone.Api/NzbDrone.Api.csproj | 1 + .../Profiles/Delay/DelayProfileModule.cs | 3 + .../Profiles/Delay/DelayProfileResource.cs | 2 + .../Profiles/Delay/DelayProfileValidator.cs | 27 +++++++ .../Migration/070_delay_profileFixture.cs | 73 ++++++++++++++--- .../ProtocolSpecificationFixture.cs | 75 +++++++++++++++++ .../NzbDrone.Core.Test.csproj | 1 + .../Datastore/Migration/070_delay_profile.cs | 8 +- .../Specifications/ProtocolSpecification.cs | 45 +++++++++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + .../Profiles/Delay/DelayProfile.cs | 2 + .../Delay/DelayProfileTagInUseValidator.cs | 1 - src/UI/Mixins/TagInput.js | 26 ++++-- .../Delay/DelayProfileItemViewTemplate.hbs | 52 ++++++++---- .../Delay/DelayProfileLayoutTemplate.hbs | 2 +- .../Delay/Edit/DelayProfileEditView.js | 81 ++++++++++++++++++- .../Edit/DelayProfileEditViewTemplate.hbs | 22 ++--- 17 files changed, 373 insertions(+), 49 deletions(-) create mode 100644 src/NzbDrone.Api/Profiles/Delay/DelayProfileValidator.cs create mode 100644 src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs create mode 100644 src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 92ff378f7..af1b5fe55 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -100,6 +100,7 @@ + diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs index 6cea9b4f7..f18bdf6ef 100644 --- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs +++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs @@ -24,6 +24,9 @@ public DelayProfileModule(IDelayProfileService delayProfileService, DelayProfile SharedValidator.RuleFor(d => d.Tags).NotEmpty().When(d => d.Id != 1); SharedValidator.RuleFor(d => d.Tags).EmptyCollection().When(d => d.Id == 1); SharedValidator.RuleFor(d => d.Tags).SetValidator(tagInUseValidator); + SharedValidator.RuleFor(d => d.UsenetDelay).GreaterThanOrEqualTo(0); + SharedValidator.RuleFor(d => d.TorrentDelay).GreaterThanOrEqualTo(0); + SharedValidator.RuleFor(d => d.Id).SetValidator(new DelayProfileValidator()); } private int Create(DelayProfileResource resource) diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs index 00e5c8e44..bbc2fc67f 100644 --- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs +++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs @@ -6,6 +6,8 @@ namespace NzbDrone.Api.Profiles.Delay { public class DelayProfileResource : RestResource { + public bool EnableUsenet { get; set; } + public bool EnableTorrent { get; set; } public DownloadProtocol PreferredProtocol { get; set; } public int UsenetDelay { get; set; } public int TorrentDelay { get; set; } diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileValidator.cs b/src/NzbDrone.Api/Profiles/Delay/DelayProfileValidator.cs new file mode 100644 index 000000000..b854d87a2 --- /dev/null +++ b/src/NzbDrone.Api/Profiles/Delay/DelayProfileValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation.Validators; +using NzbDrone.Core.Profiles.Delay; +using Omu.ValueInjecter; + +namespace NzbDrone.Api.Profiles.Delay +{ + public class DelayProfileValidator : PropertyValidator + { + public DelayProfileValidator() + : base("Usenet or Torrent must be enabled") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var delayProfile = new DelayProfile(); + delayProfile.InjectFrom(context.ParentContext.InstanceToValidate); + + if (!delayProfile.EnableUsenet && !delayProfile.EnableTorrent) + { + return false; + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs index 7ade7e918..7f0cf4d1b 100644 --- a/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs @@ -1,21 +1,20 @@ -using System; +using System.Collections.Generic; using System.Linq; using FluentAssertions; -using FluentMigrator; using NUnit.Framework; -using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Datastore.Migration.Framework; using NzbDrone.Core.Indexers; using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Tags; using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Test.Datastore.Migration { [TestFixture] public class delay_profileFixture : MigrationTest { - [TestCase] + [Test] public void should_migrate_old_delays() { WithTestDb(c => @@ -27,6 +26,7 @@ public void should_migrate_old_delays() Cutoff = "{}", Items = "{}" }); + c.Insert.IntoTable("Profiles").Row(new { GrabDelay = 2, @@ -36,17 +36,72 @@ public void should_migrate_old_delays() }); }); - var allProfiles = Mocker.Resolve().All().ToList(); - allProfiles.Should().HaveCount(3); allProfiles.Should().OnlyContain(c => c.PreferredProtocol == DownloadProtocol.Usenet); allProfiles.Should().OnlyContain(c => c.TorrentDelay == 0); allProfiles.Should().Contain(c => c.UsenetDelay == 60); allProfiles.Should().Contain(c => c.UsenetDelay == 120); + } + [Test] + public void should_create_tag_for_delay_profile() + { + WithTestDb(c => + c.Insert.IntoTable("Profiles").Row(new + { + GrabDelay = 1, + Name = "OneHour", + Cutoff = "{}", + Items = "{}" + }) + ); + + var tags = Mocker.Resolve().All().ToList(); + + tags.Should().HaveCount(1); + tags.First().Label.Should().Be("delay-60"); + } + + [Test] + public void should_add_tag_to_series_that_had_a_profile_with_delay_attached() + { + WithTestDb(c => + { + c.Insert.IntoTable("Profiles").Row(new + { + GrabDelay = 1, + Name = "OneHour", + Cutoff = "{}", + Items = "{}" + }); + + c.Insert.IntoTable("Series").Row(new + { + TvdbId = 0, + TvRageId = 0, + Title = "Series", + TitleSlug = "series", + CleanTitle = "series", + Status = 0, + Images = "[]", + Path = @"C:\Test\Series", + Monitored = 1, + SeasonFolder = 1, + RunTime = 0, + SeriesType = 0, + UseSceneNumbering = 0, + Tags = "[1]" + }); + }); + + var tag = Mocker.Resolve().All().ToList().First(); + var series = Mocker.Resolve().All().ToList(); + + series.Should().HaveCount(1); + series.First().Tags.Should().HaveCount(1); + series.First().Tags.First().Should().Be(tag.Id); } } - -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs new file mode 100644 index 000000000..4bfaf34dc --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class ProtocolSpecificationFixture : CoreTest + { + private RemoteEpisode _remoteEpisode; + private DelayProfile _delayProfile; + + [SetUp] + public void Setup() + { + _remoteEpisode = new RemoteEpisode(); + _remoteEpisode.Release = new ReleaseInfo(); + _remoteEpisode.Series = new Series(); + + _delayProfile = new DelayProfile(); + + Mocker.GetMock() + .Setup(s => s.BestForTags(It.IsAny>())) + .Returns(_delayProfile); + } + + private void GivenProtocol(DownloadProtocol downloadProtocol) + { + _remoteEpisode.Release.DownloadProtocol = downloadProtocol; + } + + [Test] + public void should_be_true_if_usenet_and_usenet_is_enabled() + { + GivenProtocol(DownloadProtocol.Usenet); + _delayProfile.EnableUsenet = true; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(true); + } + + [Test] + public void should_be_true_if_torrent_and_torrent_is_enabled() + { + GivenProtocol(DownloadProtocol.Torrent); + _delayProfile.EnableTorrent = true; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(true); + } + + [Test] + public void should_be_false_if_usenet_and_usenet_is_disabled() + { + GivenProtocol(DownloadProtocol.Usenet); + _delayProfile.EnableUsenet = false; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(false); + } + + [Test] + public void should_be_false_if_torrent_and_torrent_is_disabled() + { + GivenProtocol(DownloadProtocol.Torrent); + _delayProfile.EnableTorrent = false; + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(false); + } + } +} \ 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 221b61979..02cbab637 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -124,6 +124,7 @@ + diff --git a/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs b/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs index a8add7616..8ef186064 100644 --- a/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs +++ b/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs @@ -3,7 +3,6 @@ using System.Data; using System.Linq; using FluentMigrator; -using NzbDrone.Common; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; using NzbDrone.Core.Datastore.Migration.Framework; @@ -16,15 +15,18 @@ public class delay_profile : NzbDroneMigrationBase protected override void MainDbUpgrade() { Create.TableForModel("DelayProfiles") + .WithColumn("EnableUsenet").AsBoolean().NotNullable() + .WithColumn("EnableTorrent").AsBoolean().NotNullable() .WithColumn("PreferredProtocol").AsInt32().NotNullable() .WithColumn("UsenetDelay").AsInt32().NotNullable() .WithColumn("TorrentDelay").AsInt32().NotNullable() .WithColumn("Order").AsInt32().NotNullable() .WithColumn("Tags").AsString().NotNullable(); - Insert.IntoTable("DelayProfiles").Row(new { + EnableUsenet = 1, + EnableTorrent = 1, PreferredProtocol = 1, UsenetDelay = 0, TorrentDelay = 0, @@ -55,7 +57,7 @@ private void ConvertProfile(IDbConnection conn, IDbTransaction tran) using (IDbCommand insertDelayProfileCmd = conn.CreateCommand()) { insertDelayProfileCmd.Transaction = tran; - insertDelayProfileCmd.CommandText = "INSERT INTO DelayProfiles (PreferredProtocol, TorrentDelay, UsenetDelay, [Order], Tags) VALUES (1, 0, ?, ?, ?)"; + insertDelayProfileCmd.CommandText = "INSERT INTO DelayProfiles (EnableUsenet, EnableTorrent, PreferredProtocol, TorrentDelay, UsenetDelay, [Order], Tags) VALUES (1, 1, 1, 0, ?, ?, ?)"; insertDelayProfileCmd.AddParameter(profile.GrabDelay); insertDelayProfileCmd.AddParameter(order); insertDelayProfileCmd.AddParameter(tags); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs new file mode 100644 index 000000000..00f9e0083 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs @@ -0,0 +1,45 @@ +using NLog; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Delay; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class ProtocolSpecification : IDecisionEngineSpecification + { + private readonly IPendingReleaseService _pendingReleaseService; + private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly IDelayProfileService _delayProfileService; + private readonly Logger _logger; + + public ProtocolSpecification(IDelayProfileService delayProfileService, + Logger logger) + { + _delayProfileService = delayProfileService; + _logger = logger; + } + + public RejectionType Type { get { return RejectionType.Temporary; } } + + public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + { + var delayProfile = _delayProfileService.BestForTags(subject.Series.Tags); + + if (subject.Release.DownloadProtocol == DownloadProtocol.Usenet && !delayProfile.EnableUsenet) + { + _logger.Debug("[{0}] Usenet is not enabled for this series", subject.Release.Title); + return Decision.Reject("Usenet is not enabled for this series"); + } + + if (subject.Release.DownloadProtocol == DownloadProtocol.Torrent && !delayProfile.EnableTorrent) + { + _logger.Debug("[{0}] Torrent is not enabled for this series", subject.Release.Title); + return Decision.Reject("Torrent is not enabled for this series"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 876f10c94..b0a532c18 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -261,6 +261,7 @@ + diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs index 65f403b5b..ef20bb6a5 100644 --- a/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfile.cs @@ -6,6 +6,8 @@ namespace NzbDrone.Core.Profiles.Delay { public class DelayProfile : ModelBase { + public bool EnableUsenet { get; set; } + public bool EnableTorrent { get; set; } public DownloadProtocol PreferredProtocol { get; set; } public int UsenetDelay { get; set; } public int TorrentDelay { get; set; } diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs index 80cb89294..3a5d2a5c7 100644 --- a/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs +++ b/src/NzbDrone.Core/Profiles/Delay/DelayProfileTagInUseValidator.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using FluentValidation.Validators; -using NzbDrone.Common; using NzbDrone.Common.Extensions; using Omu.ValueInjecter; diff --git a/src/UI/Mixins/TagInput.js b/src/UI/Mixins/TagInput.js index 9c6dd81dd..a03730532 100644 --- a/src/UI/Mixins/TagInput.js +++ b/src/UI/Mixins/TagInput.js @@ -26,22 +26,27 @@ define( if (existing) { originalAdd.call(this, existing, dontPushVal); - return; } - var newTag = new TagModel(); - newTag.set({ label: item.toLowerCase() }); - TagCollection.add(newTag); + else { + var newTag = new TagModel(); + newTag.set({ label: item.toLowerCase() }); + TagCollection.add(newTag); - newTag.save().done(function () { - item = newTag.toJSON(); - originalAdd.call(self, item, dontPushVal); - }); + newTag.save().done(function () { + item = newTag.toJSON(); + originalAdd.call(self, item, dontPushVal); + }); + } } else { originalAdd.call(this, item, dontPushVal); } + + if (this.options.tag) { + self.$input.typeahead('val', ''); + } }; $.fn.tagsinput.Constructor.prototype.remove = function (item, dontPushVal) { @@ -69,6 +74,11 @@ define( } }); + self.$input.on('focusout', function () { + self.add(self.$input.val()); + self.$input.val(''); + }); + originalBuild.call(this, options); }; diff --git a/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs b/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs index 2022ee3a1..4175db917 100644 --- a/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs +++ b/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs @@ -1,29 +1,47 @@ 
- {{TitleCase preferredProtocol}} + {{#if enableUsenet}} + {{#if enableTorrent}} + {{#if_eq preferredProtocol compare="usenet"}} + Prefer Usenet + {{else}} + Prefer Torrent + {{/if_eq}} + {{else}} + Only Usenet + {{/if}} + {{else}} + Only Torrent + {{/if}}
- - {{#if_eq usenetDelay compare="0"}} - No delay - {{else}} - {{#if_eq usenetDelay compare="1"}} - 1 minute + {{#if enableUsenet}} + {{#if_eq usenetDelay compare="0"}} + No delay {{else}} - {{usenetDelay}} minutes + {{#if_eq usenetDelay compare="1"}} + 1 minute + {{else}} + {{usenetDelay}} minutes + {{/if_eq}} {{/if_eq}} - {{/if_eq}} - + {{else}} + - + {{/if}}
- {{#if_eq torrentDelay compare="0"}} - No delay - {{else}} - {{#if_eq torrentDelay compare="1"}} - 1 minute + {{#if enableTorrent}} + {{#if_eq torrentDelay compare="0"}} + No delay {{else}} - {{torrentDelay}} minutes + {{#if_eq torrentDelay compare="1"}} + 1 minute + {{else}} + {{torrentDelay}} minutes + {{/if_eq}} {{/if_eq}} - {{/if_eq}} + {{else}} + - + {{/if}}
{{tagDisplay tags}} diff --git a/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs b/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs index 2edaa4a88..78acf91e9 100644 --- a/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs +++ b/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs @@ -5,7 +5,7 @@