Season pass overhaul

New: Season Pass supports multi-select
New: Improved Season Pass toggling

Closes #396
pull/2/head
Mark McDowall 10 years ago
parent 28e2cf97da
commit 155c82c199

@ -10,6 +10,8 @@ namespace NzbDrone.Api.Mapping
{ {
public static TTarget InjectTo<TTarget>(this object source) where TTarget : new() public static TTarget InjectTo<TTarget>(this object source) where TTarget : new()
{ {
if (source == null) return default(TTarget);
var targetType = typeof(TTarget); var targetType = typeof(TTarget);
if (targetType.IsGenericType && if (targetType.IsGenericType &&

@ -216,12 +216,15 @@
<Compile Include="REST\RestResource.cs" /> <Compile Include="REST\RestResource.cs" />
<Compile Include="RootFolders\RootFolderModule.cs" /> <Compile Include="RootFolders\RootFolderModule.cs" />
<Compile Include="RootFolders\RootFolderResource.cs" /> <Compile Include="RootFolders\RootFolderResource.cs" />
<Compile Include="SeasonPass\SeasonPassResource.cs" />
<Compile Include="Series\AlternateTitleResource.cs" /> <Compile Include="Series\AlternateTitleResource.cs" />
<Compile Include="Series\SeasonResource.cs" /> <Compile Include="Series\SeasonResource.cs" />
<Compile Include="SeasonPass\SeasonPassModule.cs" />
<Compile Include="Series\SeriesEditorModule.cs" /> <Compile Include="Series\SeriesEditorModule.cs" />
<Compile Include="Series\SeriesLookupModule.cs" /> <Compile Include="Series\SeriesLookupModule.cs" />
<Compile Include="Series\SeriesModule.cs" /> <Compile Include="Series\SeriesModule.cs" />
<Compile Include="Series\SeriesResource.cs" /> <Compile Include="Series\SeriesResource.cs" />
<Compile Include="Series\SeasonStatisticsResource.cs" />
<Compile Include="System\Backup\BackupModule.cs" /> <Compile Include="System\Backup\BackupModule.cs" />
<Compile Include="System\Backup\BackupResource.cs" /> <Compile Include="System\Backup\BackupResource.cs" />
<Compile Include="System\Tasks\TaskModule.cs" /> <Compile Include="System\Tasks\TaskModule.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<SeasonPassResource>();
foreach (var s in request.Series)
{
_episodeMonitoredService.SetEpisodeMonitoredStatus(s, request.MonitoringOptions);
}
return "ok".AsResponse(HttpStatusCode.Accepted);
}
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
using NzbDrone.Core.Tv;
namespace NzbDrone.Api.SeasonPass
{
public class SeasonPassResource
{
public List<Core.Tv.Series> Series { get; set; }
public MonitoringOptions MonitoringOptions { get; set; }
}
}

@ -1,10 +1,9 @@
using System; namespace NzbDrone.Api.Series
namespace NzbDrone.Api.Series
{ {
public class SeasonResource public class SeasonResource
{ {
public int SeasonNumber { get; set; } public int SeasonNumber { get; set; }
public Boolean Monitored { get; set; } public bool Monitored { get; set; }
public SeasonStatisticsResource Statistics { get; set; }
} }
} }

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

@ -165,6 +165,11 @@ namespace NzbDrone.Api.Series
resource.NextAiring = seriesStatistics.NextAiring; resource.NextAiring = seriesStatistics.NextAiring;
resource.PreviousAiring = seriesStatistics.PreviousAiring; resource.PreviousAiring = seriesStatistics.PreviousAiring;
resource.SizeOnDisk = seriesStatistics.SizeOnDisk; resource.SizeOnDisk = seriesStatistics.SizeOnDisk;
foreach (var season in resource.Seasons)
{
season.Statistics = seriesStatistics.SeasonStatistics.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber).InjectTo<SeasonStatisticsResource>();
}
} }
private void PopulateAlternateTitles(List<SeriesResource> resources) private void PopulateAlternateTitles(List<SeriesResource> resources)

@ -69,6 +69,8 @@ namespace NzbDrone.Api.Series
public DateTime Added { get; set; } public DateTime Added { get; set; }
public AddSeriesOptions AddOptions { 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 //Used to support legacy consumers
public Int32 QualityProfileId public Int32 QualityProfileId
{ {

@ -338,7 +338,7 @@
<Compile Include="TvTests\MoveSeriesServiceFixture.cs" /> <Compile Include="TvTests\MoveSeriesServiceFixture.cs" />
<Compile Include="TvTests\RefreshEpisodeServiceFixture.cs" /> <Compile Include="TvTests\RefreshEpisodeServiceFixture.cs" />
<Compile Include="TvTests\RefreshSeriesServiceFixture.cs" /> <Compile Include="TvTests\RefreshSeriesServiceFixture.cs" />
<Compile Include="TvTests\SeriesAddedHandlerTests\SetEpisodeMontitoredFixture.cs" /> <Compile Include="TvTests\EpisodeMonitoredServiceTests\SetEpisodeMontitoredFixture.cs" />
<Compile Include="TvTests\SeriesRepositoryTests\SeriesRepositoryFixture.cs" /> <Compile Include="TvTests\SeriesRepositoryTests\SeriesRepositoryFixture.cs" />
<Compile Include="TvTests\SeriesServiceTests\AddSeriesFixture.cs" /> <Compile Include="TvTests\SeriesServiceTests\AddSeriesFixture.cs" />
<Compile Include="TvTests\SeriesServiceTests\UpdateMultipleSeriesFixture.cs" /> <Compile Include="TvTests\SeriesServiceTests\UpdateMultipleSeriesFixture.cs" />

@ -9,10 +9,10 @@ using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests namespace NzbDrone.Core.Test.TvTests.EpisodeMonitoredServiceTests
{ {
[TestFixture] [TestFixture]
public class SetEpisodeMontitoredFixture : CoreTest<SeriesScannedHandler> public class SetEpisodeMontitoredFixture : CoreTest<EpisodeMonitoredService>
{ {
private Series _series; private Series _series;
private List<Episode> _episodes; private List<Episode> _episodes;
@ -56,16 +56,6 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests
.Returns(_episodes); .Returns(_episodes);
} }
private void WithSeriesAddedEvent(AddSeriesOptions options)
{
_series.AddOptions = options;
}
private void TriggerSeriesScannedEvent()
{
Subject.Handle(new SeriesScannedEvent(_series));
}
private void GivenSpecials() private void GivenSpecials()
{ {
foreach (var episode in _episodes) foreach (var episode in _episodes)
@ -79,8 +69,7 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests
[Test] [Test]
public void should_be_able_to_monitor_all_episodes() public void should_be_able_to_monitor_all_episodes()
{ {
WithSeriesAddedEvent(new AddSeriesOptions()); Subject.SetEpisodeMonitoredStatus(_series, new MonitoringOptions());
TriggerSeriesScannedEvent();
Mocker.GetMock<IEpisodeService>() Mocker.GetMock<IEpisodeService>()
.Verify(v => v.UpdateEpisodes(It.Is<List<Episode>>(l => l.All(e => e.Monitored)))); .Verify(v => v.UpdateEpisodes(It.Is<List<Episode>>(l => l.All(e => e.Monitored))));
@ -89,13 +78,13 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests
[Test] [Test]
public void should_be_able_to_monitor_missing_episodes_only() public void should_be_able_to_monitor_missing_episodes_only()
{ {
WithSeriesAddedEvent(new AddSeriesOptions var monitoringOptions = new MonitoringOptions
{ {
IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithFiles = true,
IgnoreEpisodesWithoutFiles = false IgnoreEpisodesWithoutFiles = false
}); };
TriggerSeriesScannedEvent(); Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions);
VerifyMonitored(e => !e.HasFile); VerifyMonitored(e => !e.HasFile);
VerifyNotMonitored(e => e.HasFile); VerifyNotMonitored(e => e.HasFile);
@ -104,13 +93,13 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests
[Test] [Test]
public void should_be_able_to_monitor_new_episodes_only() public void should_be_able_to_monitor_new_episodes_only()
{ {
WithSeriesAddedEvent(new AddSeriesOptions var monitoringOptions = new MonitoringOptions
{ {
IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithFiles = true,
IgnoreEpisodesWithoutFiles = true IgnoreEpisodesWithoutFiles = true
}); };
TriggerSeriesScannedEvent(); Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions);
VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow)); VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow));
VerifyMonitored(e => !e.AirDateUtc.HasValue); VerifyMonitored(e => !e.AirDateUtc.HasValue);
@ -122,13 +111,13 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests
{ {
GivenSpecials(); GivenSpecials();
WithSeriesAddedEvent(new AddSeriesOptions var monitoringOptions = new MonitoringOptions
{ {
IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithFiles = true,
IgnoreEpisodesWithoutFiles = false IgnoreEpisodesWithoutFiles = false
}); };
TriggerSeriesScannedEvent(); Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions);
VerifyMonitored(e => !e.HasFile); VerifyMonitored(e => !e.HasFile);
VerifyNotMonitored(e => e.HasFile); VerifyNotMonitored(e => e.HasFile);
@ -139,13 +128,14 @@ namespace NzbDrone.Core.Test.TvTests.SeriesAddedHandlerTests
{ {
GivenSpecials(); GivenSpecials();
WithSeriesAddedEvent(new AddSeriesOptions var monitoringOptions = new MonitoringOptions
{ {
IgnoreEpisodesWithFiles = true, IgnoreEpisodesWithFiles = true,
IgnoreEpisodesWithoutFiles = true IgnoreEpisodesWithoutFiles = true
}); };
Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions);
TriggerSeriesScannedEvent();
VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow)); VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow));
VerifyMonitored(e => !e.AirDateUtc.HasValue); VerifyMonitored(e => !e.AirDateUtc.HasValue);
VerifyNotMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)); 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<int>())) .Setup(s => s.GetEpisodeBySeries(It.IsAny<int>()))
.Returns(_episodes); .Returns(_episodes);
WithSeriesAddedEvent(new AddSeriesOptions var monitoringOptions = new MonitoringOptions
{ {
IgnoreEpisodesWithoutFiles = true IgnoreEpisodesWithoutFiles = true
}); };
TriggerSeriesScannedEvent(); Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions);
VerifySeasonMonitored(n => n.SeasonNumber == 2); VerifySeasonMonitored(n => n.SeasonNumber == 2);
VerifySeasonNotMonitored(n => n.SeasonNumber == 1); 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<IEpisodeService>()
.Verify(v => v.UpdateEpisodes(It.Is<List<Episode>>(l => l.All(e => !e.Monitored))));
}
private void VerifyMonitored(Func<Episode, bool> predicate) private void VerifyMonitored(Func<Episode, bool> predicate)
{ {
Mocker.GetMock<IEpisodeService>() Mocker.GetMock<IEpisodeService>()

@ -91,7 +91,7 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<Profile>().RegisterModel("Profiles"); Mapper.Entity<Profile>().RegisterModel("Profiles");
Mapper.Entity<Log>().RegisterModel("Logs"); Mapper.Entity<Log>().RegisterModel("Logs");
Mapper.Entity<NamingConfig>().RegisterModel("NamingConfig"); Mapper.Entity<NamingConfig>().RegisterModel("NamingConfig");
Mapper.Entity<SeriesStatistics>().MapResultSet(); Mapper.Entity<SeasonStatistics>().MapResultSet();
Mapper.Entity<Blacklist>().RegisterModel("Blacklist"); Mapper.Entity<Blacklist>().RegisterModel("Blacklist");
Mapper.Entity<MetadataFile>().RegisterModel("MetadataFiles"); Mapper.Entity<MetadataFile>().RegisterModel("MetadataFiles");

@ -869,6 +869,7 @@
</Compile> </Compile>
<Compile Include="RootFolders\UnmappedFolder.cs" /> <Compile Include="RootFolders\UnmappedFolder.cs" />
<Compile Include="Security.cs" /> <Compile Include="Security.cs" />
<Compile Include="SeriesStats\SeasonStatistics.cs" />
<Compile Include="SeriesStats\SeriesStatistics.cs" /> <Compile Include="SeriesStats\SeriesStatistics.cs" />
<Compile Include="SeriesStats\SeriesStatisticsRepository.cs" /> <Compile Include="SeriesStats\SeriesStatisticsRepository.cs" />
<Compile Include="SeriesStats\SeriesStatisticsService.cs" /> <Compile Include="SeriesStats\SeriesStatisticsService.cs" />
@ -892,6 +893,7 @@
<Compile Include="Tv\Commands\RefreshSeriesCommand.cs" /> <Compile Include="Tv\Commands\RefreshSeriesCommand.cs" />
<Compile Include="Tv\Episode.cs" /> <Compile Include="Tv\Episode.cs" />
<Compile Include="Tv\EpisodeCutoffService.cs" /> <Compile Include="Tv\EpisodeCutoffService.cs" />
<Compile Include="Tv\EpisodeMonitoredService.cs" />
<Compile Include="Tv\EpisodeRepository.cs"> <Compile Include="Tv\EpisodeRepository.cs">
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>
@ -903,6 +905,7 @@
<Compile Include="Tv\Events\SeriesMovedEvent.cs" /> <Compile Include="Tv\Events\SeriesMovedEvent.cs" />
<Compile Include="Tv\Events\SeriesRefreshStartingEvent.cs" /> <Compile Include="Tv\Events\SeriesRefreshStartingEvent.cs" />
<Compile Include="Tv\Events\SeriesUpdatedEvent.cs" /> <Compile Include="Tv\Events\SeriesUpdatedEvent.cs" />
<Compile Include="Tv\MonitoringOptions.cs" />
<Compile Include="Tv\MoveSeriesService.cs" /> <Compile Include="Tv\MoveSeriesService.cs" />
<Compile Include="Tv\Ratings.cs" /> <Compile Include="Tv\Ratings.cs" />
<Compile Include="Tv\RefreshEpisodeService.cs" /> <Compile Include="Tv\RefreshEpisodeService.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;
}
}
}
}

