Delay Profiles

New: Select preferred protocol (usenet/torrent)
New: Option to delay grabs from usenet/torrents independently
pull/4/head
Mark McDowall 10 years ago
parent 0d61b5dc97
commit 37a1398338

@ -98,6 +98,8 @@
<Compile Include="Commands\CommandResource.cs" />
<Compile Include="Extensions\AccessControlHeaders.cs" />
<Compile Include="Extensions\Pipelines\CorsPipeline.cs" />
<Compile Include="Profiles\Delay\DelayProfileModule.cs" />
<Compile Include="Profiles\Delay\DelayProfileResource.cs" />
<Compile Include="RemotePathMappings\RemotePathMappingModule.cs" />
<Compile Include="RemotePathMappings\RemotePathMappingResource.cs" />
<Compile Include="Config\UiConfigModule.cs" />
@ -200,6 +202,7 @@
<Compile Include="Restrictions\RestrictionModule.cs" />
<Compile Include="Restrictions\RestrictionResource.cs" />
<Compile Include="REST\BadRequestException.cs" />
<Compile Include="REST\MethodNotAllowedException.cs" />
<Compile Include="REST\ResourceValidator.cs" />
<Compile Include="REST\RestModule.cs" />
<Compile Include="REST\RestResource.cs" />
@ -221,6 +224,7 @@
<Compile Include="TinyIoCNancyBootstrapper.cs" />
<Compile Include="Update\UpdateModule.cs" />
<Compile Include="Update\UpdateResource.cs" />
<Compile Include="Validation\EmptyCollectionValidator.cs" />
<Compile Include="Validation\RuleBuilderExtensions.cs" />
<Compile Include="Wanted\CutoffModule.cs" />
<Compile Include="Wanted\LegacyMissingModule.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<DelayProfileResource>
{
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<DelayProfile>();
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<DelayProfile>(_delayProfileService.Update, resource);
}
private DelayProfileResource GetById(int id)
{
return _delayProfileService.Get(id).InjectTo<DelayProfileResource>();
}
private List<DelayProfileResource> GetAll()
{
return _delayProfileService.All().InjectTo<List<DelayProfileResource>>();
}
}
}

@ -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<int> Tags { get; set; }
}
}

@ -46,8 +46,6 @@ namespace NzbDrone.Api.Profiles
model.Cutoff = (Quality)resource.Cutoff.Id;
model.Items = resource.Items.InjectTo<List<ProfileQualityItem>>();
model.Language = resource.Language;
model.GrabDelay = resource.GrabDelay;
model.GrabDelayMode = resource.GrabDelayMode;
_profileService.Update(model);
}

@ -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<ProfileQualityItemResource> Items { get; set; }
public Language Language { get; set; }
public Int32 GrabDelay { get; set; }
public GrabDelayMode GrabDelayMode { get; set; }
}
public class ProfileQualityItemResource : RestResource

@ -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)
{
}
}
}

@ -0,0 +1,23 @@
using System.Collections.Generic;
using FluentValidation.Validators;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Api.Validation
{
public class EmptyCollectionValidator<T> : 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<T>;
return collection != null && collection.Empty();
}
}
}

@ -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<T, IEnumerable<TProp>> EmptyCollection<T, TProp>(this IRuleBuilder<T, IEnumerable<TProp>> ruleBuilder)
{
return ruleBuilder.SetValidator(new EmptyCollectionValidator<TProp>());
}
}
}
}

@ -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<DownloadDecisionPriorizationService>
{
[SetUp]
public void Setup()
{
GivenPreferredDownloadProtocol(DownloadProtocol.Usenet);
}
private Episode GivenEpisode(int id)
{
return Builder<Episode>.CreateNew()
@ -25,7 +33,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.Build();
}
private RemoteEpisode GivenRemoteEpisode(List<Episode> episodes, QualityModel quality, int age = 0, long size = 0)
private RemoteEpisode GivenRemoteEpisode(List<Episode> 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<Series>.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<IDelayProfileService>()
.Setup(s => s.BestForTags(It.IsAny<HashSet<int>>()))
.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<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Torrent);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Usenet);
var decisions = new List<DownloadDecision>();
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<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Torrent);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Usenet);
var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode1));
decisions.Add(new DownloadDecision(remoteEpisode2));
var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteEpisode.Release.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
}
}
}

