From 37a1398338bbcd5395362cd84f1f10a486ff630c Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 23 Nov 2014 16:07:46 -0800 Subject: [PATCH] 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 | 73 ++++---- .../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, 1162 insertions(+), 340 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 @@ namespace NzbDrone.Api.Profiles 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 @@ namespace NzbDrone.Api.Profiles 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 @@ namespace NzbDrone.Api.Validation { return ruleBuilder.SetValidator(new NotNullValidator()).SetValidator(new NotEmptyValidator("")); } + + public static IRuleBuilderOptions> 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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests 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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests 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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests 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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions("WEBRip", null); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -70,7 +70,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions("doesnt,exist", null); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(null, "ignored"); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -86,7 +86,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(null, "edited"); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } [TestCase("EdiTED")] @@ -97,7 +97,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(required, null); - Subject.IsSatisfiedBy(_parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [TestCase("EdiTED")] @@ -108,7 +108,22 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { 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 NUnit.Framework; 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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync _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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync _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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync [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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync { _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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync _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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync .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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync .Setup(s => s.IsRevisionUpgrade(It.IsAny(), It.IsAny())) .Returns(true); - _profile.GrabDelay = 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; + _delayProfile.UsenetDelay = 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 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync GivenExistingFile(new QualityModel(Quality.SDTV)); - _profile.GrabDelay = 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() }); + _delayProfile.UsenetDelay = 12; 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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { 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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { 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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { 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.Jobs; 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 @@ namespace NzbDrone.Core.Datastore 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 @@ namespace NzbDrone.Core.DecisionEngine 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 NLog; 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 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { 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 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync } 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 (profile.GrabDelayMode == GrabDelayMode.First) + if (oldest != null && oldest.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(); - } + return Decision.Accept(); } - if (subject.Release.AgeHours < profile.GrabDelay) + if (subject.Release.AgeHours < delay) { - _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 NLog; 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 @@ namespace NzbDrone.Core.Download.Pending 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 @@ namespace NzbDrone.Core.Download.Pending private readonly IPendingReleaseRepository _repository; private readonly ISeriesService _seriesService; private readonly IParsingService _parsingService; + private readonly IDelayProfileService _delayProfileService; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; public PendingReleaseService(IPendingReleaseRepository repository, ISeriesService seriesService, IParsingService parsingService, + IDelayProfileService delayProfileService, IEventAggregator eventAggregator, Logger logger) { _repository = repository; _seriesService = seriesService; _parsingService = parsingService; + _delayProfileService = delayProfileService; _eventAggregator = eventAggregator; _logger = logger; } @@ -138,8 +144,7 @@ namespace NzbDrone.Core.Download.Pending { 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 @@ namespace NzbDrone.Core.Download.Pending 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 @@ namespace NzbDrone.Core.Download.Pending 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 @@ namespace NzbDrone.Core.Profiles 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; } } }