@ -1,16 +1,18 @@
using System; using System;
using System.Collections.Generic;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.SeriesStats namespace NzbDrone.Core.SeriesStats
{ {
public class SeriesStatistics : ResultSet public class SeriesStatistics : ResultSet
{ {
public Int32 SeriesId { get; set; } public int SeriesId { get; set; }
public String NextAiringString { get; set; } public string NextAiringString { get; set; }
public String PreviousAiringString { get; set; } public string PreviousAiringString { get; set; }
public Int32 EpisodeFileCount { get; set; } public int EpisodeFileCount { get; set; }
public Int32 EpisodeCount { get; set; } public int EpisodeCount { get; set; }
public Int64 SizeOnDisk { get; set; } public long SizeOnDisk { get; set; }
public List<SeasonStatistics> SeasonStatistics { get; set; }
public DateTime? NextAiring public DateTime? NextAiring
{ {

@ -7,8 +7,8 @@ namespace NzbDrone.Core.SeriesStats
{ {
public interface ISeriesStatisticsRepository public interface ISeriesStatisticsRepository
{ {
List<SeriesStatistics> SeriesStatistics(); List<SeasonStatistics> SeriesStatistics();
SeriesStatistics SeriesStatistics(Int32 seriesId); SeasonStatistics SeriesStatistics(Int32 seriesId);
} }
public class SeriesStatisticsRepository : ISeriesStatisticsRepository public class SeriesStatisticsRepository : ISeriesStatisticsRepository
@ -20,7 +20,7 @@ namespace NzbDrone.Core.SeriesStats
_database = database; _database = database;
} }
public List<SeriesStatistics> SeriesStatistics() public List<SeasonStatistics> SeriesStatistics()
{ {
var mapper = _database.GetDataMapper(); var mapper = _database.GetDataMapper();
@ -32,10 +32,10 @@ namespace NzbDrone.Core.SeriesStats
sb.AppendLine(GetGroupByClause()); sb.AppendLine(GetGroupByClause());
var queryText = sb.ToString(); var queryText = sb.ToString();
return mapper.Query<SeriesStatistics>(queryText); return mapper.Query<SeasonStatistics>(queryText);
} }
public SeriesStatistics SeriesStatistics(Int32 seriesId) public SeasonStatistics SeriesStatistics(Int32 seriesId)
{ {
var mapper = _database.GetDataMapper(); var mapper = _database.GetDataMapper();
@ -49,7 +49,7 @@ namespace NzbDrone.Core.SeriesStats
sb.AppendLine(GetGroupByClause()); sb.AppendLine(GetGroupByClause());
var queryText = sb.ToString(); var queryText = sb.ToString();
return mapper.Find<SeriesStatistics>(queryText); return mapper.Find<SeasonStatistics>(queryText);
} }
private String GetSelectClause() private String GetSelectClause()
@ -57,17 +57,18 @@ namespace NzbDrone.Core.SeriesStats
return @"SELECT Episodes.*, SUM(EpisodeFiles.Size) as SizeOnDisk FROM return @"SELECT Episodes.*, SUM(EpisodeFiles.Size) as SizeOnDisk FROM
(SELECT (SELECT
Episodes.SeriesId, 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 (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, 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, 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 MAX(CASE WHEN AirDateUtc >= @currentDate OR EpisodeFileId = 0 AND Monitored = 0 THEN NULL ELSE AirDateUtc END) AS PreviousAiringString
FROM Episodes FROM Episodes
GROUP BY Episodes.SeriesId) as Episodes"; GROUP BY Episodes.SeriesId, Episodes.SeasonNumber) as Episodes";
} }
private String GetGroupByClause() private String GetGroupByClause()
{ {
return "GROUP BY Episodes.SeriesId"; return "GROUP BY Episodes.SeriesId, Episodes.SeasonNumber";
} }
private String GetEpisodeFilesJoin() private String GetEpisodeFilesJoin()

@ -1,4 +1,6 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.SeriesStats namespace NzbDrone.Core.SeriesStats
{ {
@ -19,7 +21,9 @@ namespace NzbDrone.Core.SeriesStats
public List<SeriesStatistics> SeriesStatistics() public List<SeriesStatistics> 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) public SeriesStatistics SeriesStatistics(int seriesId)
@ -28,7 +32,36 @@ namespace NzbDrone.Core.SeriesStats
if (stats == null) return new SeriesStatistics(); if (stats == null) return new SeriesStatistics();
return stats; return MapSeriesStatistics(new List<SeasonStatistics> { stats });
}
private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> 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
};
} }
} }
} }