@ -15,12 +15,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestFixture]
public class ReleaseRestrictionsSpecificationFixture : CoreTest<ReleaseRestrictionsSpecification>
{
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<HashSet<Int32>>()))
.Returns(new List<Restriction>());
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<IRestrictionService>()
.Setup(s => s.AllForTags(It.IsAny<HashSet<Int32>>()))
.Returns(new List<Restriction>
{
new Restriction { Required = "x264", Ignored = "www.Speed.cd" }
});
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse();
}
}
}

@ -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<DelaySpecification>
{
private Profile _profile;
private DelayProfile _delayProfile;
private RemoteEpisode _remoteEpisode;
[SetUp]
@ -32,6 +34,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_profile = Builder<Profile>.CreateNew()
.Build();
_delayProfile = Builder<DelayProfile>.CreateNew()
.With(d => d.PreferredProtocol = DownloadProtocol.Usenet)
.Build();
var series = Builder<Series>.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<Episode>.CreateListOfSize(1).Build().ToList();
_remoteEpisode.Episodes.First().EpisodeFileId = 0;
Mocker.GetMock<IDelayProfileService>()
.Setup(s => s.BestForTags(It.IsAny<HashSet<int>>()))
.Returns(_delayProfile);
Mocker.GetMock<IPendingReleaseService>()
.Setup(s => s.GetPendingRemoteEpisodes(It.IsAny<int>()))
.Returns(new List<RemoteEpisode>());
}
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<QualityModel>(), It.IsAny<QualityModel>()))
.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<QualityModel>(), It.IsAny<QualityModel>()))
.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<IPendingReleaseService>()
.Setup(s => s.GetPendingRemoteEpisodes(It.IsAny<Int32>()))
.Returns(new List<RemoteEpisode>());
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<IPendingReleaseService>()
.Setup(s => s.GetPendingRemoteEpisodes(It.IsAny<Int32>()))
.Returns(new List<RemoteEpisode> { _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<IPendingReleaseService>()
.Setup(s => s.GetPendingRemoteEpisodes(It.IsAny<Int32>()))
.Returns(new List<RemoteEpisode> { 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<IPendingReleaseService>()
.Setup(s => s.GetPendingRemoteEpisodes(It.IsAny<Int32>()))
.Returns(new List<RemoteEpisode> { pendingRemoteEpisode1, pendingRemoteEpisode2 });
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue();
}
}
}

@ -40,7 +40,6 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
{
Name = "Test",
Cutoff = Quality.HDTV720p,
GrabDelay = 1,
Items = new List<ProfileQualityItem>
{
new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p },

@ -40,7 +40,6 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
{
Name = "Test",
Cutoff = Quality.HDTV720p,
GrabDelay = 1,
Items = new List<ProfileQualityItem>
{
new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p },

@ -40,7 +40,6 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
{
Name = "Test",
Cutoff = Quality.HDTV720p,
GrabDelay = 1,
Items = new List<ProfileQualityItem>
{
new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p },

@ -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<Profile70> GetProfiles(IDbConnection conn, IDbTransaction tran)
{
var profiles = new List<Profile70>();
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<int> 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<List<int>>(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<int> Tags { get; set; }
}
}
}

@ -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<RemotePathMapping>().RegisterModel("RemotePathMappings");
Mapper.Entity<Tag>().RegisterModel("Tags");
Mapper.Entity<Restriction>().RegisterModel("Restrictions");
Mapper.Entity<DelayProfile>().RegisterModel("DelayProfiles");
}
private static void RegisterMappers()

@ -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<DownloadDecision> PrioritizeDecisions(List<DownloadDecision> 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;
}
}
}

@ -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");
}

@ -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<DownloadDecision> grabbed);
void RemoveRejected(List<DownloadDecision> rejected);
List<ReleaseInfo> GetPending();
List<RemoteEpisode> GetPendingRemoteEpisodes(Int32 seriesId);
List<RemoteEpisode> GetPendingRemoteEpisodes(int seriesId);
List<Queue.Queue> GetPendingQueue();
RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable<int> episodeIds);
}
public class PendingReleaseService : IPendingReleaseService, IHandle<SeriesDeletedEvent>
@ -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<int> episodeIds)
{
return GetPendingRemoteEpisodes(seriesId)
.Where(r => r.Episodes.Select(e => e.Id).Intersect(episodeIds).Any())
.OrderByDescending(p => p.Release.AgeHours)
.FirstOrDefault();
}
private List<PendingRelease> GetPendingReleases()
{
var result = new List<PendingRelease>();
@ -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);

