From 05ee57a9723be13beff612e2a1a0686f62c4c19a Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 9 Dec 2014 18:20:50 -0800 Subject: [PATCH] New: options when adding series, including the ability to search for all missing episodes --- src/NzbDrone.Api/Series/SeriesModule.cs | 5 +- src/NzbDrone.Api/Series/SeriesResource.cs | 1 + .../NzbDrone.Core.Test.csproj | 1 + .../SetEpisodeMontitoredFixture.cs | 213 ++++++++++++++++++ .../077_add_add_options_to_series.cs | 14 ++ .../IndexerSearch/EpisodeSearchService.cs | 81 ++++--- .../MissingEpisodeSearchCommand.cs | 9 +- .../MediaFiles/DiskScanService.cs | 2 + .../Events/SeriesScanSkippedEvent.cs | 23 ++ src/NzbDrone.Core/NzbDrone.Core.csproj | 4 + src/NzbDrone.Core/Tv/AddSeriesOptions.cs | 11 + src/NzbDrone.Core/Tv/RefreshSeriesService.cs | 2 +- src/NzbDrone.Core/Tv/Series.cs | 1 + src/NzbDrone.Core/Tv/SeriesScannedHandler.cs | 102 +++++++++ src/NzbDrone.Core/Tv/SeriesService.cs | 7 + .../AddSeries/MonitoringTooltipTemplate.hbs | 14 ++ src/UI/AddSeries/SearchResultView.js | 106 +++++++-- src/UI/AddSeries/SearchResultViewTemplate.hbs | 25 +- src/UI/AddSeries/addSeries.less | 22 +- src/UI/Config.js | 9 +- src/UI/Content/form.less | 7 + 21 files changed, 594 insertions(+), 65 deletions(-) create mode 100644 src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs create mode 100644 src/NzbDrone.Core/Tv/AddSeriesOptions.cs create mode 100644 src/NzbDrone.Core/Tv/SeriesScannedHandler.cs create mode 100644 src/UI/AddSeries/MonitoringTooltipTemplate.hbs diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs index fd103ad34..00b9707df 100644 --- a/src/NzbDrone.Api/Series/SeriesModule.cs +++ b/src/NzbDrone.Api/Series/SeriesModule.cs @@ -16,6 +16,7 @@ using NzbDrone.Core.Tv.Events; using NzbDrone.Core.Validation.Paths; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.SignalR; +using Omu.ValueInjecter; namespace NzbDrone.Api.Series { @@ -109,7 +110,9 @@ namespace NzbDrone.Api.Series private int AddSeries(SeriesResource seriesResource) { - return GetNewId(_seriesService.AddSeries, seriesResource); + var series = _seriesService.AddSeries(seriesResource.InjectTo()); + + return series.Id; } private void UpdateSeries(SeriesResource seriesResource) diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index 6d763ead9..180990824 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -67,6 +67,7 @@ namespace NzbDrone.Api.Series public List Genres { get; set; } public HashSet Tags { get; set; } public DateTime Added { get; set; } + public AddSeriesOptions AddOptions { get; set; } //Used to support legacy consumers public Int32 QualityProfileId diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 25b96464a..d61e4cadb 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -317,6 +317,7 @@ + diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs new file mode 100644 index 000000000..0482e2c72 --- /dev/null +++ b/src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests +{ + [TestFixture] + public class SetEpisodeMontitoredFixture : CoreTest + { + private Series _series; + private List _episodes; + + [SetUp] + public void Setup() + { + var seasons = 4; + + _series = Builder.CreateNew() + .With(s => s.Seasons = Builder.CreateListOfSize(seasons) + .All() + .With(n => n.Monitored = true) + .Build() + .ToList()) + .Build(); + + _episodes = Builder.CreateListOfSize(seasons) + .All() + .With(e => e.Monitored = true) + .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(-7)) + //Missing + .TheFirst(1) + .With(e => e.EpisodeFileId = 0) + //Has File + .TheNext(1) + .With(e => e.EpisodeFileId = 1) + //Future + .TheNext(1) + .With(e => e.EpisodeFileId = 0) + .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(7)) + //Future/TBA + .TheNext(1) + .With(e => e.EpisodeFileId = 0) + .With(e => e.AirDateUtc = null) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetEpisodeBySeries(It.IsAny())) + .Returns(_episodes); + } + + private void WithSeriesAddedEvent(AddSeriesOptions options) + { + _series.AddOptions = options; + } + + private void TriggerSeriesScannedEvent() + { + Subject.Handle(new SeriesScannedEvent(_series)); + } + + private void GivenSpecials() + { + foreach (var episode in _episodes) + { + episode.SeasonNumber = 0; + } + + _series.Seasons = new List{new Season { Monitored = false, SeasonNumber = 0 }}; + } + + [Test] + public void should_be_able_to_monitor_all_episodes() + { + WithSeriesAddedEvent(new AddSeriesOptions()); + TriggerSeriesScannedEvent(); + + Mocker.GetMock() + .Verify(v => v.UpdateEpisodes(It.Is>(l => l.All(e => e.Monitored)))); + } + + [Test] + public void should_be_able_to_monitor_missing_episodes_only() + { + WithSeriesAddedEvent(new AddSeriesOptions + { + IgnoreEpisodesWithFiles = true, + IgnoreEpisodesWithoutFiles = false + }); + + TriggerSeriesScannedEvent(); + + VerifyMonitored(e => !e.HasFile); + VerifyNotMonitored(e => e.HasFile); + } + + [Test] + public void should_be_able_to_monitor_new_episodes_only() + { + WithSeriesAddedEvent(new AddSeriesOptions + { + IgnoreEpisodesWithFiles = true, + IgnoreEpisodesWithoutFiles = true + }); + + TriggerSeriesScannedEvent(); + + VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow)); + VerifyMonitored(e => !e.AirDateUtc.HasValue); + VerifyNotMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)); + } + + [Test] + public void should_not_monitor_missing_specials() + { + GivenSpecials(); + + WithSeriesAddedEvent(new AddSeriesOptions + { + IgnoreEpisodesWithFiles = true, + IgnoreEpisodesWithoutFiles = false + }); + + TriggerSeriesScannedEvent(); + + VerifyMonitored(e => !e.HasFile); + VerifyNotMonitored(e => e.HasFile); + } + + [Test] + public void should_not_monitor_new_specials() + { + GivenSpecials(); + + WithSeriesAddedEvent(new AddSeriesOptions + { + IgnoreEpisodesWithFiles = true, + IgnoreEpisodesWithoutFiles = true + }); + + TriggerSeriesScannedEvent(); + VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow)); + VerifyMonitored(e => !e.AirDateUtc.HasValue); + VerifyNotMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)); + } + + [Test] + public void should_not_monitor_season_when_all_episodes_are_monitored_except_latest_season() + { + _series.Seasons = Builder.CreateListOfSize(2) + .All() + .With(n => n.Monitored = true) + .Build() + .ToList(); + + _episodes = Builder.CreateListOfSize(5) + .All() + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeFileId = 0) + .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(-5)) + .TheLast(1) + .With(e => e.SeasonNumber = 2) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetEpisodeBySeries(It.IsAny())) + .Returns(_episodes); + + WithSeriesAddedEvent(new AddSeriesOptions + { + IgnoreEpisodesWithoutFiles = true + }); + + TriggerSeriesScannedEvent(); + + VerifySeasonMonitored(n => n.SeasonNumber == 2); + VerifySeasonNotMonitored(n => n.SeasonNumber == 1); + } + + private void VerifyMonitored(Func predicate) + { + Mocker.GetMock() + .Verify(v => v.UpdateEpisodes(It.Is>(l => l.Where(predicate).All(e => e.Monitored)))); + } + + private void VerifyNotMonitored(Func predicate) + { + Mocker.GetMock() + .Verify(v => v.UpdateEpisodes(It.Is>(l => l.Where(predicate).All(e => !e.Monitored)))); + } + + private void VerifySeasonMonitored(Func predicate) + { + Mocker.GetMock() + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => n.Monitored)))); + } + + private void VerifySeasonNotMonitored(Func predicate) + { + Mocker.GetMock() + .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => !n.Monitored)))); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs b/src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs new file mode 100644 index 000000000..5c4891e5c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(77)] + public class add_add_options_to_series : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Series").AddColumn("AddOptions").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs index 3d39e31d8..21622d7a6 100644 --- a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs @@ -6,6 +6,7 @@ using NzbDrone.Common; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -66,6 +67,36 @@ namespace NzbDrone.Core.IndexerSearch _logger.ProgressInfo("Completed search for {0} episodes. {1} reports downloaded.", missing.Count, downloadedCount); } + private void SearchForMissingEpisodes(List episodes) + { + _logger.ProgressInfo("Performing missing search for {0} episodes", episodes.Count); + var downloadedCount = 0; + + foreach (var series in episodes.GroupBy(e => e.SeriesId)) + { + foreach (var season in series.Select(e => e).GroupBy(e => e.SeasonNumber)) + { + List decisions; + + if (season.Count() > 1) + { + decisions = _nzbSearchService.SeasonSearch(series.Key, season.Key); + } + + else + { + decisions = _nzbSearchService.EpisodeSearch(season.First()); + } + + var processed = _processDownloadDecisions.ProcessDecisions(decisions); + + downloadedCount += processed.Grabbed.Count; + } + } + + _logger.ProgressInfo("Completed missing search for {0} episodes. {1} reports downloaded.", episodes.Count, downloadedCount); + } + public void Execute(EpisodeSearchCommand message) { foreach (var episodeId in message.EpisodeIds) @@ -79,36 +110,34 @@ namespace NzbDrone.Core.IndexerSearch public void Execute(MissingEpisodeSearchCommand message) { - //TODO: Look at ways to make this more efficient (grouping by series/season) + List episodes; - var episodes = - _episodeService.EpisodesWithoutFiles(new PagingSpec - { - Page = 1, - PageSize = 100000, - SortDirection = SortDirection.Ascending, - SortKey = "Id", - FilterExpression = v => v.Monitored == true && v.Series.Monitored == true - }).Records.ToList(); - - var missing = episodes.Where(e => !_queueService.GetQueue().Select(q => q.Episode.Id).Contains(e.Id)).ToList(); - - _logger.ProgressInfo("Performing missing search for {0} episodes", missing.Count); - var downloadedCount = 0; - - //Limit requests to indexers at 100 per minute - using (var rateGate = new RateGate(100, TimeSpan.FromSeconds(60))) + if (message.SeriesId > 0) { - foreach (var episode in missing) - { - rateGate.WaitToProceed(); - var decisions = _nzbSearchService.EpisodeSearch(episode); - var processed = _processDownloadDecisions.ProcessDecisions(decisions); - downloadedCount += processed.Grabbed.Count; - } + episodes = _episodeService.GetEpisodeBySeries(message.SeriesId) + .Where(e => e.Monitored && !e.HasFile) + .ToList(); } - _logger.ProgressInfo("Completed missing search for {0} episodes. {1} reports downloaded.", missing.Count, downloadedCount); + else + { + episodes = _episodeService.EpisodesWithoutFiles(new PagingSpec + { + Page = 1, + PageSize = 100000, + SortDirection = SortDirection.Ascending, + SortKey = "Id", + FilterExpression = + v => + v.Monitored == true && + v.Series.Monitored == true + }).Records.ToList(); + } + + var queue = _queueService.GetQueue().Select(q => q.Episode.Id); + var missing = episodes.Where(e => !queue.Contains(e.Id)).ToList(); + + SearchForMissingEpisodes(missing); } public void Handle(EpisodeInfoRefreshedEvent message) diff --git a/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs index fffd02661..9a6a41410 100644 --- a/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs +++ b/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; -using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.IndexerSearch { public class MissingEpisodeSearchCommand : Command { - public List EpisodeIds { get; set; } + public int SeriesId { get; private set; } public override bool SendUpdatesToClient { @@ -19,9 +18,9 @@ namespace NzbDrone.Core.IndexerSearch { } - public MissingEpisodeSearchCommand(List episodeIds) + public MissingEpisodeSearchCommand(int seriesId) { - EpisodeIds = episodeIds; + SeriesId = seriesId; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index f3f2bab99..f11591d54 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -66,6 +66,7 @@ namespace NzbDrone.Core.MediaFiles if (!_diskProvider.FolderExists(rootFolder)) { _logger.Warn("Series' root folder ({0}) doesn't exist.", rootFolder); + _eventAggregator.PublishEvent(new SeriesScanSkippedEvent(series, SeriesScanSkippedReason.RootFolderDoesNotExist)); return; } @@ -92,6 +93,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Series folder doesn't exist: {0}", series.Path); } + _eventAggregator.PublishEvent(new SeriesScanSkippedEvent(series, SeriesScanSkippedReason.SeriesFolderDoesNotExist)); return; } diff --git a/src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs new file mode 100644 index 000000000..1d4abd073 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs @@ -0,0 +1,23 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class SeriesScanSkippedEvent : IEvent + { + public Series Series { get; private set; } + public SeriesScanSkippedReason Reason { get; set; } + + public SeriesScanSkippedEvent(Series series, SeriesScanSkippedReason reason) + { + Series = series; + Reason = reason; + } + } + + public enum SeriesScanSkippedReason + { + RootFolderDoesNotExist, + SeriesFolderDoesNotExist + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index afea7a702..2c4a2f698 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -245,6 +245,7 @@ + @@ -571,6 +572,7 @@ + @@ -817,6 +819,7 @@ + @@ -838,6 +841,7 @@ + diff --git a/src/NzbDrone.Core/Tv/AddSeriesOptions.cs b/src/NzbDrone.Core/Tv/AddSeriesOptions.cs new file mode 100644 index 000000000..ad505e1d3 --- /dev/null +++ b/src/NzbDrone.Core/Tv/AddSeriesOptions.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Tv +{ + public class AddSeriesOptions : IEmbeddedDocument + { + public bool SearchForMissingEpisodes { get; set; } + public bool IgnoreEpisodesWithFiles { get; set; } + public bool IgnoreEpisodesWithoutFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 7e9e912ef..a0e65a4f6 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -171,4 +171,4 @@ namespace NzbDrone.Core.Tv RefreshSeriesInfo(message.Series); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Tv/Series.cs b/src/NzbDrone.Core/Tv/Series.cs index 5ec1328cd..798b0832a 100644 --- a/src/NzbDrone.Core/Tv/Series.cs +++ b/src/NzbDrone.Core/Tv/Series.cs @@ -49,6 +49,7 @@ namespace NzbDrone.Core.Tv public List Seasons { get; set; } public HashSet Tags { get; set; } + public AddSeriesOptions AddOptions { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs b/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs new file mode 100644 index 000000000..c06124bdf --- /dev/null +++ b/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Tv +{ + public class SeriesScannedHandler : IHandle, + IHandle + { + private readonly ISeriesService _seriesService; + private readonly IEpisodeService _episodeService; + private readonly ICommandExecutor _commandExecutor; + + private readonly Logger _logger; + + public SeriesScannedHandler(ISeriesService seriesService, + IEpisodeService episodeService, + ICommandExecutor commandExecutor, + Logger logger) + { + _seriesService = seriesService; + _episodeService = episodeService; + _commandExecutor = commandExecutor; + _logger = logger; + } + + private void SetEpisodeMonitoredStatus(Series series, List episodes) + { + _logger.Debug("[{0}] Setting episode monitored status.", series.Title); + + if (series.AddOptions.IgnoreEpisodesWithFiles) + { + _logger.Debug("Ignoring Episodes with Files"); + UnmonitorEpisodes(episodes.Where(e => e.HasFile)); + } + + if (series.AddOptions.IgnoreEpisodesWithoutFiles) + { + _logger.Debug("Ignoring Episodes without Files"); + UnmonitorEpisodes(episodes.Where(e => !e.HasFile && e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow))); + } + + var lastSeason = series.Seasons.Select(s => s.SeasonNumber).MaxOrDefault(); + + foreach (var season in series.Seasons.Where(s => s.SeasonNumber < lastSeason)) + { + if (episodes.Where(e => e.SeasonNumber == season.SeasonNumber).All(e => !e.Monitored)) + { + season.Monitored = false; + } + } + + _seriesService.UpdateSeries(series); + _episodeService.UpdateEpisodes(episodes); + } + + private void UnmonitorEpisodes(IEnumerable episodes) + { + foreach (var episode in episodes) + { + episode.Monitored = false; + } + } + + private void HandleScanEvents(Series series) + { + if (series.AddOptions == null) + { + return; + } + + _logger.Info("[{0}] was recently added, performing post-add actions", series.Title); + + var episodes = _episodeService.GetEpisodeBySeries(series.Id); + SetEpisodeMonitoredStatus(series, episodes); + + if (series.AddOptions.SearchForMissingEpisodes) + { + _commandExecutor.PublishCommand(new MissingEpisodeSearchCommand(series.Id)); + } + + series.AddOptions = null; + _seriesService.RemoveAddOptions(series); + } + + public void Handle(SeriesScannedEvent message) + { + HandleScanEvents(message.Series); + } + + public void Handle(SeriesScanSkippedEvent message) + { + HandleScanEvents(message.Series); + } + } +} diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 3224f3443..aaf5a8ecf 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Linq.Expressions; using NLog; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; @@ -27,6 +28,7 @@ namespace NzbDrone.Core.Tv Series UpdateSeries(Series series); List UpdateSeries(List series); bool SeriesPathExists(string folder); + void RemoveAddOptions(Series series); } public class SeriesService : ISeriesService @@ -216,5 +218,10 @@ namespace NzbDrone.Core.Tv { return _seriesRepository.SeriesPathExists(folder); } + + public void RemoveAddOptions(Series series) + { + _seriesRepository.SetFields(series, s => s.AddOptions); + } } } diff --git a/src/UI/AddSeries/MonitoringTooltipTemplate.hbs b/src/UI/AddSeries/MonitoringTooltipTemplate.hbs new file mode 100644 index 000000000..fbdf9f6fd --- /dev/null +++ b/src/UI/AddSeries/MonitoringTooltipTemplate.hbs @@ -0,0 +1,14 @@ +
+
All
+
Monitor all episodes except specials
+
Future
+
Monitor episodes that have not aired yet
+
Missing
+
Monitor episodes that do not have files or have not aired yet
+
Existing
+
Monitor episodes that have files or have not aired yet
+
First Season
+
Monitor all episodes of the first season. All other seasons will be ignored
+ + +
\ No newline at end of file diff --git a/src/UI/AddSeries/SearchResultView.js b/src/UI/AddSeries/SearchResultView.js index 0f4e7483b..9b16dea24 100644 --- a/src/UI/AddSeries/SearchResultView.js +++ b/src/UI/AddSeries/SearchResultView.js @@ -36,17 +36,20 @@ define( rootFolder : '.x-root-folder', seasonFolder : '.x-season-folder', seriesType : '.x-series-type', - startingSeason : '.x-starting-season', + monitor : '.x-monitor', + monitorTooltip : '.x-monitor-tooltip', addButton : '.x-add', overview : '.x-overview' }, events: { - 'click .x-add' : '_addSeries', + 'click .x-add' : '_addWithoutSearch', + 'click .x-add-search' : '_addAndSearch', 'change .x-profile' : '_profileChanged', 'change .x-root-folder' : '_rootFolderChanged', 'change .x-season-folder' : '_seasonFolderChanged', - 'change .x-series-type' : '_seriesTypeChanged' + 'change .x-series-type' : '_seriesTypeChanged', + 'change .x-monitor' : '_monitorChanged' }, initialize: function () { @@ -69,6 +72,7 @@ define( var defaultRoot = Config.getValue(Config.Keys.DefaultRootFolderId); var useSeasonFolder = Config.getValueBoolean(Config.Keys.UseSeasonFolder, true); var defaultSeriesType = Config.getValue(Config.Keys.DefaultSeriesType, 'standard'); + var defaultMonitorEpisodes = Config.getValue(Config.Keys.MonitorEpisodes, 'missing'); if (Profiles.get(defaultProfile)) { this.ui.profile.val(defaultProfile); @@ -80,18 +84,25 @@ define( this.ui.seasonFolder.prop('checked', useSeasonFolder); this.ui.seriesType.val(defaultSeriesType); - - var minSeasonNotZero = _.min(_.reject(this.model.get('seasons'), { seasonNumber: 0 }), 'seasonNumber'); - - if (minSeasonNotZero) { - this.ui.startingSeason.val(minSeasonNotZero.seasonNumber); - } + this.ui.monitor.val(defaultMonitorEpisodes); //TODO: make this work via onRender, FM? //works with onShow, but stops working after the first render this.ui.overview.dotdotdot({ height: 120 }); + + this.templateFunction = Marionette.TemplateCache.get('AddSeries/MonitoringTooltipTemplate'); + var content = this.templateFunction(); + + this.ui.monitorTooltip.popover({ + content : content, + html : true, + trigger : 'hover', + title : 'Episode Monitoring Options', + placement: 'right', + container: this.$el + }); }, _configureTemplateHelpers: function () { @@ -124,6 +135,10 @@ define( else if (options.key === Config.Keys.DefaultSeriesType) { this.ui.seriesType.val(options.value); } + + else if (options.key === Config.Keys.MonitorEpisodes) { + this.ui.monitor.val(options.value); + } }, _profileChanged: function () { @@ -150,30 +165,43 @@ define( Config.setValue(Config.Keys.DefaultSeriesType, this.ui.seriesType.val()); }, + _monitorChanged: function () { + Config.setValue(Config.Keys.MonitorEpisodes, this.ui.monitor.val()); + }, + _setRootFolder: function (options) { vent.trigger(vent.Commands.CloseModalCommand); this.ui.rootFolder.val(options.model.id); this._rootFolderChanged(); }, - _addSeries: function () { + _addWithoutSearch: function () { + this._addSeries(false); + }, + + _addAndSearch: function() { + this._addSeries(true); + }, + + _addSeries: function (searchForMissingEpisodes) { var icon = this.ui.addButton.find('icon'); icon.removeClass('icon-plus').addClass('icon-spin icon-spinner disabled'); var profile = this.ui.profile.val(); var rootFolderPath = this.ui.rootFolder.children(':selected').text(); - var startingSeason = this.ui.startingSeason.val(); var seriesType = this.ui.seriesType.val(); var seasonFolder = this.ui.seasonFolder.prop('checked'); - this.model.set({ - profileId: profile, - rootFolderPath: rootFolderPath, - seasonFolder: seasonFolder, - seriesType: seriesType - }, { silent: true }); + var options = this._getAddSeriesOptions(); + options.searchForMissingEpisodes = searchForMissingEpisodes; - this.model.setSeasonPass(startingSeason); + this.model.set({ + profileId : profile, + rootFolderPath : rootFolderPath, + seasonFolder : seasonFolder, + seriesType : seriesType, + addOptions : options + }, { silent: true }); var self = this; var promise = this.model.save(); @@ -209,6 +237,48 @@ define( _rootFoldersUpdated: function () { this._configureTemplateHelpers(); this.render(); + }, + + _getAddSeriesOptions: function () { + var monitor = this.ui.monitor.val(); + var lastSeason = _.max(this.model.get('seasons'), 'seasonNumber'); + var firstSeason = _.min(_.reject(this.model.get('seasons'), { seasonNumber: 0 }), 'seasonNumber'); + + this.model.setSeasonPass(firstSeason.seasonNumber); + + var options = { + ignoreEpisodesWithFiles: false, + ignoreEpisodesWithoutFiles: false + }; + + if (monitor === 'all') { + return options; + } + + else if (monitor === 'future') { + options.ignoreEpisodesWithFiles = true; + options.ignoreEpisodesWithoutFiles = true; + } + + else if (monitor === 'latest') { + this.model.setSeasonPass(lastSeason.seasonNumber); + } + + else if (monitor === 'first') { + this.model.setSeasonPass(lastSeason + 1); + + firstSeason.monitor = true; + } + + else if (monitor === 'missing') { + options.ignoreEpisodesWithFiles = true; + } + + else if (monitor === 'existing') { + options.ignoreEpisodesWithoutFiles = true; + } + + return options; } }); diff --git a/src/UI/AddSeries/SearchResultViewTemplate.hbs b/src/UI/AddSeries/SearchResultViewTemplate.hbs index 2dd639bfe..1edef7e6e 100644 --- a/src/UI/AddSeries/SearchResultViewTemplate.hbs +++ b/src/UI/AddSeries/SearchResultViewTemplate.hbs @@ -35,8 +35,15 @@ {{/unless}}
- - {{> StartingSeasonSelectionPartial seasons}} + +
@@ -70,10 +77,16 @@ {{#if title}}
- - + +
+ + + +
{{else}}
diff --git a/src/UI/AddSeries/addSeries.less b/src/UI/AddSeries/addSeries.less index f48327d49..b8665c199 100644 --- a/src/UI/AddSeries/addSeries.less +++ b/src/UI/AddSeries/addSeries.less @@ -84,18 +84,24 @@ } select { - font-size : 16px; + font-size : 14px; } .checkbox { margin-top : 0px; } - i { - &:before { - color : #ffffff; + .add { + i { + &:before { + color : #ffffff; + } } } + + .monitor-tooltip { + margin-left : 5px; + } } .loading-folders { @@ -107,6 +113,14 @@ color : #999999; font-style : italic; } + + .monitor-tooltip-contents { + padding-bottom : 0px; + + dd { + padding-bottom : 8px; + } + } } li.add-new { diff --git a/src/UI/Config.js b/src/UI/Config.js index 7c83f527f..7563a189b 100644 --- a/src/UI/Config.js +++ b/src/UI/Config.js @@ -10,10 +10,11 @@ define( Keys : { DefaultProfileId : 'DefaultProfileId', - DefaultRootFolderId : 'DefaultRootFolderId', - UseSeasonFolder : 'UseSeasonFolder', - DefaultSeriesType : 'DefaultSeriesType', - AdvancedSettings : 'advancedSettings' + DefaultRootFolderId : 'DefaultRootFolderId', + UseSeasonFolder : 'UseSeasonFolder', + DefaultSeriesType : 'DefaultSeriesType', + MonitorEpisodes : 'MonitorEpisodes', + AdvancedSettings : 'advancedSettings' }, getValueBoolean: function (key, defaultValue) { diff --git a/src/UI/Content/form.less b/src/UI/Content/form.less index ee8112e89..65544e690 100644 --- a/src/UI/Content/form.less +++ b/src/UI/Content/form.less @@ -26,6 +26,13 @@ } } + .btn { + i { + margin-right : 0px; + color : inherit; + } + } + i { font-size : 16px; color : #595959;