@ -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 SearchForMissingEpisodes { get; set; }
public bool IgnoreEpisodesWithFiles { get; set; }
public bool IgnoreEpisodesWithoutFiles { get; set; }
} }
} }

@ -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<Episode> episodes, bool monitored)
{
foreach (var episode in episodes)
{
episode.Monitored = monitored;
}
}
}
}

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

@ -1,8 +1,4 @@
using System; using NLog;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
@ -13,61 +9,23 @@ namespace NzbDrone.Core.Tv
public class SeriesScannedHandler : IHandle<SeriesScannedEvent>, public class SeriesScannedHandler : IHandle<SeriesScannedEvent>,
IHandle<SeriesScanSkippedEvent> IHandle<SeriesScanSkippedEvent>
{ {
private readonly IEpisodeMonitoredService _episodeMonitoredService;
private readonly ISeriesService _seriesService; private readonly ISeriesService _seriesService;
private readonly IEpisodeService _episodeService;
private readonly IManageCommandQueue _commandQueueManager; private readonly IManageCommandQueue _commandQueueManager;
private readonly Logger _logger; private readonly Logger _logger;
public SeriesScannedHandler(ISeriesService seriesService, public SeriesScannedHandler(IEpisodeMonitoredService episodeMonitoredService,
IEpisodeService episodeService, ISeriesService seriesService,
IManageCommandQueue commandQueueManager, IManageCommandQueue commandQueueManager,
Logger logger) Logger logger)
{ {
_episodeMonitoredService = episodeMonitoredService;
_seriesService = seriesService; _seriesService = seriesService;
_episodeService = episodeService;
_commandQueueManager = commandQueueManager; _commandQueueManager = commandQueueManager;
_logger = logger; _logger = logger;
} }
private void SetEpisodeMonitoredStatus(Series series, List<Episode> 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<Episode> episodes)
{
foreach (var episode in episodes)
{
episode.Monitored = false;
}
}
private void HandleScanEvents(Series series) private void HandleScanEvents(Series series)
{ {
if (series.AddOptions == null) if (series.AddOptions == null)
@ -76,9 +34,7 @@ namespace NzbDrone.Core.Tv
} }
_logger.Info("[{0}] was recently added, performing post-add actions", series.Title); _logger.Info("[{0}] was recently added, performing post-add actions", series.Title);
_episodeMonitoredService.SetEpisodeMonitoredStatus(series, series.AddOptions);
var episodes = _episodeService.GetEpisodeBySeries(series.Id);
SetEpisodeMonitoredStatus(series, episodes);
if (series.AddOptions.SearchForMissingEpisodes) if (series.AddOptions.SearchForMissingEpisodes)
{ {

@ -20,6 +20,7 @@
@import "../Shared/FileBrowser/filebrowser"; @import "../Shared/FileBrowser/filebrowser";
@import "badges"; @import "badges";
@import "../ManualImport/manualimport"; @import "../ManualImport/manualimport";
@import "../SeasonPass/seasonpass";
.main-region { .main-region {
@media (min-width : @screen-lg-min) { @media (min-width : @screen-lg-min) {

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

@ -0,0 +1,25 @@
<div class="series-editor-footer">
<div class="row">
<div class="form-group col-md-2">
<label>Monitor</label>
<select class="form-control x-action x-monitor">
<option value="all">All</option>
<option value="future">Future</option>
<option value="missing">Missing</option>
<option value="existing">Existing</option>
<option value="first">First Season</option>
<option value="latest">Latest Season</option>
<option value="none">None</option>
</select>
</div>
<div class="form-group col-md-3 actions">
<label class="x-selected-count">0 series selected</label>
<div>
<button class="btn btn-primary x-action x-update">Update Selected Series</button>
<span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span>
</div>
</div>
</div>
</div>

@ -1,9 +1,15 @@
var _ = require('underscore');
var vent = require('vent');
var Backgrid = require('backgrid');
var Marionette = require('marionette'); var Marionette = require('marionette');
var EmptyView = require('../Series/Index/EmptyView');
var SeriesCollection = require('../Series/SeriesCollection'); 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 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'); require('../Mixins/backbone.signalr.mixin');
module.exports = Marionette.Layout.extend({ module.exports = Marionette.Layout.extend({
@ -14,11 +20,38 @@ module.exports = Marionette.Layout.extend({
series : '#x-series' 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() { initialize : function() {
this.seriesCollection = SeriesCollection.clone(); this.seriesCollection = SeriesCollection.clone();
this.seriesCollection.shadowCollection.bindSignalR(); 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 = { this.filteringOptions = {
type : 'radio', type : 'radio',
@ -59,11 +92,13 @@ module.exports = Marionette.Layout.extend({
}, },
onRender : function() { onRender : function() {
this.series.show(new SeriesCollectionView({ this._showTable();
collection : this.seriesCollection
}));
this._showToolbar(); this._showToolbar();
this._showFooter();
},
onClose : function() {
vent.trigger(vent.Commands.CloseControlPanelCommand);
}, },
_showToolbar : function() { _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) { _setFilter : function(buttonContext) {
var mode = buttonContext.model.get('key'); var mode = buttonContext.model.get('key');

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

@ -0,0 +1,30 @@
{{#each seasons}}
<span class="season label label-default">
<span>
<span class="x-season-monitored season-monitored" title="Toggle season monitored status" data-season-number="{{seasonNumber}}">
<i class="x-season-monitored-icon {{#if monitored}}icon-sonarr-monitored{{else}}icon-sonarr-unmonitored{{/if}}"/>
</span>
</span>
{{#if_eq seasonNumber compare="0"}}
<span class="season-number">Specials</span>
{{else}}
<span class="season-number">S{{Pad2 seasonNumber}}</span>
{{/if_eq}}
<!--{{#if_eq statistics.episodeCount compare=0}}-->
<!--{{#if monitored}}-->
<!--<span class="badge badge-primary season-status" title="No aired episodes">&nbsp;</span>-->
<!--{{else}}-->
<!--<span class="badge badge-warning season-status" title="Season is not monitored">&nbsp;</span>-->
<!--{{/if}}-->
<!--{{else}}-->
<!--{{#with statistics}}-->
<!--{{#if_eq percentOfEpisodes compare=100}}-->
<!--<span class="badge badge-success season-status" title="{{episodeFileCount}}/{{episodeCount}} episodes downloaded">{{episodeFileCount}} / {{episodeCount}}</span>-->
<!--{{else}}-->
<!--<span class="badge badge-danger season-status" title="{{episodeFileCount}}/{{episodeCount}} episodes downloaded">{{episodeFileCount}} / {{episodeCount}}</span>-->
<!--{{/if_eq}}-->
<!--{{/with}}-->
<!--{{/if_eq}}-->
</span>
{{/each}}

@ -1,6 +0,0 @@
var Marionette = require('marionette');
var SeriesLayout = require('./SeriesLayout');
module.exports = Marionette.CollectionView.extend({
itemView : SeriesLayout
});

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

@ -1,75 +0,0 @@
<div class="seasonpass-series">
<div class="row">
<div class="col-md-12">
<i class="icon-sonarr-expand x-expander expander pull-left"/>
<i class="x-series-monitored series-monitor-toggle pull-left" title="Toggle monitored state for entire series"/>
<div class="title col-md-5">
<a href="{{route}}">
{{title}}
</a>
</div>
<div class="col-md-2">
<select class="form-control x-season-select season-select">
<option value="-1">Select season...</option>
{{#each seasons}}
{{#if_eq seasonNumber compare="0"}}
<option value="{{seasonNumber}}">Specials</option>
{{else}}
<option value="{{seasonNumber}}">Season {{seasonNumber}}</option>
{{/if_eq}}
{{/each}}
</select>
</div>
<div class="col-md-1">
<span class="help-inline">
<i class="icon-sonarr-form-info" title="Selecting a season will unmonitor all previous seasons"/>
</span>
</div>
<span class="season-pass-button">
<button class="btn x-latest last">Latest Season Only</button>
</span>
<span class="season-pass-button">
<button class="btn x-all">All Seasons</button>
</span>
</div>
</div>
<div class="row">
<div class="col-md-11">
<div class="x-season-grid season-grid">
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th class="sortable">Season</th>
</tr>
</thead>
<tbody>
{{#each seasons}}
<tr>
<td class="toggle-cell x-monitored" data-season-number="{{seasonNumber}}">
{{#if monitored}}
<i class="icon-sonarr-monitored"></i>
{{else}}
<i class="icon-sonarr-unmonitored"></i>
{{/if}}
</td>
<td>
{{#if_eq seasonNumber compare="0"}}
Specials
{{else}}
Season {{seasonNumber}}
{{/if_eq}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
</div>

@ -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;
}
}
Loading…
Cancel
Save