@ -230,6 +230,7 @@
<Compile Include="Datastore\Migration\066_add_tags.cs" />
<Compile Include="Datastore\Migration\067_add_added_to_series.cs" />
<Compile Include="Datastore\Migration\069_quality_proper.cs" />
<Compile Include="Datastore\Migration\070_delay_profile.cs" />
<Compile Include="Datastore\Migration\Framework\MigrationContext.cs" />
<Compile Include="Datastore\Migration\Framework\MigrationController.cs" />
<Compile Include="Datastore\Migration\Framework\MigrationDbFactory.cs" />
@ -623,6 +624,10 @@
<Compile Include="MetadataSource\Trakt\TraktException.cs" />
<Compile Include="MetadataSource\TraktProxy.cs" />
<Compile Include="MetadataSource\Tvdb\TvdbProxy.cs" />
<Compile Include="Profiles\Delay\DelayProfile.cs" />
<Compile Include="Profiles\Delay\DelayProfileService.cs" />
<Compile Include="Profiles\Delay\DelayProfileTagInUseValidator.cs" />
<Compile Include="Profiles\ProfileRepository.cs" />
<Compile Include="Qualities\Revision.cs" />
<Compile Include="RemotePathMappings\RemotePathMapping.cs" />
<Compile Include="RemotePathMappings\RemotePathMappingRepository.cs" />
@ -738,11 +743,10 @@
<Compile Include="Parser\ParsingService.cs" />
<Compile Include="Parser\SceneChecker.cs" />
<Compile Include="Parser\QualityParser.cs" />
<Compile Include="Profiles\GrabDelayMode.cs" />
<Compile Include="Profiles\Profile.cs" />
<Compile Include="Profiles\ProfileInUseException.cs" />
<Compile Include="Profiles\ProfileQualityItem.cs" />
<Compile Include="Profiles\ProfileRepository.cs" />
<Compile Include="Profiles\Delay\DelayProfileRepository.cs" />
<Compile Include="Profiles\ProfileService.cs" />
<Compile Include="ProgressMessaging\CommandUpdatedEvent.cs" />
<Compile Include="ProgressMessaging\ProgressMessageTarget.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<int> Tags { get; set; }
public DelayProfile()
{
Tags = new HashSet<int>();
}
public int GetProtocolDelay(DownloadProtocol protocol)
{
return protocol == DownloadProtocol.Torrent ? TorrentDelay : UsenetDelay;
}
}
}

@ -0,0 +1,18 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Profiles.Delay
{
public interface IDelayProfileRepository : IBasicRepository<DelayProfile>
{
}
public class DelayProfileRepository : BasicRepository<DelayProfile>, IDelayProfileRepository
{
public DelayProfileRepository(IDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
}
}

@ -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<DelayProfile> All();
DelayProfile Get(int id);
List<DelayProfile> AllForTags(HashSet<int> tagIds);
DelayProfile BestForTags(HashSet<int> 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<DelayProfile> All()
{
return _repo.All().ToList();
}
public DelayProfile Get(int id)
{
return _repo.Get(id);
}
public List<DelayProfile> AllForTags(HashSet<int> tagIds)
{
return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList();
}
public DelayProfile BestForTags(HashSet<int> tagIds)
{
return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty())
.OrderBy(d => d.Order).First();
}
}
}

@ -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<int>;
if (collection == null || collection.Empty()) return true;
return _delayProfileService.All().None(d => d.Id != delayProfile.Id && d.Tags.Intersect(collection).Any());
}
}
}

@ -1,9 +0,0 @@
namespace NzbDrone.Core.Profiles
{
public enum GrabDelayMode
{
First = 0,
Cutoff = 1,
Always = 2
}
}

@ -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<ProfileQualityItem> 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;
}
}
}
}

