diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css index 21025e770..9c174dfc2 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.css +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css @@ -31,7 +31,7 @@ background-color: var(--sliderAccentColor); box-shadow: 0 0 0 #000; - &:nth-child(odd) { + &:nth-child(3n+1) { background-color: #ddd; } } @@ -56,7 +56,7 @@ .megabytesPerMinute { display: flex; justify-content: space-between; - flex: 0 0 250px; + flex: 0 0 400px; } .sizeInput { diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js index fe2b82fb0..3fc196e09 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -51,7 +51,8 @@ class QualityDefinition extends Component { this.state = { sliderMinSize: getSliderValue(props.minSize, slider.min), - sliderMaxSize: getSliderValue(props.maxSize, slider.max) + sliderMaxSize: getSliderValue(props.maxSize, slider.max), + sliderPreferredSize: getSliderValue(props.preferredSize, (slider.max - 3)) }; } @@ -93,14 +94,16 @@ class QualityDefinition extends Component { // // Listeners - onSliderChange = ([sliderMinSize, sliderMaxSize]) => { + onSliderChange = ([sliderMinSize, sliderPreferredSize, sliderMaxSize]) => { this.setState({ sliderMinSize, - sliderMaxSize + sliderMaxSize, + sliderPreferredSize }); this.props.onSizeChange({ minSize: roundNumber(Math.pow(sliderMinSize, 1.1)), + preferredSize: sliderPreferredSize === (slider.max - 3) ? null : roundNumber(Math.pow(sliderPreferredSize, 1.1)), maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1)) }); }; @@ -108,12 +111,14 @@ class QualityDefinition extends Component { onAfterSliderChange = () => { const { minSize, - maxSize + maxSize, + preferredSize } = this.props; this.setState({ sliderMiSize: getSliderValue(minSize, slider.min), - sliderMaxSize: getSliderValue(maxSize, slider.max) + sliderMaxSize: getSliderValue(maxSize, slider.max), + sliderPreferredSize: getSliderValue(preferredSize, (slider.max - 3)) // fix }); }; @@ -126,7 +131,22 @@ class QualityDefinition extends Component { this.props.onSizeChange({ minSize, - maxSize: this.props.maxSize + maxSize: this.props.maxSize, + preferredSize: this.props.preferredSize + }); + }; + + onPreferredSizeChange = ({ value }) => { + const preferredSize = value === (MAX - 3) ? null : getValue(value); + + this.setState({ + sliderPreferredSize: getSliderValue(preferredSize, slider.preferred) + }); + + this.props.onSizeChange({ + minSize: this.props.minSize, + maxSize: this.props.maxSize, + preferredSize }); }; @@ -139,7 +159,8 @@ class QualityDefinition extends Component { this.props.onSizeChange({ minSize: this.props.minSize, - maxSize + maxSize, + preferredSize: this.props.preferredSize }); }; @@ -153,18 +174,23 @@ class QualityDefinition extends Component { title, minSize, maxSize, + preferredSize, advancedSettings, onTitleChange } = this.props; const { sliderMinSize, - sliderMaxSize + sliderMaxSize, + sliderPreferredSize } = this.state; const minBytes = minSize * 1024 * 1024; const minSixty = `${formatBytes(minBytes * 60)}/h`; + const preferredBytes = preferredSize * 1024 * 1024; + const preferredSixty = preferredBytes ? `${formatBytes(preferredBytes * 60)}/h` : 'Unlimited'; + const maxBytes = maxSize && maxSize * 1024 * 1024; const maxSixty = maxBytes ? `${formatBytes(maxBytes * 60)}/h` : 'Unlimited'; @@ -188,9 +214,10 @@ class QualityDefinition extends Component { min={slider.min} max={slider.max} step={slider.step} - minDistance={MIN_DISTANCE * 5} - value={[sliderMinSize, sliderMaxSize]} + minDistance={3} + value={[sliderMinSize, sliderPreferredSize, sliderMaxSize]} withTracks={true} + allowCross={false} snapDragDisabled={true} renderThumb={this.thumbRenderer} renderTrack={this.trackRenderer} @@ -215,6 +242,22 @@ class QualityDefinition extends Component { /> +
+ {preferredSixty} + } + title="Preferred Size" + body={ + + } + position={tooltipPositions.BOTTOM} + /> +
+
+
+ Preferred + + +
+
Max @@ -278,6 +336,7 @@ QualityDefinition.propTypes = { title: PropTypes.string.isRequired, minSize: PropTypes.number, maxSize: PropTypes.number, + preferredSize: PropTypes.number, advancedSettings: PropTypes.bool.isRequired, onTitleChange: PropTypes.func.isRequired, onSizeChange: PropTypes.func.isRequired diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js index b7a236a0f..eee0558f1 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js @@ -23,11 +23,12 @@ class QualityDefinitionConnector extends Component { this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value }); }; - onSizeChange = ({ minSize, maxSize }) => { + onSizeChange = ({ minSize, maxSize, preferredSize }) => { const { id, minSize: currentMinSize, - maxSize: currentMaxSize + maxSize: currentMaxSize, + preferredSize: currentPreferredSize } = this.props; if (minSize !== currentMinSize) { @@ -37,6 +38,10 @@ class QualityDefinitionConnector extends Component { if (maxSize !== currentMaxSize) { this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize }); } + + if (preferredSize !== currentPreferredSize) { + this.props.setQualityDefinitionValue({ id, name: 'preferredSize', value: preferredSize }); + } }; // @@ -57,6 +62,7 @@ QualityDefinitionConnector.propTypes = { id: PropTypes.number.isRequired, minSize: PropTypes.number, maxSize: PropTypes.number, + preferredSize: PropTypes.number, setQualityDefinitionValue: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 47998ae21..2c9c05362 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -15,7 +15,6 @@ using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Test.Languages; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Test.DecisionEngineTests @@ -27,6 +26,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void Setup() { GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(new QualityDefinition { PreferredSize = null }); + } + + private void GivenPreferredSize(double? size) + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(new QualityDefinition { PreferredSize = size }); } private Episode GivenEpisode(int id) @@ -54,6 +64,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests remoteEpisode.Release.IndexerPriority = indexerPriority; remoteEpisode.Series = Builder.CreateNew() + .With(e => e.Runtime = 60) .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() @@ -161,6 +172,44 @@ namespace NzbDrone.Core.Test.DecisionEngineTests qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisodeHdLargeYoung); } + [Test] + public void should_order_by_closest_to_preferred_size_if_both_under() + { + // 200 MB/Min * 60 Min Runtime = 12000 MB + GivenPreferredSize(200); + + var remoteEpisodeSmall = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 1200.Megabytes(), age: 1); + var remoteEpisodeLarge = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 10000.Megabytes(), age: 1); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisodeSmall)); + decisions.Add(new DownloadDecision(remoteEpisodeLarge)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisodeLarge); + } + + [Test] + public void should_order_by_closest_to_preferred_size_if_preferred_is_in_between() + { + // 46 MB/Min * 60 Min Runtime = 6900 MB + GivenPreferredSize(46); + + var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 500.Megabytes(), age: 1); + var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 2000.Megabytes(), age: 1); + var remoteEpisode3 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 3000.Megabytes(), age: 1); + var remoteEpisode4 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 5000.Megabytes(), age: 1); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteEpisode1)); + decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteEpisode3)); + decisions.Add(new DownloadDecision(remoteEpisode4)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisode3); + } + [Test] public void should_order_by_youngest() { diff --git a/src/NzbDrone.Core/Datastore/Migration/181_quality_definition_preferred_size.cs b/src/NzbDrone.Core/Datastore/Migration/181_quality_definition_preferred_size.cs new file mode 100644 index 000000000..4f15d3b5c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/181_quality_definition_preferred_size.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(181)] + public class quality_definition_preferred_size : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("QualityDefinitions").AddColumn("PreferredSize").AsDouble().Nullable(); + + Execute.Sql("UPDATE QualityDefinitions SET PreferredSize = MaxSize - 5 WHERE MaxSize > 5"); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs index c643efb78..ce29d1919 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs @@ -14,14 +14,16 @@ namespace NzbDrone.Core.DecisionEngine { private readonly IConfigService _configService; private readonly IDelayProfileService _delayProfileService; + private readonly IQualityDefinitionService _qualityDefinitionService; public delegate int CompareDelegate(DownloadDecision x, DownloadDecision y); public delegate int CompareDelegate(DownloadDecision x, DownloadDecision y); - public DownloadDecisionComparer(IConfigService configService, IDelayProfileService delayProfileService) + public DownloadDecisionComparer(IConfigService configService, IDelayProfileService delayProfileService, IQualityDefinitionService qualityDefinitionService) { _configService = configService; _delayProfileService = delayProfileService; + _qualityDefinitionService = qualityDefinitionService; } public int Compare(DownloadDecision x, DownloadDecision y) @@ -180,9 +182,25 @@ namespace NzbDrone.Core.DecisionEngine private int CompareSize(DownloadDecision x, DownloadDecision y) { - // TODO: Is smaller better? Smaller for usenet could mean no par2 files. + var sizeCompare = CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => + { + var preferredSize = _qualityDefinitionService.Get(remoteEpisode.ParsedEpisodeInfo.Quality.Quality).PreferredSize; - return CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.Release.Size.Round(200.Megabytes())); + // If no value for preferred it means unlimited so fallback to sort largest is best + if (preferredSize.HasValue && remoteEpisode.Series.Runtime > 0) + { + var preferredMovieSize = remoteEpisode.Series.Runtime * preferredSize.Value.Megabytes(); + + // Calculate closest to the preferred size + return Math.Abs((remoteEpisode.Release.Size - preferredMovieSize).Round(200.Megabytes())) * (-1); + } + else + { + return remoteEpisode.Release.Size.Round(200.Megabytes()); + } + }); + + return sizeCompare; } } } diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs index af399a838..44808ee05 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs @@ -1,7 +1,8 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Configuration; using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine { @@ -14,11 +15,13 @@ namespace NzbDrone.Core.DecisionEngine { private readonly IConfigService _configService; private readonly IDelayProfileService _delayProfileService; + private readonly IQualityDefinitionService _qualityDefinitionService; - public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService) + public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService, IQualityDefinitionService qualityDefinitionService) { _configService = configService; _delayProfileService = delayProfileService; + _qualityDefinitionService = qualityDefinitionService; } public List PrioritizeDecisions(List decisions) @@ -26,7 +29,7 @@ namespace NzbDrone.Core.DecisionEngine return decisions.Where(c => c.RemoteEpisode.Series != null) .GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, downloadDecisions) => { - return downloadDecisions.OrderByDescending(decision => decision, new DownloadDecisionComparer(_configService, _delayProfileService)); + return downloadDecisions.OrderByDescending(decision => decision, new DownloadDecisionComparer(_configService, _delayProfileService, _qualityDefinitionService)); }) .SelectMany(c => c) .Union(decisions.Where(c => c.RemoteEpisode.Series == null)) diff --git a/src/NzbDrone.Core/Qualities/Quality.cs b/src/NzbDrone.Core/Qualities/Quality.cs index 0949abbbd..c04f8d1d7 100644 --- a/src/NzbDrone.Core/Qualities/Quality.cs +++ b/src/NzbDrone.Core/Qualities/Quality.cs @@ -149,27 +149,27 @@ namespace NzbDrone.Core.Qualities DefaultQualityDefinitions = new HashSet { - new QualityDefinition(Quality.Unknown) { Weight = 1, MinSize = 1, MaxSize = 199.9 }, - new QualityDefinition(Quality.SDTV) { Weight = 2, MinSize = 2, MaxSize = 100 }, - new QualityDefinition(Quality.WEBRip480p) { Weight = 3, MinSize = 2, MaxSize = 100, GroupName = "WEB 480p" }, - new QualityDefinition(Quality.WEBDL480p) { Weight = 3, MinSize = 2, MaxSize = 100, GroupName = "WEB 480p" }, - new QualityDefinition(Quality.DVD) { Weight = 4, MinSize = 2, MaxSize = 100, GroupName = "DVD" }, - new QualityDefinition(Quality.Bluray480p) { Weight = 5, MinSize = 2, MaxSize = 100, GroupName = "DVD" }, - new QualityDefinition(Quality.HDTV720p) { Weight = 6, MinSize = 3, MaxSize = 125 }, - new QualityDefinition(Quality.HDTV1080p) { Weight = 7, MinSize = 4, MaxSize = 125 }, - new QualityDefinition(Quality.RAWHD) { Weight = 8, MinSize = 4, MaxSize = null }, - new QualityDefinition(Quality.WEBRip720p) { Weight = 9, MinSize = 3, MaxSize = 130, GroupName = "WEB 720p" }, - new QualityDefinition(Quality.WEBDL720p) { Weight = 9, MinSize = 3, MaxSize = 130, GroupName = "WEB 720p" }, - new QualityDefinition(Quality.Bluray720p) { Weight = 10, MinSize = 4, MaxSize = 130 }, - new QualityDefinition(Quality.WEBRip1080p) { Weight = 11, MinSize = 4, MaxSize = 130, GroupName = "WEB 1080p" }, - new QualityDefinition(Quality.WEBDL1080p) { Weight = 11, MinSize = 4, MaxSize = 130, GroupName = "WEB 1080p" }, - new QualityDefinition(Quality.Bluray1080p) { Weight = 12, MinSize = 4, MaxSize = 155 }, - new QualityDefinition(Quality.Bluray1080pRemux) { Weight = 13, MinSize = 35, MaxSize = null }, - new QualityDefinition(Quality.HDTV2160p) { Weight = 14, MinSize = 35, MaxSize = 199.9 }, - new QualityDefinition(Quality.WEBRip2160p) { Weight = 15, MinSize = 35, MaxSize = null, GroupName = "WEB 2160p" }, - new QualityDefinition(Quality.WEBDL2160p) { Weight = 15, MinSize = 35, MaxSize = null, GroupName = "WEB 2160p" }, - new QualityDefinition(Quality.Bluray2160p) { Weight = 16, MinSize = 35, MaxSize = null }, - new QualityDefinition(Quality.Bluray2160pRemux) { Weight = 17, MinSize = 35, MaxSize = null } + new QualityDefinition(Quality.Unknown) { Weight = 1, MinSize = 1, MaxSize = 199.9, PreferredSize = 95 }, + new QualityDefinition(Quality.SDTV) { Weight = 2, MinSize = 2, MaxSize = 100, PreferredSize = 95 }, + new QualityDefinition(Quality.WEBRip480p) { Weight = 3, MinSize = 2, MaxSize = 100, PreferredSize = 95, GroupName = "WEB 480p" }, + new QualityDefinition(Quality.WEBDL480p) { Weight = 3, MinSize = 2, MaxSize = 100, PreferredSize = 95, GroupName = "WEB 480p" }, + new QualityDefinition(Quality.DVD) { Weight = 4, MinSize = 2, MaxSize = 100, PreferredSize = 95, GroupName = "DVD" }, + new QualityDefinition(Quality.Bluray480p) { Weight = 5, MinSize = 2, MaxSize = 100, PreferredSize = 95, GroupName = "DVD" }, + new QualityDefinition(Quality.HDTV720p) { Weight = 6, MinSize = 3, MaxSize = 125, PreferredSize = 95 }, + new QualityDefinition(Quality.HDTV1080p) { Weight = 7, MinSize = 4, MaxSize = 125, PreferredSize = 95 }, + new QualityDefinition(Quality.RAWHD) { Weight = 8, MinSize = 4, MaxSize = null, PreferredSize = 95 }, + new QualityDefinition(Quality.WEBRip720p) { Weight = 9, MinSize = 3, MaxSize = 130, PreferredSize = 95, GroupName = "WEB 720p" }, + new QualityDefinition(Quality.WEBDL720p) { Weight = 9, MinSize = 3, MaxSize = 130, PreferredSize = 95, GroupName = "WEB 720p" }, + new QualityDefinition(Quality.Bluray720p) { Weight = 10, MinSize = 4, MaxSize = 130, PreferredSize = 95 }, + new QualityDefinition(Quality.WEBRip1080p) { Weight = 11, MinSize = 4, MaxSize = 130, PreferredSize = 95, GroupName = "WEB 1080p" }, + new QualityDefinition(Quality.WEBDL1080p) { Weight = 11, MinSize = 4, MaxSize = 130, PreferredSize = 95, GroupName = "WEB 1080p" }, + new QualityDefinition(Quality.Bluray1080p) { Weight = 12, MinSize = 4, MaxSize = 155, PreferredSize = 95 }, + new QualityDefinition(Quality.Bluray1080pRemux) { Weight = 13, MinSize = 35, MaxSize = null, PreferredSize = 95 }, + new QualityDefinition(Quality.HDTV2160p) { Weight = 14, MinSize = 35, MaxSize = 199.9, PreferredSize = 95 }, + new QualityDefinition(Quality.WEBRip2160p) { Weight = 15, MinSize = 35, MaxSize = null, PreferredSize = 95, GroupName = "WEB 2160p" }, + new QualityDefinition(Quality.WEBDL2160p) { Weight = 15, MinSize = 35, MaxSize = null, PreferredSize = 95, GroupName = "WEB 2160p" }, + new QualityDefinition(Quality.Bluray2160p) { Weight = 16, MinSize = 35, MaxSize = null, PreferredSize = 95 }, + new QualityDefinition(Quality.Bluray2160pRemux) { Weight = 17, MinSize = 35, MaxSize = null, PreferredSize = 95 } }; } diff --git a/src/NzbDrone.Core/Qualities/QualityDefinition.cs b/src/NzbDrone.Core/Qualities/QualityDefinition.cs index 4d73a646d..834888111 100644 --- a/src/NzbDrone.Core/Qualities/QualityDefinition.cs +++ b/src/NzbDrone.Core/Qualities/QualityDefinition.cs @@ -14,6 +14,7 @@ namespace NzbDrone.Core.Qualities public double? MinSize { get; set; } public double? MaxSize { get; set; } + public double? PreferredSize { get; set; } public QualityDefinition() { diff --git a/src/Sonarr.Api.V3/Qualities/QualityDefinitionResource.cs b/src/Sonarr.Api.V3/Qualities/QualityDefinitionResource.cs index 01c618917..abde0ce46 100644 --- a/src/Sonarr.Api.V3/Qualities/QualityDefinitionResource.cs +++ b/src/Sonarr.Api.V3/Qualities/QualityDefinitionResource.cs @@ -15,6 +15,7 @@ namespace Sonarr.Api.V3.Qualities public double? MinSize { get; set; } public double? MaxSize { get; set; } + public double? PreferredSize { get; set; } } public static class QualityDefinitionResourceMapper @@ -33,7 +34,8 @@ namespace Sonarr.Api.V3.Qualities Title = model.Title, Weight = model.Weight, MinSize = model.MinSize, - MaxSize = model.MaxSize + MaxSize = model.MaxSize, + PreferredSize = model.PreferredSize }; } @@ -51,7 +53,8 @@ namespace Sonarr.Api.V3.Qualities Title = resource.Title, Weight = resource.Weight, MinSize = resource.MinSize, - MaxSize = resource.MaxSize + MaxSize = resource.MaxSize, + PreferredSize = resource.PreferredSize }; }