diff --git a/frontend/src/Series/Details/EpisodeRow.css b/frontend/src/Series/Details/EpisodeRow.css index c9fa30526..b27a69f1a 100644 --- a/frontend/src/Series/Details/EpisodeRow.css +++ b/frontend/src/Series/Details/EpisodeRow.css @@ -22,6 +22,12 @@ width: 95px; } +.runtime { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + .size { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/Series/Details/EpisodeRow.css.d.ts b/frontend/src/Series/Details/EpisodeRow.css.d.ts index c20ef1963..138000856 100644 --- a/frontend/src/Series/Details/EpisodeRow.css.d.ts +++ b/frontend/src/Series/Details/EpisodeRow.css.d.ts @@ -8,6 +8,7 @@ interface CssExports { 'languages': string; 'monitored': string; 'releaseGroup': string; + 'runtime': string; 'size': string; 'status': string; 'subtitles': string; diff --git a/frontend/src/Series/Details/EpisodeRow.js b/frontend/src/Series/Details/EpisodeRow.js index 10e4daffb..eba223b16 100644 --- a/frontend/src/Series/Details/EpisodeRow.js +++ b/frontend/src/Series/Details/EpisodeRow.js @@ -13,6 +13,7 @@ import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnect import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector'; import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes'; import formatBytes from 'Utilities/Number/formatBytes'; +import formatRuntime from 'Utilities/Number/formatRuntime'; import styles from './EpisodeRow.css'; class EpisodeRow extends Component { @@ -59,6 +60,7 @@ class EpisodeRow extends Component { sceneEpisodeNumber, sceneAbsoluteEpisodeNumber, airDateUtc, + runtime, title, useSceneNumbering, unverifiedSceneNumbering, @@ -170,6 +172,17 @@ class EpisodeRow extends Component { ); } + if (name === 'runtime') { + return ( + + { formatRuntime(runtime) } + + ); + } + if (name === 'customFormats') { return ( @@ -330,6 +343,7 @@ EpisodeRow.propTypes = { sceneEpisodeNumber: PropTypes.number, sceneAbsoluteEpisodeNumber: PropTypes.number, airDateUtc: PropTypes.string, + runtime: PropTypes.number, title: PropTypes.string.isRequired, isSaving: PropTypes.bool, useSceneNumbering: PropTypes.bool, diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js index 0e6113247..626bdeef0 100644 --- a/frontend/src/Store/Actions/episodeActions.js +++ b/frontend/src/Store/Actions/episodeActions.js @@ -59,6 +59,11 @@ export const defaultState = { label: 'Air Date', isVisible: true }, + { + name: 'runtime', + label: 'Runtime', + isVisible: false + }, { name: 'languages', label: 'Languages', diff --git a/frontend/src/Utilities/Number/formatRuntime.ts b/frontend/src/Utilities/Number/formatRuntime.ts new file mode 100644 index 000000000..b6f19b741 --- /dev/null +++ b/frontend/src/Utilities/Number/formatRuntime.ts @@ -0,0 +1,21 @@ +function formatRuntime(runtime: number) { + if (!runtime) { + return ''; + } + + const hours = Math.floor(runtime / 60); + const minutes = runtime % 60; + const result = []; + + if (hours) { + result.push(`${hours}h`); + } + + if (minutes) { + result.push(`${minutes}m`); + } + + return result.join(' '); +} + +export default formatRuntime; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs index f8c986b9b..b11de1c93 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs @@ -33,6 +33,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _episodes = Builder.CreateListOfSize(10) .All() .With(s => s.SeasonNumber = 1) + .With(s => s.Runtime = 30) .BuildList(); _parseResultMultiSet = new RemoteEpisode @@ -40,7 +41,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Series = _series, Release = new ReleaseInfo(), ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = Builder.CreateListOfSize(6).All().With(s => s.SeasonNumber = 1).BuildList() + Episodes = Builder.CreateListOfSize(6) + .All() + .With(s => s.SeasonNumber = 1) + .With(s => s.Runtime = 30) + .BuildList() }; _parseResultMulti = new RemoteEpisode @@ -48,7 +53,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Series = _series, Release = new ReleaseInfo(), ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = Builder.CreateListOfSize(2).All().With(s => s.SeasonNumber = 1).BuildList() + Episodes = Builder.CreateListOfSize(2) + .All() + .With(s => s.SeasonNumber = 1) + .With(s => s.Runtime = 30) + .BuildList() }; _parseResultSingle = new RemoteEpisode @@ -61,6 +70,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Builder.CreateNew() .With(s => s.SeasonNumber = 1) .With(s => s.EpisodeNumber = 1) + .With(s => s.Runtime = 30) .Build() } }; @@ -94,13 +104,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Series = _series; _parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); _parseResultSingle.Episodes.First().Id = 5; + _parseResultSingle.Episodes.First().Runtime = runtime; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(expectedResult); } - [TestCase(30, 500, true)] + [TestCase(30, 250, true)] [TestCase(30, 1000, false)] - [TestCase(60, 1000, true)] + [TestCase(60, 250, true)] [TestCase(60, 2000, false)] public void should_return_expected_result_for_first_episode_of_season(int runtime, int sizeInMegaBytes, bool expectedResult) { @@ -108,13 +119,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Series = _series; _parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); _parseResultSingle.Episodes.First().Id = _episodes.First().Id; + _parseResultSingle.Episodes.First().Runtime = _episodes.First().Runtime; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(expectedResult); } - [TestCase(30, 500, true)] + [TestCase(30, 250, true)] [TestCase(30, 1000, false)] - [TestCase(60, 1000, true)] + [TestCase(60, 250, true)] [TestCase(60, 2000, false)] public void should_return_expected_result_for_last_episode_of_season(int runtime, int sizeInMegaBytes, bool expectedResult) { @@ -122,6 +134,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Series = _series; _parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); _parseResultSingle.Episodes.First().Id = _episodes.Last().Id; + _parseResultSingle.Episodes.First().Runtime = _episodes.First().Runtime; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(expectedResult); } @@ -137,6 +150,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _series.Runtime = runtime; _parseResultMulti.Series = _series; _parseResultMulti.Release.Size = sizeInMegaBytes.Megabytes(); + _parseResultMulti.Episodes.ForEach(e => e.Runtime = runtime); Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().Be(expectedResult); } @@ -152,6 +166,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _series.Runtime = runtime; _parseResultMultiSet.Series = _series; _parseResultMultiSet.Release.Size = sizeInMegaBytes.Megabytes(); + _parseResultMultiSet.Episodes.ForEach(e => e.Runtime = runtime); Subject.IsSatisfiedBy(_parseResultMultiSet, null).Accepted.Should().Be(expectedResult); } @@ -162,6 +177,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _series.Runtime = 30; _parseResultSingle.Series = _series; _parseResultSingle.Release.Size = 0; + _parseResultSingle.Episodes.First().Runtime = 30; _qualityType.MinSize = 10; _qualityType.MaxSize = 20; @@ -174,6 +190,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _series.Runtime = 30; _parseResultSingle.Series = _series; _parseResultSingle.Release.Size = 18457280000; + _parseResultSingle.Episodes.First().Runtime = 30; _qualityType.MaxSize = null; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); @@ -185,6 +202,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _series.Runtime = 60; _parseResultSingle.Series = _series; _parseResultSingle.Release.Size = 36857280000; + _parseResultSingle.Episodes.First().Runtime = 60; _qualityType.MaxSize = null; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); @@ -197,6 +215,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Series = _series; _parseResultSingle.Series.SeriesType = SeriesTypes.Daily; _parseResultSingle.Release.Size = 300.Megabytes(); + _parseResultSingle.Episodes.First().Runtime = 60; _qualityType.MaxSize = 10; @@ -212,6 +231,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Series = _series; _parseResultSingle.Series.SeriesType = SeriesTypes.Daily; _parseResultSingle.Release.Size = 8000.Megabytes(); + _parseResultSingle.Episodes.First().Runtime = 30; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } @@ -232,6 +252,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Episodes.First().Id = 5; _parseResultSingle.Release.Size = 200.Megabytes(); _parseResultSingle.Episodes.First().SeasonNumber = 2; + _parseResultSingle.Episodes.First().Runtime = 0; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(false); } @@ -247,6 +268,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Episodes.First().SeasonNumber = 1; _parseResultSingle.Episodes.First().EpisodeNumber = 2; _parseResultSingle.Episodes.First().AirDateUtc = _episodes.First().AirDateUtc.Value.AddDays(7); + _parseResultSingle.Episodes.First().Runtime = 0; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(false); } @@ -262,6 +284,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _parseResultSingle.Episodes.First().SeasonNumber = 1; _parseResultSingle.Episodes.First().EpisodeNumber = 2; _parseResultSingle.Episodes.First().AirDateUtc = _episodes.First().AirDateUtc.Value.AddHours(1); + _parseResultSingle.Episodes.First().Runtime = 0; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(true); } @@ -272,7 +295,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _series.Runtime = 0; _parseResultMulti.Series = _series; _parseResultMulti.Release.Size = 200.Megabytes(); - _parseResultMulti.Episodes.ForEach(e => e.SeasonNumber = 2); + _parseResultMulti.Episodes.ForEach(e => + { + e.SeasonNumber = 2; + e.Runtime = 0; + }); Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().Be(false); } @@ -290,6 +317,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { e.SeasonNumber = 1; e.AirDateUtc = airDateUtc; + e.Runtime = 0; }); Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().Be(false); @@ -308,9 +336,29 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { e.SeasonNumber = 1; e.AirDateUtc = airDateUtc; + e.Runtime = 0; }); Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().Be(true); } + + [Test] + public void should_use_series_runtime_if_episode_runtime_is_not_set() + { + var airDateUtc = _episodes.First().AirDateUtc.Value.AddHours(1); + + _series.Runtime = 30; + + _parseResultSingle.Series = _series; + _parseResultSingle.Release.Size = 200.Megabytes(); + _parseResultSingle.Episodes.ForEach(e => + { + e.SeasonNumber = 1; + e.AirDateUtc = airDateUtc; + e.Runtime = 0; + }); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().Be(true); + } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/185_add_episode_runtime.cs b/src/NzbDrone.Core/Datastore/Migration/185_add_episode_runtime.cs new file mode 100644 index 000000000..79b003e16 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/185_add_episode_runtime.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(185)] + public class add_episode_runtime : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Episodes").AddColumn("Runtime").AsInt32().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs index de3a2a7dc..04688ce14 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Extensions; @@ -44,9 +42,10 @@ namespace NzbDrone.Core.DecisionEngine.Specifications return Decision.Accept(); } - var runtime = subject.Series.Runtime; + var seriesRuntime = subject.Series.Runtime; + var runtime = 0; - if (runtime == 0) + if (seriesRuntime == 0) { var firstSeasonNumber = subject.Series.Seasons.Where(s => s.SeasonNumber > 0).Min(s => s.SeasonNumber); var pilotEpisode = _episodeService.GetEpisodesBySeason(subject.Series.Id, firstSeasonNumber).First(); @@ -60,16 +59,23 @@ namespace NzbDrone.Core.DecisionEngine.Specifications if (subject.Episodes.All(e => !e.AirDateUtc.HasValue || e.AirDateUtc.Value.Before(gracePeriodEnd))) { _logger.Debug("Series runtime is 0, but all episodes in release aired within 24 hours of first episode in season, defaulting runtime to 45 minutes"); - runtime = 45; + seriesRuntime = 45; } } + } - // Reject if the run time is still 0 - if (runtime == 0) - { - _logger.Debug("Series runtime is 0, unable to validate size until it is available, rejecting"); - return Decision.Reject("Series runtime is 0, unable to validate size until it is available"); - } + // For each episode use the runtime of the episode or fallback to the series runtime + // (which in turn might have fallen back to a default runtime of 45) + foreach (var episode in subject.Episodes) + { + runtime += episode.Runtime > 0 ? episode.Runtime : seriesRuntime; + } + + // Reject if the run time is 0 + if (runtime == 0) + { + _logger.Debug("Runtime of all episodes is 0, unable to validate size until it is available, rejecting"); + return Decision.Reject("Runtime of all episodes is 0, unable to validate size until it is available"); } var qualityDefinition = _qualityDefinitionService.Get(quality); @@ -78,8 +84,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { var minSize = qualityDefinition.MinSize.Value.Megabytes(); - // Multiply maxSize by Series.Runtime - minSize = minSize * runtime * subject.Episodes.Count; + // Multiply maxSize by runtime of all episodes + minSize *= runtime; // If the parsed size is smaller than minSize we don't want it if (subject.Release.Size < minSize) @@ -99,22 +105,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { var maxSize = qualityDefinition.MaxSize.Value.Megabytes(); - // Multiply maxSize by Series.Runtime - maxSize = maxSize * runtime * subject.Episodes.Count; - - if (subject.Episodes.Count == 1 && subject.Series.SeriesType == SeriesTypes.Standard) - { - var firstEpisode = subject.Episodes.First(); - var seasonEpisodes = GetSeasonEpisodes(subject, searchCriteria); - - // Ensure that this is either the first episode - // or is the last episode in a season that has 10 or more episodes - if (seasonEpisodes.First().Id == firstEpisode.Id || (seasonEpisodes.Count >= 10 && seasonEpisodes.Last().Id == firstEpisode.Id)) - { - _logger.Debug("Possible double episode, doubling allowed size."); - maxSize = maxSize * 2; - } - } + // Multiply maxSize by runtime of all episodes + maxSize *= runtime; // If the parsed size is greater than maxSize we don't want it if (subject.Release.Size > maxSize) @@ -129,17 +121,5 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger.Debug("Item: {0}, meets size constraints", subject); return Decision.Accept(); } - - private List GetSeasonEpisodes(RemoteEpisode subject, SearchCriteriaBase searchCriteria) - { - var firstEpisode = subject.Episodes.First(); - - if (searchCriteria is SeasonSearchCriteria seasonSearchCriteria && !seasonSearchCriteria.Series.UseSceneNumbering && seasonSearchCriteria.Episodes.Any(v => v.Id == firstEpisode.Id)) - { - return seasonSearchCriteria.Episodes; - } - - return _episodeService.GetEpisodesBySeason(firstEpisode.SeriesId, firstEpisode.SeasonNumber); - } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs index 28021bbb0..5fbaf2727 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace NzbDrone.Core.MetadataSource.SkyHook.Resource { @@ -14,6 +14,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook.Resource public string Title { get; set; } public string AirDate { get; set; } public DateTime? AirDateUtc { get; set; } + public int Runtime { get; set; } public RatingResource Rating { get; set; } public string Overview { get; set; } public string Image { get; set; } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 97c91ed56..015de516b 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -255,6 +255,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook episode.AirDate = oracleEpisode.AirDate; episode.AirDateUtc = oracleEpisode.AirDateUtc; + episode.Runtime = oracleEpisode.Runtime; episode.Ratings = MapRatings(oracleEpisode.Rating); diff --git a/src/NzbDrone.Core/Tv/Episode.cs b/src/NzbDrone.Core/Tv/Episode.cs index c90e476de..a06eb9202 100644 --- a/src/NzbDrone.Core/Tv/Episode.cs +++ b/src/NzbDrone.Core/Tv/Episode.cs @@ -36,6 +36,7 @@ namespace NzbDrone.Core.Tv public Ratings Ratings { get; set; } public List Images { get; set; } public DateTime? LastSearchTime { get; set; } + public int Runtime { get; set; } public string SeriesTitle { get; private set; } diff --git a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs index 146f928fb..7e41b0b91 100644 --- a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs +++ b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs @@ -75,6 +75,7 @@ namespace NzbDrone.Core.Tv episodeToUpdate.Overview = episode.Overview; episodeToUpdate.AirDate = episode.AirDate; episodeToUpdate.AirDateUtc = episode.AirDateUtc; + episodeToUpdate.Runtime = episode.Runtime; episodeToUpdate.Ratings = episode.Ratings; episodeToUpdate.Images = episode.Images; diff --git a/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs b/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs index 85913abfb..fae27e112 100644 --- a/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs +++ b/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs @@ -20,9 +20,9 @@ namespace Sonarr.Api.V3.Episodes public string Title { get; set; } public string AirDate { get; set; } public DateTime? AirDateUtc { get; set; } + public int Runtime { get; set; } public string Overview { get; set; } public EpisodeFileResource EpisodeFile { get; set; } - public bool HasFile { get; set; } public bool Monitored { get; set; } public int? AbsoluteEpisodeNumber { get; set; } @@ -63,6 +63,7 @@ namespace Sonarr.Api.V3.Episodes Title = model.Title, AirDate = model.AirDate, AirDateUtc = model.AirDateUtc, + Runtime = model.Runtime, Overview = model.Overview, // EpisodeFile