@ -9,6 +9,8 @@
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=NUnit_002ENonPublicMethodWithTestAttribute/@EntryIndexedValue">ERROR</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ReturnTypeCanBeEnumerable_002EGlobal/@EntryIndexedValue">HINT</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=TestClassNameDoesNotMatchFileNameWarning/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=TestClassNameSuffixWarning/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=UnusedParameter_002ELocal/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=UseObjectOrCollectionInitializer/@EntryIndexedValue">HINT</s:String>
<s:Boolean x:Key="/Default/CodeInspection/TestFileAnalysis/SeachForOrphanedProjectFiles/@EntryValue">True</s:Boolean>

@ -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);

@ -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;
};
}
);

@ -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;
});

@ -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;

@ -1,12 +1,12 @@
 <span class="col-sm-2">
<div>{{host}}</div>
</span>
<span class="col-sm-5">
<div>{{remotePath}}</div>
</span>
<span class="col-sm-4">
<div>{{localPath}}</div>
</span>
<span class="col-sm-1">
 <div class="col-sm-2">
{{host}}
</div>
<div class="col-sm-5">
{{remotePath}}
</div>
<div class="col-sm-4">
{{localPath}}
</div>
<div class="col-sm-1">
<div class="pull-right"><i class="icon-nd-edit x-edit" title="" data-original-title="Edit Mapping"></i></div>
</span>
</div>

@ -1,12 +1,12 @@
 <span class="col-sm-4">
 <div class="col-sm-4">
{{genericTagDisplay required 'label label-success'}}
</span>
<span class="col-sm-4">
</div>
<div class="col-sm-4">
{{genericTagDisplay ignored 'label label-danger'}}
</span>
<span class="col-sm-3">
</div>
<div class="col-sm-3">
{{tagDisplay tags}}
</span>
<span class="col-sm-1">
</div>
<div class="col-sm-1">
<div class="pull-right"><i class="icon-nd-edit x-edit" title="" data-original-title="Edit"></i></div>
</span>
</div>

@ -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'
});
});

@ -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'
}
});
});

@ -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);
}
});
});

@ -0,0 +1,39 @@
 <div class="col-sm-2">
{{TitleCase preferredProtocol}}
</div>
<div class="col-sm-2">
{{#if_eq usenetDelay compare="0"}}
No delay
{{else}}
{{#if_eq usenetDelay compare="1"}}
1 minute
{{else}}
{{usenetDelay}} minutes
{{/if_eq}}
{{/if_eq}}
</div>
<div class="col-sm-2">
{{#if_eq torrentDelay compare="0"}}
No delay
{{else}}
{{#if_eq torrentDelay compare="1"}}
1 minute
{{else}}
{{torrentDelay}} minutes
{{/if_eq}}
{{/if_eq}}
</div>
<div class="col-sm-5">
{{tagDisplay tags}}
</div>
<div class="col-sm-1">
<div class="pull-right">
{{#unless_eq id compare="1"}}
<i class="drag-handle icon-reorder x-drag-handle" title="Reorder"/>
{{/unless_eq}}
<i class="icon-nd-edit x-edit" title="Edit"></i>
</div>
</div>

@ -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');
}));
}
});
});

@ -0,0 +1,24 @@
<fieldset class="advanced-setting">
<legend>Delay Profiles</legend>
<div class="col-md-12">
<div class="rule-setting-list">
<div class="rule-setting-header x-header hidden-xs">
<div class="row">
<span class="col-sm-2">Preferred Protocol</span>
<span class="col-sm-2">Usenet Delay</span>
<span class="col-sm-2">Torrent Delay</span>
<span class="col-sm-5">Tags</span>
</div>
</div>
<div class="rows x-rows"></div>
<div class="rule-setting-footer">
<div class="pull-right">
<span class="add-rule-setting-mapping">
<i class="icon-nd-add x-add" title="Add new delay profile" />
</span>
</div>
</div>
</div>
</div>
</fieldset>

@ -0,0 +1,8 @@
'use strict';
define(
[
'backbone'
], function (Backbone) {
return Backbone.Model.extend({
});
});

@ -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);
}
});
}
});
});

@ -0,0 +1,13 @@
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3>Delete Delay Profile</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this delay profile?</p>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal">cancel</button>
<button class="btn btn-danger x-confirm-delete">delete</button>
</div>
</div>

@ -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;
});

