From 155c82c199b4f2b688282ee1593a53808418294d Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Fri, 15 May 2015 07:35:12 -0700 Subject: [PATCH] Season pass overhaul New: Season Pass supports multi-select New: Improved Season Pass toggling Closes #396 --- .../Mapping/ValueInjectorExtensions.cs | 2 + src/NzbDrone.Api/NzbDrone.Api.csproj | 5 +- .../SeasonPass/SeasonPassModule.cs | 33 ++++ .../SeasonPass/SeasonPassResource.cs | 11 ++ src/NzbDrone.Api/Series/SeasonResource.cs | 7 +- .../Series/SeasonStatisticsResource.cs | 24 +++ src/NzbDrone.Api/Series/SeriesModule.cs | 5 + src/NzbDrone.Api/Series/SeriesResource.cs | 2 + .../NzbDrone.Core.Test.csproj | 2 +- .../SetEpisodeMontitoredFixture.cs | 67 ++++---- src/NzbDrone.Core/Datastore/TableMapping.cs | 2 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 3 + .../SeriesStats/SeasonStatistics.cs | 40 +++++ .../SeriesStats/SeriesStatistics.cs | 14 +- .../SeriesStats/SeriesStatisticsRepository.cs | 17 +- .../SeriesStats/SeriesStatisticsService.cs | 39 ++++- src/NzbDrone.Core/Tv/AddSeriesOptions.cs | 8 +- .../Tv/EpisodeMonitoredService.cs | 100 ++++++++++++ src/NzbDrone.Core/Tv/MonitoringOptions.cs | 10 ++ src/NzbDrone.Core/Tv/SeriesScannedHandler.cs | 60 +------ src/UI/Content/theme.less | 1 + src/UI/SeasonPass/SeasonPassFooterView.js | 128 +++++++++++++++ .../SeasonPassFooterViewTemplate.hbs | 25 +++ src/UI/SeasonPass/SeasonPassLayout.js | 77 ++++++++- src/UI/SeasonPass/SeasonsCell.js | 26 +++ src/UI/SeasonPass/SeasonsCellTemplate.hbs | 30 ++++ src/UI/SeasonPass/SeriesCollectionView.js | 6 - src/UI/SeasonPass/SeriesLayout.js | 152 ------------------ src/UI/SeasonPass/SeriesLayoutTemplate.hbs | 75 --------- src/UI/SeasonPass/seasonpass.less | 21 +++ 30 files changed, 636 insertions(+), 356 deletions(-) create mode 100644 src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs create mode 100644 src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs create mode 100644 src/NzbDrone.Api/Series/SeasonStatisticsResource.cs rename src/NzbDrone.Core.Test/TvTests/{SeriesAddedHandlerTests => EpisodeMonitoredServiceTests}/SetEpisodeMontitoredFixture.cs (81%) create mode 100644 src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs create mode 100644 src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs create mode 100644 src/NzbDrone.Core/Tv/MonitoringOptions.cs create mode 100644 src/UI/SeasonPass/SeasonPassFooterView.js create mode 100644 src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs create mode 100644 src/UI/SeasonPass/SeasonsCell.js create mode 100644 src/UI/SeasonPass/SeasonsCellTemplate.hbs delete mode 100644 src/UI/SeasonPass/SeriesCollectionView.js delete mode 100644 src/UI/SeasonPass/SeriesLayout.js delete mode 100644 src/UI/SeasonPass/SeriesLayoutTemplate.hbs create mode 100644 src/UI/SeasonPass/seasonpass.less diff --git a/src/NzbDrone.Api/Mapping/ValueInjectorExtensions.cs b/src/NzbDrone.Api/Mapping/ValueInjectorExtensions.cs index 3adee94c2..e810bb17a 100644 --- a/src/NzbDrone.Api/Mapping/ValueInjectorExtensions.cs +++ b/src/NzbDrone.Api/Mapping/ValueInjectorExtensions.cs @@ -10,6 +10,8 @@ namespace NzbDrone.Api.Mapping { public static TTarget InjectTo(this object source) where TTarget : new() { + if (source == null) return default(TTarget); + var targetType = typeof(TTarget); if (targetType.IsGenericType && diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 1f6f2ef85..fa76d6825 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -216,12 +216,15 @@ + + + @@ -272,4 +275,4 @@ --> - + \ No newline at end of file diff --git a/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs b/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs new file mode 100644 index 000000000..eda106619 --- /dev/null +++ b/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Nancy; +using NzbDrone.Api.Extensions; +using NzbDrone.Api.Mapping; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Api.SeasonPass +{ + public class SeasonPassModule : NzbDroneApiModule + { + private readonly IEpisodeMonitoredService _episodeMonitoredService; + + public SeasonPassModule(IEpisodeMonitoredService episodeMonitoredService) + : base("/seasonpass") + { + _episodeMonitoredService = episodeMonitoredService; + Post["/"] = series => UpdateAll(); + } + + private Response UpdateAll() + { + //Read from request + var request = Request.Body.FromJson(); + + foreach (var s in request.Series) + { + _episodeMonitoredService.SetEpisodeMonitoredStatus(s, request.MonitoringOptions); + } + + return "ok".AsResponse(HttpStatusCode.Accepted); + } + } +} diff --git a/src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs b/src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs new file mode 100644 index 000000000..af537e7f9 --- /dev/null +++ b/src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Api.SeasonPass +{ + public class SeasonPassResource + { + public List Series { get; set; } + public MonitoringOptions MonitoringOptions { get; set; } + } +} diff --git a/src/NzbDrone.Api/Series/SeasonResource.cs b/src/NzbDrone.Api/Series/SeasonResource.cs index aa6b58bb9..db6af1419 100644 --- a/src/NzbDrone.Api/Series/SeasonResource.cs +++ b/src/NzbDrone.Api/Series/SeasonResource.cs @@ -1,10 +1,9 @@ -using System; - -namespace NzbDrone.Api.Series +namespace NzbDrone.Api.Series { public class SeasonResource { public int SeasonNumber { get; set; } - public Boolean Monitored { get; set; } + public bool Monitored { get; set; } + public SeasonStatisticsResource Statistics { get; set; } } } diff --git a/src/NzbDrone.Api/Series/SeasonStatisticsResource.cs b/src/NzbDrone.Api/Series/SeasonStatisticsResource.cs new file mode 100644 index 000000000..1aa29e9e9 --- /dev/null +++ b/src/NzbDrone.Api/Series/SeasonStatisticsResource.cs @@ -0,0 +1,24 @@ +using System; + +namespace NzbDrone.Api.Series +{ + public class SeasonStatisticsResource + { + public DateTime? NextAiring { get; set; } + public DateTime? PreviousAiring { get; set; } + public int EpisodeFileCount { get; set; } + public int EpisodeCount { get; set; } + public long SizeOnDisk { get; set; } + + public decimal PercentOfEpisodes + { + get + { + if (EpisodeCount == 0) return 0; + + return (decimal)EpisodeFileCount / (decimal)EpisodeCount * 100; + } + } + + } +} diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs index c5b503717..764cfd5b2 100644 --- a/src/NzbDrone.Api/Series/SeriesModule.cs +++ b/src/NzbDrone.Api/Series/SeriesModule.cs @@ -165,6 +165,11 @@ namespace NzbDrone.Api.Series resource.NextAiring = seriesStatistics.NextAiring; resource.PreviousAiring = seriesStatistics.PreviousAiring; resource.SizeOnDisk = seriesStatistics.SizeOnDisk; + + foreach (var season in resource.Seasons) + { + season.Statistics = seriesStatistics.SeasonStatistics.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber).InjectTo(); + } } private void PopulateAlternateTitles(List resources) diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index 0b619fab5..b5e925993 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -69,6 +69,8 @@ namespace NzbDrone.Api.Series public DateTime Added { get; set; } public AddSeriesOptions AddOptions { get; set; } + //TODO: Add series statistics as a property of the series (instead of individual properties) + //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 826909b6a..529e69c0e 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -338,7 +338,7 @@ - + diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs similarity index 81% rename from src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs rename to src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs index 3bcb58d61..b75e6012a 100644 --- a/src/NzbDrone.Core.Test/TvTests/SeriesAddedHandlerTests/SetEpisodeMontitoredFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs @@ -9,10 +9,10 @@ using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; -namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests +namespace NzbDrone.Core.Test.TvTests.EpisodeMonitoredServiceTests { [TestFixture] - public class SetEpisodeMontitoredFixture : CoreTest + public class SetEpisodeMontitoredFixture : CoreTest { private Series _series; private List _episodes; @@ -56,16 +56,6 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests .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) @@ -79,8 +69,7 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests [Test] public void should_be_able_to_monitor_all_episodes() { - WithSeriesAddedEvent(new AddSeriesOptions()); - TriggerSeriesScannedEvent(); + Subject.SetEpisodeMonitoredStatus(_series, new MonitoringOptions()); Mocker.GetMock() .Verify(v => v.UpdateEpisodes(It.Is>(l => l.All(e => e.Monitored)))); @@ -89,13 +78,13 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests [Test] public void should_be_able_to_monitor_missing_episodes_only() { - WithSeriesAddedEvent(new AddSeriesOptions - { - IgnoreEpisodesWithFiles = true, - IgnoreEpisodesWithoutFiles = false - }); + var monitoringOptions = new MonitoringOptions + { + IgnoreEpisodesWithFiles = true, + IgnoreEpisodesWithoutFiles = false + }; - TriggerSeriesScannedEvent(); + Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); VerifyMonitored(e => !e.HasFile); VerifyNotMonitored(e => e.HasFile); @@ -104,13 +93,13 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests [Test] public void should_be_able_to_monitor_new_episodes_only() { - WithSeriesAddedEvent(new AddSeriesOptions + var monitoringOptions = new MonitoringOptions { IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithoutFiles = true - }); + }; - TriggerSeriesScannedEvent(); + Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow)); VerifyMonitored(e => !e.AirDateUtc.HasValue); @@ -122,13 +111,13 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests { GivenSpecials(); - WithSeriesAddedEvent(new AddSeriesOptions + var monitoringOptions = new MonitoringOptions { IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithoutFiles = false - }); + }; - TriggerSeriesScannedEvent(); + Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); VerifyMonitored(e => !e.HasFile); VerifyNotMonitored(e => e.HasFile); @@ -139,13 +128,14 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests { GivenSpecials(); - WithSeriesAddedEvent(new AddSeriesOptions + var monitoringOptions = new MonitoringOptions { IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithoutFiles = true - }); + }; + + Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); - 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)); @@ -174,17 +164,28 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests .Setup(s => s.GetEpisodeBySeries(It.IsAny())) .Returns(_episodes); - WithSeriesAddedEvent(new AddSeriesOptions + var monitoringOptions = new MonitoringOptions { IgnoreEpisodesWithoutFiles = true - }); - - TriggerSeriesScannedEvent(); + }; + + Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); VerifySeasonMonitored(n => n.SeasonNumber == 2); VerifySeasonNotMonitored(n => n.SeasonNumber == 1); } + [Test] + public void should_ignore_episodes_when_season_is_not_monitored() + { + _series.Seasons.ForEach(s => s.Monitored = false); + + Subject.SetEpisodeMonitoredStatus(_series, new MonitoringOptions()); + + Mocker.GetMock() + .Verify(v => v.UpdateEpisodes(It.Is>(l => l.All(e => !e.Monitored)))); + } + private void VerifyMonitored(Func predicate) { Mocker.GetMock() diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 180486861..16bd5fcda 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -91,7 +91,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("Profiles"); Mapper.Entity().RegisterModel("Logs"); Mapper.Entity().RegisterModel("NamingConfig"); - Mapper.Entity().MapResultSet(); + Mapper.Entity().MapResultSet(); Mapper.Entity().RegisterModel("Blacklist"); Mapper.Entity().RegisterModel("MetadataFiles"); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 1818ed1e5..6c7875871 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -869,6 +869,7 @@ + @@ -892,6 +893,7 @@ + Code @@ -903,6 +905,7 @@ + diff --git a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs new file mode 100644 index 000000000..24a22c25a --- /dev/null +++ b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs @@ -0,0 +1,40 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.SeriesStats +{ + public class SeasonStatistics : ResultSet + { + public int SeriesId { get; set; } + public int SeasonNumber { get; set; } + public string NextAiringString { get; set; } + public string PreviousAiringString { get; set; } + public int EpisodeFileCount { get; set; } + public int EpisodeCount { get; set; } + public long SizeOnDisk { get; set; } + + public DateTime? NextAiring + { + get + { + DateTime nextAiring; + + if (!DateTime.TryParse(NextAiringString, out nextAiring)) return null; + + return nextAiring; + } + } + + public DateTime? PreviousAiring + { + get + { + DateTime previousAiring; + + if (!DateTime.TryParse(PreviousAiringString, out previousAiring)) return null; + + return previousAiring; + } + } + } +} diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs index 38989a907..f03fbc29c 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs @@ -1,16 +1,18 @@ using System; +using System.Collections.Generic; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.SeriesStats { public class SeriesStatistics : ResultSet { - public Int32 SeriesId { get; set; } - public String NextAiringString { get; set; } - public String PreviousAiringString { get; set; } - public Int32 EpisodeFileCount { get; set; } - public Int32 EpisodeCount { get; set; } - public Int64 SizeOnDisk { get; set; } + public int SeriesId { get; set; } + public string NextAiringString { get; set; } + public string PreviousAiringString { get; set; } + public int EpisodeFileCount { get; set; } + public int EpisodeCount { get; set; } + public long SizeOnDisk { get; set; } + public List SeasonStatistics { get; set; } public DateTime? NextAiring { diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs index 0d7a5eacc..5566d4dee 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs @@ -7,8 +7,8 @@ namespace NzbDrone.Core.SeriesStats { public interface ISeriesStatisticsRepository { - List SeriesStatistics(); - SeriesStatistics SeriesStatistics(Int32 seriesId); + List SeriesStatistics(); + SeasonStatistics SeriesStatistics(Int32 seriesId); } public class SeriesStatisticsRepository : ISeriesStatisticsRepository @@ -20,7 +20,7 @@ namespace NzbDrone.Core.SeriesStats _database = database; } - public List SeriesStatistics() + public List SeriesStatistics() { var mapper = _database.GetDataMapper(); @@ -32,10 +32,10 @@ namespace NzbDrone.Core.SeriesStats sb.AppendLine(GetGroupByClause()); var queryText = sb.ToString(); - return mapper.Query(queryText); + return mapper.Query(queryText); } - public SeriesStatistics SeriesStatistics(Int32 seriesId) + public SeasonStatistics SeriesStatistics(Int32 seriesId) { var mapper = _database.GetDataMapper(); @@ -49,7 +49,7 @@ namespace NzbDrone.Core.SeriesStats sb.AppendLine(GetGroupByClause()); var queryText = sb.ToString(); - return mapper.Find(queryText); + return mapper.Find(queryText); } private String GetSelectClause() @@ -57,17 +57,18 @@ namespace NzbDrone.Core.SeriesStats return @"SELECT Episodes.*, SUM(EpisodeFiles.Size) as SizeOnDisk FROM (SELECT Episodes.SeriesId, + Episodes.SeasonNumber, SUM(CASE WHEN (Monitored = 1 AND AirdateUtc <= @currentDate) OR EpisodeFileId > 0 THEN 1 ELSE 0 END) AS EpisodeCount, SUM(CASE WHEN EpisodeFileId > 0 THEN 1 ELSE 0 END) AS EpisodeFileCount, MIN(CASE WHEN AirDateUtc < @currentDate OR EpisodeFileId > 0 OR Monitored = 0 THEN NULL ELSE AirDateUtc END) AS NextAiringString, MAX(CASE WHEN AirDateUtc >= @currentDate OR EpisodeFileId = 0 AND Monitored = 0 THEN NULL ELSE AirDateUtc END) AS PreviousAiringString FROM Episodes - GROUP BY Episodes.SeriesId) as Episodes"; + GROUP BY Episodes.SeriesId, Episodes.SeasonNumber) as Episodes"; } private String GetGroupByClause() { - return "GROUP BY Episodes.SeriesId"; + return "GROUP BY Episodes.SeriesId, Episodes.SeasonNumber"; } private String GetEpisodeFilesJoin() diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs index 648fb841f..d6169b68c 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; namespace NzbDrone.Core.SeriesStats { @@ -19,7 +21,9 @@ namespace NzbDrone.Core.SeriesStats public List SeriesStatistics() { - return _seriesStatisticsRepository.SeriesStatistics(); + var seasonStatistics = _seriesStatisticsRepository.SeriesStatistics(); + + return seasonStatistics.GroupBy(s => s.SeriesId).Select(s => MapSeriesStatistics(s.ToList())).ToList(); } public SeriesStatistics SeriesStatistics(int seriesId) @@ -28,7 +32,36 @@ namespace NzbDrone.Core.SeriesStats if (stats == null) return new SeriesStatistics(); - return stats; + return MapSeriesStatistics(new List { stats }); + } + + private SeriesStatistics MapSeriesStatistics(List seasonStatistics) + { + return new SeriesStatistics + { + SeasonStatistics = seasonStatistics, + SeriesId = seasonStatistics.First().SeriesId, + EpisodeFileCount = seasonStatistics.Sum(s => s.EpisodeFileCount), + EpisodeCount = seasonStatistics.Sum(s => s.EpisodeCount), + SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk), + NextAiringString = seasonStatistics.OrderBy(s => + { + DateTime nextAiring; + + if (!DateTime.TryParse(s.NextAiringString, out nextAiring)) return DateTime.MinValue; + + return nextAiring; + }).First().NextAiringString, + + PreviousAiringString = seasonStatistics.OrderBy(s => + { + DateTime nextAiring; + + if (!DateTime.TryParse(s.PreviousAiringString, out nextAiring)) return DateTime.MinValue; + + return nextAiring; + }).Last().PreviousAiringString + }; } } } diff --git a/src/NzbDrone.Core/Tv/AddSeriesOptions.cs b/src/NzbDrone.Core/Tv/AddSeriesOptions.cs index ad505e1d3..fceae6586 100644 --- a/src/NzbDrone.Core/Tv/AddSeriesOptions.cs +++ b/src/NzbDrone.Core/Tv/AddSeriesOptions.cs @@ -1,11 +1,7 @@ -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Tv +namespace NzbDrone.Core.Tv { - public class AddSeriesOptions : IEmbeddedDocument + public class AddSeriesOptions : MonitoringOptions { public bool SearchForMissingEpisodes { get; set; } - public bool IgnoreEpisodesWithFiles { get; set; } - public bool IgnoreEpisodesWithoutFiles { get; set; } } } diff --git a/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs b/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs new file mode 100644 index 000000000..51502811f --- /dev/null +++ b/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Tv +{ + public interface IEpisodeMonitoredService + { + void SetEpisodeMonitoredStatus(Series series, MonitoringOptions monitoringOptions); + } + + public class EpisodeMonitoredService : IEpisodeMonitoredService + { + private readonly ISeriesService _seriesService; + private readonly IEpisodeService _episodeService; + private readonly Logger _logger; + + public EpisodeMonitoredService(ISeriesService seriesService, IEpisodeService episodeService, Logger logger) + { + _seriesService = seriesService; + _episodeService = episodeService; + _logger = logger; + } + + public void SetEpisodeMonitoredStatus(Series series, MonitoringOptions monitoringOptions) + { + _logger.Debug("[{0}] Setting episode monitored status.", series.Title); + + var episodes = _episodeService.GetEpisodeBySeries(series.Id); + + if (monitoringOptions.IgnoreEpisodesWithFiles) + { + _logger.Debug("Ignoring Episodes with Files"); + ToggleEpisodesMonitoredState(episodes.Where(e => e.HasFile), false); + } + + else + { + _logger.Debug("Monitoring Episodes with Files"); + ToggleEpisodesMonitoredState(episodes.Where(e => e.HasFile), true); + } + + if (monitoringOptions.IgnoreEpisodesWithoutFiles) + { + _logger.Debug("Ignoring Episodes without Files"); + ToggleEpisodesMonitoredState(episodes.Where(e => !e.HasFile && e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)), false); + } + + else + { + _logger.Debug("Monitoring Episodes without Files"); + ToggleEpisodesMonitoredState(episodes.Where(e => !e.HasFile && e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)), true); + } + + var lastSeason = series.Seasons.Select(s => s.SeasonNumber).MaxOrDefault(); + + foreach (var s in series.Seasons) + { + var season = s; + + if (season.Monitored) + { + if (!monitoringOptions.IgnoreEpisodesWithFiles && !monitoringOptions.IgnoreEpisodesWithoutFiles) + { + ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), true); + } + } + + else + { + if (!monitoringOptions.IgnoreEpisodesWithFiles && !monitoringOptions.IgnoreEpisodesWithoutFiles) + { + ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), false); + } + } + + if (season.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 ToggleEpisodesMonitoredState(IEnumerable episodes, bool monitored) + { + foreach (var episode in episodes) + { + episode.Monitored = monitored; + } + } + } +} diff --git a/src/NzbDrone.Core/Tv/MonitoringOptions.cs b/src/NzbDrone.Core/Tv/MonitoringOptions.cs new file mode 100644 index 000000000..2cda68b1c --- /dev/null +++ b/src/NzbDrone.Core/Tv/MonitoringOptions.cs @@ -0,0 +1,10 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Tv +{ + public class MonitoringOptions : IEmbeddedDocument + { + public bool IgnoreEpisodesWithFiles { get; set; } + public bool IgnoreEpisodesWithoutFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs b/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs index 77989d675..aa8d90d5a 100644 --- a/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs +++ b/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; +using NLog; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Commands; @@ -13,61 +9,23 @@ namespace NzbDrone.Core.Tv public class SeriesScannedHandler : IHandle, IHandle { + private readonly IEpisodeMonitoredService _episodeMonitoredService; private readonly ISeriesService _seriesService; - private readonly IEpisodeService _episodeService; private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; - public SeriesScannedHandler(ISeriesService seriesService, - IEpisodeService episodeService, - IManageCommandQueue commandQueueManager, - Logger logger) + public SeriesScannedHandler(IEpisodeMonitoredService episodeMonitoredService, + ISeriesService seriesService, + IManageCommandQueue commandQueueManager, + Logger logger) { + _episodeMonitoredService = episodeMonitoredService; _seriesService = seriesService; - _episodeService = episodeService; _commandQueueManager = commandQueueManager; _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) @@ -76,9 +34,7 @@ namespace NzbDrone.Core.Tv } _logger.Info("[{0}] was recently added, performing post-add actions", series.Title); - - var episodes = _episodeService.GetEpisodeBySeries(series.Id); - SetEpisodeMonitoredStatus(series, episodes); + _episodeMonitoredService.SetEpisodeMonitoredStatus(series, series.AddOptions); if (series.AddOptions.SearchForMissingEpisodes) { diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less index 88c809c6a..3e4229809 100644 --- a/src/UI/Content/theme.less +++ b/src/UI/Content/theme.less @@ -20,6 +20,7 @@ @import "../Shared/FileBrowser/filebrowser"; @import "badges"; @import "../ManualImport/manualimport"; +@import "../SeasonPass/seasonpass"; .main-region { @media (min-width : @screen-lg-min) { diff --git a/src/UI/SeasonPass/SeasonPassFooterView.js b/src/UI/SeasonPass/SeasonPassFooterView.js new file mode 100644 index 000000000..082a82638 --- /dev/null +++ b/src/UI/SeasonPass/SeasonPassFooterView.js @@ -0,0 +1,128 @@ +var _ = require('underscore'); +var $ = require('jquery'); +var Marionette = require('marionette'); +var vent = require('vent'); +var RootFolders = require('../AddSeries/RootFolders/RootFolderCollection'); + +module.exports = Marionette.ItemView.extend({ + template : 'SeasonPass/SeasonPassFooterViewTemplate', + + ui : { + monitor : '.x-monitor', + selectedCount : '.x-selected-count', + container : '.series-editor-footer', + actions : '.x-action', + indicator : '.x-indicator', + indicatorIcon : '.x-indicator-icon' + }, + + events : { + 'click .x-update' : '_update' + }, + + initialize : function(options) { + this.seriesCollection = options.collection; + + RootFolders.fetch().done(function() { + RootFolders.synced = true; + }); + + this.editorGrid = options.editorGrid; + this.listenTo(this.seriesCollection, 'backgrid:selected', this._updateInfo); + }, + + onRender : function() { + this._updateInfo(); + }, + + _update : function() { + var self = this; + var selected = this.editorGrid.getSelectedModels(); + var monitoringOptions; + + _.each(selected, function(model) { + monitoringOptions = self._getMonitoringOptions(model); + + model.set('addOptions', monitoringOptions); + }); + + var promise = $.ajax({ + url : window.NzbDrone.ApiRoot + '/seasonpass', + type : 'POST', + data : JSON.stringify({ + series : _.map(selected, function (model) { + return model.toJSON(); + }), + monitoringOptions : monitoringOptions + }) + }); + + this.ui.indicator.show(); + + promise.always(function () { + self.ui.indicator.hide(); + }); + + promise.done(function () { + self.seriesCollection.trigger('seasonpass:saved'); + }); + }, + + _updateInfo : function() { + var selected = this.editorGrid.getSelectedModels(); + var selectedCount = selected.length; + + this.ui.selectedCount.html('{0} series selected'.format(selectedCount)); + + if (selectedCount === 0) { + this.ui.actions.attr('disabled', 'disabled'); + } else { + this.ui.actions.removeAttr('disabled'); + } + }, + + _getMonitoringOptions : function(model) { + var monitor = this.ui.monitor.val(); + var lastSeason = _.max(model.get('seasons'), 'seasonNumber'); + var firstSeason = _.min(_.reject(model.get('seasons'), { seasonNumber : 0 }), 'seasonNumber'); + + 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') { + model.setSeasonPass(lastSeason.seasonNumber); + } + + else if (monitor === 'first') { + model.setSeasonPass(lastSeason.seasonNumber + 1); + model.setSeasonMonitored(firstSeason.seasonNumber); + } + + else if (monitor === 'missing') { + options.ignoreEpisodesWithFiles = true; + } + + else if (monitor === 'existing') { + options.ignoreEpisodesWithoutFiles = true; + } + + else if (monitor === 'none') { + model.setSeasonPass(lastSeason.seasonNumber + 1); + } + + return options; + } +}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs b/src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs new file mode 100644 index 000000000..e4ff59a95 --- /dev/null +++ b/src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs @@ -0,0 +1,25 @@ + diff --git a/src/UI/SeasonPass/SeasonPassLayout.js b/src/UI/SeasonPass/SeasonPassLayout.js index 3aa80c961..ac301bb68 100644 --- a/src/UI/SeasonPass/SeasonPassLayout.js +++ b/src/UI/SeasonPass/SeasonPassLayout.js @@ -1,9 +1,15 @@ +var _ = require('underscore'); +var vent = require('vent'); +var Backgrid = require('backgrid'); var Marionette = require('marionette'); +var EmptyView = require('../Series/Index/EmptyView'); var SeriesCollection = require('../Series/SeriesCollection'); -var SeasonCollection = require('../Series/SeasonCollection'); -var SeriesCollectionView = require('./SeriesCollectionView'); -var LoadingView = require('../Shared/LoadingView'); var ToolbarLayout = require('../Shared/Toolbar/ToolbarLayout'); +var FooterView = require('./SeasonPassFooterView'); +var SelectAllCell = require('../Cells/SelectAllCell'); +var SeriesStatusCell = require('../Cells/SeriesStatusCell'); +var SeriesTitleCell = require('../Cells/SeriesTitleCell'); +var SeasonsCell = require('./SeasonsCell'); require('../Mixins/backbone.signalr.mixin'); module.exports = Marionette.Layout.extend({ @@ -14,11 +20,38 @@ module.exports = Marionette.Layout.extend({ series : '#x-series' }, + columns : [ + { + name : '', + cell : SelectAllCell, + headerCell : 'select-all', + sortable : false + }, + { + name : 'statusWeight', + label : '', + cell : SeriesStatusCell + }, + { + name : 'title', + label : 'Title', + cell : SeriesTitleCell, + cellValue : 'this' + }, + { + name : 'seasons', + label : 'Seasons', + cell : SeasonsCell, + cellValue : 'this' + } + ], + initialize : function() { this.seriesCollection = SeriesCollection.clone(); this.seriesCollection.shadowCollection.bindSignalR(); - this.listenTo(this.seriesCollection, 'sync', this.render); +// this.listenTo(this.seriesCollection, 'sync', this.render); + this.listenTo(this.seriesCollection, 'seasonpass:saved', this.render); this.filteringOptions = { type : 'radio', @@ -59,11 +92,13 @@ module.exports = Marionette.Layout.extend({ }, onRender : function() { - this.series.show(new SeriesCollectionView({ - collection : this.seriesCollection - })); - + this._showTable(); this._showToolbar(); + this._showFooter(); + }, + + onClose : function() { + vent.trigger(vent.Commands.CloseControlPanelCommand); }, _showToolbar : function() { @@ -73,6 +108,32 @@ module.exports = Marionette.Layout.extend({ })); }, + _showTable : function() { + if (this.seriesCollection.shadowCollection.length === 0) { + this.series.show(new EmptyView()); + this.toolbar.close(); + return; + } + + this.columns[0].sortedCollection = this.seriesCollection; + + this.editorGrid = new Backgrid.Grid({ + collection : this.seriesCollection, + columns : this.columns, + className : 'table table-hover' + }); + + this.series.show(this.editorGrid); + this._showFooter(); + }, + + _showFooter : function() { + vent.trigger(vent.Commands.OpenControlPanelCommand, new FooterView({ + editorGrid : this.editorGrid, + collection : this.seriesCollection + })); + }, + _setFilter : function(buttonContext) { var mode = buttonContext.model.get('key'); diff --git a/src/UI/SeasonPass/SeasonsCell.js b/src/UI/SeasonPass/SeasonsCell.js new file mode 100644 index 000000000..81af02d1b --- /dev/null +++ b/src/UI/SeasonPass/SeasonsCell.js @@ -0,0 +1,26 @@ +var _ = require('underscore'); +var TemplatedCell = require('../Cells/TemplatedCell'); +//require('../Handlebars/Helpers/Numbers'); + +module.exports = TemplatedCell.extend({ + className : 'seasons-cell', + template : 'SeasonPass/SeasonsCellTemplate', + + events : { + 'click .x-season-monitored' : '_toggleSeasonMonitored' + }, + + _toggleSeasonMonitored : function(e) { + var target = this.$(e.target).closest('.x-season-monitored'); + var seasonNumber = parseInt(this.$(target).data('season-number'), 10); + var icon = this.$(target).children('.x-season-monitored-icon'); + + this.model.setSeasonMonitored(seasonNumber); + + //TODO: unbounce the save so we don't multiple to the server at the same time + var savePromise = this.model.save(); + + icon.spinForPromise(savePromise); + savePromise.always(this.render.bind(this)); + } +}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonsCellTemplate.hbs b/src/UI/SeasonPass/SeasonsCellTemplate.hbs new file mode 100644 index 000000000..18f8bbde1 --- /dev/null +++ b/src/UI/SeasonPass/SeasonsCellTemplate.hbs @@ -0,0 +1,30 @@ +{{#each seasons}} + + + + + + + {{#if_eq seasonNumber compare="0"}} + Specials + {{else}} + S{{Pad2 seasonNumber}} + {{/if_eq}} + + + + + + + + + + + + + + + + + +{{/each}} \ No newline at end of file diff --git a/src/UI/SeasonPass/SeriesCollectionView.js b/src/UI/SeasonPass/SeriesCollectionView.js deleted file mode 100644 index 0b1c9a69d..000000000 --- a/src/UI/SeasonPass/SeriesCollectionView.js +++ /dev/null @@ -1,6 +0,0 @@ -var Marionette = require('marionette'); -var SeriesLayout = require('./SeriesLayout'); - -module.exports = Marionette.CollectionView.extend({ - itemView : SeriesLayout -}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeriesLayout.js b/src/UI/SeasonPass/SeriesLayout.js deleted file mode 100644 index 5697c3027..000000000 --- a/src/UI/SeasonPass/SeriesLayout.js +++ /dev/null @@ -1,152 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var SeasonCollection = require('../Series/SeasonCollection'); - -module.exports = Marionette.Layout.extend({ - template : 'SeasonPass/SeriesLayoutTemplate', - - ui : { - seasonSelect : '.x-season-select', - expander : '.x-expander', - seasonGrid : '.x-season-grid', - seriesMonitored : '.x-series-monitored' - }, - - events : { - 'change .x-season-select' : '_seasonSelected', - 'click .x-expander' : '_expand', - 'click .x-latest' : '_latest', - 'click .x-all' : '_all', - 'click .x-monitored' : '_toggleSeasonMonitored', - 'click .x-series-monitored' : '_toggleSeriesMonitored' - }, - - regions : { - seasonGrid : '.x-season-grid' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this._setSeriesMonitoredState); - this.seasonCollection = new SeasonCollection(this.model.get('seasons')); - this.expanded = false; - }, - - onRender : function() { - if (!this.expanded) { - this.ui.seasonGrid.hide(); - } - - this._setExpanderIcon(); - this._setSeriesMonitoredState(); - }, - - _seasonSelected : function() { - var seasonNumber = parseInt(this.ui.seasonSelect.val(), 10); - - if (seasonNumber === -1 || isNaN(seasonNumber)) { - return; - } - - this._setSeasonMonitored(seasonNumber); - }, - - _expand : function() { - if (this.expanded) { - this.ui.seasonGrid.slideUp(); - this.expanded = false; - } - - else { - this.ui.seasonGrid.slideDown(); - this.expanded = true; - } - - this._setExpanderIcon(); - }, - - _setExpanderIcon : function() { - if (this.expanded) { - this.ui.expander.removeClass('icon-sonarr-expand'); - this.ui.expander.addClass('icon-sonarr-expanded'); - } - - else { - this.ui.expander.removeClass('icon-sonarr-expanded'); - this.ui.expander.addClass('icon-sonarr-expand'); - } - }, - - _latest : function() { - var season = _.max(this.model.get('seasons'), function(s) { - return s.seasonNumber; - }); - - this._setSeasonMonitored(season.seasonNumber); - }, - - _all : function() { - var minSeasonNotZero = _.min(_.reject(this.model.get('seasons'), { seasonNumber : 0 }), 'seasonNumber'); - - this._setSeasonMonitored(minSeasonNotZero.seasonNumber); - }, - - _setSeasonMonitored : function(seasonNumber) { - var self = this; - - this.model.setSeasonPass(seasonNumber); - - var promise = this.model.save(); - - promise.done(function(data) { - self.seasonCollection = new SeasonCollection(data); - self.render(); - }); - }, - - _toggleSeasonMonitored : function(e) { - var seasonNumber = 0; - var element; - - if (e.target.localName === 'i') { - seasonNumber = parseInt(this.$(e.target).parent('td').attr('data-season-number'), 10); - element = this.$(e.target); - } - - else { - seasonNumber = parseInt(this.$(e.target).attr('data-season-number'), 10); - element = this.$(e.target).children('i'); - } - - this.model.setSeasonMonitored(seasonNumber); - - var savePromise = this.model.save().always(this.render.bind(this)); - element.spinForPromise(savePromise); - }, - - _afterToggleSeasonMonitored : function() { - this.render(); - }, - - _setSeriesMonitoredState : function() { - var monitored = this.model.get('monitored'); - - this.ui.seriesMonitored.removeAttr('data-idle-icon'); - - if (monitored) { - this.ui.seriesMonitored.addClass('icon-sonarr-monitored'); - this.ui.seriesMonitored.removeClass('icon-sonarr-unmonitored'); - } else { - this.ui.seriesMonitored.addClass('icon-sonarr-unmonitored'); - this.ui.seriesMonitored.removeClass('icon-sonarr-monitored'); - } - }, - - _toggleSeriesMonitored : function() { - var savePromise = this.model.save('monitored', !this.model.get('monitored'), { - wait : true - }); - - this.ui.seriesMonitored.spinForPromise(savePromise); - } -}); diff --git a/src/UI/SeasonPass/SeriesLayoutTemplate.hbs b/src/UI/SeasonPass/SeriesLayoutTemplate.hbs deleted file mode 100644 index b77516fba..000000000 --- a/src/UI/SeasonPass/SeriesLayoutTemplate.hbs +++ /dev/null @@ -1,75 +0,0 @@ -
-
-
- - - - -
- -
- -
- - - -
- - - - - - - - -
-
- -
-
-
- - - - - - - - - {{#each seasons}} - - - - - {{/each}} - -
Season
- {{#if monitored}} - - {{else}} - - {{/if}} - - {{#if_eq seasonNumber compare="0"}} - Specials - {{else}} - Season {{seasonNumber}} - {{/if_eq}} -
-
-
-
-
diff --git a/src/UI/SeasonPass/seasonpass.less b/src/UI/SeasonPass/seasonpass.less new file mode 100644 index 000000000..ba0ef2584 --- /dev/null +++ b/src/UI/SeasonPass/seasonpass.less @@ -0,0 +1,21 @@ +@import "../Shared/Styles/clickable.less"; + +.season { + display : inline-block; + font-size : 14px; + margin-bottom : 4px; + background-color: #eee; + border: 1px solid #999; + color: #999; + + .season-monitored { + display : inline-block; + width : 16px; + + .clickable(); + } + + .season-number { + font-size : 12px; + } +}