@ -0,0 +1,76 @@
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" aria-hidden="true" data-dismiss="modal">&times;</button>
{{#if id}}
<h3>Edit - Delay Profile</h3>
{{else}}
<h3>Add - Delay Profile</h3>
{{/if}}
</div>
<div class="modal-body indexer-modal">
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-3 control-label">Preferred Protocol</label>
<div class="col-sm-1 col-sm-push-5 help-inline">
<i class="icon-nd-form-info" title="Choose which protocol to use when choosing between otherwise equal releases" />
</div>
<div class="col-sm-5 col-sm-pull-1">
<select class="form-control" name="preferredProtocol">
<option value="1">Usenet</option>
<option value="2">Torrents</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Usenet Delay</label>
<div class="col-sm-1 col-sm-push-5 help-inline">
<i class="icon-nd-form-info" title="Delay in minutes to wait before grabbing a release from Usenet" />
</div>
<div class="col-sm-5 col-sm-pull-1">
<input type="number" class="form-control" name="usenetDelay"/>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Torrent Delay</label>
<div class="col-sm-1 col-sm-push-5 help-inline">
<i class="icon-nd-form-info" title="Delay in minutes to wait before grabbing a torrent" />
</div>
<div class="col-sm-5 col-sm-pull-1">
<input type="number" class="form-control" name="torrentDelay"/>
</div>
</div>
{{#if_eq id compare="1"}}
<div class="alert alert-info" role="alert">The default Delay Profile applies to all series unless overridden by a Delay Profile attached to a matching tag</div>
{{else}}
<div class="form-group">
<label class="col-sm-3 control-label">Tags</label>
<div class="col-sm-1 col-sm-push-5 help-inline">
<i class="icon-nd-form-info" title="One or more tags to apply these rules to matching series" />
</div>
<div class="col-sm-5 col-sm-pull-1">
<input type="text" class="form-control x-tags">
</div>
</div>
{{/if_eq}}
</div>
</div>
<div class="modal-footer">
{{#if id}}
<button class="btn btn-danger pull-left x-delete">delete</button>
{{/if}}
<span class="indicator x-indicator"><i class="icon-spinner icon-spin"></i></span>
<button class="btn" data-dismiss="modal">cancel</button>
<button class="btn btn-primary x-save">save</button>
</div>
</div>

@ -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();
}
}
});

@ -24,34 +24,6 @@
</div>
</div>
<div class="form-group advanced-setting">
<label class="col-sm-3 control-label">Delay</label>
<div class="col-sm-5">
<input type="number" min="0" max="72" name="grabDelay" class="form-control x-delay">
</div>
<div class="col-sm-1 help-inline">
<i class="icon-nd-form-info" title="Wait time in hours before grabbing a release automatically, set to 0 to disable. The highest allowed quality in the profile will be grabbed immediately when available."/>
</div>
</div>
<div class="form-group advanced-setting x-delay-mode">
<label class="col-sm-3 control-label">Delay Mode</label>
<div class="col-sm-5">
<select class="form-control" name="grabDelayMode">
<option value="first">First</option>
<option value="cutoff">Cutoff</option>
<option value="always">Always</option>
</select>
</div>
<div class="col-sm-1 help-inline">
<i class="icon-nd-form-info" data-html="true" title="First: Delay until first wanted release passes delay, grabbing best quality release at that time. Cutoff: Delay for all qualities below the cutoff. Always: Delay before grabbing all qualities"/>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Cutoff</label>

@ -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}));
}
});
});

@ -1,3 +1,5 @@
<div class="row">
<div class="col-md-12" id="profile"/>
<div class="col-md-12 delay-profile-region" id="delay-profile"/>
</div>

@ -5,11 +5,8 @@
<div class="language">
{{languageLabel}}
{{#if_gt grabDelay compare="0"}}
<i class="icon-time" title="{{grabDelay}} hour, Mode: {{TitleCase grabDelayMode}}"></i>
{{/if_gt}}
</div>
<ul class="allowed-qualities">
{{allowedLabeler}}
</ul>

@ -29,3 +29,15 @@
margin-bottom: 3px;
}
}
.delay-profile-region {
margin-top : 30px;
}
.delay-profiles {
padding-left : 0px;
li {
list-style-type : none;
}
}

@ -154,7 +154,8 @@ li.save-and-add:hover {
padding : 5px;
i {
cursor : pointer;
cursor : pointer;
margin-left : 5px;
}
}
}

Loading…
Cancel
Save