diff --git a/.gitignore b/.gitignore index d1a9a9a44..d537ab816 100644 --- a/.gitignore +++ b/.gitignore @@ -84,7 +84,6 @@ Generated_Code #added for RIA/Silverlight projects # Backup & report files from converting an old project file to a newer # Visual Studio version. Backup files are not needed, because we have git ;-) _UpgradeReport_Files/ -Backup*/ UpgradeLog*.XML # SQL Server files diff --git a/Gruntfile.js b/Gruntfile.js index fc75499ec..35deb38bb 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -37,6 +37,7 @@ module.exports = function (grunt) { 'Content/theme.less', 'Content/overrides.less', 'Series/series.less', + 'History/history.less', 'AddSeries/addSeries.less', 'Calendar/calendar.less', 'Cells/cells.less', diff --git a/src/Common/CommonVersionInfo.cs b/src/Common/CommonVersionInfo.cs index beed2ca20..d674c376f 100644 --- a/src/Common/CommonVersionInfo.cs +++ b/src/Common/CommonVersionInfo.cs @@ -2,6 +2,4 @@ using System.Reflection; -[assembly: AssemblyVersion("1.1.0")] -[assembly: AssemblyFileVersion("1.1.0.0")] -[assembly: AssemblyInformationalVersion("1.1.3")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/Exceptron.Client/Properties/AssemblyInfo.cs b/src/Exceptron.Client/Properties/AssemblyInfo.cs index ddd8d11af..915d4e52d 100644 --- a/src/Exceptron.Client/Properties/AssemblyInfo.cs +++ b/src/Exceptron.Client/Properties/AssemblyInfo.cs @@ -22,18 +22,8 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("a463887e-594f-4733-b227-a79f4ffb2158")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] + [assembly: InternalsVisibleTo("Exceptron.Client.Tests")] [assembly: InternalsVisibleTo("Exceptron.Api.v1.Tests")] [assembly: InternalsVisibleTo("Exceptron.Rush")] diff --git a/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs b/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs index 54d0e8dcc..00c52d90e 100644 --- a/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs +++ b/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs @@ -51,7 +51,7 @@ namespace NzbDrone.Api.Test.MappingTests } [Test] - public void should_map_lay_loaded_values_should_not_be_inject_if_not_loaded() + public void should_map_lazy_loaded_values_should_not_be_inject_if_not_loaded() { var modelWithLazy = new ModelWithLazy() { diff --git a/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs index a0de49863..4d2901c1a 100644 --- a/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("260b2ff9-d3b7-4d8a-b720-a12c93d045e5")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Api/Blacklist/BlacklistModule.cs b/src/NzbDrone.Api/Blacklist/BlacklistModule.cs index 913810767..b35596a01 100644 --- a/src/NzbDrone.Api/Blacklist/BlacklistModule.cs +++ b/src/NzbDrone.Api/Blacklist/BlacklistModule.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Api.Blacklist { _blacklistService = blacklistService; GetResourcePaged = GetBlacklist; - DeleteResource = Delete; + DeleteResource = DeleteBlacklist; } private PagingResource GetBlacklist(PagingResource pagingResource) @@ -25,16 +25,10 @@ namespace NzbDrone.Api.Blacklist SortDirection = pagingResource.SortDirection }; - //This is a hack to deal with backgrid setting the sortKey to the column name instead of sortValue - if (pagingSpec.SortKey.Equals("series", StringComparison.InvariantCultureIgnoreCase)) - { - pagingSpec.SortKey = "series.title"; - } - return ApplyToPage(_blacklistService.Paged, pagingSpec); } - private void Delete(int id) + private void DeleteBlacklist(int id) { _blacklistService.Delete(id); } diff --git a/src/NzbDrone.Api/ClientSchema/Field.cs b/src/NzbDrone.Api/ClientSchema/Field.cs index 6d5539016..dfab26737 100644 --- a/src/NzbDrone.Api/ClientSchema/Field.cs +++ b/src/NzbDrone.Api/ClientSchema/Field.cs @@ -1,16 +1,18 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace NzbDrone.Api.ClientSchema { public class Field { - public int Order { get; set; } - public string Name { get; set; } - public string Label { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } - public object Value { get; set; } - public string Type { get; set; } + public Int32 Order { get; set; } + public String Name { get; set; } + public String Label { get; set; } + public String HelpText { get; set; } + public String HelpLink { get; set; } + public Object Value { get; set; } + public String Type { get; set; } + public Boolean Advanced { get; set; } public List SelectOptions { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs b/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs index 29abd1468..10038ffb7 100644 --- a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs +++ b/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs @@ -1,6 +1,8 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json.Linq; using NzbDrone.Common; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Reflection; @@ -26,13 +28,14 @@ namespace NzbDrone.Api.ClientSchema if (fieldAttribute != null) { - var field = new Field() + var field = new Field { Name = propertyInfo.Name, Label = fieldAttribute.Label, HelpText = fieldAttribute.HelpText, HelpLink = fieldAttribute.HelpLink, Order = fieldAttribute.Order, + Advanced = fieldAttribute.Advanced, Type = fieldAttribute.Type.ToString().ToLowerInvariant() }; @@ -101,6 +104,23 @@ namespace NzbDrone.Api.ClientSchema propertyInfo.SetValue(target, value, null); } + else if (propertyInfo.PropertyType == typeof (IEnumerable)) + { + IEnumerable value; + + if (field.Value.GetType() == typeof (JArray)) + { + value = ((JArray) field.Value).Select(s => s.Value()); + } + + else + { + value = field.Value.ToString().Split(new []{','}, StringSplitOptions.RemoveEmptyEntries).Select(s => Convert.ToInt32(s)); + } + + propertyInfo.SetValue(target, value, null); + } + else { propertyInfo.SetValue(target, field.Value, null); diff --git a/src/NzbDrone.Api/Config/NamingConfigModule.cs b/src/NzbDrone.Api/Config/NamingConfigModule.cs index 36876a406..03d494aac 100644 --- a/src/NzbDrone.Api/Config/NamingConfigModule.cs +++ b/src/NzbDrone.Api/Config/NamingConfigModule.cs @@ -39,6 +39,7 @@ namespace NzbDrone.Api.Config SharedValidator.RuleFor(c => c.MultiEpisodeStyle).InclusiveBetween(0, 3); SharedValidator.RuleFor(c => c.StandardEpisodeFormat).ValidEpisodeFormat(); SharedValidator.RuleFor(c => c.DailyEpisodeFormat).ValidDailyEpisodeFormat(); + SharedValidator.RuleFor(c => c.AnimeEpisodeFormat).ValidAnimeEpisodeFormat(); SharedValidator.RuleFor(c => c.SeriesFolderFormat).ValidSeriesFolderFormat(); SharedValidator.RuleFor(c => c.SeasonFolderFormat).ValidSeasonFolderFormat(); } @@ -80,6 +81,7 @@ namespace NzbDrone.Api.Config var singleEpisodeSampleResult = _filenameSampleService.GetStandardSample(nameSpec); var multiEpisodeSampleResult = _filenameSampleService.GetMultiEpisodeSample(nameSpec); var dailyEpisodeSampleResult = _filenameSampleService.GetDailySample(nameSpec); + var animeEpisodeSampleResult = _filenameSampleService.GetAnimeSample(nameSpec); sampleResource.SingleEpisodeExample = _filenameValidationService.ValidateStandardFilename(singleEpisodeSampleResult) != null ? "Invalid format" @@ -93,6 +95,10 @@ namespace NzbDrone.Api.Config ? "Invalid format" : dailyEpisodeSampleResult.Filename; + sampleResource.AnimeEpisodeExample = _filenameValidationService.ValidateAnimeFilename(animeEpisodeSampleResult) != null + ? "Invalid format" + : animeEpisodeSampleResult.Filename; + sampleResource.SeriesFolderExample = nameSpec.SeriesFolderFormat.IsNullOrWhiteSpace() ? "Invalid format" : _filenameSampleService.GetSeriesFolderSample(nameSpec); diff --git a/src/NzbDrone.Api/Config/NamingConfigResource.cs b/src/NzbDrone.Api/Config/NamingConfigResource.cs index 1e5ab16e3..d050b8c3f 100644 --- a/src/NzbDrone.Api/Config/NamingConfigResource.cs +++ b/src/NzbDrone.Api/Config/NamingConfigResource.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Api.Config public Int32 MultiEpisodeStyle { get; set; } public string StandardEpisodeFormat { get; set; } public string DailyEpisodeFormat { get; set; } + public string AnimeEpisodeFormat { get; set; } public string SeriesFolderFormat { get; set; } public string SeasonFolderFormat { get; set; } public bool IncludeSeriesTitle { get; set; } diff --git a/src/NzbDrone.Api/Config/NamingSampleResource.cs b/src/NzbDrone.Api/Config/NamingSampleResource.cs index 56ff031d2..0f8abdcbc 100644 --- a/src/NzbDrone.Api/Config/NamingSampleResource.cs +++ b/src/NzbDrone.Api/Config/NamingSampleResource.cs @@ -5,6 +5,7 @@ public string SingleEpisodeExample { get; set; } public string MultiEpisodeExample { get; set; } public string DailyEpisodeExample { get; set; } + public string AnimeEpisodeExample { get; set; } public string SeriesFolderExample { get; set; } public string SeasonFolderExample { get; set; } } diff --git a/src/NzbDrone.Api/Episodes/EpisodeModule.cs b/src/NzbDrone.Api/Episodes/EpisodeModule.cs index e698ab242..96ddf161a 100644 --- a/src/NzbDrone.Api/Episodes/EpisodeModule.cs +++ b/src/NzbDrone.Api/Episodes/EpisodeModule.cs @@ -16,7 +16,6 @@ namespace NzbDrone.Api.Episodes GetResourceAll = GetEpisodes; UpdateResource = SetMonitored; - GetResourceById = GetEpisode; } private List GetEpisodes() diff --git a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs index 7612a9601..e25d1bd6b 100644 --- a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs +++ b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs @@ -26,11 +26,15 @@ namespace NzbDrone.Api.Episodes : base(commandExecutor, resource) { _episodeService = episodeService; + + GetResourceById = GetEpisode; } protected EpisodeResource GetEpisode(int id) { - return _episodeService.GetEpisode(id).InjectTo(); + var episode = _episodeService.GetEpisode(id); + episode.EpisodeFile.LazyLoad(); + return episode.InjectTo(); } public void Handle(EpisodeGrabbedEvent message) diff --git a/src/NzbDrone.Api/Episodes/EpisodeResource.cs b/src/NzbDrone.Api/Episodes/EpisodeResource.cs index e1210cc7d..b3a7d1058 100644 --- a/src/NzbDrone.Api/Episodes/EpisodeResource.cs +++ b/src/NzbDrone.Api/Episodes/EpisodeResource.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Api.Episodes public Boolean HasFile { get; set; } public Boolean Monitored { get; set; } + public Nullable SceneAbsoluteEpisodeNumber { get; set; } public Int32 SceneEpisodeNumber { get; set; } public Int32 SceneSeasonNumber { get; set; } public Int32 TvDbEpisodeId { get; set; } diff --git a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs index 28cecd695..183326415 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs +++ b/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs @@ -1,11 +1,18 @@ using Nancy; using Nancy.Bootstrapper; -using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Api.Frontend; namespace NzbDrone.Api.Extensions.Pipelines { - public class NzbDroneVersionPipeline : IRegisterNancyPipeline + public class CacheHeaderPipeline : IRegisterNancyPipeline { + private readonly ICacheableSpecification _cacheableSpecification; + + public CacheHeaderPipeline(ICacheableSpecification cacheableSpecification) + { + _cacheableSpecification = cacheableSpecification; + } + public void Register(IPipelines pipelines) { pipelines.AfterRequest.AddItemToStartOfPipeline(Handle); @@ -13,9 +20,13 @@ namespace NzbDrone.Api.Extensions.Pipelines private void Handle(NancyContext context) { - if (!context.Response.Headers.ContainsKey("X-ApplicationVersion")) + if (_cacheableSpecification.IsCacheable(context)) { - context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString()); + context.Response.Headers.EnableCache(); + } + else + { + context.Response.Headers.DisableCache(); } } } diff --git a/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs index 183326415..28cecd695 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs +++ b/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs @@ -1,18 +1,11 @@ using Nancy; using Nancy.Bootstrapper; -using NzbDrone.Api.Frontend; +using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Api.Extensions.Pipelines { - public class CacheHeaderPipeline : IRegisterNancyPipeline + public class NzbDroneVersionPipeline : IRegisterNancyPipeline { - private readonly ICacheableSpecification _cacheableSpecification; - - public CacheHeaderPipeline(ICacheableSpecification cacheableSpecification) - { - _cacheableSpecification = cacheableSpecification; - } - public void Register(IPipelines pipelines) { pipelines.AfterRequest.AddItemToStartOfPipeline(Handle); @@ -20,13 +13,9 @@ namespace NzbDrone.Api.Extensions.Pipelines private void Handle(NancyContext context) { - if (_cacheableSpecification.IsCacheable(context)) + if (!context.Response.Headers.ContainsKey("X-ApplicationVersion")) { - context.Response.Headers.EnableCache(); - } - else - { - context.Response.Headers.DisableCache(); + context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString()); } } } diff --git a/src/NzbDrone.Api/Frontend/IsCacheableSpecification.cs b/src/NzbDrone.Api/Frontend/CacheableSpecification.cs similarity index 86% rename from src/NzbDrone.Api/Frontend/IsCacheableSpecification.cs rename to src/NzbDrone.Api/Frontend/CacheableSpecification.cs index 875e5727d..52b177ce8 100644 --- a/src/NzbDrone.Api/Frontend/IsCacheableSpecification.cs +++ b/src/NzbDrone.Api/Frontend/CacheableSpecification.cs @@ -1,5 +1,6 @@ using System; using Nancy; +using NzbDrone.Common; using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Api.Frontend @@ -20,7 +21,13 @@ namespace NzbDrone.Api.Frontend if (context.Request.Query.v == BuildInfo.Version) return true; - if (context.Request.Path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase)) return false; + if (context.Request.Path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase)) + { + if (context.Request.Path.ContainsIgnoreCase("/MediaCover")) return true; + + return false; + } + if (context.Request.Path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) return false; if (context.Request.Path.EndsWith("main.js")) return false; if (context.Request.Path.StartsWith("/feed", StringComparison.CurrentCultureIgnoreCase)) return false; diff --git a/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs new file mode 100644 index 000000000..a2e111430 --- /dev/null +++ b/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs @@ -0,0 +1,31 @@ +using System.IO; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Api.Frontend.Mappers +{ + public class BackupFileMapper : StaticResourceMapperBase + { + private readonly IAppFolderInfo _appFolderInfo; + + public BackupFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger) + : base(diskProvider, logger) + { + _appFolderInfo = appFolderInfo; + } + + protected override string Map(string resourceUrl) + { + var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar); + + return Path.Combine(_appFolderInfo.GetBackupFolder(), path); + } + + public override bool CanHandle(string resourceUrl) + { + return resourceUrl.StartsWith("/backup/") && resourceUrl.ContainsIgnoreCase("nzbdrone_backup_") && resourceUrl.EndsWith(".zip"); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/History/HistoryModule.cs b/src/NzbDrone.Api/History/HistoryModule.cs index 69ed1dc15..232edfd79 100644 --- a/src/NzbDrone.Api/History/HistoryModule.cs +++ b/src/NzbDrone.Api/History/HistoryModule.cs @@ -10,12 +10,12 @@ namespace NzbDrone.Api.History public class HistoryModule : NzbDroneRestModule { private readonly IHistoryService _historyService; - private readonly IFailedDownloadService _failedDownloadService; + private readonly IDownloadTrackingService _downloadTrackingService; - public HistoryModule(IHistoryService historyService, IFailedDownloadService failedDownloadService) + public HistoryModule(IHistoryService historyService, IDownloadTrackingService downloadTrackingService) { _historyService = historyService; - _failedDownloadService = failedDownloadService; + _downloadTrackingService = downloadTrackingService; GetResourcePaged = GetHistory; Post["/failed"] = x => MarkAsFailed(); @@ -33,12 +33,6 @@ namespace NzbDrone.Api.History SortDirection = pagingResource.SortDirection }; - //This is a hack to deal with backgrid setting the sortKey to the column name instead of sortValue - if (pagingSpec.SortKey.Equals("series", StringComparison.InvariantCultureIgnoreCase)) - { - pagingSpec.SortKey = "series.title"; - } - if (pagingResource.FilterKey == "eventType") { var filterValue = (HistoryEventType)Convert.ToInt32(pagingResource.FilterValue); @@ -57,7 +51,7 @@ namespace NzbDrone.Api.History private Response MarkAsFailed() { var id = (int)Request.Form.Id; - _failedDownloadService.MarkAsFailed(id); + _downloadTrackingService.MarkAsFailed(id); return new Object().AsResponse(); } } diff --git a/src/NzbDrone.Api/Indexers/ReleaseModule.cs b/src/NzbDrone.Api/Indexers/ReleaseModule.cs index 0f91ad102..0e01fada3 100644 --- a/src/NzbDrone.Api/Indexers/ReleaseModule.cs +++ b/src/NzbDrone.Api/Indexers/ReleaseModule.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using FluentValidation; using Nancy; +using NLog; using NzbDrone.Api.Mapping; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; @@ -13,6 +15,7 @@ using Omu.ValueInjecter; using System.Linq; using Nancy.ModelBinding; using NzbDrone.Api.Extensions; +using NzbDrone.Core.Qualities; namespace NzbDrone.Api.Indexers { @@ -21,20 +24,26 @@ namespace NzbDrone.Api.Indexers private readonly IFetchAndParseRss _rssFetcherAndParser; private readonly ISearchForNzb _nzbSearchService; private readonly IMakeDownloadDecision _downloadDecisionMaker; + private readonly IPrioritizeDownloadDecision _prioritizeDownloadDecision; private readonly IDownloadService _downloadService; private readonly IParsingService _parsingService; + private readonly Logger _logger; public ReleaseModule(IFetchAndParseRss rssFetcherAndParser, ISearchForNzb nzbSearchService, IMakeDownloadDecision downloadDecisionMaker, + IPrioritizeDownloadDecision prioritizeDownloadDecision, IDownloadService downloadService, - IParsingService parsingService) + IParsingService parsingService, + Logger logger) { _rssFetcherAndParser = rssFetcherAndParser; _nzbSearchService = nzbSearchService; _downloadDecisionMaker = downloadDecisionMaker; + _prioritizeDownloadDecision = prioritizeDownloadDecision; _downloadService = downloadService; _parsingService = parsingService; + _logger = logger; GetResourceAll = GetReleases; Post["/"] = x=> DownloadRelease(this.Bind()); @@ -43,7 +52,7 @@ namespace NzbDrone.Api.Indexers private Response DownloadRelease(ReleaseResource release) { - var remoteEpisode = _parsingService.Map(release.InjectTo(), 0); + var remoteEpisode = _parsingService.Map(release.InjectTo(), release.TvRageId); remoteEpisode.Release = release.InjectTo(); _downloadService.DownloadReport(remoteEpisode); @@ -62,17 +71,28 @@ namespace NzbDrone.Api.Indexers private List GetEpisodeReleases(int episodeId) { - var decisions = _nzbSearchService.EpisodeSearch(episodeId); + try + { + var decisions = _nzbSearchService.EpisodeSearch(episodeId); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); - return MapDecisions(decisions); + return MapDecisions(prioritizedDecisions); + } + catch (Exception ex) + { + _logger.ErrorException("Episode search failed: " + ex.Message, ex); + } + + return new List(); } private List GetRss() { var reports = _rssFetcherAndParser.Fetch(); var decisions = _downloadDecisionMaker.GetRssDecision(reports); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); - return MapDecisions(decisions); + return MapDecisions(prioritizedDecisions); } private static List MapDecisions(IEnumerable decisions) @@ -89,6 +109,18 @@ namespace NzbDrone.Api.Indexers release.Rejections = downloadDecision.Rejections.ToList(); release.DownloadAllowed = downloadDecision.RemoteEpisode.DownloadAllowed; + release.ReleaseWeight = result.Count; + + if (downloadDecision.RemoteEpisode.Series != null) + { + release.QualityWeight = downloadDecision.RemoteEpisode.Series.QualityProfile.Value.Items.FindIndex(v => v.Quality == release.Quality.Quality) * 2; + } + + if (!release.Quality.Proper) + { + release.QualityWeight++; + } + result.Add(release); } diff --git a/src/NzbDrone.Api/Indexers/ReleaseResource.cs b/src/NzbDrone.Api/Indexers/ReleaseResource.cs index 859399588..bb6f53379 100644 --- a/src/NzbDrone.Api/Indexers/ReleaseResource.cs +++ b/src/NzbDrone.Api/Indexers/ReleaseResource.cs @@ -10,11 +10,14 @@ namespace NzbDrone.Api.Indexers public class ReleaseResource : RestResource { public QualityModel Quality { get; set; } + public Int32 QualityWeight { get; set; } public Int32 Age { get; set; } public Double AgeHours { get; set; } public Int64 Size { get; set; } public String Indexer { get; set; } public String ReleaseGroup { get; set; } + public String SubGroup { get; set; } + public String ReleaseHash { get; set; } public String Title { get; set; } public Boolean FullSeason { get; set; } public Boolean SceneSource { get; set; } @@ -23,14 +26,21 @@ namespace NzbDrone.Api.Indexers public String AirDate { get; set; } public String SeriesTitle { get; set; } public int[] EpisodeNumbers { get; set; } + public int[] AbsoluteEpisodeNumbers { get; set; } public Boolean Approved { get; set; } public Int32 TvRageId { get; set; } - public List Rejections { get; set; } + public IEnumerable Rejections { get; set; } public DateTime PublishDate { get; set; } public String CommentUrl { get; set; } public String DownloadUrl { get; set; } public String InfoUrl { get; set; } public Boolean DownloadAllowed { get; set; } public DownloadProtocol DownloadProtocol { get; set; } + public Int32 ReleaseWeight { get; set; } + + public Boolean IsDaily { get; set; } + public Boolean IsAbsoluteNumbering { get; set; } + public Boolean IsPossibleSpecialEpisode { get; set; } + public Boolean Special { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs b/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs index 7b0cc4ea9..fae294784 100644 --- a/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs +++ b/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; +using System.IO; using Nancy; using Nancy.Responses; using NzbDrone.Common; diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 21a84886e..75878901e 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -122,15 +122,16 @@ - + - + + @@ -164,6 +165,10 @@ + + + + diff --git a/src/NzbDrone.Api/Properties/AssemblyInfo.cs b/src/NzbDrone.Api/Properties/AssemblyInfo.cs index 8d8f697db..6149a06c4 100644 --- a/src/NzbDrone.Api/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Api/Properties/AssemblyInfo.cs @@ -7,6 +7,5 @@ using System.Runtime.InteropServices; [assembly: Guid("4c0922d7-979e-4ff7-b44b-b8ac2100eeb5")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] [assembly: InternalsVisibleTo("NzbDrone.Core")] diff --git a/src/NzbDrone.Api/ProviderModuleBase.cs b/src/NzbDrone.Api/ProviderModuleBase.cs index 943f27077..a35858633 100644 --- a/src/NzbDrone.Api/ProviderModuleBase.cs +++ b/src/NzbDrone.Api/ProviderModuleBase.cs @@ -23,7 +23,10 @@ namespace NzbDrone.Api : base(resource) { _providerFactory = providerFactory; + Get["schema"] = x => GetTemplates(); + Post["test"] = x => Test(ReadResourceFromRequest()); + GetResourceAll = GetAll; GetResourceById = GetProviderById; CreateResource = CreateProvider; @@ -63,16 +66,26 @@ namespace NzbDrone.Api private int CreateProvider(TProviderResource providerResource) { - var provider = GetDefinition(providerResource); - provider = _providerFactory.Create(provider); - return provider.Id; + var providerDefinition = GetDefinition(providerResource); + + if (providerDefinition.Enable) + { + Test(providerDefinition); + } + + providerDefinition = _providerFactory.Create(providerDefinition); + + return providerDefinition.Id; } private void UpdateProvider(TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource); - Validate(providerDefinition); + if (providerDefinition.Enable) + { + Test(providerDefinition); + } _providerFactory.Update(providerDefinition); } @@ -133,6 +146,25 @@ namespace NzbDrone.Api return result.AsResponse(); } + private Response Test(TProviderResource providerResource) + { + var providerDefinition = GetDefinition(providerResource); + + Test(providerDefinition); + + return "{}"; + } + + private void Test(TProviderDefinition providerDefinition) + { + var result = _providerFactory.Test(providerDefinition); + + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } + protected virtual void Validate(TProviderDefinition definition) { var validationResult = definition.Settings.Validate(); @@ -143,4 +175,4 @@ namespace NzbDrone.Api } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Queue/QueueResource.cs b/src/NzbDrone.Api/Queue/QueueResource.cs index d47dbbd8f..72df87f84 100644 --- a/src/NzbDrone.Api/Queue/QueueResource.cs +++ b/src/NzbDrone.Api/Queue/QueueResource.cs @@ -17,5 +17,6 @@ namespace NzbDrone.Api.Queue public Decimal Sizeleft { get; set; } public TimeSpan? Timeleft { get; set; } public String Status { get; set; } + public String ErrorMessage { get; set; } } } diff --git a/src/NzbDrone.Api/REST/RestModule.cs b/src/NzbDrone.Api/REST/RestModule.cs index 49cd14fa9..e7acab8f6 100644 --- a/src/NzbDrone.Api/REST/RestModule.cs +++ b/src/NzbDrone.Api/REST/RestModule.cs @@ -194,7 +194,7 @@ namespace NzbDrone.Api.REST var errors = SharedValidator.Validate(resource).Errors.ToList(); - if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase)) + if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) { errors.AddRange(PostValidator.Validate(resource).Errors); } diff --git a/src/NzbDrone.Api/Series/AlternateTitleResource.cs b/src/NzbDrone.Api/Series/AlternateTitleResource.cs new file mode 100644 index 000000000..d79df4d0c --- /dev/null +++ b/src/NzbDrone.Api/Series/AlternateTitleResource.cs @@ -0,0 +1,10 @@ +using System; + +namespace NzbDrone.Api.Series +{ + public class AlternateTitleResource + { + public String Title { get; set; } + public Int32 SeasonNumber { get; set; } + } +} diff --git a/src/NzbDrone.Api/Series/SeasonResource.cs b/src/NzbDrone.Api/Series/SeasonResource.cs new file mode 100644 index 000000000..2d68fdee4 --- /dev/null +++ b/src/NzbDrone.Api/Series/SeasonResource.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Api.Series +{ + public class SeasonResource + { + public int SeasonNumber { get; set; } + public Boolean Monitored { get; set; } + } +} diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs index 0439fdb27..1c5806549 100644 --- a/src/NzbDrone.Api/Series/SeriesModule.cs +++ b/src/NzbDrone.Api/Series/SeriesModule.cs @@ -15,6 +15,7 @@ using NzbDrone.Api.Mapping; using NzbDrone.Core.Tv.Events; using NzbDrone.Core.Validation.Paths; using NzbDrone.Core.DataAugmentation.Scene; +using Omu.ValueInjecter; namespace NzbDrone.Api.Series { @@ -78,21 +79,6 @@ namespace NzbDrone.Api.Series PutValidator.RuleFor(s => s.Path).IsValidPath(); } - private void PopulateAlternativeTitles(List resources) - { - foreach (var resource in resources) - { - PopulateAlternativeTitles(resource); - } - } - - private void PopulateAlternativeTitles(SeriesResource resource) - { - var mapping = _sceneMappingService.FindByTvdbid(resource.TvdbId); - if (mapping == null) return; - resource.AlternativeTitles = mapping.Select(x => x.Title).Distinct().ToList(); - } - private SeriesResource GetSeries(int id) { var series = _seriesService.GetSeries(id); @@ -106,7 +92,7 @@ namespace NzbDrone.Api.Series var resource = series.InjectTo(); MapCoversToLocal(resource); FetchAndLinkSeriesStatistics(resource); - PopulateAlternativeTitles(resource); + PopulateAlternateTitles(resource); return resource; } @@ -118,7 +104,7 @@ namespace NzbDrone.Api.Series MapCoversToLocal(seriesResources.ToArray()); LinkSeriesStatistics(seriesResources, seriesStats); - PopulateAlternativeTitles(seriesResources); + PopulateAlternateTitles(seriesResources); return seriesResources; } @@ -177,6 +163,24 @@ namespace NzbDrone.Api.Series resource.EpisodeCount = seriesStatistics.EpisodeCount; resource.EpisodeFileCount = seriesStatistics.EpisodeFileCount; resource.NextAiring = seriesStatistics.NextAiring; + resource.PreviousAiring = seriesStatistics.PreviousAiring; + } + + private void PopulateAlternateTitles(List resources) + { + foreach (var resource in resources) + { + PopulateAlternateTitles(resource); + } + } + + private void PopulateAlternateTitles(SeriesResource resource) + { + var mappings = _sceneMappingService.FindByTvdbid(resource.TvdbId); + + if (mappings == null) return; + + resource.AlternateTitles = mappings.InjectTo>(); } public void Handle(EpisodeImportedEvent message) diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index 6fdff13df..b6b980b03 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using NzbDrone.Api.REST; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Tv; @@ -15,7 +16,8 @@ namespace NzbDrone.Api.Series //View Only public String Title { get; set; } - public List AlternativeTitles { get; set; } + public List AlternateTitles { get; set; } + public String SortTitle { get; set; } public Int32 SeasonCount { @@ -33,12 +35,13 @@ namespace NzbDrone.Api.Series public String QualityProfileName { get; set; } public String Overview { get; set; } public DateTime? NextAiring { get; set; } + public DateTime? PreviousAiring { get; set; } public String Network { get; set; } public String AirTime { get; set; } public List Images { get; set; } public String RemotePoster { get; set; } - public List Seasons { get; set; } + public List Seasons { get; set; } public Int32 Year { get; set; } //View & Edit diff --git a/src/NzbDrone.Api/System/Backup/BackupModule.cs b/src/NzbDrone.Api/System/Backup/BackupModule.cs new file mode 100644 index 000000000..b5074793e --- /dev/null +++ b/src/NzbDrone.Api/System/Backup/BackupModule.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NzbDrone.Core.Backup; + +namespace NzbDrone.Api.System.Backup +{ + public class BackupModule : NzbDroneRestModule + { + private readonly IBackupService _backupService; + + public BackupModule(IBackupService backupService) : base("system/backup") + { + _backupService = backupService; + GetResourceAll = GetBackupFiles; + } + + public List GetBackupFiles() + { + var backups = _backupService.GetBackups(); + + return backups.Select(b => new BackupResource + { + Id = b.Path.GetHashCode(), + Name = Path.GetFileName(b.Path), + Path = b.Path, + Type = b.Type, + Time = b.Time + }).ToList(); + } + } +} diff --git a/src/NzbDrone.Api/System/Backup/BackupResource.cs b/src/NzbDrone.Api/System/Backup/BackupResource.cs new file mode 100644 index 000000000..732eee3c0 --- /dev/null +++ b/src/NzbDrone.Api/System/Backup/BackupResource.cs @@ -0,0 +1,14 @@ +using System; +using NzbDrone.Api.REST; +using NzbDrone.Core.Backup; + +namespace NzbDrone.Api.System.Backup +{ + public class BackupResource : RestResource + { + public String Name { get; set; } + public String Path { get; set; } + public BackupType Type { get; set; } + public DateTime Time { get; set; } + } +} diff --git a/src/NzbDrone.Api/Wanted/CutoffModule.cs b/src/NzbDrone.Api/Wanted/CutoffModule.cs index da2e44ef7..384ee43ac 100644 --- a/src/NzbDrone.Api/Wanted/CutoffModule.cs +++ b/src/NzbDrone.Api/Wanted/CutoffModule.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using NzbDrone.Api.Episodes; using NzbDrone.Api.Extensions; using NzbDrone.Core.Datastore; diff --git a/src/NzbDrone.Api/Wanted/MissingModule.cs b/src/NzbDrone.Api/Wanted/MissingModule.cs index 14fef9104..f551cdd67 100644 --- a/src/NzbDrone.Api/Wanted/MissingModule.cs +++ b/src/NzbDrone.Api/Wanted/MissingModule.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using NzbDrone.Api.Episodes; using NzbDrone.Api.Extensions; using NzbDrone.Core.Datastore; diff --git a/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs index cb6a4605b..86a324eef 100644 --- a/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("b47d34ef-05e8-4826-8a57-9dd05106c964")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs index 849fe1d0c..a5d255084 100644 --- a/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("6b8945f5-f5b5-4729-865d-f958fbd673d9")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs b/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs index 5af9890ba..493914c12 100644 --- a/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs +++ b/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs @@ -172,7 +172,7 @@ namespace NzbDrone.Common.Test.DiskProviderTests public void empty_folder_should_return_folder_modified_date() { var tempfolder = new DirectoryInfo(TempFolder); - Subject.FolderGetLastWrite(TempFolder).Should().Be(tempfolder.LastWriteTimeUtc); + Subject.FolderGetLastWriteUtc(TempFolder).Should().Be(tempfolder.LastWriteTimeUtc); } [Test] @@ -189,8 +189,8 @@ namespace NzbDrone.Common.Test.DiskProviderTests Subject.WriteAllText(testFile, "Test"); - Subject.FolderGetLastWrite(TempFolder).Should().BeOnOrAfter(DateTime.UtcNow.AddMinutes(-1)); - Subject.FolderGetLastWrite(TempFolder).Should().BeBefore(DateTime.UtcNow.AddMinutes(1)); + Subject.FolderGetLastWriteUtc(TempFolder).Should().BeOnOrAfter(DateTime.UtcNow.AddMinutes(-1)); + Subject.FolderGetLastWriteUtc(TempFolder).Should().BeBefore(DateTime.UtcNow.AddMinutes(1)); } [Test] @@ -238,7 +238,7 @@ namespace NzbDrone.Common.Test.DiskProviderTests [Explicit] public void check_last_write() { - Console.WriteLine(Subject.FolderGetLastWrite(GetFilledTempFolder().FullName)); + Console.WriteLine(Subject.FolderGetLastWriteUtc(GetFilledTempFolder().FullName)); Console.WriteLine(GetFilledTempFolder().LastWriteTimeUtc); } diff --git a/src/NzbDrone.Common.Test/DiskProviderTests/FreeSpaceFixtureBase.cs b/src/NzbDrone.Common.Test/DiskProviderTests/FreeSpaceFixtureBase.cs index 098456276..d59cb37e5 100644 --- a/src/NzbDrone.Common.Test/DiskProviderTests/FreeSpaceFixtureBase.cs +++ b/src/NzbDrone.Common.Test/DiskProviderTests/FreeSpaceFixtureBase.cs @@ -26,14 +26,6 @@ namespace NzbDrone.Common.Test.DiskProviderTests Subject.GetAvailableSpace(Path.Combine(path, "invalidFolder")).Should().NotBe(0); } - [Test] - public void should_get_free_space_for_drive_that_doesnt_exist() - { - WindowsOnly(); - - Assert.Throws(() => Subject.GetAvailableSpace("J:\\").Should().NotBe(0)); - } - [Test] public void should_be_able_to_check_space_on_ramdrive() { diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs new file mode 100644 index 000000000..e78d7b70f --- /dev/null +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Text; +using System.Collections.Generic; +using NUnit.Framework; +using NzbDrone.Common.Instrumentation; +using FluentAssertions; + +namespace NzbDrone.Common.Test.InstrumentationTests +{ + [TestFixture] + public class CleanseLogMessageFixture + { + [TestCase(@"http://127.0.0.1:1234/api/call?vv=1&apikey=mySecret")] + [TestCase(@"http://127.0.0.1:1234/api/call?vv=1&ma_username=mySecret&ma_password=mySecret")] + // NzbGet + [TestCase(@"{ ""Name"" : ""ControlUsername"", ""Value"" : ""mySecret"" }, { ""Name"" : ""ControlPassword"", ""Value"" : ""mySecret"" }, ")] + [TestCase(@"{ ""Name"" : ""Server1.Username"", ""Value"" : ""mySecret"" }, { ""Name"" : ""Server1.Password"", ""Value"" : ""mySecret"" }, ")] + // Sabnzbd + [TestCase(@"""config"":{""newzbin"":{""username"":""mySecret"",""password"":""mySecret""}")] + [TestCase(@"""nzbxxx"":{""username"":""mySecret"",""apikey"":""mySecret""}")] + [TestCase(@"""growl"":{""growl_password"":""mySecret"",""growl_server"":""""}")] + [TestCase(@"""nzbmatrix"":{""username"":""mySecret"",""apikey"":""mySecret""}")] + [TestCase(@"""misc"":{""username"":""mySecret"",""api_key"":""mySecret"",""password"":""mySecret"",""nzb_key"":""mySecret""}")] + [TestCase(@"""servers"":[{""username"":""mySecret"",""password"":""mySecret""}]")] + [TestCase(@"""misc"":{""email_account"":""mySecret"",""email_to"":[],""email_from"":"""",""email_pwd"":""mySecret""}")] + public void should_clean_message(String message) + { + var cleansedMessage = CleanseLogMessage.Cleanse(message); + + cleansedMessage.Should().NotContain("mySecret"); + } + } +} diff --git a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj index e7f6a681f..5b9259033 100644 --- a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj +++ b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj @@ -67,6 +67,7 @@ + diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index 4b8c355dd..734b15e13 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -136,6 +136,23 @@ namespace NzbDrone.Common.Test parentPath.IsParentPath(childPath).Should().Be(expectedResult); } + [TestCase(@"C:\Test\mydir", @"C:\Test")] + [TestCase(@"C:\Test\", @"C:")] + [TestCase(@"C:\", null)] + public void path_should_return_parent(string path, string parentPath) + { + path.GetParentPath().Should().Be(parentPath); + } + + [Test] + public void path_should_return_parent_for_oversized_path() + { + var path = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories"; + var parentPath = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing"; + + path.GetParentPath().Should().Be(parentPath); + } + [Test] [Ignore] public void should_not_be_parent_when_it_is_grandparent() diff --git a/src/NzbDrone.Common/ArchiveProvider.cs b/src/NzbDrone.Common/ArchiveService.cs similarity index 86% rename from src/NzbDrone.Common/ArchiveProvider.cs rename to src/NzbDrone.Common/ArchiveService.cs index 5d7d644d5..1899d8f3d 100644 --- a/src/NzbDrone.Common/ArchiveProvider.cs +++ b/src/NzbDrone.Common/ArchiveService.cs @@ -12,6 +12,8 @@ namespace NzbDrone.Common public interface IArchiveService { void Extract(string compressedFile, string destination); + void ExtractZip(string compressedFile, string destination); + void CreateZip(string path, params string[] files); } public class ArchiveService : IArchiveService @@ -40,7 +42,22 @@ namespace NzbDrone.Common _logger.Debug("Extraction complete."); } - private void ExtractZip(string compressedFile, string destination) + public void CreateZip(string path, params string[] files) + { + using (var zipFile = ZipFile.Create(path)) + { + zipFile.BeginUpdate(); + + foreach (var file in files) + { + zipFile.Add(file, Path.GetFileName(file)); + } + + zipFile.CommitUpdate(); + } + } + + public void ExtractZip(string compressedFile, string destination) { using (var fileStream = File.OpenRead(compressedFile)) { diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index c9de8d11c..540c694cd 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -33,7 +33,7 @@ namespace NzbDrone.Common.Disk return new DirectoryInfo(path).CreationTimeUtc; } - public DateTime FolderGetLastWrite(string path) + public DateTime FolderGetLastWriteUtc(string path) { CheckFolderExists(path); diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index afbd7ce60..473a0211d 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Common.Disk void SetPermissions(string path, string mask, string user, string group); long? GetTotalSize(string path); DateTime FolderGetCreationTimeUtc(string path); - DateTime FolderGetLastWrite(string path); + DateTime FolderGetLastWriteUtc(string path); DateTime FileGetCreationTimeUtc(string path); DateTime FileGetLastWrite(string path); DateTime FileGetLastWriteUtc(string path); diff --git a/src/NzbDrone.Common/IEnumerableExtensions.cs b/src/NzbDrone.Common/IEnumerableExtensions.cs index e4d3f5bfe..4ccbcae6d 100644 --- a/src/NzbDrone.Common/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/IEnumerableExtensions.cs @@ -22,5 +22,10 @@ namespace NzbDrone.Common source.Add(item); } + + public static bool Empty(this IEnumerable source) + { + return !source.Any(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs index 8c5096976..9befc8f1f 100644 --- a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs +++ b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs @@ -4,8 +4,21 @@ namespace NzbDrone.Common.Instrumentation { public class CleanseLogMessage { - //TODO: remove password= - private static readonly Regex CleansingRegex = new Regex(@"(?<=apikey=)(\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex[] CleansingRules = new[] + { + // Url + new Regex(@"(<=\?|&)apikey=(?\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"(<=\?|&)[^=]*?(username|password)=(?\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + + // NzbGet + new Regex(@"""Name""\s*:\s*""[^""]*(username|password)""\s*,\s*""Value""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + + // Sabnzbd + new Regex(@"""[^""]*(username|password|api_?key|nzb_key)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"""email_(account|to|from|pwd)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase) + }; + + //private static readonly Regex CleansingRegex = new Regex(@"(?<=apikey=)(\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static string Cleanse(string message) { @@ -14,7 +27,12 @@ namespace NzbDrone.Common.Instrumentation return message; } - return CleansingRegex.Replace(message, ""); + foreach (var regex in CleansingRules) + { + message = regex.Replace(message, m => m.Value.Replace(m.Groups["secret"].Index - m.Index, m.Groups["secret"].Length, "")); + } + + return message; } } } diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index bab538ec7..c5ebd8d90 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -43,6 +43,10 @@ ..\packages\SharpZipLib.0.86.0\lib\20\ICSharpCode.SharpZipLib.dll + + False + ..\packages\RestSharp.104.4.0\lib\net4\RestSharp.dll + @@ -59,8 +63,8 @@ - + diff --git a/src/NzbDrone.Common/PathExtensions.cs b/src/NzbDrone.Common/PathExtensions.cs index 7598f84f8..b99d39a69 100644 --- a/src/NzbDrone.Common/PathExtensions.cs +++ b/src/NzbDrone.Common/PathExtensions.cs @@ -11,9 +11,9 @@ namespace NzbDrone.Common private const string APP_CONFIG_FILE = "config.xml"; private const string NZBDRONE_DB = "nzbdrone.db"; private const string NZBDRONE_LOG_DB = "logs.db"; - private const string BACKUP_ZIP_FILE = "NzbDrone_Backup.zip"; private const string NLOG_CONFIG_FILE = "nlog.config"; private const string UPDATE_CLIENT_EXE = "NzbDrone.Update.exe"; + private const string BACKUP_FOLDER = "Backups"; private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "nzbdrone_update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "NzbDrone" + Path.DirectorySeparatorChar; @@ -53,6 +53,22 @@ namespace NzbDrone.Common return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar); } + public static string GetParentPath(this string childPath) + { + var parentPath = childPath.TrimEnd('\\', '/'); + + var index = parentPath.LastIndexOfAny(new[] { '\\', '/' }); + + if (index != -1) + { + return parentPath.Substring(0, index); + } + else + { + return null; + } + } + public static bool IsParentPath(this string parentPath, string childPath) { parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar); @@ -210,9 +226,9 @@ namespace NzbDrone.Common return Path.Combine(GetUpdateSandboxFolder(appFolderInfo), UPDATE_CLIENT_EXE); } - public static string GetConfigBackupFile(this IAppFolderInfo appFolderInfo) + public static string GetBackupFolder(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_ZIP_FILE); + return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_FOLDER); } public static string GetNzbDroneDatabase(this IAppFolderInfo appFolderInfo) diff --git a/src/NzbDrone.Common/Properties/AssemblyInfo.cs b/src/NzbDrone.Common/Properties/AssemblyInfo.cs index e9f498cc8..e8cdf90c1 100644 --- a/src/NzbDrone.Common/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Common/Properties/AssemblyInfo.cs @@ -9,8 +9,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("b6eaa144-e13b-42e5-a738-c60d89c0f728")] -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: - [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs index 898d45119..a9ea86359 100644 --- a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs +++ b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs @@ -15,16 +15,16 @@ namespace NzbDrone.Common.Reflection return properties.Where(c => c.PropertyType.IsSimpleType()).ToList(); } - public static List ImplementationsOf(this Assembly assembly) { return assembly.GetTypes().Where(c => typeof(T).IsAssignableFrom(c)).ToList(); } - public static bool IsSimpleType(this Type type) { - if (type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(Nullable<>) || type.GetGenericTypeDefinition() == typeof(List<>))) + if (type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(Nullable<>) || + type.GetGenericTypeDefinition() == typeof(List<>) || + type.GetGenericTypeDefinition() == typeof(IEnumerable<>))) { type = type.GetGenericArguments()[0]; } diff --git a/src/NzbDrone.Common/Serializer/Json.cs b/src/NzbDrone.Common/Serializer/Json.cs index 1abb2019b..0c52f849c 100644 --- a/src/NzbDrone.Common/Serializer/Json.cs +++ b/src/NzbDrone.Common/Serializer/Json.cs @@ -48,12 +48,12 @@ namespace NzbDrone.Common.Serializer result = Deserialize(json); return true; } - catch (JsonReaderException ex) + catch (JsonReaderException) { result = default(T); return false; } - catch (JsonSerializationException ex) + catch (JsonSerializationException) { result = default(T); return false; diff --git a/src/NzbDrone.Common/StringExtensions.cs b/src/NzbDrone.Common/StringExtensions.cs index 77a6c9532..cb0137bed 100644 --- a/src/NzbDrone.Common/StringExtensions.cs +++ b/src/NzbDrone.Common/StringExtensions.cs @@ -32,6 +32,13 @@ namespace NzbDrone.Common private static readonly Regex CollapseSpace = new Regex(@"\s+", RegexOptions.Compiled); + public static string Replace(this string text, int index, int length, string replacement) + { + text = text.Remove(index, length); + text = text.Insert(index, replacement); + return text; + } + public static string RemoveAccent(this string text) { var normalizedString = text.Normalize(NormalizationForm.FormD); diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config index 4e193d0ba..67af54535 100644 --- a/src/NzbDrone.Common/packages.config +++ b/src/NzbDrone.Common/packages.config @@ -3,5 +3,6 @@ + \ No newline at end of file diff --git a/src/NzbDrone.Console/Properties/AssemblyInfo.cs b/src/NzbDrone.Console/Properties/AssemblyInfo.cs index 32f30358f..ed519f028 100644 --- a/src/NzbDrone.Console/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Console/Properties/AssemblyInfo.cs @@ -8,8 +8,4 @@ using System.Runtime.InteropServices; [assembly: AssemblyTitle("NzbDrone.Host")] [assembly: Guid("67AADCD9-89AA-4D95-8281-3193740E70E5")] -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: - -[assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] \ No newline at end of file +[assembly: AssemblyVersion("10.0.0.*")] \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DataAugmentationFixture/Scene/SceneMappingServiceFixture.cs b/src/NzbDrone.Core.Test/DataAugmentationFixture/Scene/SceneMappingServiceFixture.cs index 66a5a900a..8faa4b997 100644 --- a/src/NzbDrone.Core.Test/DataAugmentationFixture/Scene/SceneMappingServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DataAugmentationFixture/Scene/SceneMappingServiceFixture.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Net; using FizzWare.NBuilder; using Moq; @@ -14,9 +16,11 @@ namespace NzbDrone.Core.Test.DataAugmentationFixture.Scene public class SceneMappingServiceFixture : CoreTest { - private List _fakeMappings; + private Mock _provider1; + private Mock _provider2; + [SetUp] public void Setup() { @@ -33,14 +37,24 @@ namespace NzbDrone.Core.Test.DataAugmentationFixture.Scene _fakeMappings[2].ParseTerm = "Can"; _fakeMappings[3].ParseTerm = "Be"; _fakeMappings[4].ParseTerm = "Cleaned"; + + _provider1 = new Mock(); + _provider1.Setup(s => s.GetSceneMappings()).Returns(_fakeMappings); + + _provider2 = new Mock(); + _provider2.Setup(s => s.GetSceneMappings()).Returns(_fakeMappings); } - + private void GivenProviders(IEnumerable> providers) + { + Mocker.SetConstant>(providers.Select(s => s.Object)); + } [Test] - public void UpdateMappings_purge_existing_mapping_and_add_new_ones() + public void should_purge_existing_mapping_and_add_new_ones() { - Mocker.GetMock().Setup(c => c.Fetch()).Returns(_fakeMappings); + GivenProviders(new [] { _provider1 }); + Mocker.GetMock().Setup(c => c.All()).Returns(_fakeMappings); Subject.Execute(new UpdateSceneMappingCommand()); @@ -48,27 +62,26 @@ namespace NzbDrone.Core.Test.DataAugmentationFixture.Scene AssertMappingUpdated(); } - - [Test] - public void UpdateMappings_should_not_delete_if_fetch_fails() + public void should_not_delete_if_fetch_fails() { + GivenProviders(new[] { _provider1 }); - Mocker.GetMock().Setup(c => c.Fetch()).Throws(new WebException()); + _provider1.Setup(c => c.GetSceneMappings()).Throws(new WebException()); Subject.Execute(new UpdateSceneMappingCommand()); AssertNoUpdate(); ExceptionVerification.ExpectedErrors(1); - } [Test] - public void UpdateMappings_should_not_delete_if_fetch_returns_empty_list() + public void should_not_delete_if_fetch_returns_empty_list() { + GivenProviders(new[] { _provider1 }); - Mocker.GetMock().Setup(c => c.Fetch()).Returns(new List()); + _provider1.Setup(c => c.GetSceneMappings()).Returns(new List()); Subject.Execute(new UpdateSceneMappingCommand()); @@ -77,28 +90,37 @@ namespace NzbDrone.Core.Test.DataAugmentationFixture.Scene ExceptionVerification.ExpectedWarns(1); } + [Test] + public void should_get_mappings_for_all_providers() + { + GivenProviders(new[] { _provider1, _provider2 }); + + Mocker.GetMock().Setup(c => c.All()).Returns(_fakeMappings); + + Subject.Execute(new UpdateSceneMappingCommand()); + + _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); + _provider2.Verify(c => c.GetSceneMappings(), Times.Once()); + } + private void AssertNoUpdate() { - Mocker.GetMock().Verify(c => c.Fetch(), Times.Once()); - Mocker.GetMock().Verify(c => c.Purge(It.IsAny()), Times.Never()); + _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); + Mocker.GetMock().Verify(c => c.Clear(It.IsAny()), Times.Never()); Mocker.GetMock().Verify(c => c.InsertMany(_fakeMappings), Times.Never()); } private void AssertMappingUpdated() { - Mocker.GetMock().Verify(c => c.Fetch(), Times.Once()); - Mocker.GetMock().Verify(c => c.Purge(It.IsAny()), Times.Once()); + _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); + Mocker.GetMock().Verify(c => c.Clear(It.IsAny()), Times.Once()); Mocker.GetMock().Verify(c => c.InsertMany(_fakeMappings), Times.Once()); - foreach (var sceneMapping in _fakeMappings) { - Subject.GetSceneName(sceneMapping.TvdbId).Should().Be(sceneMapping.SearchTerm); + Subject.GetSceneNames(sceneMapping.TvdbId, _fakeMappings.Select(m => m.SeasonNumber)).Should().Contain(sceneMapping.SearchTerm); Subject.GetTvDbId(sceneMapping.ParseTerm).Should().Be(sceneMapping.TvdbId); } } - - - } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs index a41d36669..ff0457b43 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs @@ -54,26 +54,30 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void GivenEmptyQueue() { - Mocker.GetMock() - .Setup(s => s.GetQueue()) - .Returns(new List()); + Mocker.GetMock() + .Setup(s => s.GetQueuedDownloads()) + .Returns(new TrackedDownload[0]); } - private void GivenQueue(IEnumerable remoteEpisodes) + private void GivenQueue(IEnumerable remoteEpisodes, TrackedDownloadState state = TrackedDownloadState.Downloading) { - var queue = new List(); + var queue = new List(); foreach (var remoteEpisode in remoteEpisodes) { - queue.Add(new Queue.Queue + queue.Add(new TrackedDownload { - RemoteEpisode = remoteEpisode - }); + State = state, + DownloadItem = new DownloadClientItem + { + RemoteEpisode = remoteEpisode + } + }); } - Mocker.GetMock() - .Setup(s => s.GetQueue()) - .Returns(queue); + Mocker.GetMock() + .Setup(s => s.GetQueuedDownloads()) + .Returns(queue.ToArray()); } [Test] @@ -95,6 +99,23 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(_remoteEpisode, null).Should().BeTrue(); } + [Test] + public void should_return_true_when_download_is_failed() + { + var remoteEpisode = Builder.CreateNew() + .With(r => r.Series = _series) + .With(r => r.Episodes = new List { _episode }) + .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo + { + Quality = new QualityModel(Quality.DVD) + }) + .Build(); + + GivenQueue(new List { remoteEpisode }, TrackedDownloadState.DownloadFailed); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Should().BeTrue(); + } + [Test] public void should_return_true_when_quality_in_queue_is_lower() { diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/GetQualifiedReportsFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs similarity index 87% rename from src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/GetQualifiedReportsFixture.cs rename to src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 4c8aafaa4..a4c62160d 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/GetQualifiedReportsFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -1,20 +1,21 @@ using System; using System.Collections.Generic; using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine.Specifications; -using NzbDrone.Core.Download; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; +using NUnit.Framework; +using FluentAssertions; +using FizzWare.NBuilder; +using NzbDrone.Core.Test.Framework; -namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests +namespace NzbDrone.Core.Test.DecisionEngineTests { [TestFixture] - public class DownloadApprovedReportsFixture : CoreTest + public class PrioritizeDownloadDecisionFixture : CoreTest { private Episode GetEpisode(int id) { @@ -44,16 +45,6 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests return remoteEpisode; } - [Test] - public void should_return_an_empty_list_when_none_are_appproved() - { - var decisions = new List(); - decisions.Add(new DownloadDecision(null, "Failure!")); - decisions.Add(new DownloadDecision(null, "Failure!")); - - Subject.GetQualifiedReports(decisions).Should().BeEmpty(); - } - [Test] public void should_put_propers_before_non_propers() { @@ -64,7 +55,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode2)); - var qualifiedReports = Subject.GetQualifiedReports(decisions); + var qualifiedReports = Subject.PrioritizeDecisions(decisions); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Proper.Should().BeTrue(); } @@ -78,7 +69,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode2)); - var qualifiedReports = Subject.GetQualifiedReports(decisions); + var qualifiedReports = Subject.PrioritizeDecisions(decisions); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Quality.Should().Be(Quality.HDTV720p); } @@ -92,7 +83,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode2)); - var qualifiedReports = Subject.GetQualifiedReports(decisions); + var qualifiedReports = Subject.PrioritizeDecisions(decisions); qualifiedReports.First().RemoteEpisode.Episodes.First().EpisodeNumber.Should().Be(1); } @@ -106,7 +97,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode2)); - var qualifiedReports = Subject.GetQualifiedReports(decisions); + var qualifiedReports = Subject.PrioritizeDecisions(decisions); qualifiedReports.First().RemoteEpisode.Episodes.First().EpisodeNumber.Should().Be(1); } @@ -125,7 +116,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisodeHdSmallYounge)); decisions.Add(new DownloadDecision(remoteEpisodeHdLargeYounge)); - var qualifiedReports = Subject.GetQualifiedReports(decisions); + var qualifiedReports = Subject.PrioritizeDecisions(decisions); qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisodeHdSmallYounge); } @@ -140,7 +131,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode2)); - var qualifiedReports = Subject.GetQualifiedReports(decisions); + var qualifiedReports = Subject.PrioritizeDecisions(decisions); qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisode2); } } diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs index 0d7b6d1e9..043aa6f55 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Test.Common; using NzbDrone.Core.Tv; using NzbDrone.Core.Parser.Model; @@ -103,14 +104,20 @@ namespace NzbDrone.Core.Test.Download { Mocker.GetMock() .Setup(v => v.ProcessFolder(It.IsAny(), It.IsAny())) - .Returns(new List() { new Core.MediaFiles.EpisodeImport.ImportDecision(null) }); + .Returns(new List() + { + new ImportDecision(null) + }); } private void GivenFailedImport() { Mocker.GetMock() .Setup(v => v.ProcessFolder(It.IsAny(), It.IsAny())) - .Returns(new List()); + .Returns(new List() + { + new ImportDecision(new LocalEpisode() { Path = @"C:\TestPath\Droned.S01E01.mkv" }, "Test Failure") + }); } private void VerifyNoImports() @@ -265,6 +272,8 @@ namespace NzbDrone.Core.Test.Download Subject.Execute(new CheckForFinishedDownloadCommand()); VerifyNoImports(); + + ExceptionVerification.IgnoreErrors(); } [Test] @@ -289,6 +298,8 @@ namespace NzbDrone.Core.Test.Download Subject.Execute(new CheckForFinishedDownloadCommand()); VerifyNoImports(); + + ExceptionVerification.IgnoreWarns(); } [Test] @@ -412,6 +423,8 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Verify(c => c.DeleteFolder(It.IsAny(), true), Times.Never()); + + ExceptionVerification.IgnoreErrors(); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs index 0563db94e..ed80e2c3c 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs @@ -11,12 +11,21 @@ using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; +using NzbDrone.Core.DecisionEngine; namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests { [TestFixture] public class DownloadApprovedFixture : CoreTest { + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(v => v.PrioritizeDecisions(It.IsAny>())) + .Returns>(v => v); + } + private Episode GetEpisode(int id) { return Builder.CreateNew() @@ -163,5 +172,15 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests Subject.DownloadApproved(decisions).Should().BeEmpty(); ExceptionVerification.ExpectedWarns(1); } + + [Test] + public void should_return_an_empty_list_when_none_are_appproved() + { + var decisions = new List(); + decisions.Add(new DownloadDecision(null, "Failure!")); + decisions.Add(new DownloadDecision(null, "Failure!")); + + Subject.GetQualifiedReports(decisions).Should().BeEmpty(); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs index 0dffee397..15e90536a 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs @@ -1,17 +1,18 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common; +using NzbDrone.Core.Tv; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Nzbget; using NzbDrone.Core.Parser.Model; +using NzbDrone.Test.Common; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using System.Collections.Generic; namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { @@ -28,7 +29,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new NzbgetSettings { - Host = "192.168.5.55", + Host = "127.0.0.1", Port = 2222, Username = "admin", Password = "pass", @@ -65,7 +66,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests FileSizeLo = 1000, Category = "tv", Name = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", - DestDir = "somedirectory", + DestDir = "/remote/mount/tv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", Parameters = new List { new NzbgetParameter { Name = "drone", Value = "id" } }, ParStatus = "SUCCESS", UnpackStatus = "NONE", @@ -81,6 +82,19 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { DownloadRate = 7000000 }); + + var configItems = new Dictionary(); + configItems.Add("Category1.Name", "tv"); + configItems.Add("Category1.DestDir", @"/remote/mount/tv"); + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(configItems); + } + + protected void WithMountPoint(String mountPath) + { + (Subject.Definition.Settings as NzbgetSettings).TvCategoryLocalPath = mountPath; } protected void WithFailedDownload() @@ -223,5 +237,40 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests items.Should().BeEmpty(); } + + [Test] + public void should_return_status_with_outputdir() + { + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"/remote/mount/tv"); + } + + [Test] + public void should_return_status_with_mounted_outputdir() + { + WithMountPoint(@"O:\mymount".AsOsAgnostic()); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"O:\mymount".AsOsAgnostic()); + } + + [Test] + public void should_remap_storage_if_mounted() + { + WithMountPoint(@"O:\mymount".AsOsAgnostic()); + + WithQueue(null); + WithHistory(_completed); + + var result = Subject.GetItems().Single(); + + result.OutputPath.Should().Be(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs index 3fd83618e..8e73da554 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests private SabnzbdQueue _queued; private SabnzbdHistory _failed; private SabnzbdHistory _completed; + private SabnzbdConfig _config; [SetUp] public void Setup() @@ -30,7 +31,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new SabnzbdSettings { - Host = "192.168.5.55", + Host = "127.0.0.1", Port = 2222, ApiKey = "5c770e3197e4fe763423ee7c392c25d1", Username = "admin", @@ -40,6 +41,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests }; _queued = new SabnzbdQueue { + DefaultRootFolder = @"Y:\nzbget\root".AsOsAgnostic(), Paused = false, Items = new List() { @@ -82,10 +84,31 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests Category = "tv", Id = "sabnzbd_nzb12345", Title = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", - Storage = "somedirectory" + Storage = "/remote/mount/vv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE" } } }; + + _config = new SabnzbdConfig + { + Misc = new SabnzbdConfigMisc + { + complete_dir = @"/remote/mount" + }, + Categories = new List + { + new SabnzbdCategory { Name = "tv", Dir = "vv" } + } + }; + + Mocker.GetMock() + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(_config); + } + + protected void WithMountPoint(String mountPath) + { + (Subject.Definition.Settings as SabnzbdSettings).TvCategoryLocalPath = mountPath; } protected void WithFailedDownload() @@ -110,7 +133,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { if (queue == null) { - queue = new SabnzbdQueue() { Items = new List() }; + queue = new SabnzbdQueue() + { + DefaultRootFolder = _queued.DefaultRootFolder, + Items = new List() + }; } Mocker.GetMock() @@ -256,17 +283,34 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests .Verify(v => v.DownloadNzb(It.IsAny(), It.IsAny(), It.IsAny(), (int)SabnzbdPriority.High, It.IsAny()), Times.Once()); } - [Test] - public void should_return_path_to_folder_instead_of_file() + [TestCase(@"Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", @"Droned.S01E01_Pilot_1080p_WEB-DL-DRONE.mkv")] + [TestCase(@"Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", @"SubDir\Droned.S01E01_Pilot_1080p_WEB-DL-DRONE.mkv")] + [TestCase(@"Droned.S01E01.Pilot.1080p.WEB-DL-DRONE.mkv", @"SubDir\Droned.S01E01_Pilot_1080p_WEB-DL-DRONE.mkv")] + [TestCase(@"Droned.S01E01.Pilot.1080p.WEB-DL-DRONE.mkv", @"SubDir\SubDir\Droned.S01E01_Pilot_1080p_WEB-DL-DRONE.mkv")] + public void should_return_path_to_jobfolder(String title, String storage) { - _completed.Items.First().Storage = @"C:\sorted\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE\Droned.S01E01_Pilot_1080p_WEB-DL-DRONE.mkv".AsOsAgnostic(); + _completed.Items.First().Title = title; + _completed.Items.First().Storage = (@"C:\sorted\" + title + @"\" + storage).AsOsAgnostic(); WithQueue(null); WithHistory(_completed); - + var result = Subject.GetItems().Single(); - result.OutputPath.Should().Be(@"C:\sorted\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); + result.OutputPath.Should().Be((@"C:\sorted\" + title).AsOsAgnostic()); + } + + [Test] + public void should_remap_storage_if_mounted() + { + WithMountPoint(@"O:\mymount".AsOsAgnostic()); + + WithQueue(null); + WithHistory(_completed); + + var result = Subject.GetItems().Single(); + + result.OutputPath.Should().Be(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); } [Test] @@ -281,5 +325,51 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests result.OutputPath.Should().Be(@"C:\".AsOsAgnostic()); } + + [Test] + public void should_not_blow_up_if_storage_doesnt_have_jobfolder() + { + _completed.Items.First().Storage = @"C:\sorted\somewhere\asdfasdf\asdfasdf.mkv".AsOsAgnostic(); + + WithQueue(null); + WithHistory(_completed); + + var result = Subject.GetItems().Single(); + + result.OutputPath.Should().Be(@"C:\sorted\somewhere\asdfasdf\asdfasdf.mkv".AsOsAgnostic()); + } + + [TestCase(@"Y:\nzbget\root", @"completed\downloads", @"vv", @"Y:\nzbget\root\completed\downloads\vv")] + [TestCase(@"Y:\nzbget\root", @"completed", @"vv", @"Y:\nzbget\root\completed\vv")] + [TestCase(@"/nzbget/root", @"completed/downloads", @"vv", @"/nzbget/root/completed/downloads/vv")] + [TestCase(@"/nzbget/root", @"completed", @"vv", @"/nzbget/root/completed/vv")] + public void should_return_status_with_outputdir(String rootFolder, String completeDir, String categoryDir, String expectedDir) + { + _queued.DefaultRootFolder = rootFolder; + _config.Misc.complete_dir = completeDir; + _config.Categories.First().Dir = categoryDir; + + WithQueue(null); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(expectedDir); + } + + [Test] + public void should_return_status_with_mounted_outputdir() + { + WithMountPoint(@"O:\mymount".AsOsAgnostic()); + + WithQueue(null); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"O:\mymount".AsOsAgnostic()); + } } } diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs index 55d5bc25e..8a0bbf7da 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Processes; using NzbDrone.Core.HealthCheck.Checks; @@ -78,5 +77,30 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks Subject.Check().ShouldBeOk(); } + + [Test] + public void should_return_ok_when_mono_3_6_1() + { + GivenOutput("3.6.1"); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_ok_when_mono_3_6_1_with_custom_output() + { + Mocker.GetMock() + .Setup(s => s.StartAndCapture("mono", "--version")) + .Returns(new ProcessOutput + { + Standard = new List + { + "Mono JIT compiler version 3.6.1 (master/fce3972 Fri Jul 4 01:12:43 CEST 2014)", + "Copyright (C) 2002-2011 Novell, Inc, Xamarin, Inc and Contributors. www.mono-project.com" + } + }); + + Subject.Check().ShouldBeOk(); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs index d6780b56f..c0e7c0e1c 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs @@ -1,4 +1,5 @@ -using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.DataAugmentation.Scene; +using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Test.Framework; using FizzWare.NBuilder; using System; @@ -29,9 +30,9 @@ namespace NzbDrone.Core.Test.IndexerSearchTests .Setup(s => s.GetAvailableProviders()) .Returns(new List { indexer.Object }); - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.GetSearchDecision(It.IsAny>(), It.IsAny())) - .Returns(new List()); + .Returns(new List()); _xemSeries = Builder.CreateNew() .With(v => v.UseSceneNumbering = true) @@ -46,6 +47,10 @@ namespace NzbDrone.Core.Test.IndexerSearchTests Mocker.GetMock() .Setup(v => v.GetEpisodesBySeason(_xemSeries.Id, It.IsAny())) .Returns((i, j) => _xemEpisodes.Where(d => d.SeasonNumber == j).ToList()); + + Mocker.GetMock() + .Setup(s => s.GetSceneNames(It.IsAny(), It.IsAny>())) + .Returns(new List()); } private void WithEpisode(int seasonNumber, int episodeNumber, int sceneSeasonNumber, int sceneEpisodeNumber) @@ -90,7 +95,7 @@ namespace NzbDrone.Core.Test.IndexerSearchTests private List WatchForSearchCriteria() { - List result = new List(); + var result = new List(); Mocker.GetMock() .Setup(v => v.Fetch(It.IsAny(), It.IsAny())) @@ -102,6 +107,11 @@ namespace NzbDrone.Core.Test.IndexerSearchTests .Callback((i, s) => result.Add(s)) .Returns(new List()); + Mocker.GetMock() + .Setup(v => v.Fetch(It.IsAny(), It.IsAny())) + .Callback((i, s) => result.Add(s)) + .Returns(new List()); + return result; } @@ -186,5 +196,21 @@ namespace NzbDrone.Core.Test.IndexerSearchTests criteria.Count.Should().Be(1); criteria[0].SeasonNumber.Should().Be(7); } + + [Test] + public void season_search_for_anime_should_search_for_each_episode() + { + WithEpisodes(); + _xemSeries.SeriesType = SeriesTypes.Anime; + var seasonNumber = 1; + + var allCriteria = WatchForSearchCriteria(); + + Subject.SeasonSearch(_xemSeries.Id, seasonNumber); + + var criteria = allCriteria.OfType().ToList(); + + criteria.Count.Should().Be(_xemEpisodes.Count(e => e.SeasonNumber == seasonNumber)); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs index a03d62210..413e56512 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Test.Framework; @@ -12,8 +14,8 @@ namespace NzbDrone.Core.Test.IndexerSearchTests [TestCase("Franklin & Bash", Result = "Franklin+and+Bash")] public string should_replace_some_special_characters(string input) { - Subject.SceneTitle = input; - return Subject.QueryTitle; + Subject.SceneTitles = new List { input }; + return Subject.QueryTitles.First(); } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs index ae5915799..e27c02492 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs @@ -1,8 +1,6 @@ using System.Collections.Generic; -using System.Linq; using FizzWare.NBuilder; using FluentAssertions; -using Moq; using NUnit.Framework; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Newznab; @@ -22,7 +20,7 @@ namespace NzbDrone.Core.Test.IndexerTests { _indexers = new List(); - _indexers.Add(new Newznab()); + _indexers.Add(Mocker.GetMock().Object); _indexers.Add(new Omgwtfnzbs()); _indexers.Add(new Wombles()); diff --git a/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs index 74fda4de8..a66a687c6 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs @@ -4,7 +4,6 @@ using FizzWare.NBuilder; using FluentValidation.Results; using Moq; using NUnit.Framework; -using NzbDrone.Common; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch.Definitions; @@ -38,7 +37,7 @@ namespace NzbDrone.Core.Test.IndexerTests indexer.Setup(s => s.Parser.Process(It.IsAny(), It.IsAny())) .Returns(results); - indexer.Setup(s => s.GetSeasonSearchUrls(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + indexer.Setup(s => s.GetSeasonSearchUrls(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { "http://www.nzbdrone.com" }); indexer.SetupGet(s => s.SupportedPageSize).Returns(paging ? 100 : 0); @@ -56,7 +55,7 @@ namespace NzbDrone.Core.Test.IndexerTests public void should_not_use_offset_if_result_count_is_less_than_90() { var indexer = WithIndexer(true, 25); - Subject.Fetch(indexer, new SeasonSearchCriteria { Series = _series, SceneTitle = _series.Title }); + Subject.Fetch(indexer, new SeasonSearchCriteria { Series = _series, SceneTitles = new List{_series.Title} }); Mocker.GetMock().Verify(v => v.DownloadString(It.IsAny()), Times.Once()); } @@ -65,7 +64,7 @@ namespace NzbDrone.Core.Test.IndexerTests public void should_not_use_offset_for_sites_that_do_not_support_it() { var indexer = WithIndexer(false, 125); - Subject.Fetch(indexer, new SeasonSearchCriteria { Series = _series, SceneTitle = _series.Title }); + Subject.Fetch(indexer, new SeasonSearchCriteria { Series = _series, SceneTitles = new List { _series.Title } }); Mocker.GetMock().Verify(v => v.DownloadString(It.IsAny()), Times.Once()); } @@ -74,7 +73,7 @@ namespace NzbDrone.Core.Test.IndexerTests public void should_not_use_offset_if_its_already_tried_10_times() { var indexer = WithIndexer(true, 100); - Subject.Fetch(indexer, new SeasonSearchCriteria { Series = _series, SceneTitle = _series.Title }); + Subject.Fetch(indexer, new SeasonSearchCriteria { Series = _series, SceneTitles = new List { _series.Title } }); Mocker.GetMock().Verify(v => v.DownloadString(It.IsAny()), Times.Exactly(10)); } diff --git a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs index 41734daed..cdc09ed36 100644 --- a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs @@ -40,6 +40,7 @@ namespace NzbDrone.Core.Test.MetadataSourceTests [TestCase(75978)] [TestCase(83462)] + [TestCase(266189)] public void should_be_able_to_get_series_detail(int tvdbId) { var details = Subject.GetSeriesInfo(tvdbId); @@ -56,11 +57,20 @@ namespace NzbDrone.Core.Test.MetadataSourceTests ExceptionVerification.ExpectedWarns(1); } + [Test] + public void should_not_have_period_at_start_of_title_slug() + { + var details = Subject.GetSeriesInfo(79099); + + details.Item1.TitleSlug.Should().Be("dothack"); + } + private void ValidateSeries(Series series) { series.Should().NotBeNull(); series.Title.Should().NotBeBlank(); series.CleanTitle.Should().Be(Parser.Parser.CleanSeriesTitle(series.Title)); + series.SortTitle.Should().Be(Parser.Parser.NormalizeEpisodeTitle(series.Title)); series.Overview.Should().NotBeBlank(); series.AirTime.Should().NotBeBlank(); series.FirstAired.Should().HaveValue(); diff --git a/src/NzbDrone.Core.Test/MetadataSourceTests/TvdbProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSourceTests/TvdbProxyFixture.cs new file mode 100644 index 000000000..5d06260d0 --- /dev/null +++ b/src/NzbDrone.Core.Test/MetadataSourceTests/TvdbProxyFixture.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.MetadataSource.Tvdb; +using NzbDrone.Core.Rest; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; +using NzbDrone.Test.Common.Categories; + +namespace NzbDrone.Core.Test.MetadataSourceTests +{ + [TestFixture] + [IntegrationTest] + public class TvdbProxyFixture : CoreTest + { +// [TestCase("The Simpsons", "The Simpsons")] +// [TestCase("South Park", "South Park")] +// [TestCase("Franklin & Bash", "Franklin & Bash")] +// [TestCase("Mr. D", "Mr. D")] +// [TestCase("Rob & Big", "Rob and Big")] +// [TestCase("M*A*S*H", "M*A*S*H")] +// public void successful_search(string title, string expected) +// { +// var result = Subject.SearchForNewSeries(title); +// +// result.Should().NotBeEmpty(); +// +// result[0].Title.Should().Be(expected); +// } +// +// [Test] +// public void no_search_result() +// { +// var result = Subject.SearchForNewSeries(Guid.NewGuid().ToString()); +// result.Should().BeEmpty(); +// } + + [TestCase(88031)] + [TestCase(179321)] + public void should_be_able_to_get_series_detail(int tvdbId) + { + var details = Subject.GetSeriesInfo(tvdbId); + + //ValidateSeries(details.Item1); + ValidateEpisodes(details.Item2); + } + +// [Test] +// public void getting_details_of_invalid_series() +// { +// Assert.Throws(() => Subject.GetSeriesInfo(Int32.MaxValue)); +// +// ExceptionVerification.ExpectedWarns(1); +// } +// +// [Test] +// public void should_not_have_period_at_start_of_title_slug() +// { +// var details = Subject.GetSeriesInfo(79099); +// +// details.Item1.TitleSlug.Should().Be("dothack"); +// } + + private void ValidateSeries(Series series) + { + series.Should().NotBeNull(); + series.Title.Should().NotBeBlank(); + series.CleanTitle.Should().Be(Parser.Parser.CleanSeriesTitle(series.Title)); + series.Overview.Should().NotBeBlank(); + series.AirTime.Should().NotBeBlank(); + series.FirstAired.Should().HaveValue(); + series.FirstAired.Value.Kind.Should().Be(DateTimeKind.Utc); + series.Images.Should().NotBeEmpty(); + series.ImdbId.Should().NotBeBlank(); + series.Network.Should().NotBeBlank(); + series.Runtime.Should().BeGreaterThan(0); + series.TitleSlug.Should().NotBeBlank(); + series.TvRageId.Should().BeGreaterThan(0); + series.TvdbId.Should().BeGreaterThan(0); + } + + private void ValidateEpisodes(List episodes) + { + episodes.Should().NotBeEmpty(); + + episodes.GroupBy(e => e.SeasonNumber.ToString("000") + e.EpisodeNumber.ToString("000")) + .Max(e => e.Count()).Should().Be(1); + + episodes.Should().Contain(c => c.SeasonNumber > 0); +// episodes.Should().Contain(c => !string.IsNullOrWhiteSpace(c.Overview)); + + foreach (var episode in episodes) + { + ValidateEpisode(episode); + + //if atleast one episdoe has title it means parse it working. +// episodes.Should().Contain(c => !string.IsNullOrWhiteSpace(c.Title)); + } + } + + private void ValidateEpisode(Episode episode) + { + episode.Should().NotBeNull(); + + //TODO: Is there a better way to validate that episode number or season number is greater than zero? + (episode.EpisodeNumber + episode.SeasonNumber).Should().NotBe(0); + + episode.Should().NotBeNull(); + +// if (episode.AirDateUtc.HasValue) +// { +// episode.AirDateUtc.Value.Kind.Should().Be(DateTimeKind.Utc); +// } + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 1305b127d..59e3a0599 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -114,7 +114,7 @@ - + @@ -173,6 +173,7 @@ + @@ -189,6 +190,7 @@ + @@ -249,7 +251,7 @@ - + diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderFixture.cs similarity index 82% rename from src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs rename to src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderFixture.cs index 6e1258888..e0a06619d 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderFixture.cs @@ -42,12 +42,14 @@ namespace NzbDrone.Core.Test.OrganizerTests .With(e => e.Title = "City Sushi") .With(e => e.SeasonNumber = 15) .With(e => e.EpisodeNumber = 6) + .With(e => e.AbsoluteEpisodeNumber = 100) .Build(); _episode2 = Builder.CreateNew() .With(e => e.Title = "City Sushi") .With(e => e.SeasonNumber = 15) .With(e => e.EpisodeNumber = 7) + .With(e => e.AbsoluteEpisodeNumber = 101) .Build(); _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "DRONE" }; @@ -435,5 +437,78 @@ namespace NzbDrone.Core.Test.OrganizerTests Subject.BuildFilename(new List { episode }, new Series { Title = "Chicago P.D.." }, _episodeFile) .Should().Be("Chicago.P.D.S06E06.Part.1"); } + + [Test] + public void should_not_replace_absolute_numbering_when_series_is_not_anime() + { + _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("South.Park.S15E06.City.Sushi"); + } + + [Test] + public void should_replace_standard_and_absolute_numbering_when_series_is_anime() + { + _series.SeriesType = SeriesTypes.Anime; + _namingConfig.AnimeEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("South.Park.S15E06.100.City.Sushi"); + } + + [Test] + public void should_replace_standard_numbering_when_series_is_anime() + { + _series.SeriesType = SeriesTypes.Anime; + _namingConfig.AnimeEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("South.Park.S15E06.City.Sushi"); + } + + [Test] + public void should_replace_absolute_numbering_when_series_is_anime() + { + _series.SeriesType = SeriesTypes.Anime; + _namingConfig.AnimeEpisodeFormat = "{Series.Title}.{absolute:00}.{Episode.Title}"; + + Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) + .Should().Be("South.Park.100.City.Sushi"); + } + + [Test] + public void should_use_dash_as_separator_when_multi_episode_style_is_extend_for_anime() + { + _series.SeriesType = SeriesTypes.Anime; + _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; + + Subject.BuildFilename(new List { _episode1, _episode2 }, _series, _episodeFile) + .Should().Be("South Park - 100-101 - City Sushi"); + } + + [Test] + public void should_use_standard_naming_when_anime_episode_has_absolute_number_of_zero() + { + _series.SeriesType = SeriesTypes.Anime; + _episode1.AbsoluteEpisodeNumber = 0; + + _namingConfig.StandardEpisodeFormat = "{Series Title} - {season:0}x{episode:00} - {Episode Title}"; + _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; + + Subject.BuildFilename(new List { _episode1, }, _series, _episodeFile) + .Should().Be("South Park - 15x06 - City Sushi"); + } + + [Test] + public void should_duplicate_absolute_pattern_when_multi_episode_style_is_duplicate() + { + _series.SeriesType = SeriesTypes.Anime; + _namingConfig.MultiEpisodeStyle = (int)MultiEpisodeStyle.Duplicate; + _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; + + Subject.BuildFilename(new List { _episode1, _episode2 }, _series, _episodeFile) + .Should().Be("South Park - 100 - 101 - City Sushi"); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs index 7fb5d1986..9abb28e58 100644 --- a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs @@ -34,6 +34,38 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[SFW-sage] Bakuman S3 - 12 [720p][D07C91FC]", "Bakuman S3", 12, 0, 0)] [TestCase("ducktales_e66_time_is_money_part_one_marking_time", "DuckTales", 66, 0, 0)] [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0].mkv", "No Game No Life", 1, 0, 0)] + [TestCase("[FroZen] Miyuki - 23 [DVD][7F6170E6]", "Miyuki", 23, 0, 0)] + [TestCase("[Commie] Yowamushi Pedal - 32 [0BA19D5B]", "Yowamushi Pedal", 32, 0, 0)] + [TestCase("[Doki] Mahouka Koukou no Rettousei - 07 (1280x720 Hi10P AAC) [80AF7DDE]", "Mahouka Koukou no Rettousei", 7, 0, 0)] + [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [480p]", "Yowamushi Pedal", 32, 0, 0)] + [TestCase("[CR] Sailor Moon - 004 [480p][48CE2D0F]", "Sailor Moon", 4, 0, 0)] + [TestCase("[Chibiki] Puchimas!! - 42 [360p][7A4FC77B]", "Puchimas", 42, 0, 0)] + [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [1080p]", "Yowamushi Pedal", 32, 0, 0)] + [TestCase("[HorribleSubs] Love Live! S2 - 07 [720p]", "Love Live! S2", 7, 0, 0)] + [TestCase("[DeadFish] Onee-chan ga Kita - 09v2 [720p][AAC]", "Onee-chan ga Kita", 9, 0, 0)] + [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0]", "No Game No Life", 1, 0, 0)] + [TestCase("[S-T-D] Soul Eater Not! - 06 (1280x720 10bit AAC) [59B3F2EA].mkv", "Soul Eater Not!", 6, 0, 0)] + [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0].mkv", "No Game No Life", 1, 0, 0)] + [TestCase("No Game No Life - 010 (720p) [27AAA0A0].mkv", "No Game No Life", 10, 0, 0)] + [TestCase("Initial D Fifth Stage - 01 DVD - Central Anime", "Initial D Fifth Stage", 1, 0, 0)] + [TestCase("Initial_D_Fifth_Stage_-_01(DVD)_-_(Central_Anime)[5AF6F1E4].mkv", "Initial D Fifth Stage", 1, 0, 0)] + [TestCase("Initial_D_Fifth_Stage_-_02(DVD)_-_(Central_Anime)[0CA65F00].mkv", "Initial D Fifth Stage", 2, 0, 0)] + [TestCase("Initial D Fifth Stage - 03 DVD - Central Anime", "Initial D Fifth Stage", 3, 0, 0)] + [TestCase("Initial_D_Fifth_Stage_-_03(DVD)_-_(Central_Anime)[629BD592].mkv", "Initial D Fifth Stage", 3, 0, 0)] + [TestCase("Initial D Fifth Stage - 14 DVD - Central Anime", "Initial D Fifth Stage", 14, 0, 0)] + [TestCase("Initial_D_Fifth_Stage_-_14(DVD)_-_(Central_Anime)[0183D922].mkv", "Initial D Fifth Stage", 14, 0, 0)] +// [TestCase("Initial D - 4th Stage Ep 01.mkv", "Initial D - 4th Stage", 1, 0, 0)] + [TestCase("[ChihiroDesuYo].No.Game.No.Life.-.09.1280x720.10bit.AAC.[24CCE81D]", "No.Game.No.Life", 9, 0, 0)] + [TestCase("Fairy Tail - 001 - Fairy Tail", "Fairy Tail", 001, 0, 0)] + [TestCase("Fairy Tail - 049 - The Day of Fated Meeting", "Fairy Tail", 049, 0, 0)] + [TestCase("Fairy Tail - 050 - Special Request Watch Out for the Guy You Like!", "Fairy Tail", 050, 0, 0)] + [TestCase("Fairy Tail - 099 - Natsu vs. Gildarts", "Fairy Tail", 099, 0, 0)] + [TestCase("Fairy Tail - 100 - Mest", "Fairy Tail", 100, 0, 0)] +// [TestCase("Fairy Tail - 101 - Mest", "Fairy Tail", 101, 0, 0)] //This gets caught up in the 'see' numbering + [TestCase("[Exiled-Destiny] Angel Beats Ep01 (D2201EC5).mkv", "Angel Beats!", 1, 0, 0)] + [TestCase("[Commie] Nobunaga the Fool - 23 [5396CA24].mkv", "Nobunaga the Fool", 23, 0, 0)] + [TestCase("[FFF] Seikoku no Dragonar - 01 [1FB538B5].mkv", "Seikoku no Dragonar", 1, 0, 0)] + [TestCase("[Hatsuyuki]Fate_Zero-01[1280x720][122E6EF8]", "Fate/Zero", 1, 0, 0)] public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) { var result = Parser.Parser.ParseTitle(postTitle); diff --git a/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs new file mode 100644 index 000000000..3306d7035 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class AnimeMetadataParserFixture : CoreTest + { + [TestCase("[SubDESU]_High_School_DxD_07_(1280x720_x264-AAC)_[6B7FD717]", "SubDESU", "6B7FD717")] + [TestCase("[Chihiro]_Working!!_-_06_[848x480_H.264_AAC][859EEAFA]", "Chihiro", "859EEAFA")] + [TestCase("[Underwater]_Rinne_no_Lagrange_-_12_(720p)_[5C7BC4F9]", "Underwater", "5C7BC4F9")] + [TestCase("[HorribleSubs]_Hunter_X_Hunter_-_33_[720p]", "HorribleSubs", "")] + [TestCase("[HorribleSubs] Tonari no Kaibutsu-kun - 13 [1080p].mkv", "HorribleSubs", "")] + [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", "Doremi", "C65D4B1F")] + [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F]", "Doremi", "C65D4B1F")] + [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].mkv", "Doremi", "")] + [TestCase("[K-F] One Piece 214", "K-F", "")] + [TestCase("[K-F] One Piece S10E14 214", "K-F", "")] + [TestCase("[K-F] One Piece 10x14 214", "K-F", "")] + [TestCase("[K-F] One Piece 214 10x14", "K-F", "")] + [TestCase("Bleach - 031 - The Resolution to Kill [Lunar].avi", "Lunar", "")] + [TestCase("[ACX]Hack Sign 01 Role Play [Kosaka] [9C57891E].mkv", "ACX", "9C57891E")] + [TestCase("[S-T-D] Soul Eater Not! - 06 (1280x720 10bit AAC) [59B3F2EA].mkv", "S-T-D", "59B3F2EA")] + public void should_parse_absolute_numbers(string postTitle, string subGroup, string hash) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.Should().NotBeNull(); + result.ReleaseGroup.Should().Be(subGroup); + result.ReleaseHash.Should().Be(hash); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs index 83c9315e7..71699402c 100644 --- a/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs @@ -32,6 +32,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("0e895c37245186812cb08aab1529cf8ee389dd05.mkv")] [TestCase("08bbc153931ce3ca5fcafe1b92d3297285feb061.mkv")] [TestCase("185d86a343e39f3341e35c4dad3ff159")] + [TestCase("ah63jka93jf0jh26ahjas961.mkv")] public void should_not_parse_crap(string title) { Parser.Parser.ParseTitle(title).Should().BeNull(); diff --git a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs index 5d0cc3829..b295f822a 100644 --- a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.ParserTests SeriesTitle = "" }; - parsedEpisodeInfo.IsPossibleSpecialEpisode().Should().BeFalse(); + parsedEpisodeInfo.IsPossibleSpecialEpisode.Should().BeFalse(); } [Test] @@ -33,7 +33,15 @@ namespace NzbDrone.Core.Test.ParserTests SeriesTitle = "" }; - parsedEpisodeInfo.IsPossibleSpecialEpisode().Should().BeTrue(); + parsedEpisodeInfo.IsPossibleSpecialEpisode.Should().BeTrue(); + } + + [TestCase("Under.the.Dome.S02.Special-Inside.Chesters.Mill.HDTV.x264-BAJSKORV")] + [TestCase("Under.the.Dome.S02.Special-Inside.Chesters.Mill.720p.HDTV.x264-BAJSKORV")] + [TestCase("Rookie.Blue.Behind.the.Badge.S05.Special.HDTV.x264-2HD")] + public void IsPossibleSpecialEpisode_should_be_true(string title) + { + Parser.Parser.ParseTitle(title).IsPossibleSpecialEpisode.Should().BeTrue(); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs index dfa5b5989..bef9f4c41 100644 --- a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs @@ -52,6 +52,9 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Sonny.With.a.Chance.S02E15.divx", false)] [TestCase("The.Girls.Next.Door.S03E06.HDTV-WiDE", false)] [TestCase("Degrassi.S10E27.WS.DSR.XviD-2HD", false)] + [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [480p]", false)] + [TestCase("[CR] Sailor Moon - 004 [480p][48CE2D0F]", false)] + [TestCase("[Hatsuyuki] Naruto Shippuuden - 363 [848x480][ADE35E38]", false)] public void should_parse_sdtv_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.SDTV, proper); @@ -69,8 +72,10 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("The.Girls.Next.Door.S03E06.DVD.Rip.XviD-WiDE", false)] [TestCase("the.shield.1x13.circles.ws.xvidvd-tns", false)] [TestCase("the_x-files.9x18.sunshine_days.ac3.ws_dvdrip_xvid-fov.avi", false)] + [TestCase("[FroZen] Miyuki - 23 [DVD][7F6170E6]", false)] [TestCase("Hannibal.S01E05.576p.BluRay.DD5.1.x264-HiSD", false)] [TestCase("Hannibal.S01E05.480p.BluRay.DD5.1.x264-HiSD", false)] + [TestCase("Heidi Girl of the Alps (BD)(640x480(RAW) (BATCH 1) (1-13)", false)] public void should_parse_dvd_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.DVD, proper); @@ -96,6 +101,13 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Sonny.With.a.Chance.S02E15.mkv", false)] [TestCase(@"E:\Downloads\tv\The.Big.Bang.Theory.S01E01.720p.HDTV\ajifajjjeaeaeqwer_eppj.avi", false)] [TestCase("Gem.Hunt.S01E08.Tourmaline.Nepal.720p.HDTV.x264-DHD", false)] + [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0]", false)] + [TestCase("[Doki] Mahouka Koukou no Rettousei - 07 (1280x720 Hi10P AAC) [80AF7DDE]", false)] + [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", false)] + [TestCase("[HorribleSubs]_Fairy_Tail_-_145_[720p]", false)] + [TestCase("[Eveyuu] No Game No Life - 10 [Hi10P 1280x720 H264][10B23BD8]", false)] + [TestCase("Hells.Kitchen.US.S12E17.HR.WS.PDTV.X264-DIMENSION", false)] + [TestCase("Survivorman.The.Lost.Pilots.Summer.HR.WS.PDTV.x264-DHD", false)] public void should_parse_hdtv720p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.HDTV720p, proper); @@ -106,6 +118,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("DEXTER.S07E01.ARE.YOU.1080P.HDTV.x264-QCF", false)] [TestCase("DEXTER.S07E01.ARE.YOU.1080P.HDTV.proper.X264-QCF", true)] [TestCase("Dexter - S01E01 - Title [HDTV-1080p]", false)] + [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [1080p]", false)] public void should_parse_hdtv1080p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.HDTV1080p, proper); @@ -144,6 +157,12 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Chuck - S01E03 - Come Fly With Me - 720p BluRay.mkv", false)] [TestCase("The Big Bang Theory.S03E01.The Electric Can Opener Fluctuation.m2ts", false)] [TestCase("Revolution.S01E02.Chained.Heat.[Bluray720p].mkv", false)] + [TestCase("[FFF] DATE A LIVE - 01 [BD][720p-AAC][0601BED4]", false)] + [TestCase("[coldhell] Pupa v3 [BD720p][03192D4C]", false)] + [TestCase("[RandomRemux] Nobunagun - 01 [720p BD][043EA407].mkv", false)] + [TestCase("[Kaylith] Isshuukan Friends Specials - 01 [BD 720p AAC][B7EEE164].mkv", false)] + [TestCase("WEEDS.S03E01-06.DUAL.Blu-ray.AC3.-HELLYWOOD.avi", false)] + [TestCase("WEEDS.S03E01-06.DUAL.720p.Blu-ray.AC3.-HELLYWOOD.avi", false)] public void should_parse_bluray720p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.Bluray720p, proper); @@ -152,6 +171,12 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Chuck - S01E03 - Come Fly With Me - 1080p BluRay.mkv", false)] [TestCase("Sons.Of.Anarchy.S02E13.1080p.BluRay.x264-AVCDVD", false)] [TestCase("Revolution.S01E02.Chained.Heat.[Bluray1080p].mkv", false)] + [TestCase("[FFF] Namiuchigiwa no Muromi-san - 10 [BD][1080p-FLAC][0C4091AF]", false)] + [TestCase("[coldhell] Pupa v2 [BD1080p][5A45EABE].mkv", false)] + [TestCase("[Kaylith] Isshuukan Friends Specials - 01 [BD 1080p FLAC][429FD8C7].mkv", false)] + [TestCase("[Zurako] Log Horizon - 01 - The Apocalypse (BD 1080p AAC) [7AE12174].mkv", false)] + [TestCase("WEEDS.S03E01-06.DUAL.1080p.Blu-ray.AC3.-HELLYWOOD.avi", false)] + [TestCase("[Coalgirls]_Durarara!!_01_(1920x1080_Blu-ray_FLAC)_[8370CB8F].mkv", false)] public void should_parse_bluray1080p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.Bluray1080p, proper); diff --git a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs index 6789bc48b..624e7b1f1 100644 --- a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Eureka S 01 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] [TestCase("Doctor Who Confidential Season 3", "Doctor Who Confidential", 3)] [TestCase("Fleming.S01.720p.WEBDL.DD5.1.H.264-NTb", "Fleming", 1)] - public void should_parsefull_season_release(string postTitle, string title, int season) + public void should_parse_full_season_release(string postTitle, string title, int season) { var result = Parser.Parser.ParseTitle(postTitle); result.SeasonNumber.Should().Be(season); diff --git a/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs index 6da4a65a4..fca9cdaa2 100644 --- a/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs @@ -25,18 +25,6 @@ using System.Runtime.InteropServices; [assembly: Guid("699aed1b-015e-4f0d-9c81-d5557b05d260")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] - [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] [assembly: InternalsVisibleTo("NzbDrone.Core")] \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs index 9436770ae..2f92eae2a 100644 --- a/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs +++ b/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests private void WithExpired() { - Mocker.GetMock().Setup(s => s.FolderGetLastWrite(It.IsAny())) + Mocker.GetMock().Setup(s => s.FolderGetLastWriteUtc(It.IsAny())) .Returns(DateTime.UtcNow.AddDays(-10)); Mocker.GetMock().Setup(s => s.FileGetLastWriteUtc(It.IsAny())) @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests private void WithNonExpired() { - Mocker.GetMock().Setup(s => s.FolderGetLastWrite(It.IsAny())) + Mocker.GetMock().Setup(s => s.FolderGetLastWriteUtc(It.IsAny())) .Returns(DateTime.UtcNow.AddDays(-3)); Mocker.GetMock().Setup(s => s.FileGetLastWriteUtc(It.IsAny())) diff --git a/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs b/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs index f0b28d563..f012b1af8 100644 --- a/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs +++ b/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs @@ -23,13 +23,10 @@ namespace NzbDrone.Core.Test.Providers { var ids = Subject.GetXemSeriesIds(); - ids.Should().NotBeEmpty(); ids.Should().Contain(i => i == 73141); - } - [Test] [Ignore("XEM's data is not clean")] public void get_mapping_for_all_series() @@ -45,7 +42,7 @@ namespace NzbDrone.Core.Test.Providers } [TestCase(12345, Description = "invalid id")] - [TestCase(267440, Description = "no single connection")] + [TestCase(279042, Description = "no single connection")] public void should_return_empty_when_known_error(int id) { Subject.GetSceneTvdbMappings(id).Should().BeEmpty(); @@ -62,7 +59,6 @@ namespace NzbDrone.Core.Test.Providers result.Should().OnlyContain(c => c.Tvdb != null); } - [TestCase(78916)] public void should_filter_out_episodes_without_scene_mapping(int seriesId) { diff --git a/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs b/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs index 1b689c53c..1172daff2 100644 --- a/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs +++ b/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs @@ -39,6 +39,11 @@ namespace NzbDrone.Core.Test.SeriesStatsTests _episode.EpisodeFileId = 1; } + private void GivenOldEpisode() + { + _episode.AirDateUtc = DateTime.Now.AddSeconds(-10); + } + private void GivenMonitoredEpisode() { _episode.Monitored = true; @@ -59,6 +64,7 @@ namespace NzbDrone.Core.Test.SeriesStatsTests stats.Should().HaveCount(1); stats.First().NextAiring.Should().Be(_episode.AirDateUtc); + stats.First().PreviousAiring.Should().NotHaveValue(); } [Test] @@ -73,6 +79,47 @@ namespace NzbDrone.Core.Test.SeriesStatsTests stats.First().NextAiring.Should().NotHaveValue(); } + [Test] + public void should_have_previous_airing_for_old_episode_with_file() + { + GivenEpisodeWithFile(); + GivenOldEpisode(); + GivenFile(); + + var stats = Subject.SeriesStatistics(); + + stats.Should().HaveCount(1); + stats.First().NextAiring.Should().NotHaveValue(); + stats.First().PreviousAiring.Should().Be(_episode.AirDateUtc); + } + + [Test] + public void should_have_previous_airing_for_old_episode_without_file_monitored() + { + GivenMonitoredEpisode(); + GivenOldEpisode(); + GivenFile(); + + var stats = Subject.SeriesStatistics(); + + stats.Should().HaveCount(1); + stats.First().NextAiring.Should().NotHaveValue(); + stats.First().PreviousAiring.Should().Be(_episode.AirDateUtc); + } + + [Test] + public void should_not_have_previous_airing_for_old_episode_without_file_unmonitored() + { + GivenOldEpisode(); + GivenFile(); + + var stats = Subject.SeriesStatistics(); + + stats.Should().HaveCount(1); + stats.First().NextAiring.Should().NotHaveValue(); + stats.First().PreviousAiring.Should().NotHaveValue(); + } + [Test] public void should_not_include_unmonitored_episode_in_episode_count() { diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs index b375ed4a2..0284b2431 100644 --- a/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.MetadataSource.Tvdb; using NzbDrone.Core.Tv; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -42,6 +43,15 @@ namespace NzbDrone.Core.Test.TvTests return series; } + private Series GetAnimeSeries() + { + var series = Builder.CreateNew().Build(); + series.SeriesType = SeriesTypes.Anime; + series.Seasons = new List(); + + return series; + } + [SetUp] public void Setup() { @@ -61,11 +71,18 @@ namespace NzbDrone.Core.Test.TvTests .Callback>(e => _deletedEpisodes = e); } + private void GivenAnimeEpisodes(List episodes) + { + Mocker.GetMock() + .Setup(s => s.GetEpisodeInfo(It.IsAny())) + .Returns(episodes); + } + [Test] public void should_create_all_when_no_existing_episodes() { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(new List()); Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); @@ -78,7 +95,7 @@ namespace NzbDrone.Core.Test.TvTests [Test] public void should_update_all_when_all_existing_episodes() { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(GetEpisodes()); Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); @@ -91,7 +108,7 @@ namespace NzbDrone.Core.Test.TvTests [Test] public void should_delete_all_when_all_existing_episodes_are_gone_from_trakt() { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(GetEpisodes()); Subject.RefreshEpisodeInfo(GetSeries(), new List()); @@ -106,7 +123,7 @@ namespace NzbDrone.Core.Test.TvTests { var duplicateEpisodes = GetEpisodes().Skip(5).Take(2).ToList(); - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(GetEpisodes().Union(duplicateEpisodes).ToList()); Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); @@ -127,7 +144,7 @@ namespace NzbDrone.Core.Test.TvTests episodes.ForEach(e => e.Monitored = true); - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(episodes); Subject.RefreshEpisodeInfo(series, GetEpisodes()); @@ -139,7 +156,7 @@ namespace NzbDrone.Core.Test.TvTests [Test] public void should_remove_duplicate_remote_episodes_before_processing() { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(new List()); var episodes = Builder.CreateListOfSize(5) @@ -157,21 +174,137 @@ namespace NzbDrone.Core.Test.TvTests } [Test] - public void should_set_absolute_episode_number() + public void should_not_set_absolute_episode_number_for_non_anime() { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(new List()); Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); - var season1 = _insertedEpisodes.Where(e => e.SeasonNumber == 1 && e.EpisodeNumber > 0); - var season2episode1 = _insertedEpisodes.Single(e => e.SeasonNumber == 2 && e.EpisodeNumber == 1); + _insertedEpisodes.All(e => e.AbsoluteEpisodeNumber == 0 || !e.AbsoluteEpisodeNumber.HasValue).Should().BeTrue(); + } - season2episode1.AbsoluteEpisodeNumber.Should().Be(season1.Count() + 1); + [Test] + public void should_set_absolute_episode_number_for_anime() + { + var episodes = Builder.CreateListOfSize(3).Build().ToList(); + GivenAnimeEpisodes(episodes); - _insertedEpisodes.Where(e => e.SeasonNumber > 0 && e.EpisodeNumber > 0).All(e => e.AbsoluteEpisodeNumber > 0).Should().BeTrue(); + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(new List()); + + Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); + + _insertedEpisodes.All(e => e.AbsoluteEpisodeNumber > 0).Should().BeTrue(); _updatedEpisodes.Should().BeEmpty(); _deletedEpisodes.Should().BeEmpty(); } + + [Test] + public void should_set_absolute_episode_number_even_if_not_previously_set_for_anime() + { + var episodes = Builder.CreateListOfSize(3).Build().ToList(); + GivenAnimeEpisodes(episodes); + + var existingEpisodes = episodes.JsonClone(); + existingEpisodes.ForEach(e => e.AbsoluteEpisodeNumber = 0); + + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(existingEpisodes); + + Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); + + _insertedEpisodes.Should().BeEmpty(); + _updatedEpisodes.All(e => e.AbsoluteEpisodeNumber > 0).Should().BeTrue(); + _deletedEpisodes.Should().BeEmpty(); + } + + [Test] + public void should_get_new_season_and_episode_numbers_when_absolute_episode_number_match_found() + { + const Int32 expectedSeasonNumber = 10; + const Int32 expectedEpisodeNumber = 5; + const Int32 expectedAbsoluteNumber = 3; + + var episode = Builder.CreateNew() + .With(e => e.SeasonNumber = expectedSeasonNumber) + .With(e => e.EpisodeNumber = expectedEpisodeNumber) + .With(e => e.AbsoluteEpisodeNumber = expectedAbsoluteNumber) + .Build(); + + GivenAnimeEpisodes(new List { episode }); + + var existingEpisode = episode.JsonClone(); + existingEpisode.SeasonNumber = 1; + existingEpisode.EpisodeNumber = 1; + existingEpisode.AbsoluteEpisodeNumber = expectedAbsoluteNumber; + + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(new List{ existingEpisode }); + + Subject.RefreshEpisodeInfo(GetAnimeSeries(), new List { episode }); + + _insertedEpisodes.Should().BeEmpty(); + _deletedEpisodes.Should().BeEmpty(); + + _updatedEpisodes.First().SeasonNumber.Should().Be(expectedSeasonNumber); + _updatedEpisodes.First().EpisodeNumber.Should().Be(expectedEpisodeNumber); + _updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(expectedAbsoluteNumber); + } + + [Test] + public void should_prefer_absolute_match_over_season_and_epsiode_match() + { + var episodes = Builder.CreateListOfSize(2) + .Build() + .ToList(); + + episodes[0].AbsoluteEpisodeNumber = 0; + episodes[0].SeasonNumber.Should().NotBe(episodes[1].SeasonNumber); + episodes[0].EpisodeNumber.Should().NotBe(episodes[1].EpisodeNumber); + episodes[0].AbsoluteEpisodeNumber.Should().NotBe(episodes[1].AbsoluteEpisodeNumber); + + GivenAnimeEpisodes(episodes); + + var existingEpisode = new Episode + { + SeasonNumber = episodes[0].SeasonNumber, + EpisodeNumber = episodes[0].EpisodeNumber, + AbsoluteEpisodeNumber = episodes[1].AbsoluteEpisodeNumber + }; + + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(new List { existingEpisode }); + + Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); + + _updatedEpisodes.First().SeasonNumber.Should().Be(episodes[1].SeasonNumber); + _updatedEpisodes.First().EpisodeNumber.Should().Be(episodes[1].EpisodeNumber); + _updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[1].AbsoluteEpisodeNumber); + } + + [Test] + public void should_ignore_episodes_with_absolute_episode_of_zero_in_distinct_by_absolute() + { + var episodes = Builder.CreateListOfSize(10) + .Build() + .ToList(); + + episodes[0].AbsoluteEpisodeNumber = 0; + episodes[1].AbsoluteEpisodeNumber = 0; + episodes[2].AbsoluteEpisodeNumber = 0; + episodes[3].AbsoluteEpisodeNumber = 0; + episodes[4].AbsoluteEpisodeNumber = 0; + + GivenAnimeEpisodes(episodes); + + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(new List()); + + Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); + + _insertedEpisodes.Should().HaveCount(episodes.Count); + + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs index 7e263b235..ea948dd67 100644 --- a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs @@ -49,12 +49,12 @@ namespace NzbDrone.Core.Test.TvTests [Test] public void should_monitor_new_seasons_automatically() { - var series = _series.JsonClone(); - series.Seasons.Add(Builder.CreateNew() + var newSeriesInfo = _series.JsonClone(); + newSeriesInfo.Seasons.Add(Builder.CreateNew() .With(s => s.SeasonNumber = 2) .Build()); - GivenNewSeriesInfo(series); + GivenNewSeriesInfo(newSeriesInfo); Subject.Execute(new RefreshSeriesCommand(_series.Id)); @@ -77,5 +77,19 @@ namespace NzbDrone.Core.Test.TvTests Mocker.GetMock() .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2 && s.Seasons.Single(season => season.SeasonNumber == 0).Monitored == false))); } + + [Test] + public void should_update_tvrage_id_if_changed() + { + var newSeriesInfo = _series.JsonClone(); + newSeriesInfo.TvRageId = _series.TvRageId + 1; + + GivenNewSeriesInfo(newSeriesInfo); + + Subject.Execute(new RefreshSeriesCommand(_series.Id)); + + Mocker.GetMock() + .Verify(v => v.UpdateSeries(It.Is(s => s.TvRageId == newSeriesInfo.TvRageId))); + } } } diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index de22c722f..da6b88f1b 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -10,11 +10,12 @@ namespace NzbDrone.Core.Annotations Order = order; } - public int Order { get; private set; } - public string Label { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } + public Int32 Order { get; private set; } + public String Label { get; set; } + public String HelpText { get; set; } + public String HelpLink { get; set; } public FieldType Type { get; set; } + public Boolean Advanced { get; set; } public Type SelectOptions { get; set; } } diff --git a/src/NzbDrone.Core/Backup/Backup.cs b/src/NzbDrone.Core/Backup/Backup.cs new file mode 100644 index 000000000..880ef6106 --- /dev/null +++ b/src/NzbDrone.Core/Backup/Backup.cs @@ -0,0 +1,11 @@ +using System; + +namespace NzbDrone.Core.Backup +{ + public class Backup + { + public String Path { get; set; } + public BackupType Type { get; set; } + public DateTime Time { get; set; } + } +} diff --git a/src/NzbDrone.Core/Backup/BackupCommand.cs b/src/NzbDrone.Core/Backup/BackupCommand.cs new file mode 100644 index 000000000..29199c67a --- /dev/null +++ b/src/NzbDrone.Core/Backup/BackupCommand.cs @@ -0,0 +1,24 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Backup +{ + public class BackupCommand : Command + { + public BackupType Type { get; set; } + + public override bool SendUpdatesToClient + { + get + { + return true; + } + } + } + + public enum BackupType + { + Scheduled = 0 , + Manual = 1, + Update = 2 + } +} diff --git a/src/NzbDrone.Core/Backup/BackupService.cs b/src/NzbDrone.Core/Backup/BackupService.cs new file mode 100644 index 000000000..33209cc9b --- /dev/null +++ b/src/NzbDrone.Core/Backup/BackupService.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Marr.Data; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Instrumentation.Extensions; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Backup +{ + public interface IBackupService + { + void Backup(BackupType backupType); + List GetBackups(); + } + + public class BackupService : IBackupService, IExecute + { + private readonly IDatabase _maindDb; + private readonly IDiskProvider _diskProvider; + private readonly IAppFolderInfo _appFolderInfo; + private readonly IArchiveService _archiveService; + private readonly Logger _logger; + + private string _backupTempFolder; + + private static readonly Regex BackupFileRegex = new Regex(@"nzbdrone_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public BackupService(IDatabase maindDb, + IDiskProvider diskProvider, + IAppFolderInfo appFolderInfo, + IArchiveService archiveService, + Logger logger) + { + _maindDb = maindDb; + _diskProvider = diskProvider; + _appFolderInfo = appFolderInfo; + _archiveService = archiveService; + _logger = logger; + + _backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "nzbdrone_backup"); + } + + public void Backup(BackupType backupType) + { + _logger.ProgressInfo("Starting Backup"); + + _diskProvider.EnsureFolder(_backupTempFolder); + _diskProvider.EnsureFolder(GetBackupFolder(backupType)); + + var backupFilename = String.Format("nzbdrone_backup_{0:yyyy.MM.dd_HH.mm.ss}.zip", DateTime.Now); + var backupPath = Path.Combine(GetBackupFolder(backupType), backupFilename); + + Cleanup(); + + if (backupType != BackupType.Manual) + { + CleanupOldBackups(backupType); + } + + BackupConfigFile(); + BackupDatabase(); + + _logger.ProgressDebug("Creating backup zip"); + _archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly)); + _logger.ProgressDebug("Backup zip created"); + } + + public List GetBackups() + { + var backups = new List(); + + foreach (var backupType in Enum.GetValues(typeof(BackupType)).Cast()) + { + var folder = GetBackupFolder(backupType); + + if (_diskProvider.FolderExists(folder)) + { + backups.AddRange(GetBackupFiles(folder).Select(b => new Backup + { + Path = Path.GetFileName(b), + Type = backupType, + Time = _diskProvider.FileGetLastWriteUtc(b) + })); + } + } + + return backups; + } + + private void Cleanup() + { + if (_diskProvider.FolderExists(_backupTempFolder)) + { + _diskProvider.EmptyFolder(_backupTempFolder); + } + } + + private void BackupDatabase() + { + _logger.ProgressDebug("Backing up database"); + + using (var unitOfWork = new UnitOfWork(() => _maindDb.GetDataMapper())) + { + unitOfWork.BeginTransaction(); + + var databaseFile = _appFolderInfo.GetNzbDroneDatabase(); + var tempDatabaseFile = Path.Combine(_backupTempFolder, Path.GetFileName(databaseFile)); + + _diskProvider.CopyFile(databaseFile, tempDatabaseFile, true); + + unitOfWork.Commit(); + } + } + + private void BackupConfigFile() + { + _logger.ProgressDebug("Backing up config.xml"); + + var configFile = _appFolderInfo.GetConfigPath(); + var tempConfigFile = Path.Combine(_backupTempFolder, Path.GetFileName(configFile)); + + _diskProvider.CopyFile(configFile, tempConfigFile, true); + } + + private void CleanupOldBackups(BackupType backupType) + { + _logger.Debug("Cleaning up old backup files"); + var files = GetBackupFiles(GetBackupFolder(backupType)); + + foreach (var file in files) + { + var lastWriteTime = _diskProvider.FileGetLastWriteUtc(file); + + if (lastWriteTime.AddDays(28) < DateTime.UtcNow) + { + _logger.Debug("Deleting old backup file: {0}", file); + _diskProvider.DeleteFile(file); + } + } + + _logger.Debug("Finished cleaning up old backup files"); + } + + private String GetBackupFolder(BackupType backupType) + { + return Path.Combine(_appFolderInfo.GetBackupFolder(), backupType.ToString().ToLower()); + } + + private IEnumerable GetBackupFiles(String path) + { + var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly); + + return files.Where(f => BackupFileRegex.IsMatch(f)); + } + + public void Execute(BackupCommand message) + { + Backup(message.Type); + } + } +} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/ISceneMappingProvider.cs b/src/NzbDrone.Core/DataAugmentation/Scene/ISceneMappingProvider.cs new file mode 100644 index 000000000..58b69f2b9 --- /dev/null +++ b/src/NzbDrone.Core/DataAugmentation/Scene/ISceneMappingProvider.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.DataAugmentation.Scene +{ + public interface ISceneMappingProvider + { + List GetSceneMappings(); + } +} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs index a29518718..fae578450 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs @@ -16,5 +16,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene [JsonProperty("season")] public int SeasonNumber { get; set; } + + public string Type { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs index f59e3a64b..712b528d3 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs @@ -1,3 +1,4 @@ +using System; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; using System.Collections.Generic; @@ -8,6 +9,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene public interface ISceneMappingRepository : IBasicRepository { List FindByTvdbid(int tvdbId); + void Clear(string type); } public class SceneMappingRepository : BasicRepository, ISceneMappingRepository @@ -21,5 +23,10 @@ namespace NzbDrone.Core.DataAugmentation.Scene { return Query.Where(x => x.TvdbId == tvdbId); } + + public void Clear(string type) + { + Delete(s => s.Type == type); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs index 42137b1ac..fe112a3e0 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs @@ -1,56 +1,66 @@ using System; using System.Linq; using NLog; +using NzbDrone.Common; using NzbDrone.Common.Cache; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using System.Collections.Generic; +using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.DataAugmentation.Scene { public interface ISceneMappingService { - string GetSceneName(int tvdbId); - Nullable GetTvDbId(string cleanName); + List GetSceneNames(int tvdbId, IEnumerable seasonNumbers); + Nullable GetTvDbId(string title); List FindByTvdbid(int tvdbId); + Nullable GetSeasonNumber(string title); } public class SceneMappingService : ISceneMappingService, - IHandleAsync, - IExecute + IHandleAsync, + IHandle, + IExecute { private readonly ISceneMappingRepository _repository; - private readonly ISceneMappingProxy _sceneMappingProxy; + private readonly IEnumerable _sceneMappingProviders; private readonly Logger _logger; - private readonly ICached _getSceneNameCache; private readonly ICached _gettvdbIdCache; private readonly ICached> _findbytvdbIdCache; - public SceneMappingService(ISceneMappingRepository repository, ISceneMappingProxy sceneMappingProxy, ICacheManager cacheManager, Logger logger) + public SceneMappingService(ISceneMappingRepository repository, + ICacheManager cacheManager, + IEnumerable sceneMappingProviders, + Logger logger) { _repository = repository; - _sceneMappingProxy = sceneMappingProxy; + _sceneMappingProviders = sceneMappingProviders; - _getSceneNameCache = cacheManager.GetCache(GetType(), "scene_name"); _gettvdbIdCache = cacheManager.GetCache(GetType(), "tvdb_id"); _findbytvdbIdCache = cacheManager.GetCache>(GetType(), "find_tvdb_id"); _logger = logger; } - public string GetSceneName(int tvdbId) + public List GetSceneNames(int tvdbId, IEnumerable seasonNumbers) { - var mapping = _getSceneNameCache.Find(tvdbId.ToString()); + var names = _findbytvdbIdCache.Find(tvdbId.ToString()); - if (mapping == null) return null; + if (names == null) + { + return new List(); + } - return mapping.SearchTerm; + return FilterNonEnglish(names.Where(s => seasonNumbers.Contains(s.SeasonNumber) || + s.SeasonNumber == -1) + .Select(m => m.SearchTerm).Distinct().ToList()); } - public Nullable GetTvDbId(string cleanName) + public Nullable GetTvDbId(string title) { - var mapping = _gettvdbIdCache.Find(cleanName.CleanSeriesTitle()); + var mapping = _gettvdbIdCache.Find(title.CleanSeriesTitle()); if (mapping == null) return null; @@ -60,66 +70,95 @@ namespace NzbDrone.Core.DataAugmentation.Scene public List FindByTvdbid(int tvdbId) { - return _findbytvdbIdCache.Find(tvdbId.ToString()); + var mappings = _findbytvdbIdCache.Find(tvdbId.ToString()); + + if (mappings == null) + { + return new List(); + } + + return mappings; + } + + public Nullable GetSeasonNumber(string title) + { + var mapping = _gettvdbIdCache.Find(title.CleanSeriesTitle()); + + if (mapping == null) + return null; + + return mapping.SeasonNumber; } private void UpdateMappings() { - _logger.Info("Updating Scene mapping"); + _logger.Info("Updating Scene mappings"); - try + foreach (var sceneMappingProvider in _sceneMappingProviders) { - var mappings = _sceneMappingProxy.Fetch(); - - if (mappings.Any()) + try { - _repository.Purge(); + var mappings = sceneMappingProvider.GetSceneMappings(); - foreach (var sceneMapping in mappings) + if (mappings.Any()) { - sceneMapping.ParseTerm = sceneMapping.Title.CleanSeriesTitle(); + _repository.Clear(sceneMappingProvider.GetType().Name); + + foreach (var sceneMapping in mappings) + { + sceneMapping.ParseTerm = sceneMapping.Title.CleanSeriesTitle(); + sceneMapping.Type = sceneMappingProvider.GetType().Name; + } + + _repository.InsertMany(mappings.DistinctBy(s => s.ParseTerm).ToList()); + } + else + { + _logger.Warn("Received empty list of mapping. will not update."); } - - _repository.InsertMany(mappings); } - else + catch (Exception ex) { - _logger.Warn("Received empty list of mapping. will not update."); + _logger.ErrorException("Failed to Update Scene Mappings:", ex); } } - catch (Exception ex) - { - _logger.ErrorException("Failed to Update Scene Mappings:", ex); - } - + RefreshCache(); } private void RefreshCache() { - var mappings = _repository.All(); + var mappings = _repository.All().ToList(); _gettvdbIdCache.Clear(); - _getSceneNameCache.Clear(); _findbytvdbIdCache.Clear(); foreach (var sceneMapping in mappings) { - _getSceneNameCache.Set(sceneMapping.TvdbId.ToString(), sceneMapping); _gettvdbIdCache.Set(sceneMapping.ParseTerm.CleanSeriesTitle(), sceneMapping); } + foreach (var sceneMapping in mappings.GroupBy(x => x.TvdbId)) { _findbytvdbIdCache.Set(sceneMapping.Key.ToString(), sceneMapping.ToList()); } } + private List FilterNonEnglish(List titles) + { + return titles.Where(title => title.All(c => c <= 255)).ToList(); + } public void HandleAsync(ApplicationStartedEvent message) { UpdateMappings(); } + public void Handle(SeriesRefreshStartingEvent message) + { + UpdateMappings(); + } + public void Execute(UpdateSceneMappingCommand message) { UpdateMappings(); diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/ServicesProvider.cs b/src/NzbDrone.Core/DataAugmentation/Scene/ServicesProvider.cs new file mode 100644 index 000000000..d1d5a4acc --- /dev/null +++ b/src/NzbDrone.Core/DataAugmentation/Scene/ServicesProvider.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.DataAugmentation.Scene +{ + public class ServicesProvider : ISceneMappingProvider + { + private readonly ISceneMappingProxy _sceneMappingProxy; + + public ServicesProvider(ISceneMappingProxy sceneMappingProxy) + { + _sceneMappingProxy = sceneMappingProxy; + } + + public List GetSceneMappings() + { + return _sceneMappingProxy.Fetch(); + } + } +} diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs index 80b1f58e2..009fc8146 100644 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs +++ b/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json.Linq; using NLog; +using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.DataAugmentation.Xem.Model; using NzbDrone.Core.Rest; using RestSharp; @@ -12,6 +14,7 @@ namespace NzbDrone.Core.DataAugmentation.Xem { List GetXemSeriesIds(); List GetSceneTvdbMappings(int id); + List GetSceneTvdbNames(); } public class XemProxy : IXemProxy @@ -40,7 +43,7 @@ namespace NzbDrone.Core.DataAugmentation.Xem { _logger.Debug("Fetching Series IDs from"); - var restClient = new RestClient(XEM_BASE_URL); + var restClient = RestClientFactory.BuildClient(XEM_BASE_URL); var request = BuildRequest("havemap"); @@ -54,7 +57,7 @@ namespace NzbDrone.Core.DataAugmentation.Xem { _logger.Debug("Fetching Mappings for: {0}", id); - var restClient = new RestClient(XEM_BASE_URL); + var restClient = RestClientFactory.BuildClient(XEM_BASE_URL); var request = BuildRequest("all"); request.AddParameter("id", id); @@ -65,6 +68,52 @@ namespace NzbDrone.Core.DataAugmentation.Xem return response.Data.Where(c => c.Scene != null).ToList(); } + public List GetSceneTvdbNames() + { + _logger.Debug("Fetching alternate names"); + var restClient = RestClientFactory.BuildClient(XEM_BASE_URL); + + var request = BuildRequest("allNames"); + request.AddParameter("origin", "tvdb"); + request.AddParameter("seasonNumbers", true); + + var response = restClient.ExecuteAndValidate>>>(request); + CheckForFailureResult(response); + + var result = new List(); + + foreach (var series in response.Data) + { + foreach (var name in series.Value) + { + foreach (var n in name) + { + int seasonNumber; + if (!Int32.TryParse(n.Value.ToString(), out seasonNumber)) + { + continue; + } + + //hack to deal with Fate/Zero + if (series.Key == 79151 && seasonNumber > 1) + { + continue; + } + + result.Add(new SceneMapping + { + Title = n.Key, + SearchTerm = n.Key, + SeasonNumber = seasonNumber, + TvdbId = series.Key + }); + } + } + } + + return result; + } + private static void CheckForFailureResult(XemResult response) { if (response.Result.Equals("failure", StringComparison.InvariantCultureIgnoreCase) && @@ -73,7 +122,5 @@ namespace NzbDrone.Core.DataAugmentation.Xem throw new Exception("Error response received from Xem: " + response.Message); } } - - } } diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs index 57fa653af..4b9b4e43e 100644 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs @@ -1,14 +1,16 @@ using System; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tv; using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.DataAugmentation.Xem { - public class XemService : IHandle, IHandle + public class XemService : ISceneMappingProvider, IHandle, IHandle { private readonly IEpisodeService _episodeService; private readonly IXemProxy _xemProxy; @@ -47,7 +49,7 @@ namespace NzbDrone.Core.DataAugmentation.Xem foreach (var episode in episodes) { - episode.AbsoluteEpisodeNumber = 0; + episode.SceneAbsoluteEpisodeNumber = 0; episode.SceneSeasonNumber = 0; episode.SceneEpisodeNumber = 0; } @@ -64,7 +66,7 @@ namespace NzbDrone.Core.DataAugmentation.Xem continue; } - episode.AbsoluteEpisodeNumber = mapping.Scene.Absolute; + episode.SceneAbsoluteEpisodeNumber = mapping.Scene.Absolute; episode.SceneSeasonNumber = mapping.Scene.Season; episode.SceneEpisodeNumber = mapping.Scene.Episode; } @@ -96,6 +98,24 @@ namespace NzbDrone.Core.DataAugmentation.Xem } } + public List GetSceneMappings() + { + var mappings = _xemProxy.GetSceneTvdbNames(); + + return mappings.Where(m => + { + int id; + + if (Int32.TryParse(m.Title, out id)) + { + _logger.Debug("Skipping all numeric name: {0} for {1}", m.Title, m.TvdbId); + return false; + } + + return true; + }).ToList(); + } + public void Handle(SeriesUpdatedEvent message) { if (_cache.Count == 0) diff --git a/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs b/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs index 89e099605..4a2e94bbf 100644 --- a/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs +++ b/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs @@ -1,8 +1,4 @@ using NzbDrone.Core.Datastore.Migration.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using FluentMigrator; namespace NzbDrone.Core.Datastore.Migration diff --git a/src/NzbDrone.Core/Datastore/Migration/052_add_columns_for_anime.cs b/src/NzbDrone.Core/Datastore/Migration/052_add_columns_for_anime.cs new file mode 100644 index 000000000..e781ca010 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/052_add_columns_for_anime.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Datastore.Migration.Framework; +using FluentMigrator; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(52)] + public class add_columns_for_anime : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + //Support XEM names + Alter.Table("SceneMappings").AddColumn("Type").AsString().Nullable(); + Execute.Sql("DELETE FROM SceneMappings"); + + //Add AnimeEpisodeFormat (set to Stardard Episode format for now) + Alter.Table("NamingConfig").AddColumn("AnimeEpisodeFormat").AsString().Nullable(); + Execute.Sql("UPDATE NamingConfig SET AnimeEpisodeFormat = StandardEpisodeFormat"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/053_add_series_sorttitle.cs b/src/NzbDrone.Core/Datastore/Migration/053_add_series_sorttitle.cs new file mode 100644 index 000000000..fccaa97b1 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/053_add_series_sorttitle.cs @@ -0,0 +1,46 @@ +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(53)] + public class add_series_sorttitle : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.Column("SortTitle").OnTable("Series").AsString().Nullable(); + + Execute.WithConnection(SetSortTitles); + } + + private void SetSortTitles(IDbConnection conn, IDbTransaction tran) + { + using (IDbCommand getSeriesCmd = conn.CreateCommand()) + { + getSeriesCmd.Transaction = tran; + getSeriesCmd.CommandText = @"SELECT Id, Title FROM Series"; + using (IDataReader seriesReader = getSeriesCmd.ExecuteReader()) + { + while (seriesReader.Read()) + { + var id = seriesReader.GetInt32(0); + var title = seriesReader.GetString(1); + + var sortTitle = Parser.Parser.NormalizeEpisodeTitle(title).ToLower(); + + using (IDbCommand updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE Series SET SortTitle = ? WHERE Id = ?"; + updateCmd.AddParameter(sortTitle); + updateCmd.AddParameter(id); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index cfed22a5c..3babd682b 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -2,12 +2,14 @@ using System; using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Serializer; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Instrumentation.Extensions; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using NzbDrone.Common.Serializer; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine { @@ -63,16 +65,17 @@ namespace NzbDrone.Core.DecisionEngine { var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); - if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) { var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, report.TvRageId, searchCriteria); + if (specialEpisodeInfo != null) { parsedEpisodeInfo = specialEpisodeInfo; } } - if (parsedEpisodeInfo != null && !string.IsNullOrWhiteSpace(parsedEpisodeInfo.SeriesTitle)) + if (parsedEpisodeInfo != null && !parsedEpisodeInfo.SeriesTitle.IsNullOrWhiteSpace()) { var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvRageId, searchCriteria); remoteEpisode.Release = report; diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs new file mode 100644 index 000000000..2980a1132 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Text; +using System.Collections.Generic; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.DecisionEngine.Specifications; + +namespace NzbDrone.Core.DecisionEngine +{ + public interface IPrioritizeDownloadDecision + { + List PrioritizeDecisions(List decisions); + } + + public class DownloadDecisionPriorizationService : IPrioritizeDownloadDecision + { + public List PrioritizeDecisions(List decisions) + { + return decisions + .Where(c => c.RemoteEpisode.Series != null) + .GroupBy(c => c.RemoteEpisode.Series.Id, (i, s) => s + .OrderByDescending(c => c.RemoteEpisode.ParsedEpisodeInfo.Quality, new QualityModelComparer(s.First().RemoteEpisode.Series.QualityProfile)) + .ThenBy(c => c.RemoteEpisode.Episodes.Select(e => e.EpisodeNumber).MinOrDefault()) + .ThenBy(c => c.RemoteEpisode.Release.Size.Round(200.Megabytes()) / c.RemoteEpisode.Episodes.Count) + .ThenBy(c => c.RemoteEpisode.Release.Age)) + .SelectMany(c => c) + .Union(decisions.Where(c => c.RemoteEpisode.Series == null)) + .ToList(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs index 92f73d562..64f19ad72 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs @@ -11,12 +11,12 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { public class NotInQueueSpecification : IDecisionEngineSpecification { - private readonly IQueueService _queueService; + private readonly IDownloadTrackingService _downloadTrackingService; private readonly Logger _logger; - public NotInQueueSpecification(IQueueService queueService, Logger logger) + public NotInQueueSpecification(IDownloadTrackingService downloadTrackingService, Logger logger) { - _queueService = queueService; + _downloadTrackingService = downloadTrackingService; _logger = logger; } @@ -30,7 +30,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public bool IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) { - var queue = _queueService.GetQueue().Select(q => q.RemoteEpisode); + var queue = _downloadTrackingService.GetQueuedDownloads() + .Where(v => v.State == TrackedDownloadState.Downloading) + .Select(q => q.DownloadItem.RemoteEpisode).ToList(); if (IsInQueue(subject, queue)) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs index 6698d49dd..4d089ce68 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/DailyEpisodeMatchSpecification.cs @@ -36,7 +36,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.Search var episode = _episodeService.GetEpisode(dailySearchSpec.Series.Id, dailySearchSpec.AirDate.ToString(Episode.AIR_DATE_FORMAT)); - if (!remoteEpisode.ParsedEpisodeInfo.IsDaily() || remoteEpisode.ParsedEpisodeInfo.AirDate != episode.AirDate) + if (!remoteEpisode.ParsedEpisodeInfo.IsDaily || remoteEpisode.ParsedEpisodeInfo.AirDate != episode.AirDate) { _logger.Debug("Episode AirDate does not match searched episode number, skipping."); return false; diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs index 2b8a8674a..9598e04ef 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs @@ -1,14 +1,30 @@ -using NzbDrone.Common.Exceptions; +using System; +using NzbDrone.Common.Exceptions; namespace NzbDrone.Core.Download.Clients { public class DownloadClientException : NzbDroneException { - public DownloadClientException(string message, params object[] args) : base(message, args) + public DownloadClientException(string message, params object[] args) + : base(string.Format(message, args)) { + } - public DownloadClientException(string message) : base(message) + public DownloadClientException(string message) + : base(message) + { + + } + + public DownloadClientException(string message, Exception innerException, params object[] args) + : base(string.Format(message, args), innerException) + { + + } + + public DownloadClientException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index 6dd047f4c..76a87c911 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -2,29 +2,31 @@ using System.IO; using System.Linq; using System.Collections.Generic; +using FluentValidation.Results; using NLog; using NzbDrone.Common; +using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using Omu.ValueInjecter; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.Nzbget { - public class Nzbget : DownloadClientBase, IExecute + public class Nzbget : DownloadClientBase { private readonly INzbgetProxy _proxy; private readonly IHttpProvider _httpProvider; public Nzbget(INzbgetProxy proxy, - IConfigService configService, - IParsingService parsingService, IHttpProvider httpProvider, + IConfigService configService, + IDiskProvider diskProvider, + IParsingService parsingService, Logger logger) - : base(configService, parsingService, logger) + : base(configService, diskProvider, parsingService, logger) { _proxy = proxy; _httpProvider = httpProvider; @@ -188,25 +190,49 @@ namespace NzbDrone.Core.Download.Clients.Nzbget public override IEnumerable GetItems() { + Dictionary config = null; + NzbgetCategory category = null; + try + { + if (!Settings.TvCategoryLocalPath.IsNullOrWhiteSpace()) + { + config = _proxy.GetConfig(Settings); + category = GetCategories(config).FirstOrDefault(v => v.Name == Settings.TvCategory); + } + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + yield break; + } + foreach (var downloadClientItem in GetQueue().Concat(GetHistory())) { - if (downloadClientItem.Category != Settings.TvCategory) continue; + if (downloadClientItem.Category == Settings.TvCategory) + { + if (category != null) + { + RemapStorage(downloadClientItem, category.DestDir, Settings.TvCategoryLocalPath); + } - downloadClientItem.RemoteEpisode = GetRemoteEpisode(downloadClientItem.Title); - if (downloadClientItem.RemoteEpisode == null) continue; + downloadClientItem.RemoteEpisode = GetRemoteEpisode(downloadClientItem.Title); + if (downloadClientItem.RemoteEpisode == null) continue; - yield return downloadClientItem; + yield return downloadClientItem; + } } } - public override void RemoveItem(string id) + public override void RemoveItem(String id) { _proxy.RemoveFromHistory(id, Settings); } - public override void RetryDownload(string id) + public override String RetryDownload(String id) { _proxy.RetryDownload(id, Settings); + + return id; } public override DownloadClientStatus GetStatus() @@ -222,7 +248,14 @@ namespace NzbDrone.Core.Download.Clients.Nzbget if (category != null) { - status.OutputRootFolders = new List { category.DestDir }; + if (Settings.TvCategoryLocalPath.IsNullOrWhiteSpace()) + { + status.OutputRootFolders = new List { category.DestDir }; + } + else + { + status.OutputRootFolders = new List { Settings.TvCategoryLocalPath }; + } } return status; @@ -260,30 +293,51 @@ namespace NzbDrone.Core.Download.Clients.Nzbget } } - private String GetVersion(string host = null, int port = 0, string username = null, string password = null) + protected override void Test(List failures) { - return _proxy.GetVersion(Settings); - } + failures.AddIfNotNull(TestConnection()); + failures.AddIfNotNull(TestCategory()); - public override void Test(NzbgetSettings settings) - { - _proxy.GetVersion(settings); - - var config = _proxy.GetConfig(settings); - var categories = GetCategories(config); - - if (!settings.TvCategory.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == settings.TvCategory)) + if (!Settings.TvCategoryLocalPath.IsNullOrWhiteSpace()) { - throw new ApplicationException("Category does not exist"); + failures.AddIfNotNull(TestFolder(Settings.TvCategoryLocalPath, "TvCategoryLocalPath")); } } - public void Execute(TestNzbgetCommand message) + private ValidationFailure TestConnection() { - var settings = new NzbgetSettings(); - settings.InjectFrom(message); + try + { + _proxy.GetVersion(Settings); + } + catch (Exception ex) + { + if (ex.Message.ContainsIgnoreCase("Authentication failed")) + { + return new ValidationFailure("Username", "Authentication failed"); + } + _logger.ErrorException(ex.Message, ex); + return new ValidationFailure("Host", "Unable to connect to NZBGet"); + } - Test(settings); + return null; + } + + private ValidationFailure TestCategory() + { + var config = _proxy.GetConfig(Settings); + var categories = GetCategories(config); + + if (!Settings.TvCategory.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.TvCategory)) + { + return new NzbDroneValidationFailure("TvCategory", "Category does not exist") + { + InfoLink = String.Format("http://{0}:{1}/", Settings.Host, Settings.Port), + DetailedDescription = "The Category your entered doesn't exist in NzbGet. Go to NzbGet to create it." + }; + } + + return null; } // Javascript doesn't support 64 bit integers natively so json officially doesn't either. diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs index 6f7f50a6f..343250598 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -172,7 +172,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget _logger.Debug("Url: " + url); - var client = new RestClient(url); + var client = RestClientFactory.BuildClient(url); client.Authenticator = new HttpBasicAuthenticator(settings.Username, settings.Password); return client; @@ -193,7 +193,12 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { if (response.ResponseStatus != ResponseStatus.Completed) { - throw new DownloadClientException("Unable to connect to NzbGet, please check your settings"); + throw new DownloadClientException("Unable to connect to NzbGet, please check your settings", response.ErrorException); + } + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + throw new DownloadClientException("Authentication failed for NzbGet, please check your settings", response.ErrorException); } var result = Json.Deserialize(response.Content); diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs index 57f79bfb4..83d72e2e4 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs @@ -3,6 +3,7 @@ using FluentValidation; using FluentValidation.Results; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation.Paths; namespace NzbDrone.Core.Download.Clients.Nzbget { @@ -14,6 +15,9 @@ namespace NzbDrone.Core.Download.Clients.Nzbget RuleFor(c => c.Port).GreaterThan(0); RuleFor(c => c.Username).NotEmpty().When(c => !String.IsNullOrWhiteSpace(c.Password)); RuleFor(c => c.Password).NotEmpty().When(c => !String.IsNullOrWhiteSpace(c.Username)); + + RuleFor(c => c.TvCategory).NotEmpty().When(c => !String.IsNullOrWhiteSpace(c.TvCategoryLocalPath)); + RuleFor(c => c.TvCategoryLocalPath).IsValidPath().When(c => !String.IsNullOrWhiteSpace(c.TvCategoryLocalPath)); } } @@ -45,13 +49,16 @@ namespace NzbDrone.Core.Download.Clients.Nzbget [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox)] public String TvCategory { get; set; } - [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(5, Label = "Category Local Path", Type = FieldType.Textbox, Advanced = true, HelpText = "Local path to the category output dir. Useful if Nzbget runs on another computer.")] + public String TvCategoryLocalPath { get; set; } + + [FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] public Int32 RecentTvPriority { get; set; } - [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public Int32 OlderTvPriority { get; set; } - [FieldDefinition(7, Label = "Use SSL", Type = FieldType.Checkbox)] + [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] public Boolean UseSsl { get; set; } public ValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs deleted file mode 100644 index f596d6b25..000000000 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Download.Clients.Nzbget -{ - public class TestNzbgetCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - - public String Host { get; set; } - public Int32 Port { get; set; } - public String Username { get; set; } - public String Password { get; set; } - public String TvCategory { get; set; } - public Int32 RecentTvPriority { get; set; } - public Int32 OlderTvPriority { get; set; } - public Boolean UseSsl { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index e8f0b7a09..c0f202af3 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -1,37 +1,31 @@ using System; using System.Collections.Generic; using System.IO; +using FluentValidation.Results; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Http; -using NzbDrone.Common.Instrumentation; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using Omu.ValueInjecter; namespace NzbDrone.Core.Download.Clients.Pneumatic { - public class Pneumatic : DownloadClientBase, IExecute + public class Pneumatic : DownloadClientBase { private readonly IHttpProvider _httpProvider; - private readonly IDiskProvider _diskProvider; - - private static readonly Logger logger = NzbDroneLogger.GetLogger(); public Pneumatic(IHttpProvider httpProvider, - IDiskProvider diskProvider, IConfigService configService, + IDiskProvider diskProvider, IParsingService parsingService, Logger logger) - : base(configService, parsingService, logger) + : base(configService, diskProvider, parsingService, logger) { _httpProvider = httpProvider; - _diskProvider = diskProvider; } public override DownloadProtocol Protocol @@ -57,10 +51,10 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic //Save to the Pneumatic directory (The user will need to ensure its accessible by XBMC) var filename = Path.Combine(Settings.NzbFolder, title + ".nzb"); - logger.Debug("Downloading NZB from: {0} to: {1}", url, filename); + _logger.Debug("Downloading NZB from: {0} to: {1}", url, filename); _httpProvider.DownloadFile(url, filename); - logger.Debug("NZB Download succeeded, saved to: {0}", filename); + _logger.Debug("NZB Download succeeded, saved to: {0}", filename); var contents = String.Format("plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb={0}&nzbname={1}", filename, title); _diskProvider.WriteAllText(Path.Combine(_configService.DownloadedEpisodesFolder, title + ".strm"), contents); @@ -80,13 +74,13 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic { return new DownloadClientItem[0]; } - - public override void RemoveItem(string id) + + public override void RemoveItem(String id) { throw new NotSupportedException(); } - public override void RetryDownload(string id) + public override String RetryDownload(String id) { throw new NotSupportedException(); } @@ -101,24 +95,31 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic return status; } - public override void Test(PneumaticSettings settings) + protected override void Test(List failures) { - PerformWriteTest(settings.NzbFolder); + failures.AddIfNotNull(TestWrite(Settings.NzbFolder, "NzbFolder")); } - private void PerformWriteTest(string folder) + private ValidationFailure TestWrite(String folder, String propertyName) { - var testPath = Path.Combine(folder, "drone_test.txt"); - _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); - _diskProvider.DeleteFile(testPath); - } + if (!_diskProvider.FolderExists(folder)) + { + return new ValidationFailure(propertyName, "Folder does not exist"); + } - public void Execute(TestPneumaticCommand message) - { - var settings = new PneumaticSettings(); - settings.InjectFrom(message); + try + { + var testPath = Path.Combine(folder, "drone_test.txt"); + _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); + _diskProvider.DeleteFile(testPath); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new ValidationFailure(propertyName, "Unable to write to folder"); + } - Test(settings); + return null; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs deleted file mode 100644 index 097a39aa5..000000000 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Download.Clients.Pneumatic -{ - public class TestPneumaticCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - public String Folder { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs new file mode 100644 index 000000000..ae681718a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdConfigResponse + { + public SabnzbdConfig Config { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 5c5a05e31..9dbd60a8f 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -2,32 +2,34 @@ using System.IO; using System.Collections.Generic; using System.Linq; +using FluentValidation.Results; using NLog; using NzbDrone.Common; +using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using Omu.ValueInjecter; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public class Sabnzbd : DownloadClientBase, IExecute + public class Sabnzbd : DownloadClientBase { private readonly IHttpProvider _httpProvider; private readonly ISabnzbdProxy _proxy; - public Sabnzbd(IHttpProvider httpProvider, - ISabnzbdProxy proxy, + public Sabnzbd(ISabnzbdProxy proxy, + IHttpProvider httpProvider, IConfigService configService, + IDiskProvider diskProvider, IParsingService parsingService, Logger logger) - : base(configService, parsingService, logger) + : base(configService, diskProvider, parsingService, logger) { - _httpProvider = httpProvider; _proxy = proxy; + _httpProvider = httpProvider; } public override DownloadProtocol Protocol @@ -161,14 +163,16 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd if (!sabHistoryItem.Storage.IsNullOrWhiteSpace()) { - var parent = Directory.GetParent(sabHistoryItem.Storage); - if (parent != null && parent.Name == sabHistoryItem.Title) + historyItem.OutputPath = sabHistoryItem.Storage; + + var parent = sabHistoryItem.Storage.GetParentPath(); + while (parent != null) { - historyItem.OutputPath = parent.FullName; - } - else - { - historyItem.OutputPath = sabHistoryItem.Storage; + if (Path.GetFileName(parent) == sabHistoryItem.Title) + { + historyItem.OutputPath = parent; + } + parent = parent.GetParentPath(); } } @@ -180,18 +184,40 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd public override IEnumerable GetItems() { + SabnzbdConfig config = null; + SabnzbdCategory category = null; + try + { + if (!Settings.TvCategoryLocalPath.IsNullOrWhiteSpace()) + { + config = _proxy.GetConfig(Settings); + category = GetCategories(config).FirstOrDefault(v => v.Name == Settings.TvCategory); + } + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + yield break; + } + foreach (var downloadClientItem in GetQueue().Concat(GetHistory())) { - if (downloadClientItem.Category != Settings.TvCategory) continue; + if (downloadClientItem.Category == Settings.TvCategory) + { + if (category != null) + { + RemapStorage(downloadClientItem, category.FullPath, Settings.TvCategoryLocalPath); + } - downloadClientItem.RemoteEpisode = GetRemoteEpisode(downloadClientItem.Title); - if (downloadClientItem.RemoteEpisode == null) continue; + downloadClientItem.RemoteEpisode = GetRemoteEpisode(downloadClientItem.Title); + if (downloadClientItem.RemoteEpisode == null) continue; - yield return downloadClientItem; + yield return downloadClientItem; + } } } - public override void RemoveItem(string id) + public override void RemoveItem(String id) { if (GetQueue().Any(v => v.DownloadClientId == id)) { @@ -203,37 +229,206 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } } - public override void RetryDownload(string id) + public override String RetryDownload(String id) { + // Sabnzbd changed the nzo_id for retried downloads without reporting it back to us. We need to try to determine the new ID. + + var history = GetHistory().Where(v => v.DownloadClientId == id).ToList(); + _proxy.RetryDownload(id, Settings); + + if (history.Count() != 1) + { + return id; + } + + var queue = GetQueue().Where(v => v.Category == history.First().Category && v.Title == history.First().Title).ToList(); + + if (queue.Count() != 1) + { + return id; + } + + return queue.First().DownloadClientId; + } + + protected IEnumerable GetCategories(SabnzbdConfig config) + { + var completeDir = config.Misc.complete_dir.TrimEnd('\\', '/'); + + if (!completeDir.StartsWith("/") && !completeDir.StartsWith("\\") && !completeDir.Contains(':')) + { + var queue = _proxy.GetQueue(0, 1, Settings); + + if (queue.DefaultRootFolder.StartsWith("/")) + { + completeDir = queue.DefaultRootFolder + "/" + completeDir; + } + else + { + completeDir = queue.DefaultRootFolder + "\\" + completeDir; + } + } + + foreach (var category in config.Categories) + { + var relativeDir = category.Dir.TrimEnd('*'); + + if (relativeDir.IsNullOrWhiteSpace()) + { + category.FullPath = completeDir; + } + else if (completeDir.StartsWith("/")) + { // Process remote Linux paths irrespective of our own OS. + if (relativeDir.StartsWith("/")) + { + category.FullPath = relativeDir; + } + else + { + category.FullPath = completeDir + "/" + relativeDir; + } + } + else + { // Process remote Windows paths irrespective of our own OS. + if (relativeDir.StartsWith("\\") || relativeDir.Contains(':')) + { + category.FullPath = relativeDir; + } + else + { + category.FullPath = completeDir + "\\" + relativeDir; + } + } + + yield return category; + } } public override DownloadClientStatus GetStatus() { + var config = _proxy.GetConfig(Settings); + var categories = GetCategories(config).ToArray(); + + var category = categories.FirstOrDefault(v => v.Name == Settings.TvCategory); + + if (category == null) + { + category = categories.FirstOrDefault(v => v.Name == "*"); + } + var status = new DownloadClientStatus { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; + if (category != null) + { + if (Settings.TvCategoryLocalPath.IsNullOrWhiteSpace()) + { + status.OutputRootFolders = new List { category.FullPath }; + } + else + { + status.OutputRootFolders = new List { Settings.TvCategoryLocalPath }; + } + } + return status; } - public override void Test(SabnzbdSettings settings) + protected override void Test(List failures) { - var categories = _proxy.GetCategories(settings); + failures.AddIfNotNull(TestConnection()); + failures.AddIfNotNull(TestAuthentication()); + failures.AddIfNotNull(TestCategory()); - if (!settings.TvCategory.IsNullOrWhiteSpace() && !categories.Any(v => v == settings.TvCategory)) + if (!Settings.TvCategoryLocalPath.IsNullOrWhiteSpace()) { - throw new ApplicationException("Category does not exist"); + failures.AddIfNotNull(TestFolder(Settings.TvCategoryLocalPath, "TvCategoryLocalPath")); } } - public void Execute(TestSabnzbdCommand message) + private ValidationFailure TestConnection() { - var settings = new SabnzbdSettings(); - settings.InjectFrom(message); + try + { + _proxy.GetVersion(Settings); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new ValidationFailure("Host", "Unable to connect to SABnzbd"); + } - Test(settings); + return null; + } + + private ValidationFailure TestAuthentication() + { + try + { + _proxy.GetConfig(Settings); + } + catch (Exception ex) + { + if (ex.Message.ContainsIgnoreCase("API Key Incorrect")) + { + return new ValidationFailure("APIKey", "API Key Incorrect"); + } + if (ex.Message.ContainsIgnoreCase("API Key Required")) + { + return new ValidationFailure("APIKey", "API Key Required"); + } + throw; + } + + return null; + } + + private ValidationFailure TestCategory() + { + var config = this._proxy.GetConfig(Settings); + var category = GetCategories(config).FirstOrDefault((SabnzbdCategory v) => v.Name == Settings.TvCategory); + + if (category != null) + { + if (category.Dir.EndsWith("*")) + { + return new NzbDroneValidationFailure("TvCategory", "Enable Job folders") + { + InfoLink = String.Format("http://{0}:{1}/sabnzbd/config/categories/", Settings.Host, Settings.Port), + DetailedDescription = "NzbDrone prefers each download to have a separate folder. With * appended to the Folder/Path Sabnzbd will not create these job folders. Go to Sabnzbd to fix it." + }; + } + } + else + { + if (!Settings.TvCategory.IsNullOrWhiteSpace()) + { + return new NzbDroneValidationFailure("TvCategory", "Category does not exist") + { + InfoLink = String.Format("http://{0}:{1}/sabnzbd/config/categories/", Settings.Host, Settings.Port), + DetailedDescription = "The Category your entered doesn't exist in Sabnzbd. Go to Sabnzbd to create it." + }; + } + } + + if (config.Misc.enable_tv_sorting) + { + if (!config.Misc.tv_categories.Any() || + config.Misc.tv_categories.Contains(Settings.TvCategory) || + (Settings.TvCategory.IsNullOrWhiteSpace() && config.Misc.tv_categories.Contains("Default"))) + { + return new NzbDroneValidationFailure("TvCategory", "Disable TV Sorting") + { + InfoLink = String.Format("http://{0}:{1}/sabnzbd/config/sorting/", Settings.Host, Settings.Port), + DetailedDescription = "You must disable Sabnzbd TV Sorting for the category NzbDrone uses to prevent import issues. Go to Sabnzbd to fix it." + }; + } + } + + return null; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs new file mode 100644 index 000000000..15913c620 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdConfig + { + public SabnzbdConfigMisc Misc { get; set; } + + public List Categories { get; set; } + + public List Servers { get; set; } + } + + public class SabnzbdConfigMisc + { + public String complete_dir { get; set; } + public String[] tv_categories { get; set; } + public Boolean enable_tv_sorting { get; set; } + } + + public class SabnzbdCategory + { + public Int32 Priority { get; set; } + public String PP { get; set; } + public String Name { get; set; } + public String Script { get; set; } + public String Dir { get; set; } + + public String FullPath { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs index 00e943721..fb54f969c 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -7,6 +7,7 @@ using NLog; using NzbDrone.Common; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; +using NzbDrone.Core.Rest; using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; using RestSharp; @@ -18,7 +19,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd void RemoveFrom(string source, string id, SabnzbdSettings settings); string ProcessRequest(IRestRequest restRequest, string action, SabnzbdSettings settings); SabnzbdVersionResponse GetVersion(SabnzbdSettings settings); - List GetCategories(SabnzbdSettings settings); + SabnzbdConfig GetConfig(SabnzbdSettings settings); SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings); SabnzbdHistory GetHistory(int start, int limit, SabnzbdSettings settings); void RetryDownload(string id, SabnzbdSettings settings); @@ -85,14 +86,14 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd return response; } - public List GetCategories(SabnzbdSettings settings) + public SabnzbdConfig GetConfig(SabnzbdSettings settings) { var request = new RestRequest(); - var action = "mode=get_cats"; + var action = "mode=get_config"; - var response = Json.Deserialize(ProcessRequest(request, action, settings)).Categories; + var response = Json.Deserialize(ProcessRequest(request, action, settings)); - return response; + return response.Config; } public SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings) @@ -138,14 +139,14 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd _logger.Debug("Url: " + url); - return new RestClient(url); + return RestClientFactory.BuildClient(url); } private void CheckForError(IRestResponse response) { if (response.ResponseStatus != ResponseStatus.Completed) { - throw new DownloadClientException("Unable to connect to SABnzbd, please check your settings"); + throw new DownloadClientException("Unable to connect to SABnzbd, please check your settings", response.ErrorException); } SabnzbdJsonError result; diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs index edbdab5da..5640ae4ec 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs @@ -5,6 +5,9 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { public class SabnzbdQueue { + [JsonProperty(PropertyName = "my_home")] + public string DefaultRootFolder { get; set; } + public bool Paused { get; set; } [JsonProperty(PropertyName = "slots")] diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs index 749cd2ed1..75d6e918b 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs @@ -3,6 +3,7 @@ using FluentValidation; using FluentValidation.Results; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation.Paths; namespace NzbDrone.Core.Download.Clients.Sabnzbd { @@ -21,10 +22,12 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd .WithMessage("Username is required when API key is not configured") .When(c => String.IsNullOrWhiteSpace(c.ApiKey)); - RuleFor(c => c.Password).NotEmpty() .WithMessage("Password is required when API key is not configured") .When(c => String.IsNullOrWhiteSpace(c.ApiKey)); + + RuleFor(c => c.TvCategory).NotEmpty().When(c => !String.IsNullOrWhiteSpace(c.TvCategoryLocalPath)); + RuleFor(c => c.TvCategoryLocalPath).IsValidPath().When(c => !String.IsNullOrWhiteSpace(c.TvCategoryLocalPath)); } } @@ -59,13 +62,16 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox)] public String TvCategory { get; set; } - [FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(6, Label = "Category Local Path", Type = FieldType.Textbox, Advanced = true, HelpText = "Local path to the category output dir. Useful if Sabnzbd runs on another computer.")] + public String TvCategoryLocalPath { get; set; } + + [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] public Int32 RecentTvPriority { get; set; } - [FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(8, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] public Int32 OlderTvPriority { get; set; } - [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] + [FieldDefinition(9, Label = "Use SSL", Type = FieldType.Checkbox)] public Boolean UseSsl { get; set; } public ValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs deleted file mode 100644 index 458b62f3a..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class TestSabnzbdCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - - public String Host { get; set; } - public Int32 Port { get; set; } - public String ApiKey { get; set; } - public String Username { get; set; } - public String Password { get; set; } - public String TvCategory { get; set; } - public Int32 RecentTvPriority { get; set; } - public Int32 OlderTvPriority { get; set; } - public Boolean UseSsl { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/TestUsenetBlackholeCommand.cs b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/TestUsenetBlackholeCommand.cs deleted file mode 100644 index e4db46d4a..000000000 --- a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/TestUsenetBlackholeCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Download.Clients.UsenetBlackhole -{ - public class TestUsenetBlackholeCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - public String NzbFolder { get; set; } - public String WatchFolder { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs index 290eb8564..bbc077dcd 100644 --- a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs @@ -2,36 +2,33 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using FluentValidation.Results; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.MediaFiles; -using Omu.ValueInjecter; namespace NzbDrone.Core.Download.Clients.UsenetBlackhole { - public class UsenetBlackhole : DownloadClientBase, IExecute + public class UsenetBlackhole : DownloadClientBase { - private readonly IDiskProvider _diskProvider; private readonly IDiskScanService _diskScanService; private readonly IHttpProvider _httpProvider; - public UsenetBlackhole(IDiskProvider diskProvider, - IDiskScanService diskScanService, + public UsenetBlackhole(IDiskScanService diskScanService, IHttpProvider httpProvider, IConfigService configService, + IDiskProvider diskProvider, IParsingService parsingService, Logger logger) - : base(configService, parsingService, logger) + : base(configService, diskProvider, parsingService, logger) { - _diskProvider = diskProvider; _diskScanService = diskScanService; _httpProvider = httpProvider; } @@ -127,12 +124,12 @@ namespace NzbDrone.Core.Download.Clients.UsenetBlackhole } } - public override void RemoveItem(string id) + public override void RemoveItem(String id) { throw new NotSupportedException(); } - public override void RetryDownload(string id) + public override String RetryDownload(String id) { throw new NotSupportedException(); } @@ -146,25 +143,10 @@ namespace NzbDrone.Core.Download.Clients.UsenetBlackhole }; } - public override void Test(UsenetBlackholeSettings settings) + protected override void Test(List failures) { - PerformWriteTest(settings.NzbFolder); - PerformWriteTest(settings.WatchFolder); - } - - private void PerformWriteTest(string folder) - { - var testPath = Path.Combine(folder, "drone_test.txt"); - _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); - _diskProvider.DeleteFile(testPath); - } - - public void Execute(TestUsenetBlackholeCommand message) - { - var settings = new UsenetBlackholeSettings(); - settings.InjectFrom(message); - - Test(settings); + failures.AddIfNotNull(TestFolder(Settings.NzbFolder, "NzbFolder")); + failures.AddIfNotNull(TestFolder(Settings.WatchFolder, "WatchFolder")); } } } diff --git a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackholeSettings.cs b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackholeSettings.cs index dd5371af8..90d99484a 100644 --- a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackholeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackholeSettings.cs @@ -1,7 +1,6 @@ using System; using FluentValidation; using FluentValidation.Results; -using NzbDrone.Common.Disk; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation.Paths; @@ -12,7 +11,6 @@ namespace NzbDrone.Core.Download.Clients.UsenetBlackhole { public UsenetBlackholeSettingsValidator() { - //Todo: Validate that the path actually exists RuleFor(c => c.NzbFolder).IsValidPath(); RuleFor(c => c.WatchFolder).IsValidPath(); } diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 7ae4b422b..21c8c8835 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Download if (!grabbedItems.Any() && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) { - _logger.Trace("Ignoring download that wasn't grabbed by drone: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Download wasn't grabbed by drone or not in a category, ignoring download."); return; } @@ -77,7 +77,7 @@ namespace NzbDrone.Core.Download { trackedDownload.State = TrackedDownloadState.Imported; - _logger.Debug("Already added to history as imported: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Already added to history as imported."); } else { @@ -85,13 +85,13 @@ namespace NzbDrone.Core.Download string downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; if (downloadItemOutputPath.IsNullOrWhiteSpace()) { - _logger.Trace("Storage path not specified: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Download doesn't contain intermediate path, ignoring download."); return; } if (!downloadedEpisodesFolder.IsNullOrWhiteSpace() && (downloadedEpisodesFolder.PathEquals(downloadItemOutputPath) || downloadedEpisodesFolder.IsParentPath(downloadItemOutputPath))) { - _logger.Trace("Storage path inside drone factory, ignoring download: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Intermediate Download path inside drone factory, ignoring download."); return; } @@ -99,19 +99,49 @@ namespace NzbDrone.Core.Download { var decisions = _downloadedEpisodesImportService.ProcessFolder(new DirectoryInfo(trackedDownload.DownloadItem.OutputPath), trackedDownload.DownloadItem); - if (decisions.Any()) + if (!decisions.Any()) { + UpdateStatusMessage(trackedDownload, LogLevel.Error, "No files found eligible for import in {0}", trackedDownload.DownloadItem.OutputPath); + } + else if (decisions.Any(v => v.Approved)) + { + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Imported {0} files.", decisions.Count(v => v.Approved)); + trackedDownload.State = TrackedDownloadState.Imported; } + else + { + var rejections = decisions + .Where(v => !v.Approved) + .Select(v => v.Rejections.Aggregate(Path.GetFileName(v.LocalEpisode.Path), (a, r) => a + "\r\n- " + r)) + .Aggregate("Failed to import:", (a, r) => a + "\r\n" + r); + + UpdateStatusMessage(trackedDownload, LogLevel.Error, rejections); + } } else if (_diskProvider.FileExists(trackedDownload.DownloadItem.OutputPath)) { var decisions = _downloadedEpisodesImportService.ProcessFile(new FileInfo(trackedDownload.DownloadItem.OutputPath), trackedDownload.DownloadItem); - if (decisions.Any()) + if (!decisions.Any()) { + UpdateStatusMessage(trackedDownload, LogLevel.Error, "No files found eligible for import in {0}", trackedDownload.DownloadItem.OutputPath); + } + else if (decisions.Any(v => v.Approved)) + { + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Imported {0} files.", decisions.Count(v => v.Approved)); + trackedDownload.State = TrackedDownloadState.Imported; } + else + { + var rejections = decisions + .Where(v => !v.Approved) + .Select(v => v.Rejections.Aggregate(Path.GetFileName(v.LocalEpisode.Path), (a, r) => a + "\r\n- " + r)) + .Aggregate("Failed to import:", (a, r) => a + "\r\n" + r); + + UpdateStatusMessage(trackedDownload, LogLevel.Error, rejections); + } } else { @@ -137,13 +167,13 @@ namespace NzbDrone.Core.Download importedItems.First().Data[DownloadTrackingService.DOWNLOAD_CLIENT_ID] = grabbedItems.First().Data[DownloadTrackingService.DOWNLOAD_CLIENT_ID]; _historyService.UpdateHistoryData(importedItems.First().Id, importedItems.First().Data); - _logger.Debug("Storage path does not exist, but found probable drone factory ImportEvent: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Intermediate Download path does not exist, but found probable drone factory ImportEvent."); return; } } } - _logger.Debug("Storage path does not exist: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Error, "Intermediate Download path does not exist: {0}", trackedDownload.DownloadItem.OutputPath); return; } } @@ -153,17 +183,17 @@ namespace NzbDrone.Core.Download { try { - _logger.Info("Removing completed download from history: {0}", trackedDownload.DownloadItem.Title); + _logger.Debug("[{0}] Removing completed download from history.", trackedDownload.DownloadItem.Title); downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadClientId); if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath)) { - _logger.Info("Removing completed download directory: {0}", trackedDownload.DownloadItem.OutputPath); + _logger.Debug("Removing completed download directory: {0}", trackedDownload.DownloadItem.OutputPath); _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath, true); } else if (_diskProvider.FileExists(trackedDownload.DownloadItem.OutputPath)) { - _logger.Info("Removing completed download file: {0}", trackedDownload.DownloadItem.OutputPath); + _logger.Debug("Removing completed download file: {0}", trackedDownload.DownloadItem.OutputPath); _diskProvider.DeleteFile(trackedDownload.DownloadItem.OutputPath); } @@ -171,9 +201,26 @@ namespace NzbDrone.Core.Download } catch (NotSupportedException) { - _logger.Debug("Removing item not supported by your download client"); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Removing item not supported by your download client."); } } } + + private void UpdateStatusMessage(TrackedDownload trackedDownload, LogLevel logLevel, String message, params object[] args) + { + var statusMessage = String.Format(message, args); + var logMessage = String.Format("[{0}] {1}", trackedDownload.DownloadItem.Title, statusMessage); + + if (trackedDownload.StatusMessage != statusMessage) + { + trackedDownload.HasError = logLevel >= LogLevel.Warn; + trackedDownload.StatusMessage = statusMessage; + _logger.Log(logLevel, logMessage); + } + else + { + _logger.Debug(logMessage); + } + } } } diff --git a/src/NzbDrone.Core/Download/DownloadApprovedReports.cs b/src/NzbDrone.Core/Download/DownloadApprovedReports.cs index 1c8ef160b..c5dd7ef58 100644 --- a/src/NzbDrone.Core/Download/DownloadApprovedReports.cs +++ b/src/NzbDrone.Core/Download/DownloadApprovedReports.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Qualities; @@ -15,20 +16,23 @@ namespace NzbDrone.Core.Download public class DownloadApprovedReports : IDownloadApprovedReports { private readonly IDownloadService _downloadService; + private readonly IPrioritizeDownloadDecision _prioritizeDownloadDecision; private readonly Logger _logger; - public DownloadApprovedReports(IDownloadService downloadService, Logger logger) + public DownloadApprovedReports(IDownloadService downloadService, IPrioritizeDownloadDecision prioritizeDownloadDecision, Logger logger) { _downloadService = downloadService; + _prioritizeDownloadDecision = prioritizeDownloadDecision; _logger = logger; } public List DownloadApproved(List decisions) { var qualifiedReports = GetQualifiedReports(decisions); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(qualifiedReports); var downloadedReports = new List(); - foreach (var report in qualifiedReports) + foreach (var report in prioritizedDecisions) { var remoteEpisode = report.RemoteEpisode; @@ -57,14 +61,7 @@ namespace NzbDrone.Core.Download public List GetQualifiedReports(IEnumerable decisions) { - return decisions.Where(c => c.Approved && c.RemoteEpisode.Episodes.Any()) - .GroupBy(c => c.RemoteEpisode.Series.Id, (i,s) => s - .OrderByDescending(c => c.RemoteEpisode.ParsedEpisodeInfo.Quality, new QualityModelComparer(s.First().RemoteEpisode.Series.QualityProfile)) - .ThenBy(c => c.RemoteEpisode.Episodes.Select(e => e.EpisodeNumber).MinOrDefault()) - .ThenBy(c => c.RemoteEpisode.Release.Size.Round(200.Megabytes()) / c.RemoteEpisode.Episodes.Count) - .ThenBy(c => c.RemoteEpisode.Release.Age)) - .SelectMany(c => c) - .ToList(); + return decisions.Where(c => c.Approved && c.RemoteEpisode.Episodes.Any()).ToList(); } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 2176ba21f..e3b07e4dc 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -1,12 +1,17 @@ using System; +using System.IO; using System.Linq; using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Disk; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Configuration; using NLog; +using FluentValidation.Results; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download { @@ -14,7 +19,8 @@ namespace NzbDrone.Core.Download where TSettings : IProviderConfig, new() { protected readonly IConfigService _configService; - private readonly IParsingService _parsingService; + protected readonly IDiskProvider _diskProvider; + protected readonly IParsingService _parsingService; protected readonly Logger _logger; public Type ConfigContract @@ -43,9 +49,10 @@ namespace NzbDrone.Core.Download } } - protected DownloadClientBase(IConfigService configService, IParsingService parsingService, Logger logger) + protected DownloadClientBase(IConfigService configService, IDiskProvider diskProvider, IParsingService parsingService, Logger logger) { _configService = configService; + _diskProvider = diskProvider; _parsingService = parsingService; _logger = logger; } @@ -55,21 +62,17 @@ namespace NzbDrone.Core.Download return GetType().Name; } - - public abstract DownloadProtocol Protocol { get; } - public abstract string Download(RemoteEpisode remoteEpisode); + public abstract String Download(RemoteEpisode remoteEpisode); public abstract IEnumerable GetItems(); public abstract void RemoveItem(string id); - public abstract void RetryDownload(string id); + public abstract String RetryDownload(string id); public abstract DownloadClientStatus GetStatus(); - public abstract void Test(TSettings settings); - protected RemoteEpisode GetRemoteEpisode(String title) { var parsedEpisodeInfo = Parser.Parser.ParseTitle(title); @@ -80,5 +83,72 @@ namespace NzbDrone.Core.Download return remoteEpisode; } + + protected void RemapStorage(DownloadClientItem downloadClientItem, String remotePath, String localPath) + { + if (downloadClientItem.OutputPath.IsNullOrWhiteSpace() || localPath.IsNullOrWhiteSpace()) + { + return; + } + + remotePath = remotePath.TrimEnd('/', '\\'); + localPath = localPath.TrimEnd('/', '\\'); + + if (downloadClientItem.OutputPath.StartsWith(remotePath)) + { + downloadClientItem.OutputPath = localPath + downloadClientItem.OutputPath.Substring(remotePath.Length); + downloadClientItem.OutputPath = downloadClientItem.OutputPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + } + } + + public ValidationResult Test() + { + var failures = new List(); + + try + { + Test(failures); + } + catch (Exception ex) + { + _logger.ErrorException("Test aborted due to exception", ex); + failures.Add(new ValidationFailure(string.Empty, "Test was aborted due to an error: " + ex.Message)); + } + + return new ValidationResult(failures); + } + + protected abstract void Test(List failures); + + protected ValidationFailure TestFolder(String folder, String propertyName, Boolean mustBeWritable = true) + { + if (!_diskProvider.FolderExists(folder)) + { + return new NzbDroneValidationFailure(propertyName, "Folder does not exist") + { + DetailedDescription = "The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account that is used to execute NzbDrone." + }; + } + + if (mustBeWritable) + { + try + { + var testPath = Path.Combine(folder, "drone_test.txt"); + _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); + _diskProvider.DeleteFile(testPath); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(propertyName, "Unable to write to folder") + { + DetailedDescription = "The folder you specified is not writable. Please verify the folder permissions for the user account that is used to execute NzbDrone." + }; + } + } + + return null; + } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientDefinition.cs b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs index 479d10925..b81536a53 100644 --- a/src/NzbDrone.Core/Download/DownloadClientDefinition.cs +++ b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs @@ -1,12 +1,10 @@ -using System; -using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Download { public class DownloadClientDefinition : ProviderDefinition { - public Boolean Enable { get; set; } public DownloadProtocol Protocol { get; set; } } } diff --git a/src/NzbDrone.Core/Download/DownloadTrackingService.cs b/src/NzbDrone.Core/Download/DownloadTrackingService.cs index 463c06d97..557c61cb5 100644 --- a/src/NzbDrone.Core/Download/DownloadTrackingService.cs +++ b/src/NzbDrone.Core/Download/DownloadTrackingService.cs @@ -18,9 +18,11 @@ namespace NzbDrone.Core.Download TrackedDownload[] GetTrackedDownloads(); TrackedDownload[] GetCompletedDownloads(); TrackedDownload[] GetQueuedDownloads(); + + void MarkAsFailed(Int32 historyId); } - public class DownloadTrackingService : IDownloadTrackingService, IExecute, IHandle, IHandle + public class DownloadTrackingService : IDownloadTrackingService, IExecute, IHandleAsync, IHandle { private readonly IProvideDownloadClient _downloadClientProvider; private readonly IHistoryService _historyService; @@ -78,6 +80,22 @@ namespace NzbDrone.Core.Download }, TimeSpan.FromSeconds(5.0)); } + public void MarkAsFailed(Int32 historyId) + { + var item = _historyService.Get(historyId); + + var trackedDownload = GetTrackedDownloads() + .Where(h => h.DownloadItem.DownloadClientId.Equals(item.Data.GetValueOrDefault(DOWNLOAD_CLIENT_ID))) + .FirstOrDefault(); + + if (trackedDownload != null && trackedDownload.State == TrackedDownloadState.Unknown) + { + ProcessTrackedDownloads(); + } + + _failedDownloadService.MarkAsFailed(trackedDownload, item); + } + private TrackedDownload[] FilterQueuedDownloads(IEnumerable trackedDownloads) { var enabledFailedDownloadHandling = _configService.EnableFailedDownloadHandling; @@ -201,7 +219,7 @@ namespace NzbDrone.Core.Download ProcessTrackedDownloads(); } - public void Handle(ApplicationStartedEvent message) + public void HandleAsync(ApplicationStartedEvent message) { ProcessTrackedDownloads(); } diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index cc25cf872..b0137c663 100644 --- a/src/NzbDrone.Core/Download/FailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Download { public interface IFailedDownloadService { - void MarkAsFailed(int historyId); + void MarkAsFailed(TrackedDownload trackedDownload, History.History grabbedHistory); void CheckForFailedItem(IDownloadClient downloadClient, TrackedDownload trackedDownload, List grabbedHistory, List failedHistory); } @@ -35,10 +35,14 @@ namespace NzbDrone.Core.Download _logger = logger; } - public void MarkAsFailed(int historyId) + public void MarkAsFailed(TrackedDownload trackedDownload, History.History grabbedHistory) { - var item = _historyService.Get(historyId); - PublishDownloadFailedEvent(new List { item }, "Manually marked as failed"); + if (trackedDownload != null && trackedDownload.State == TrackedDownloadState.Downloading) + { + trackedDownload.State = TrackedDownloadState.DownloadFailed; + } + + PublishDownloadFailedEvent(new List { grabbedHistory }, "Manually marked as failed"); } public void CheckForFailedItem(IDownloadClient downloadClient, TrackedDownload trackedDownload, List grabbedHistory, List failedHistory) @@ -54,7 +58,7 @@ namespace NzbDrone.Core.Download if (!grabbedItems.Any()) { - _logger.Debug("Download was not grabbed by drone, ignoring."); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Download was not grabbed by drone, ignoring download"); return; } @@ -64,7 +68,7 @@ namespace NzbDrone.Core.Download if (failedItems.Any()) { - _logger.Debug("Already added to history as failed"); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Already added to history as failed."); } else { @@ -78,7 +82,7 @@ namespace NzbDrone.Core.Download if (!grabbedItems.Any()) { - _logger.Debug("Download was not grabbed by drone, ignoring."); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Download wasn't grabbed by drone or not in a category, ignoring download."); return; } @@ -86,13 +90,13 @@ namespace NzbDrone.Core.Download if (trackedDownload.DownloadItem.Message.Equals("Unpacking failed, write error or disk is full?", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Debug("Failed due to lack of disk space, do not blacklist"); + UpdateStatusMessage(trackedDownload, LogLevel.Error, "Download failed due to lack of disk space, not blacklisting."); return; } if (FailedDownloadForRecentRelease(downloadClient, trackedDownload, grabbedItems)) { - _logger.Debug("Recent release Failed, do not blacklist"); + _logger.Debug("[{0}] Recent release Failed, do not blacklist.", trackedDownload.DownloadItem.Title); return; } @@ -102,7 +106,7 @@ namespace NzbDrone.Core.Download if (failedItems.Any()) { - _logger.Debug("Already added to history as failed"); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Already added to history as failed."); } else { @@ -110,18 +114,30 @@ namespace NzbDrone.Core.Download } } + if (trackedDownload.DownloadItem.Status != DownloadItemStatus.Failed && trackedDownload.State == TrackedDownloadState.Downloading) + { + var grabbedItems = GetHistoryItems(grabbedHistory, trackedDownload.DownloadItem.DownloadClientId); + var failedItems = GetHistoryItems(failedHistory, trackedDownload.DownloadItem.DownloadClientId); + + if (grabbedItems.Any() && failedItems.Any()) + { + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Already added to history as failed, updating tracked state."); + trackedDownload.State = TrackedDownloadState.DownloadFailed; + } + } + if (_configService.RemoveFailedDownloads && trackedDownload.State == TrackedDownloadState.DownloadFailed) { try { - _logger.Info("Removing failed download from client: {0}", trackedDownload.DownloadItem.Title); + _logger.Debug("[{0}] Removing failed download from client.", trackedDownload.DownloadItem.Title); downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadClientId); trackedDownload.State = TrackedDownloadState.Removed; } catch (NotSupportedException) { - _logger.Debug("Removing item not supported by your download client"); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Removing item not supported by your download client."); } } } @@ -132,38 +148,62 @@ namespace NzbDrone.Core.Download if (!Double.TryParse(matchingHistoryItems.First().Data.GetValueOrDefault("ageHours"), out ageHours)) { - _logger.Debug("Unable to determine age of failed download"); + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Unable to determine age of failed download."); return false; } if (ageHours > _configService.BlacklistGracePeriod) { - _logger.Debug("Failed download is older than the grace period"); + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Download Failed, Failed download is older than the grace period."); return false; } if (trackedDownload.RetryCount >= _configService.BlacklistRetryLimit) { - _logger.Debug("Retry limit reached"); + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Download Failed, Retry limit reached."); return false; } - if (trackedDownload.RetryCount == 0 || trackedDownload.LastRetry.AddMinutes(_configService.BlacklistRetryInterval) < DateTime.UtcNow) + if (trackedDownload.LastRetry == new DateTime()) + { + trackedDownload.LastRetry = DateTime.UtcNow; + } + + if (trackedDownload.LastRetry.AddMinutes(_configService.BlacklistRetryInterval) < DateTime.UtcNow) { - _logger.Debug("Retrying failed release"); trackedDownload.LastRetry = DateTime.UtcNow; trackedDownload.RetryCount++; + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Download Failed, initiating retry attempt {0}/{1}.", trackedDownload.RetryCount, _configService.BlacklistRetryLimit); + try { - downloadClient.RetryDownload(trackedDownload.DownloadItem.DownloadClientId); + var newDownloadClientId = downloadClient.RetryDownload(trackedDownload.DownloadItem.DownloadClientId); + + if (newDownloadClientId != trackedDownload.DownloadItem.DownloadClientId) + { + var oldTrackingId = trackedDownload.TrackingId; + var newTrackingId = String.Format("{0}-{1}", downloadClient.Definition.Id, newDownloadClientId); + + trackedDownload.TrackingId = newTrackingId; + trackedDownload.DownloadItem.DownloadClientId = newDownloadClientId; + + _logger.Debug("[{0}] Changed id from {1} to {2}.", trackedDownload.DownloadItem.Title, oldTrackingId, newTrackingId); + var newHistoryData = new Dictionary(matchingHistoryItems.First().Data); + newHistoryData[DownloadTrackingService.DOWNLOAD_CLIENT_ID] = newDownloadClientId; + _historyService.UpdateHistoryData(matchingHistoryItems.First().Id, newHistoryData); + } } - catch (NotSupportedException ex) + catch (NotSupportedException) { - _logger.Debug("Retrying failed downloads is not supported by your download client"); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Retrying failed downloads is not supported by your download client."); return false; } } + else + { + UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Download Failed, waiting for retry interval to expire."); + } return true; } @@ -193,5 +233,22 @@ namespace NzbDrone.Core.Download _eventAggregator.PublishEvent(downloadFailedEvent); } + + private void UpdateStatusMessage(TrackedDownload trackedDownload, LogLevel logLevel, String message, params object[] args) + { + var statusMessage = String.Format(message, args); + var logMessage = String.Format("[{0}] {1}", trackedDownload.DownloadItem.Title, statusMessage); + + if (trackedDownload.StatusMessage != statusMessage) + { + trackedDownload.HasError = logLevel >= LogLevel.Warn; + trackedDownload.StatusMessage = statusMessage; + _logger.Log(logLevel, logMessage); + } + else + { + _logger.Debug(logMessage); + } + } } } diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index b0b3d5fb9..193c59254 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; @@ -9,10 +10,10 @@ namespace NzbDrone.Core.Download { DownloadProtocol Protocol { get; } - string Download(RemoteEpisode remoteEpisode); + String Download(RemoteEpisode remoteEpisode); IEnumerable GetItems(); - void RemoveItem(string id); - void RetryDownload(string id); + void RemoveItem(String id); + String RetryDownload(String id); DownloadClientStatus GetStatus(); } diff --git a/src/NzbDrone.Core/Download/TrackedDownload.cs b/src/NzbDrone.Core/Download/TrackedDownload.cs index 9d490c51e..841e803a5 100644 --- a/src/NzbDrone.Core/Download/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownload.cs @@ -12,6 +12,8 @@ namespace NzbDrone.Core.Download public DateTime StartedTracking { get; set; } public DateTime LastRetry { get; set; } public Int32 RetryCount { get; set; } + public Boolean HasError { get; set; } + public String StatusMessage { get; set; } } public enum TrackedDownloadState diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs index 1234b8730..6a0ca2f7a 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs @@ -42,7 +42,11 @@ namespace NzbDrone.Core.HealthCheck.Checks if (downloadClients.All(v => v.downloadClient is Sabnzbd)) { - // With Sabnzbd we cannot check the category settings. + // With Sabnzbd we can check if the category should be changed. + if (downloadClientOutputInDroneFactory) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Sabnzbd - Conflicting Category)", "Migrating-to-Completed-Download-Handling#sabnzbd-conflicting-download-client-category"); + } return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Sabnzbd)", "Migrating-to-Completed-Download-Handling#sabnzbd-enable-completed-download-handling"); } diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index e9846031e..e2d359016 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Common; using NzbDrone.Core.Datastore; using NzbDrone.Core.Download; using NzbDrone.Core.MediaFiles.Events; @@ -136,6 +137,11 @@ namespace NzbDrone.Core.History history.Data.Add("DownloadClientId", message.DownloadClientId); } + if (!message.Episode.ParsedEpisodeInfo.ReleaseHash.IsNullOrWhiteSpace()) + { + history.Data.Add("ReleaseHash", message.Episode.ParsedEpisodeInfo.ReleaseHash); + } + _historyRepository.Insert(history); } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateAnimeCategories.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateAnimeCategories.cs new file mode 100644 index 000000000..7278015e1 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateAnimeCategories.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Newznab; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class UpdateAnimeCategories : IHousekeepingTask + { + private readonly IIndexerFactory _indexerFactory; + private readonly Logger _logger; + + private const int NZBS_ORG_ANIME_ID = 7040; + private const int NEWZNAB_ANIME_ID = 5070; + + public UpdateAnimeCategories(IIndexerFactory indexerFactory, Logger logger) + { + _indexerFactory = indexerFactory; + _logger = logger; + } + + public void Clean() + { + //TODO: We should remove this before merging it into develop + _logger.Debug("Updating Anime Categories for newznab indexers"); + + var indexers = _indexerFactory.All().Where(i => i.Implementation == typeof (Newznab).Name); + + foreach (var indexer in indexers) + { + var settings = indexer.Settings as NewznabSettings; + + if (settings.Url.ContainsIgnoreCase("nzbs.org") && settings.Categories.Contains(NZBS_ORG_ANIME_ID)) + { + var animeCategories = new List(settings.AnimeCategories); + animeCategories.Add(NZBS_ORG_ANIME_ID); + + settings.AnimeCategories = animeCategories; + + settings.Categories = settings.Categories.Where(c => c != NZBS_ORG_ANIME_ID); + + indexer.Settings = settings; + _indexerFactory.Update(indexer); + } + + else if (settings.Categories.Contains(NEWZNAB_ANIME_ID)) + { + var animeCategories = new List(settings.AnimeCategories); + animeCategories.Add(NEWZNAB_ANIME_ID); + + settings.AnimeCategories = animeCategories; + + settings.Categories = settings.Categories.Where(c => c != NEWZNAB_ANIME_ID); + + indexer.Settings = settings; + _indexerFactory.Update(indexer); + } + } + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs new file mode 100644 index 000000000..089740d6f --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs @@ -0,0 +1,14 @@ +using System; + +namespace NzbDrone.Core.IndexerSearch.Definitions +{ + public class AnimeEpisodeSearchCriteria : SearchCriteriaBase + { + public int AbsoluteEpisodeNumber { get; set; } + + public override string ToString() + { + return string.Format("[{0} : {1:00}]", Series.Title, AbsoluteEpisodeNumber); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs index 3ffba7578..6035f9fab 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - return string.Format("[{0} : {1}", SceneTitle, AirDate); + return string.Format("[{0} : {1}", Series.Title, AirDate); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index 689d36ab3..fc3be82a5 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using NzbDrone.Common.EnsureThat; using NzbDrone.Core.Tv; @@ -12,14 +13,14 @@ namespace NzbDrone.Core.IndexerSearch.Definitions private static readonly Regex BeginningThe = new Regex(@"^the\s", RegexOptions.IgnoreCase | RegexOptions.Compiled); public Series Series { get; set; } - public string SceneTitle { get; set; } + public List SceneTitles { get; set; } public List Episodes { get; set; } - public string QueryTitle + public List QueryTitles { get { - return GetQueryTitle(SceneTitle); + return SceneTitles.Select(GetQueryTitle).ToList(); } } diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs index b2868e6a1..cec5aad37 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs @@ -6,7 +6,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - return string.Format("[{0} : S{1:00}]", SceneTitle, SeasonNumber); + return string.Format("[{0} : S{1:00}]", Series.Title, SeasonNumber); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs index 56d110079..797482846 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - return string.Format("[{0} : S{1:00}E{2:00}]", SceneTitle, SeasonNumber, EpisodeNumber); + return string.Format("[{0} : S{1:00}E{2:00}]", Series.Title, SeasonNumber, EpisodeNumber); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs index 93bdfd0e0..85f543ab7 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs @@ -11,7 +11,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - return string.Format("[{0} : {1}]", SceneTitle, String.Join(",", EpisodeQueryTitles)); + return string.Format("[{0} : {1}]", Series.Title, String.Join(",", EpisodeQueryTitles)); } } } diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 1b71323e6..691d66e18 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Runtime.Remoting.Messaging; using System.Threading.Tasks; using NLog; +using NzbDrone.Common; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; @@ -70,6 +72,10 @@ namespace NzbDrone.Core.IndexerSearch return SearchDaily(series, episode); } + if (series.SeriesType == SeriesTypes.Anime) + { + return SearchAnime(series, episode); + } if (episode.SeasonNumber == 0) { @@ -80,75 +86,33 @@ namespace NzbDrone.Core.IndexerSearch return SearchSingle(series, episode); } - private List SearchSingle(Series series, Episode episode) - { - var searchSpec = Get(series, new List{episode}); - - if (series.UseSceneNumbering) - { - if (episode.SceneSeasonNumber > 0 && episode.SceneEpisodeNumber > 0) - { - searchSpec.EpisodeNumber = episode.SceneEpisodeNumber; - searchSpec.SeasonNumber = episode.SceneSeasonNumber; - } - - else - { - searchSpec.EpisodeNumber = episode.EpisodeNumber; - searchSpec.SeasonNumber = episode.SeasonNumber; - } - } - else - { - searchSpec.EpisodeNumber = episode.EpisodeNumber; - searchSpec.SeasonNumber = episode.SeasonNumber; - } - - return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); - } - - private List SearchDaily(Series series, Episode episode) - { - var airDate = DateTime.ParseExact(episode.AirDate, Episode.AIR_DATE_FORMAT, CultureInfo.InvariantCulture); - var searchSpec = Get(series, new List{ episode }); - searchSpec.AirDate = airDate; - - return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); - } - - private List SearchSpecial(Series series, List episodes) - { - var searchSpec = Get(series, episodes); - // build list of queries for each episode in the form: " " - searchSpec.EpisodeQueryTitles = episodes.Where(e => !String.IsNullOrWhiteSpace(e.Title)) - .Select(e => searchSpec.QueryTitle + " " + SearchCriteriaBase.GetQueryTitle(e.Title)) - .ToArray(); - - return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); - } - public List SeasonSearch(int seriesId, int seasonNumber) { var series = _seriesService.GetSeries(seriesId); var episodes = _episodeService.GetEpisodesBySeason(seriesId, seasonNumber); + if (series.SeriesType == SeriesTypes.Anime) + { + return SearchAnimeSeason(series, episodes); + } + if (seasonNumber == 0) { // search for special episodes in season 0 return SearchSpecial(series, episodes); } - List downloadDecisions = new List(); + var downloadDecisions = new List(); if (series.UseSceneNumbering) { - var sceneSeasonGroups = episodes.GroupBy(v => - { - if (v.SceneSeasonNumber == 0 && v.SceneEpisodeNumber == 0) - return v.SeasonNumber; - else - return v.SceneSeasonNumber; - }).Distinct(); + var sceneSeasonGroups = episodes.GroupBy(v => + { + if (v.SceneSeasonNumber == 0 && v.SceneEpisodeNumber == 0) + return v.SeasonNumber; + else + return v.SceneSeasonNumber; + }).Distinct(); foreach (var sceneSeasonEpisodes in sceneSeasonGroups) { @@ -187,18 +151,96 @@ namespace NzbDrone.Core.IndexerSearch return downloadDecisions; } + private List SearchSingle(Series series, Episode episode) + { + var searchSpec = Get(series, new List{episode}); + + if (series.UseSceneNumbering) + { + if (episode.SceneSeasonNumber > 0 && episode.SceneEpisodeNumber > 0) + { + searchSpec.EpisodeNumber = episode.SceneEpisodeNumber; + searchSpec.SeasonNumber = episode.SceneSeasonNumber; + } + + else + { + searchSpec.EpisodeNumber = episode.EpisodeNumber; + searchSpec.SeasonNumber = episode.SeasonNumber; + } + } + else + { + searchSpec.EpisodeNumber = episode.EpisodeNumber; + searchSpec.SeasonNumber = episode.SeasonNumber; + } + + return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + } + + private List SearchDaily(Series series, Episode episode) + { + var airDate = DateTime.ParseExact(episode.AirDate, Episode.AIR_DATE_FORMAT, CultureInfo.InvariantCulture); + var searchSpec = Get(series, new List{ episode }); + searchSpec.AirDate = airDate; + + return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + } + + private List SearchAnime(Series series, Episode episode) + { + var searchSpec = Get(series, new List { episode }); + searchSpec.AbsoluteEpisodeNumber = episode.SceneAbsoluteEpisodeNumber.GetValueOrDefault(0); + + if (searchSpec.AbsoluteEpisodeNumber == 0) + { + searchSpec.AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber.GetValueOrDefault(0); + } + + if (searchSpec.AbsoluteEpisodeNumber == 0) + { + throw new ArgumentOutOfRangeException("AbsoluteEpisodeNumber", "Can not search for an episode absolute episode number of zero"); + } + + return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + } + + private List SearchSpecial(Series series, List episodes) + { + var searchSpec = Get(series, episodes); + // build list of queries for each episode in the form: " " + searchSpec.EpisodeQueryTitles = episodes.Where(e => !String.IsNullOrWhiteSpace(e.Title)) + .SelectMany(e => searchSpec.QueryTitles.Select(title => title + " " + SearchCriteriaBase.GetQueryTitle(e.Title))) + .ToArray(); + + return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + } + + private List SearchAnimeSeason(Series series, List episodes) + { + var downloadDecisions = new List(); + + foreach (var episode in episodes) + { + downloadDecisions.AddRange(SearchAnime(series, episode)); + } + + return downloadDecisions; + } + private TSpec Get(Series series, List episodes) where TSpec : SearchCriteriaBase, new() { var spec = new TSpec(); spec.Series = series; - spec.SceneTitle = _sceneMapping.GetSceneName(series.TvdbId); + spec.SceneTitles = _sceneMapping.GetSceneNames(series.TvdbId, + episodes.Select(e => e.SeasonNumber) + .Concat(episodes.Select(e => e.SceneSeasonNumber) + .Distinct())); + spec.Episodes = episodes; - if (string.IsNullOrWhiteSpace(spec.SceneTitle)) - { - spec.SceneTitle = series.Title; - } + spec.SceneTitles.Add(series.Title); return spec; } diff --git a/src/NzbDrone.Core/Indexers/Animezb/Animezb.cs b/src/NzbDrone.Core/Indexers/Animezb/Animezb.cs new file mode 100644 index 000000000..b465c6df3 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Animezb/Animezb.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using FluentValidation.Results; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Indexers.Animezb +{ + public class Animezb : IndexerBase + { + private static readonly Regex RemoveCharactersRegex = new Regex(@"[!?`]", RegexOptions.Compiled); + private static readonly Regex RemoveSingleCharacterRegex = new Regex(@"\b[a-z0-9]\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex DuplicateCharacterRegex = new Regex(@"[ +]{2,}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public override DownloadProtocol Protocol + { + get + { + return DownloadProtocol.Usenet; + } + } + + public override bool SupportsSearching + { + get + { + return true; + } + } + + public override IParseFeed Parser + { + get + { + return new AnimezbParser(); + } + } + + public override IEnumerable RecentFeed + { + get + { + yield return "https://animezb.com/rss?cat=anime&max=100"; + } + } + + public override IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber) + { + return new List(); + } + + public override IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset) + { + return new List(); + } + + public override IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date) + { + return new List(); + } + + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) + { + return titles.SelectMany(title => + RecentFeed.Select(url => + String.Format("{0}&q={1}", url, GetSearchQuery(title, absoluteEpisodeNumber)))); + + } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } + + public override ValidationResult Test() + { + return new ValidationResult(); + } + + private String GetSearchQuery(string title, int absoluteEpisodeNumber) + { + var match = RemoveSingleCharacterRegex.Match(title); + + if (match.Success) + { + title = RemoveSingleCharacterRegex.Replace(title, ""); + + //Since we removed a character we need to not wrap it in quotes and hope animedb doesn't give us a million results + return CleanTitle(String.Format("{0}+{1:00}", title, absoluteEpisodeNumber)); + } + + //Wrap the query in quotes and search! + return CleanTitle(String.Format("\"{0}+{1:00}\"", title, absoluteEpisodeNumber)); + } + + private String CleanTitle(String title) + { + title = RemoveCharactersRegex.Replace(title, ""); + return DuplicateCharacterRegex.Replace(title, "+"); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Animezb/AnimezbParser.cs b/src/NzbDrone.Core/Indexers/Animezb/AnimezbParser.cs new file mode 100644 index 000000000..3b7b8639f --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Animezb/AnimezbParser.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; +using System.Linq; + +namespace NzbDrone.Core.Indexers.Animezb +{ + public class AnimezbParser : RssParserBase + { + protected override string GetNzbInfoUrl(XElement item) + { + IEnumerable matches = item.DescendantsAndSelf("link"); + if (matches.Any()) + { + return matches.First().Value; + } + return String.Empty; + } + + protected override long GetSize(XElement item) + { + IEnumerable matches = item.DescendantsAndSelf("enclosure"); + if (matches.Any()) + { + XElement enclosureElement = matches.First(); + return Convert.ToInt64(enclosureElement.Attribute("length").Value); + } + return 0; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs b/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs new file mode 100644 index 000000000..848811f29 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using FluentValidation.Results; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Indexers.Fanzub +{ + public class Fanzub : IndexerBase + { + private static readonly Regex RemoveCharactersRegex = new Regex(@"[!?`]", RegexOptions.Compiled); + + public override DownloadProtocol Protocol + { + get + { + return DownloadProtocol.Usenet; + } + } + + public override bool SupportsSearching + { + get + { + return true; + } + } + + public override IParseFeed Parser + { + get + { + return new FanzubParser(); + } + } + + public override IEnumerable RecentFeed + { + get + { + yield return "https://fanzub.com/rss/?cat=anime&max=100"; + } + } + + public override IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber) + { + return new List(); + } + + public override IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset) + { + return new List(); + } + + public override IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date) + { + return new List(); + } + + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) + { + return RecentFeed.Select(url => String.Format("{0}&q={1}", + url, + String.Join("|", titles.SelectMany(title => GetTitleSearchStrings(title, absoluteEpisodeNumber))))); + } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } + + public override ValidationResult Test() + { + return new ValidationResult(); + } + + private IEnumerable GetTitleSearchStrings(string title, int absoluteEpisodeNumber) + { + var formats = new[] { "{0}%20{1:00}", "{0}%20-%20{1:00}" }; + + return formats.Select(s => "\"" + String.Format(s, CleanTitle(title), absoluteEpisodeNumber) + "\"" ); + } + + private String CleanTitle(String title) + { + return RemoveCharactersRegex.Replace(title, ""); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Fanzub/FanzubParser.cs b/src/NzbDrone.Core/Indexers/Fanzub/FanzubParser.cs new file mode 100644 index 000000000..fe3229ca8 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Fanzub/FanzubParser.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Xml.Linq; +using System.Linq; + +namespace NzbDrone.Core.Indexers.Fanzub +{ + public class FanzubParser : RssParserBase + { + protected override string GetNzbInfoUrl(XElement item) + { + IEnumerable matches = item.DescendantsAndSelf("link"); + if (matches.Any()) + { + return matches.First().Value; + } + return String.Empty; + } + + protected override long GetSize(XElement item) + { + IEnumerable matches = item.DescendantsAndSelf("enclosure"); + if (matches.Any()) + { + XElement enclosureElement = matches.First(); + return Convert.ToInt64(enclosureElement.Attribute("length").Value); + } + return 0; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index fd70a2473..d183ef7a3 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -13,9 +13,10 @@ namespace NzbDrone.Core.Indexers Boolean SupportsSearching { get; } IEnumerable RecentFeed { get; } - IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); - IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date); - IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset); + IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber); + IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date); + IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber); + IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset); IEnumerable GetSearchUrls(string query, int offset = 0); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index bb73432c6..21754cd44 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using FluentValidation.Results; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers @@ -32,6 +33,7 @@ namespace NzbDrone.Core.Indexers public virtual ProviderDefinition Definition { get; set; } + public abstract ValidationResult Test(); public abstract DownloadProtocol Protocol { get; } public virtual Boolean SupportsFeed { get { return true; } } @@ -50,9 +52,10 @@ namespace NzbDrone.Core.Indexers public virtual IParseFeed Parser { get; private set; } public abstract IEnumerable RecentFeed { get; } - public abstract IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); - public abstract IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date); - public abstract IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset); + public abstract IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber); + public abstract IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date); + public abstract IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber); + public abstract IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset); public abstract IEnumerable GetSearchUrls(string query, int offset); public override string ToString() diff --git a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index c1509952e..03d82ae49 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -4,8 +4,6 @@ namespace NzbDrone.Core.Indexers { public class IndexerDefinition : ProviderDefinition { - public bool Enable { get; set; } - public DownloadProtocol Protocol { get; set; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index 2b214ed7c..45bb0b705 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFactory.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFactory.cs @@ -14,7 +14,6 @@ namespace NzbDrone.Core.Indexers public class IndexerFactory : ProviderFactory, IIndexerFactory { - private readonly IIndexerRepository _providerRepository; private readonly INewznabTestService _newznabTestService; public IndexerFactory(IIndexerRepository providerRepository, @@ -25,7 +24,6 @@ namespace NzbDrone.Core.Indexers Logger logger) : base(providerRepository, providers, container, eventAggregator, logger) { - _providerRepository = providerRepository; _newznabTestService = newznabTestService; } @@ -39,17 +37,6 @@ namespace NzbDrone.Core.Indexers return base.Active().Where(c => c.Enable).ToList(); } - public override IndexerDefinition Create(IndexerDefinition definition) - { - if (definition.Implementation == typeof(Newznab.Newznab).Name) - { - var indexer = GetInstance(definition); - _newznabTestService.Test(indexer); - } - - return base.Create(definition); - } - protected override IndexerDefinition GetProviderCharacteristics(IIndexer provider, IndexerDefinition definition) { definition = base.GetProviderCharacteristics(provider, definition); diff --git a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs index 154c160a0..22255865a 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.Indexers IList Fetch(IIndexer indexer, SeasonSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, SingleEpisodeSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, DailyEpisodeSearchCriteria searchCriteria); + IList Fetch(IIndexer indexer, AnimeEpisodeSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria); } @@ -58,7 +59,7 @@ namespace NzbDrone.Core.Indexers { _logger.Debug("Searching for {0} offset: {1}", searchCriteria, offset); - var searchUrls = indexer.GetSeasonSearchUrls(searchCriteria.QueryTitle, searchCriteria.Series.TvRageId, searchCriteria.SeasonNumber, offset); + var searchUrls = indexer.GetSeasonSearchUrls(searchCriteria.QueryTitles, searchCriteria.Series.TvRageId, searchCriteria.SeasonNumber, offset); var result = Fetch(indexer, searchUrls); _logger.Info("{0} offset {1}. Found {2}", indexer, searchCriteria, result.Count); @@ -75,7 +76,7 @@ namespace NzbDrone.Core.Indexers { _logger.Debug("Searching for {0}", searchCriteria); - var searchUrls = indexer.GetEpisodeSearchUrls(searchCriteria.QueryTitle, searchCriteria.Series.TvRageId, searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber); + var searchUrls = indexer.GetEpisodeSearchUrls(searchCriteria.QueryTitles, searchCriteria.Series.TvRageId, searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber); var result = Fetch(indexer, searchUrls); _logger.Info("Finished searching {0} for {1}. Found {2}", indexer, searchCriteria, result.Count); @@ -86,13 +87,24 @@ namespace NzbDrone.Core.Indexers { _logger.Debug("Searching for {0}", searchCriteria); - var searchUrls = indexer.GetDailyEpisodeSearchUrls(searchCriteria.QueryTitle, searchCriteria.Series.TvRageId, searchCriteria.AirDate); + var searchUrls = indexer.GetDailyEpisodeSearchUrls(searchCriteria.QueryTitles, searchCriteria.Series.TvRageId, searchCriteria.AirDate); var result = Fetch(indexer, searchUrls); _logger.Info("Finished searching {0} for {1}. Found {2}", indexer, searchCriteria, result.Count); return result; } + public IList Fetch(IIndexer indexer, AnimeEpisodeSearchCriteria searchCriteria) + { + _logger.Debug("Searching for {0}", searchCriteria); + + var searchUrls = indexer.GetAnimeEpisodeSearchUrls(searchCriteria.SceneTitles, searchCriteria.Series.TvRageId, searchCriteria.AbsoluteEpisodeNumber); + var result = Fetch(indexer, searchUrls); + _logger.Info("Finished searching {0} for {1}. Found {2}", indexer, searchCriteria, result.Count); + + return result; + } + public IList Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria) { var queryUrls = new List(); diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 9adcd88aa..f46858a45 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -1,12 +1,35 @@ using System; using System.Collections.Generic; using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers.Exceptions; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers.Newznab { public class Newznab : IndexerBase { + private readonly IFetchFeedFromIndexers _feedFetcher; + private readonly HttpProvider _httpProvider; + private readonly Logger _logger; + + public Newznab(IFetchFeedFromIndexers feedFetcher, HttpProvider httpProvider, Logger logger) + { + _feedFetcher = feedFetcher; + _httpProvider = httpProvider; + _logger = logger; + } + + //protected so it can be mocked, but not used for DI + //TODO: Is there a better way to achieve this? + protected Newznab() + { + } + public override DownloadProtocol Protocol { get { return DownloadProtocol.Usenet; } } public override Int32 SupportedPageSize { get { return 100; } } @@ -57,6 +80,22 @@ namespace NzbDrone.Core.Indexers.Newznab Settings = GetSettings("https://www.oznzb.com", new List()) }); + list.Add(new IndexerDefinition + { + Enable = false, + Name = "nzbplanet.net", + Implementation = GetType().Name, + Settings = GetSettings("https://nzbplanet.net", new List()) + }); + + list.Add(new IndexerDefinition + { + Enable = false, + Name = "NZBgeek", + Implementation = GetType().Name, + Settings = GetSettings("https://api.nzbgeek.info", new List()) + }); + return list; } } @@ -79,13 +118,9 @@ namespace NzbDrone.Core.Indexers.Newznab { get { - //Todo: We should be able to update settings on start - if (Settings.Url.Contains("nzbs.org")) - { - Settings.Categories = new List { 5000 }; - } + var categories = String.Join(",", Settings.Categories.Concat(Settings.AnimeCategories)); - var url = String.Format("{0}/api?t=tvsearch&cat={1}&extended=1", Settings.Url.TrimEnd('/'), String.Join(",", Settings.Categories)); + var url = String.Format("{0}/api?t=tvsearch&cat={1}&extended=1{2}", Settings.Url.TrimEnd('/'), categories, Settings.AdditionalParameters); if (!String.IsNullOrWhiteSpace(Settings.ApiKey)) { @@ -96,14 +131,71 @@ namespace NzbDrone.Core.Indexers.Newznab } } - public override IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber) + public override IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber) { + if (Settings.Categories.Empty()) + { + return Enumerable.Empty(); + } + if (tvRageId > 0) { return RecentFeed.Select(url => String.Format("{0}&limit=100&rid={1}&season={2}&ep={3}", url, tvRageId, seasonNumber, episodeNumber)); } - return RecentFeed.Select(url => String.Format("{0}&limit=100&q={1}&season={2}&ep={3}", url, NewsnabifyTitle(seriesTitle), seasonNumber, episodeNumber)); + return titles.SelectMany(title => + RecentFeed.Select(url => + String.Format("{0}&limit=100&q={1}&season={2}&ep={3}", + url, NewsnabifyTitle(title), seasonNumber, episodeNumber))); + } + + public override IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date) + { + if (Settings.Categories.Empty()) + { + return Enumerable.Empty(); + } + + if (tvRageId > 0) + { + return RecentFeed.Select(url => String.Format("{0}&limit=100&rid={1}&season={2:yyyy}&ep={2:MM}/{2:dd}", url, tvRageId, date)).ToList(); + } + + return titles.SelectMany(title => + RecentFeed.Select(url => + String.Format("{0}&limit=100&q={1}&season={2:yyyy}&ep={2:MM}/{2:dd}", + url, NewsnabifyTitle(title), date)).ToList()); + } + + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) + { + if (Settings.AnimeCategories.Empty()) + { + return Enumerable.Empty(); + } + + return titles.SelectMany(title => + RecentFeed.Select(url => + String.Format("{0}&limit=100&q={1}+{2:00}", + url.Replace("t=tvsearch", "t=search"), NewsnabifyTitle(title), absoluteEpisodeNumber))); + } + + public override IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset) + { + if (Settings.Categories.Empty()) + { + return Enumerable.Empty(); + } + + if (tvRageId > 0) + { + return RecentFeed.Select(url => String.Format("{0}&limit=100&rid={1}&season={2}&offset={3}", url, tvRageId, seasonNumber, offset)); + } + + return titles.SelectMany(title => + RecentFeed.Select(url => + String.Format("{0}&limit=100&q={1}&season={2}&offset={3}", + url, NewsnabifyTitle(title), seasonNumber, offset))); } public override IEnumerable GetSearchUrls(string query, int offset) @@ -114,25 +206,39 @@ namespace NzbDrone.Core.Indexers.Newznab return RecentFeed.Select(url => String.Format("{0}&offset={1}&limit=100&q={2}", url.Replace("t=tvsearch", "t=search"), offset, query)); } - - public override IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date) + public override ValidationResult Test() { - if (tvRageId > 0) + var releases = _feedFetcher.FetchRss(this); + + if (releases.Any()) return new ValidationResult(); + + try { - return RecentFeed.Select(url => String.Format("{0}&limit=100&rid={1}&season={2:yyyy}&ep={2:MM}/{2:dd}", url, tvRageId, date)).ToList(); + var url = RecentFeed.First(); + var xml = _httpProvider.DownloadString(url); + + NewznabPreProcessor.Process(xml, url); + } + catch (ApiKeyException) + { + _logger.Warn("Indexer returned result for Newznab RSS URL, API Key appears to be invalid"); + + var apiKeyFailure = new ValidationFailure("ApiKey", "Invalid API Key"); + return new ValidationResult(new List { apiKeyFailure }); + } + catch (RequestLimitReachedException) + { + _logger.Warn("Request limit reached"); + } + catch (Exception ex) + { + _logger.WarnException("Unable to connect to indexer: " + ex.Message, ex); + + var failure = new ValidationFailure("Url", "Unable to connect to indexer, check the log for more details"); + return new ValidationResult(new List { failure }); } - return RecentFeed.Select(url => String.Format("{0}&limit=100&q={1}&season={2:yyyy}&ep={2:MM}/{2:dd}", url, NewsnabifyTitle(seriesTitle), date)).ToList(); - } - - public override IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset) - { - if (tvRageId > 0) - { - return RecentFeed.Select(url => String.Format("{0}&limit=100&rid={1}&season={2}&offset={3}", url, tvRageId, seasonNumber, offset)); - } - - return RecentFeed.Select(url => String.Format("{0}&limit=100&q={1}&season={2}&offset={3}", url, NewsnabifyTitle(seriesTitle), seasonNumber, offset)); + return new ValidationResult(); } private static string NewsnabifyTitle(string title) diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs index 7012b2f1d..a144881be 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Results; +using NzbDrone.Common; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -32,10 +34,17 @@ namespace NzbDrone.Core.Indexers.Newznab return ApiKeyWhiteList.Any(c => settings.Url.ToLowerInvariant().Contains(c)); } + private static readonly Regex AdditionalParametersRegex = new Regex(@"(&.+?\=.+?)+", RegexOptions.Compiled); + public NewznabSettingsValidator() { RuleFor(c => c.Url).ValidRootUrl(); RuleFor(c => c.ApiKey).NotEmpty().When(ShouldHaveApiKey); + RuleFor(c => c.Categories).NotEmpty().When(c => !c.AnimeCategories.Any()); + RuleFor(c => c.AnimeCategories).NotEmpty().When(c => !c.Categories.Any()); + RuleFor(c => c.AdditionalParameters) + .Matches(AdditionalParametersRegex) + .When(c => !c.AdditionalParameters.IsNullOrWhiteSpace()); } } @@ -46,6 +55,7 @@ namespace NzbDrone.Core.Indexers.Newznab public NewznabSettings() { Categories = new[] { 5030, 5040 }; + AnimeCategories = Enumerable.Empty(); } [FieldDefinition(0, Label = "URL")] @@ -54,8 +64,15 @@ namespace NzbDrone.Core.Indexers.Newznab [FieldDefinition(1, Label = "API Key")] public String ApiKey { get; set; } + [FieldDefinition(2, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows", Advanced = true)] public IEnumerable Categories { get; set; } + [FieldDefinition(3, Label = "Anime Categories", HelpText = "Comma Separated list, leave blank to disable anime", Advanced = true)] + public IEnumerable AnimeCategories { get; set; } + + [FieldDefinition(4, Label = "Additional Parameters", HelpText = "Additional newznab parameters", Advanced = true)] + public String AdditionalParameters { get; set; } + public ValidationResult Validate() { return Validator.Validate(this); diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs index 6978213f8..77161f28e 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; namespace NzbDrone.Core.Indexers.Omgwtfnzbs { @@ -24,37 +26,52 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs } } - public override IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber) + public override IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber) { var searchUrls = new List(); foreach (var url in RecentFeed) { - searchUrls.Add(String.Format("{0}&search={1}+S{2:00}E{3:00}", url, seriesTitle, seasonNumber, episodeNumber)); + foreach (var title in titles) + { + searchUrls.Add(String.Format("{0}&search={1}+S{2:00}E{3:00}", url, title, seasonNumber, episodeNumber)); + } } return searchUrls; } - public override IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date) + public override IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date) { var searchUrls = new List(); foreach (var url in RecentFeed) { - searchUrls.Add(String.Format("{0}&search={1}+{2:yyyy MM dd}", url, seriesTitle, date)); + foreach (var title in titles) + { + searchUrls.Add(String.Format("{0}&search={1}+{2:yyyy MM dd}", url, title, date)); + } } return searchUrls; } - public override IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset) + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) + { + // TODO: Implement + return new List(); + } + + public override IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset) { var searchUrls = new List(); foreach (var url in RecentFeed) { - searchUrls.Add(String.Format("{0}&search={1}+S{2:00}", url, seriesTitle, seasonNumber)); + foreach (var title in titles) + { + searchUrls.Add(String.Format("{0}&search={1}+S{2:00}", url, title, seasonNumber)); + } } return searchUrls; @@ -64,5 +81,10 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs { return new List(); } + + public override ValidationResult Test() + { + return new ValidationResult(); + } } } diff --git a/src/NzbDrone.Core/Indexers/RssParserBase.cs b/src/NzbDrone.Core/Indexers/RssParserBase.cs index 8b300c6d5..0af4a50b5 100644 --- a/src/NzbDrone.Core/Indexers/RssParserBase.cs +++ b/src/NzbDrone.Core/Indexers/RssParserBase.cs @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Indexers { PreProcess(xml, url); - using (var xmlTextReader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings { ProhibitDtd = false, IgnoreComments = true })) + using (var xmlTextReader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, IgnoreComments = true })) { var document = XDocument.Load(xmlTextReader); diff --git a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs index 8565ef9b9..ebb9dac40 100644 --- a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs +++ b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers.Wombles @@ -22,24 +24,34 @@ namespace NzbDrone.Core.Indexers.Wombles get { yield return "http://newshost.co.za/rss/?sec=TV&fr=false"; } } - public override IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber) + public override IEnumerable GetEpisodeSearchUrls(List titles, int tvRageId, int seasonNumber, int episodeNumber) { return new List(); } - public override IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset) + public override IEnumerable GetSeasonSearchUrls(List titles, int tvRageId, int seasonNumber, int offset) { return new List(); } - public override IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date) + public override IEnumerable GetDailyEpisodeSearchUrls(List titles, int tvRageId, DateTime date) { return new List(); } + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) + { + return new string[0]; + } + public override IEnumerable GetSearchUrls(string query, int offset) { return new List(); } + + public override ValidationResult Test() + { + return new ValidationResult(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/XElementExtensions.cs b/src/NzbDrone.Core/Indexers/XElementExtensions.cs index fb24e526f..ba9a072af 100644 --- a/src/NzbDrone.Core/Indexers/XElementExtensions.cs +++ b/src/NzbDrone.Core/Indexers/XElementExtensions.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Xml.Linq; using NLog; +using NzbDrone.Common; using NzbDrone.Common.Instrumentation; namespace NzbDrone.Core.Indexers @@ -45,7 +46,7 @@ namespace NzbDrone.Core.Indexers dateString = RemoveTimeZoneRegex.Replace(dateString, ""); result = DateTime.Parse(dateString, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal); } - return result.ToUniversalTime().Date; + return result.ToUniversalTime(); } catch (FormatException e) { @@ -89,5 +90,30 @@ namespace NzbDrone.Core.Indexers return element != null ? element.Value : defaultValue; } + + public static T TryGetValue(this XElement item, string elementName, T defaultValue) + { + var element = item.Element(elementName); + + if (element == null) + { + return defaultValue; + } + + if (element.Value.IsNullOrWhiteSpace()) + { + return defaultValue; + } + + try + { + return (T)Convert.ChangeType(element.Value, typeof(T)); + } + + catch (InvalidCastException) + { + return defaultValue; + } + } } } diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 788e6df0c..4656f3a76 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Core.Backup; using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.DataAugmentation.Scene; @@ -56,6 +57,7 @@ namespace NzbDrone.Core.Jobs new ScheduledTask{ Interval = 6*60, TypeName = typeof(CheckHealthCommand).FullName}, new ScheduledTask{ Interval = 12*60, TypeName = typeof(RefreshSeriesCommand).FullName}, new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName}, + new ScheduledTask{ Interval = 7*24*60, TypeName = typeof(BackupCommand).FullName}, new ScheduledTask { diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs index fc3c20785..2c5469de6 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs @@ -18,7 +18,6 @@ namespace NzbDrone.Core.MediaCover { void ConvertToLocalUrls(int seriesId, IEnumerable covers); string GetCoverPath(int seriesId, MediaCoverTypes mediaCoverTypes); - } public class MediaCoverService : diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index f73e9555c..b7d0637b3 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -82,7 +82,7 @@ namespace NzbDrone.Core.MediaFiles _diskProvider.DeleteFolder(directoryInfo.FullName, true); } - return importedDecisions; + return importedDecisions.Union(decisions).ToList(); } public List ProcessFile(FileInfo fileInfo, DownloadClientItem downloadClientItem) @@ -99,7 +99,7 @@ namespace NzbDrone.Core.MediaFiles var importedDecisions = _importApprovedEpisodes.Import(decisions, true, downloadClientItem); - return importedDecisions; + return importedDecisions.Union(decisions).ToList(); } private void ProcessDownloadedEpisodesFolder() diff --git a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs index ac4fa3c3d..b8fad1a47 100644 --- a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs +++ b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs @@ -128,7 +128,7 @@ namespace NzbDrone.Core.MediaFiles foreach (var folder in _diskProvider.GetDirectories(_configService.RecycleBin)) { - if (_diskProvider.FolderGetLastWrite(folder).AddDays(7) > DateTime.UtcNow) + if (_diskProvider.FolderGetLastWriteUtc(folder).AddDays(7) > DateTime.UtcNow) { logger.Debug("Folder hasn't expired yet, skipping: {0}", folder); continue; diff --git a/src/NzbDrone.Core/MetaData/Consumers/MediaBrowser/MediaBrowserMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/MediaBrowser/MediaBrowserMetadata.cs new file mode 100644 index 000000000..735fa525c --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Consumers/MediaBrowser/MediaBrowserMetadata.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Metadata.Consumers.MediaBrowser +{ + public class MediaBrowserMetadata : MetadataBase + { + private readonly IMapCoversToLocal _mediaCoverService; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public MediaBrowserMetadata(IMapCoversToLocal mediaCoverService, + IDiskProvider diskProvider, + Logger logger) + { + _mediaCoverService = mediaCoverService; + _diskProvider = diskProvider; + _logger = logger; + } + + public override List AfterRename(Series series, List existingMetadataFiles, List episodeFiles) + { + var updatedMetadataFiles = new List(); + + return updatedMetadataFiles; + } + + public override MetadataFile FindMetadataFile(Series series, string path) + { + var filename = Path.GetFileName(path); + + if (filename == null) return null; + + var metadata = new MetadataFile + { + SeriesId = series.Id, + Consumer = GetType().Name, + RelativePath = series.Path.GetRelativePath(path) + }; + + if (filename.Equals("series.xml", StringComparison.InvariantCultureIgnoreCase)) + { + metadata.Type = MetadataType.SeriesMetadata; + return metadata; + } + + return null; + } + + public override MetadataFileResult SeriesMetadata(Series series) + { + if (!Settings.SeriesMetadata) + { + return null; + } + + _logger.Debug("Generating series.xml for: {0}", series.Title); + var sb = new StringBuilder(); + var xws = new XmlWriterSettings(); + xws.OmitXmlDeclaration = true; + xws.Indent = false; + + using (var xw = XmlWriter.Create(sb, xws)) + { + var tvShow = new XElement("Series"); + + tvShow.Add(new XElement("id", series.TvdbId)); + tvShow.Add(new XElement("Status", series.Status)); + tvShow.Add(new XElement("Network", series.Network)); + tvShow.Add(new XElement("Airs_Time", series.AirTime)); + //tvShow.Add(new XElement("Airs_DayOfWeek", + if (series.FirstAired.HasValue) + { + tvShow.Add(new XElement("FirstAired", series.FirstAired.Value.ToString("yyyy-MM-dd"))); + } + tvShow.Add(new XElement("ContentRating", series.Certification)); + tvShow.Add(new XElement("Added", DateTime.Now.ToString("MM/dd/yyyy HH:mm:ss tt"))); + tvShow.Add(new XElement("LockData", "false")); + tvShow.Add(new XElement("Overview", series.Overview)); + tvShow.Add(new XElement("LocalTitle", series.Title)); + if (series.FirstAired.HasValue) + { + tvShow.Add(new XElement("PremiereDate", series.FirstAired.Value.ToString("yyyy-MM-dd"))); + } + //tvShow.Add(new XElement("EndDate", series.EndDate.ToString("yyyy-MM-dd"))); + tvShow.Add(new XElement("Rating", (decimal)series.Ratings.Percentage / 10)); + //tvShow.Add(new XElement("VoteCount", + tvShow.Add(new XElement("ProductionYear", series.Year)); + //tvShow.Add(new XElement("Website", + tvShow.Add(new XElement("RunningTime", series.Runtime)); + tvShow.Add(new XElement("IMDB", series.ImdbId)); + //tvShow.Add(new XElement("TMDbId", + //tvShow.Add(new XElement("Zap2itId", + tvShow.Add(new XElement("TVRageId", series.TvRageId)); + tvShow.Add(new XElement("Genres", series.Genres.Select(genre => new XElement("Genre", genre)))); + + // Studios + // Studio + // Studio + // ?? + + var persons = new XElement("Persons"); + + foreach (var person in series.Actors) + { + persons.Add(new XElement("Person", + new XElement("Name", person.Name), + new XElement("Type", "Actor"), + new XElement("Role", person.Character) + )); + } + + tvShow.Add(persons); + + + var doc = new XDocument(tvShow); + doc.Save(xw); + + _logger.Debug("Saving series.xml for {0}", series.Title); + + return new MetadataFileResult(Path.Combine(series.Path, "series.xml"), doc.ToString()); + } + } + + public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile) + { + return null; + } + + public override List SeriesImages(Series series) + { + return new List(); + } + + public override List SeasonImages(Series series, Season season) + { + return new List(); + } + + public override List EpisodeImages(Series series, EpisodeFile episodeFile) + { + return new List(); + } + + private IEnumerable ProcessSeriesImages(Series series) + { + return new List(); + } + + private IEnumerable ProcessSeasonImages(Series series, Season season) + { + return new List(); + } + + private string GetEpisodeNfoFilename(string episodeFilePath) + { + return null; + } + + private string GetEpisodeImageFilename(string episodeFilePath) + { + return null; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetaData/Consumers/MediaBrowser/MediaBrowserMetadataSettings.cs b/src/NzbDrone.Core/MetaData/Consumers/MediaBrowser/MediaBrowserMetadataSettings.cs new file mode 100644 index 000000000..3fbab1719 --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Consumers/MediaBrowser/MediaBrowserMetadataSettings.cs @@ -0,0 +1,41 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Metadata.Consumers.MediaBrowser +{ + public class MediaBrowserSettingsValidator : AbstractValidator + { + public MediaBrowserSettingsValidator() + { + } + } + + public class MediaBrowserMetadataSettings : IProviderConfig + { + private static readonly MediaBrowserSettingsValidator Validator = new MediaBrowserSettingsValidator(); + + public MediaBrowserMetadataSettings() + { + SeriesMetadata = true; + } + + [FieldDefinition(0, Label = "Series Metadata", Type = FieldType.Checkbox)] + public Boolean SeriesMetadata { get; set; } + + public bool IsValid + { + get + { + return true; + } + } + + public ValidationResult Validate() + { + return Validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs index 3547fdf52..8aaca9a16 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -9,10 +9,8 @@ using System.Xml.Linq; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Metadata.Files; using NzbDrone.Core.Tv; diff --git a/src/NzbDrone.Core/MetaData/MetadataDefinition.cs b/src/NzbDrone.Core/MetaData/MetadataDefinition.cs index c796eb8ab..0784037dc 100644 --- a/src/NzbDrone.Core/MetaData/MetadataDefinition.cs +++ b/src/NzbDrone.Core/MetaData/MetadataDefinition.cs @@ -1,10 +1,8 @@ -using System; -using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Metadata { public class MetadataDefinition : ProviderDefinition { - public Boolean Enable { get; set; } } } diff --git a/src/NzbDrone.Core/Metadata/MetadataBase.cs b/src/NzbDrone.Core/Metadata/MetadataBase.cs index a68d60174..bb5d4d23a 100644 --- a/src/NzbDrone.Core/Metadata/MetadataBase.cs +++ b/src/NzbDrone.Core/Metadata/MetadataBase.cs @@ -1,10 +1,7 @@ using System; using System.Collections.Generic; -using System.Net; -using NLog; -using NzbDrone.Common; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; +using System.Linq; +using FluentValidation.Results; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Metadata.Files; using NzbDrone.Core.ThingiProvider; @@ -32,6 +29,11 @@ namespace NzbDrone.Core.Metadata public ProviderDefinition Definition { get; set; } + public ValidationResult Test() + { + return new ValidationResult(); + } + public abstract List AfterRename(Series series, List existingMetadataFiles, List episodeFiles); public abstract MetadataFile FindMetadataFile(Series series, string path); diff --git a/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs index 7eb8ace28..f2ab03336 100644 --- a/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs +++ b/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs @@ -6,6 +6,6 @@ namespace NzbDrone.Core.MetadataSource { public interface IProvideSeriesInfo { - Tuple> GetSeriesInfo(int tvDbSeriesId); + Tuple> GetSeriesInfo(int tvdbSeriesId); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs index 0236a4169..c5f4d8724 100644 --- a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs @@ -31,6 +31,18 @@ namespace NzbDrone.Core.MetadataSource { try { + if (title.StartsWith("imdb:") || title.StartsWith("imdbid:")) + { + var slug = title.Split(':')[1].TrimStart('t'); + + if (slug.IsNullOrWhiteSpace() || !slug.All(char.IsDigit) || slug.Length < 7) + { + return new List(); + } + + title = "tt" + slug; + } + if (title.StartsWith("tvdb:") || title.StartsWith("tvdbid:") || title.StartsWith("slug:")) { try @@ -69,7 +81,7 @@ namespace NzbDrone.Core.MetadataSource .ToList(); } } - catch (WebException ex) + catch (WebException) { throw new TraktException("Search for '{0}' failed. Unable to communicate with Trakt.", title); } @@ -80,10 +92,10 @@ namespace NzbDrone.Core.MetadataSource } } - public Tuple> GetSeriesInfo(int tvDbSeriesId) + public Tuple> GetSeriesInfo(int tvdbSeriesId) { var client = BuildClient("show", "summary"); - var restRequest = new RestRequest(tvDbSeriesId.ToString() + "/extended"); + var restRequest = new RestRequest(tvdbSeriesId.ToString() + "/extended"); var response = client.ExecuteAndValidate(restRequest); var episodes = response.seasons.SelectMany(c => c.episodes).Select(MapEpisode).ToList(); @@ -94,7 +106,7 @@ namespace NzbDrone.Core.MetadataSource private static IRestClient BuildClient(string resource, string method) { - return new RestClient(string.Format("http://api.trakt.tv/{0}/{1}.json/bc3c2c460f22cbb01c264022b540e191", resource, method)); + return RestClientFactory.BuildClient(string.Format("http://api.trakt.tv/{0}/{1}.json/bc3c2c460f22cbb01c264022b540e191", resource, method)); } private static Series MapSeries(Show show) @@ -105,13 +117,14 @@ namespace NzbDrone.Core.MetadataSource series.ImdbId = show.imdb_id; series.Title = show.title; series.CleanTitle = Parser.Parser.CleanSeriesTitle(show.title); + series.SortTitle = Parser.Parser.NormalizeEpisodeTitle(show.title).ToLower(); series.Year = GetYear(show.year, show.first_aired); series.FirstAired = FromIso(show.first_aired_iso); series.Overview = show.overview; series.Runtime = show.runtime; series.Network = show.network; series.AirTime = show.air_time; - series.TitleSlug = show.url.ToLower().Replace("http://trakt.tv/show/", ""); + series.TitleSlug = GetTitleSlug(show.url); series.Status = GetSeriesStatus(show.status, show.ended); series.Ratings = GetRatings(show.ratings); series.Genres = show.genres; @@ -131,7 +144,6 @@ namespace NzbDrone.Core.MetadataSource var episode = new Episode(); episode.Overview = traktEpisode.overview; episode.SeasonNumber = traktEpisode.season; - episode.EpisodeNumber = traktEpisode.episode; episode.EpisodeNumber = traktEpisode.number; episode.Title = traktEpisode.title; episode.AirDate = FromIsoToString(traktEpisode.first_aired_iso); @@ -273,5 +285,17 @@ namespace NzbDrone.Core.MetadataSource return seasons; } + + private static String GetTitleSlug(String url) + { + var slug = url.ToLower().Replace("http://trakt.tv/show/", ""); + + if (slug.StartsWith(".")) + { + slug = "dot" + slug.Substring(1); + } + + return slug; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/Tvdb/TvdbProxy.cs b/src/NzbDrone.Core/MetadataSource/Tvdb/TvdbProxy.cs new file mode 100644 index 000000000..88af8bad4 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Tvdb/TvdbProxy.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using NzbDrone.Core.Rest; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Tv; +using RestSharp; + +namespace NzbDrone.Core.MetadataSource.Tvdb +{ + public interface ITvdbProxy + { + List GetEpisodeInfo(int tvdbSeriesId); + } + + public class TvdbProxy : ITvdbProxy + { + public Tuple> GetSeriesInfo(int tvdbSeriesId) + { + var client = BuildClient("series"); + var request = new RestRequest(tvdbSeriesId + "/all"); + + var response = client.Execute(request); + + var xml = XDocument.Load(new StringReader(response.Content)); + + var episodes = xml.Descendants("Episode").Select(MapEpisode).ToList(); + var series = MapSeries(xml.Element("Series")); + + return new Tuple>(series, episodes); + } + + public List GetEpisodeInfo(int tvdbSeriesId) + { + return GetSeriesInfo(tvdbSeriesId).Item2; + } + + private static IRestClient BuildClient(string resource) + { + return RestClientFactory.BuildClient(String.Format("http://thetvdb.com/data/{0}", resource)); + } + + private static Series MapSeries(XElement item) + { + //TODO: We should map all the data incase we want to actually use it + var series = new Series(); + + return series; + } + + private static Episode MapEpisode(XElement item) + { + //TODO: We should map all the data incase we want to actually use it + var episode = new Episode(); + episode.SeasonNumber = item.TryGetValue("SeasonNumber", 0); + episode.EpisodeNumber = item.TryGetValue("EpisodeNumber", 0); + episode.AbsoluteEpisodeNumber = item.TryGetValue("absolute_number", 0); + + return episode; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Email/Email.cs b/src/NzbDrone.Core/Notifications/Email/Email.cs index d92c21a18..9624f4165 100644 --- a/src/NzbDrone.Core/Notifications/Email/Email.cs +++ b/src/NzbDrone.Core/Notifications/Email/Email.cs @@ -1,15 +1,18 @@ using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Email { public class Email : NotificationBase { - private readonly IEmailService _smtpProvider; + private readonly IEmailService _emailService; - public Email(IEmailService smtpProvider) + public Email(IEmailService emailService) { - _smtpProvider = smtpProvider; + _emailService = emailService; } public override string Link @@ -20,9 +23,9 @@ namespace NzbDrone.Core.Notifications.Email public override void OnGrab(string message) { const string subject = "NzbDrone [TV] - Grabbed"; - var body = String.Format("{0} sent to SABnzbd queue.", message); + var body = String.Format("{0} sent to queue.", message); - _smtpProvider.SendEmail(Settings, subject, body); + _emailService.SendEmail(Settings, subject, body); } public override void OnDownload(DownloadMessage message) @@ -30,11 +33,20 @@ namespace NzbDrone.Core.Notifications.Email const string subject = "NzbDrone [TV] - Downloaded"; var body = String.Format("{0} Downloaded and sorted.", message.Message); - _smtpProvider.SendEmail(Settings, subject, body); + _emailService.SendEmail(Settings, subject, body); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_emailService.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/Email/EmailService.cs b/src/NzbDrone.Core/Notifications/Email/EmailService.cs index 7e3e5b504..27655faeb 100644 --- a/src/NzbDrone.Core/Notifications/Email/EmailService.cs +++ b/src/NzbDrone.Core/Notifications/Email/EmailService.cs @@ -1,18 +1,18 @@ using System; using System.Net; using System.Net.Mail; +using FluentValidation.Results; using NLog; -using NzbDrone.Core.Messaging.Commands; -using Omu.ValueInjecter; namespace NzbDrone.Core.Notifications.Email { public interface IEmailService { void SendEmail(EmailSettings settings, string subject, string body, bool htmlBody = false); + ValidationFailure Test(EmailSettings settings); } - public class EmailService : IEmailService, IExecute + public class EmailService : IEmailService { private readonly Logger _logger; @@ -66,14 +66,21 @@ namespace NzbDrone.Core.Notifications.Email } } - public void Execute(TestEmailCommand message) + public ValidationFailure Test(EmailSettings settings) { - var settings = new EmailSettings(); - settings.InjectFrom(message); - const string body = "Success! You have properly configured your email notification settings"; - SendEmail(settings, "NzbDrone - Test Notification", body); + try + { + SendEmail(settings, "NzbDrone - Test Notification", body); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test email: " + ex.Message, ex); + return new ValidationFailure("Server", "Unable to send test email"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/Email/TestEmailCommand.cs b/src/NzbDrone.Core/Notifications/Email/TestEmailCommand.cs deleted file mode 100644 index 3d9ec3797..000000000 --- a/src/NzbDrone.Core/Notifications/Email/TestEmailCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Email -{ - public class TestEmailCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - public string Server { get; set; } - public int Port { get; set; } - public bool Ssl { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string From { get; set; } - public string To { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Growl/Growl.cs b/src/NzbDrone.Core/Notifications/Growl/Growl.cs index 9682a5acb..217e26e20 100644 --- a/src/NzbDrone.Core/Notifications/Growl/Growl.cs +++ b/src/NzbDrone.Core/Notifications/Growl/Growl.cs @@ -1,14 +1,17 @@ -using NzbDrone.Core.Tv; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Growl { public class Growl : NotificationBase { - private readonly IGrowlService _growlProvider; + private readonly IGrowlService _growlService; - public Growl(IGrowlService growlProvider) + public Growl(IGrowlService growlService) { - _growlProvider = growlProvider; + _growlService = growlService; } public override string Link @@ -20,18 +23,27 @@ namespace NzbDrone.Core.Notifications.Growl { const string title = "Episode Grabbed"; - _growlProvider.SendNotification(title, message, "GRAB", Settings.Host, Settings.Port, Settings.Password); + _growlService.SendNotification(title, message, "GRAB", Settings.Host, Settings.Port, Settings.Password); } public override void OnDownload(DownloadMessage message) { const string title = "Episode Downloaded"; - _growlProvider.SendNotification(title, message.Message, "DOWNLOAD", Settings.Host, Settings.Port, Settings.Password); + _growlService.SendNotification(title, message.Message, "DOWNLOAD", Settings.Host, Settings.Port, Settings.Password); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_growlService.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs b/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs index d7d8686f3..88fd7a337 100644 --- a/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs +++ b/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs @@ -2,10 +2,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using FluentValidation.Results; using Growl.Connector; using NLog; using NzbDrone.Common.Instrumentation; -using NzbDrone.Core.Messaging.Commands; using GrowlNotification = Growl.Connector.Notification; namespace NzbDrone.Core.Notifications.Growl @@ -13,18 +13,20 @@ namespace NzbDrone.Core.Notifications.Growl public interface IGrowlService { void SendNotification(string title, string message, string notificationTypeName, string hostname, int port, string password); + ValidationFailure Test(GrowlSettings settings); } - public class GrowlService : IGrowlService, IExecute + public class GrowlService : IGrowlService { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(); + private readonly Logger _logger; private readonly Application _growlApplication = new Application("NzbDrone"); private GrowlConnector _growlConnector; private readonly List _notificationTypes; - public GrowlService() + public GrowlService(Logger logger) { + _logger = logger; _notificationTypes = GetNotificationTypes(); _growlApplication.Icon = "https://raw.github.com/NzbDrone/NzbDrone/master/Logo/64.png"; } @@ -36,13 +38,13 @@ namespace NzbDrone.Core.Notifications.Growl _growlConnector = new GrowlConnector(password, hostname, port); - Logger.Debug("Sending Notification to: {0}:{1}", hostname, port); + _logger.Debug("Sending Notification to: {0}:{1}", hostname, port); _growlConnector.Notify(notification); } private void Register(string host, int port, string password) { - Logger.Debug("Registering NzbDrone with Growl host: {0}:{1}", host, port); + _logger.Debug("Registering NzbDrone with Growl host: {0}:{1}", host, port); _growlConnector = new GrowlConnector(password, host, port); _growlConnector.Register(_growlApplication, _notificationTypes.ToArray()); } @@ -57,16 +59,26 @@ namespace NzbDrone.Core.Notifications.Growl return notificationTypes; } - public void Execute(TestGrowlCommand message) + public ValidationFailure Test(GrowlSettings settings) { - Register(message.Host, message.Port, message.Password); + try + { + Register(settings.Host, settings.Port, settings.Password); - const string title = "Test Notification"; - const string body = "This is a test message from NzbDrone"; + const string title = "Test Notification"; + const string body = "This is a test message from NzbDrone"; - Thread.Sleep(5000); + Thread.Sleep(5000); - SendNotification(title, body, "TEST", message.Host, message.Port, message.Password); + SendNotification(title, body, "TEST", settings.Host, settings.Port, settings.Password); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("Host", "Unable to send test message"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/Growl/TestGrowlCommand.cs b/src/NzbDrone.Core/Notifications/Growl/TestGrowlCommand.cs deleted file mode 100644 index c1d880541..000000000 --- a/src/NzbDrone.Core/Notifications/Growl/TestGrowlCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Growl -{ - public class TestGrowlCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - public string Host { get; set; } - public int Port { get; set; } - public string Password { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/NotificationBase.cs b/src/NzbDrone.Core/Notifications/NotificationBase.cs index 61377a483..70d5e7b85 100644 --- a/src/NzbDrone.Core/Notifications/NotificationBase.cs +++ b/src/NzbDrone.Core/Notifications/NotificationBase.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Tv; @@ -24,6 +26,7 @@ namespace NzbDrone.Core.Notifications } public ProviderDefinition Definition { get; set; } + public abstract ValidationResult Test(); public abstract string Link { get; } diff --git a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs index b1143db3f..957a5ea45 100644 --- a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs +++ b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs @@ -8,5 +8,13 @@ namespace NzbDrone.Core.Notifications public Boolean OnGrab { get; set; } public Boolean OnDownload { get; set; } public Boolean OnUpgrade { get; set; } + + public override Boolean Enable + { + get + { + return OnGrab || OnDownload || OnUpgrade; + } + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs index ffc1aa6ab..ac37b7307 100644 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs +++ b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs @@ -1,14 +1,19 @@ -using NzbDrone.Core.Tv; + +using System.Collections.Generic; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.NotifyMyAndroid { public class NotifyMyAndroid : NotificationBase { - private readonly INotifyMyAndroidProxy _notifyMyAndroidProxy; + private readonly INotifyMyAndroidProxy _proxy; - public NotifyMyAndroid(INotifyMyAndroidProxy notifyMyAndroidProxy) + public NotifyMyAndroid(INotifyMyAndroidProxy proxy) { - _notifyMyAndroidProxy = notifyMyAndroidProxy; + _proxy = proxy; } public override string Link @@ -20,18 +25,27 @@ namespace NzbDrone.Core.Notifications.NotifyMyAndroid { const string title = "Episode Grabbed"; - _notifyMyAndroidProxy.SendNotification(title, message, Settings.ApiKey, (NotifyMyAndroidPriority)Settings.Priority); + _proxy.SendNotification(title, message, Settings.ApiKey, (NotifyMyAndroidPriority)Settings.Priority); } public override void OnDownload(DownloadMessage message) { const string title = "Episode Downloaded"; - _notifyMyAndroidProxy.SendNotification(title, message.Message, Settings.ApiKey, (NotifyMyAndroidPriority)Settings.Priority); + _proxy.SendNotification(title, message.Message, Settings.ApiKey, (NotifyMyAndroidPriority)Settings.Priority); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_proxy.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs index 76ae52d98..3aca9b420 100644 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs +++ b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs @@ -2,8 +2,9 @@ using System.Linq; using System.Net; using System.Xml.Linq; +using FluentValidation.Results; +using NLog; using NzbDrone.Core.Exceptions; -using NzbDrone.Core.Messaging.Commands; using RestSharp; using NzbDrone.Core.Rest; @@ -12,15 +13,22 @@ namespace NzbDrone.Core.Notifications.NotifyMyAndroid public interface INotifyMyAndroidProxy { void SendNotification(string title, string message, string apiKye, NotifyMyAndroidPriority priority); + ValidationFailure Test(NotifyMyAndroidSettings settings); } - public class NotifyMyAndroidProxy : INotifyMyAndroidProxy, IExecute + public class NotifyMyAndroidProxy : INotifyMyAndroidProxy { + private readonly Logger _logger; private const string URL = "https://www.notifymyandroid.com/publicapi"; + public NotifyMyAndroidProxy(Logger logger) + { + _logger = logger; + } + public void SendNotification(string title, string message, string apiKey, NotifyMyAndroidPriority priority) { - var client = new RestClient(URL); + var client = RestClientFactory.BuildClient(URL); var request = new RestRequest("notify", Method.POST); request.RequestFormat = DataFormat.Xml; request.AddParameter("apikey", apiKey); @@ -35,7 +43,7 @@ namespace NzbDrone.Core.Notifications.NotifyMyAndroid private void Verify(string apiKey) { - var client = new RestClient(URL); + var client = RestClientFactory.BuildClient(URL); var request = new RestRequest("verify", Method.GET); request.RequestFormat = DataFormat.Xml; request.AddParameter("apikey", apiKey, ParameterType.GetOrPost); @@ -56,12 +64,22 @@ namespace NzbDrone.Core.Notifications.NotifyMyAndroid } } - public void Execute(TestNotifyMyAndroidCommand message) + public ValidationFailure Test(NotifyMyAndroidSettings settings) { - const string title = "Test Notification"; - const string body = "This is a test message from NzbDrone"; - Verify(message.ApiKey); - SendNotification(title, body, message.ApiKey, (NotifyMyAndroidPriority)message.Priority); + try + { + const string title = "Test Notification"; + const string body = "This is a test message from NzbDrone"; + Verify(settings.ApiKey); + SendNotification(title, body, settings.ApiKey, (NotifyMyAndroidPriority)settings.Priority); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("ApiKey", "Unable to send test message"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs index 1a05d92b3..f476f071c 100644 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs +++ b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.NotifyMyAndroid { get { - return !String.IsNullOrWhiteSpace(ApiKey) && Priority != null & Priority >= -1 && Priority <= 2; + return !String.IsNullOrWhiteSpace(ApiKey) && Priority >= -1 && Priority <= 2; } } diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/TestNotifyMyAndroidCommand.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/TestNotifyMyAndroidCommand.cs deleted file mode 100644 index bb93b8fdd..000000000 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/TestNotifyMyAndroidCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.NotifyMyAndroid -{ - public class TestNotifyMyAndroidCommand : Command - { - - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - public string ApiKey { get; set; } - public int Priority { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs b/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs index 322af1f06..0b2ffa23e 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs @@ -1,14 +1,17 @@ -using NzbDrone.Core.Tv; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Plex { public class PlexClient : NotificationBase { - private readonly IPlexService _plexProvider; + private readonly IPlexService _plexService; - public PlexClient(IPlexService plexProvider) + public PlexClient(IPlexService plexService) { - _plexProvider = plexProvider; + _plexService = plexService; } public override string Link @@ -19,17 +22,26 @@ namespace NzbDrone.Core.Notifications.Plex public override void OnGrab(string message) { const string header = "NzbDrone [TV] - Grabbed"; - _plexProvider.Notify(Settings, header, message); + _plexService.Notify(Settings, header, message); } public override void OnDownload(DownloadMessage message) { const string header = "NzbDrone [TV] - Downloaded"; - _plexProvider.Notify(Settings, header, message.Message); + _plexService.Notify(Settings, header, message.Message); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_plexService.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs index 31ff31aaf..267099479 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs @@ -1,14 +1,17 @@ -using NzbDrone.Core.Tv; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Plex { public class PlexServer : NotificationBase { - private readonly IPlexService _plexProvider; + private readonly IPlexService _plexService; - public PlexServer(IPlexService plexProvider) + public PlexServer(IPlexService plexService) { - _plexProvider = plexProvider; + _plexService = plexService; } public override string Link @@ -34,8 +37,17 @@ namespace NzbDrone.Core.Notifications.Plex { if (Settings.UpdateLibrary) { - _plexProvider.UpdateLibrary(Settings); + _plexService.UpdateLibrary(Settings); } } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_plexService.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs index d0355835f..9ed970171 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs @@ -7,6 +7,7 @@ using NLog; using NzbDrone.Common; using NzbDrone.Common.Cache; using NzbDrone.Common.Serializer; +using NzbDrone.Core.Rest; using RestSharp; namespace NzbDrone.Core.Notifications.Plex @@ -75,7 +76,7 @@ namespace NzbDrone.Core.Notifications.Plex private RestClient GetMyPlexClient(string username, string password) { - var client = new RestClient("https://my.plexapp.com"); + var client = RestClientFactory.BuildClient("https://my.plexapp.com"); client.Authenticator = new HttpBasicAuthenticator(username, password); return client; @@ -96,7 +97,7 @@ namespace NzbDrone.Core.Notifications.Plex private RestClient GetPlexServerClient(PlexServerSettings settings) { - return new RestClient(String.Format("http://{0}:{1}", settings.Host, settings.Port)); + return RestClientFactory.BuildClient(String.Format("http://{0}:{1}", settings.Host, settings.Port)); } private RestRequest GetPlexServerRequest(string resource, Method method, PlexServerSettings settings) diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexService.cs index 357668aae..011a70963 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexService.cs @@ -1,12 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; -using System.Xml.Linq; +using FluentValidation.Results; using NLog; -using NzbDrone.Common; using NzbDrone.Common.Http; -using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.Notifications.Plex { @@ -14,9 +11,11 @@ namespace NzbDrone.Core.Notifications.Plex { void Notify(PlexClientSettings settings, string header, string message); void UpdateLibrary(PlexServerSettings settings); + ValidationFailure Test(PlexClientSettings settings); + ValidationFailure Test(PlexServerSettings settings); } - public class PlexService : IPlexService, IExecute, IExecute + public class PlexService : IPlexService { private readonly IHttpProvider _httpProvider; private readonly IPlexServerProxy _plexServerProxy; @@ -84,31 +83,51 @@ namespace NzbDrone.Core.Notifications.Plex return _httpProvider.DownloadString(url); } - public void Execute(TestPlexClientCommand message) + public ValidationFailure Test(PlexClientSettings settings) { - _logger.Debug("Sending Test Notifcation to Plex Client: {0}", message.Host); - var command = String.Format("ExecBuiltIn(Notification({0}, {1}))", "Test Notification", "Success! Notifications are setup correctly"); - var result = SendCommand(message.Host, message.Port, command, message.Username, message.Password); - - if (String.IsNullOrWhiteSpace(result) || - result.IndexOf("error", StringComparison.InvariantCultureIgnoreCase) > -1) + try { - throw new Exception("Unable to connect to Plex Client"); + _logger.Debug("Sending Test Notifcation to Plex Client: {0}", settings.Host); + var command = String.Format("ExecBuiltIn(Notification({0}, {1}))", "Test Notification", "Success! Notifications are setup correctly"); + var result = SendCommand(settings.Host, settings.Port, command, settings.Username, settings.Password); + + if (String.IsNullOrWhiteSpace(result) || + result.IndexOf("error", StringComparison.InvariantCultureIgnoreCase) > -1) + { + throw new Exception("Unable to connect to Plex Client"); + } } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("Host", "Unable to send test message"); + } + + return null; } - public void Execute(TestPlexServerCommand message) + public ValidationFailure Test(PlexServerSettings settings) { - if (!GetSectionKeys(new PlexServerSettings - { - Host = message.Host, - Port = message.Port, - Username = message.Username, - Password = message.Password - }).Any()) + try { - throw new Exception("Unable to connect to Plex Server"); + if (!GetSectionKeys(new PlexServerSettings + { + Host = settings.Host, + Port = settings.Port, + Username = settings.Username, + Password = settings.Password + }).Any()) + { + throw new Exception("Unable to connect to Plex Server"); + } } + catch (Exception ex) + { + _logger.ErrorException("Unable to connect to Plex Server: " + ex.Message, ex); + return new ValidationFailure("Host", "Unable to connect to Plex Server"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/TestPlexClientCommand.cs b/src/NzbDrone.Core/Notifications/Plex/TestPlexClientCommand.cs deleted file mode 100644 index 6de213789..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/TestPlexClientCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class TestPlexClientCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - public string Host { get; set; } - public int Port { get; set; } - public string Username { get; set; } - public string Password { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/TestPlexServerCommand.cs b/src/NzbDrone.Core/Notifications/Plex/TestPlexServerCommand.cs deleted file mode 100644 index de8394e97..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/TestPlexServerCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class TestPlexServerCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - public string Host { get; set; } - public int Port { get; set; } - public string Username { get; set; } - public string Password { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs b/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs index b5264412a..006e301e1 100644 --- a/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs +++ b/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs @@ -1,15 +1,18 @@ -using NzbDrone.Core.Tv; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; +using NzbDrone.Core.Tv; using Prowlin; namespace NzbDrone.Core.Notifications.Prowl { public class Prowl : NotificationBase { - private readonly IProwlService _prowlProvider; + private readonly IProwlService _prowlService; - public Prowl(IProwlService prowlProvider) + public Prowl(IProwlService prowlService) { - _prowlProvider = prowlProvider; + _prowlService = prowlService; } public override string Link @@ -21,18 +24,27 @@ namespace NzbDrone.Core.Notifications.Prowl { const string title = "Episode Grabbed"; - _prowlProvider.SendNotification(title, message, Settings.ApiKey, (NotificationPriority)Settings.Priority); + _prowlService.SendNotification(title, message, Settings.ApiKey, (NotificationPriority)Settings.Priority); } public override void OnDownload(DownloadMessage message) { const string title = "Episode Downloaded"; - _prowlProvider.SendNotification(title, message.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); + _prowlService.SendNotification(title, message.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_prowlService.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs b/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs index 7742b2f05..29a8d5af0 100644 --- a/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs +++ b/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs @@ -1,6 +1,6 @@ using System; +using FluentValidation.Results; using NLog; -using NzbDrone.Core.Messaging.Commands; using Prowlin; namespace NzbDrone.Core.Notifications.Prowl @@ -8,9 +8,10 @@ namespace NzbDrone.Core.Notifications.Prowl public interface IProwlService { void SendNotification(string title, string message, string apiKey, NotificationPriority priority = NotificationPriority.Normal, string url = null); + ValidationFailure Test(ProwlSettings settings); } - public class ProwlService : IProwlService, IExecute + public class ProwlService : IProwlService { private readonly Logger _logger; @@ -80,14 +81,24 @@ namespace NzbDrone.Core.Notifications.Prowl } } - public void Execute(TestProwlCommand message) + public ValidationFailure Test(ProwlSettings settings) { - Verify(message.ApiKey); + try + { + Verify(settings.ApiKey); - const string title = "Test Notification"; - const string body = "This is a test message from NzbDrone"; + const string title = "Test Notification"; + const string body = "This is a test message from NzbDrone"; - SendNotification(title, body, message.ApiKey); + SendNotification(title, body, settings.ApiKey); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("ApiKey", "Unable to send test message"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/Prowl/ProwlSettings.cs b/src/NzbDrone.Core/Notifications/Prowl/ProwlSettings.cs index 3d68d3674..14f412402 100644 --- a/src/NzbDrone.Core/Notifications/Prowl/ProwlSettings.cs +++ b/src/NzbDrone.Core/Notifications/Prowl/ProwlSettings.cs @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.Prowl { get { - return !string.IsNullOrWhiteSpace(ApiKey) && Priority != null & Priority >= -2 && Priority <= 2; + return !string.IsNullOrWhiteSpace(ApiKey) && Priority >= -2 && Priority <= 2; } } diff --git a/src/NzbDrone.Core/Notifications/Prowl/TestProwlCommand.cs b/src/NzbDrone.Core/Notifications/Prowl/TestProwlCommand.cs deleted file mode 100644 index 50d02677e..000000000 --- a/src/NzbDrone.Core/Notifications/Prowl/TestProwlCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Prowl -{ - public class TestProwlCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - public string ApiKey { get; set; } - public int Priority { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs index 5f3931075..d0d5f9c0e 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs @@ -1,14 +1,17 @@ -using NzbDrone.Core.Tv; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.PushBullet { public class PushBullet : NotificationBase { - private readonly IPushBulletProxy _pushBulletProxy; + private readonly IPushBulletProxy _proxy; - public PushBullet(IPushBulletProxy pushBulletProxy) + public PushBullet(IPushBulletProxy proxy) { - _pushBulletProxy = pushBulletProxy; + _proxy = proxy; } public override string Link @@ -18,20 +21,29 @@ namespace NzbDrone.Core.Notifications.PushBullet public override void OnGrab(string message) { - const string title = "Episode Grabbed"; + const string title = "NzbDrone - Episode Grabbed"; - _pushBulletProxy.SendNotification(title, message, Settings.ApiKey, Settings.DeviceId); + _proxy.SendNotification(title, message, Settings.ApiKey, Settings.DeviceId); } public override void OnDownload(DownloadMessage message) { - const string title = "Episode Downloaded"; + const string title = "NzbDrone - Episode Downloaded"; - _pushBulletProxy.SendNotification(title, message.Message, Settings.ApiKey, Settings.DeviceId); + _proxy.SendNotification(title, message.Message, Settings.ApiKey, Settings.DeviceId); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_proxy.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs index eb85e573f..4683c96ad 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs @@ -1,5 +1,7 @@ using System; -using NzbDrone.Core.Messaging.Commands; +using System.Net; +using FluentValidation.Results; +using NLog; using RestSharp; using NzbDrone.Core.Rest; @@ -8,15 +10,22 @@ namespace NzbDrone.Core.Notifications.PushBullet public interface IPushBulletProxy { void SendNotification(string title, string message, string apiKey, string deviceId); + ValidationFailure Test(PushBulletSettings settings); } - public class PushBulletProxy : IPushBulletProxy, IExecute + public class PushBulletProxy : IPushBulletProxy { + private readonly Logger _logger; private const string URL = "https://api.pushbullet.com/api/pushes"; + public PushBulletProxy(Logger logger) + { + _logger = logger; + } + public void SendNotification(string title, string message, string apiKey, string deviceId) { - var client = new RestClient(URL); + var client = RestClientFactory.BuildClient(URL); var request = BuildRequest(deviceId); request.AddParameter("type", "note"); @@ -45,12 +54,33 @@ namespace NzbDrone.Core.Notifications.PushBullet return request; } - public void Execute(TestPushBulletCommand message) + public ValidationFailure Test(PushBulletSettings settings) { - const string title = "Test Notification"; - const string body = "This is a test message from NzbDrone"; + try + { + const string title = "NzbDrone - Test Notification"; + const string body = "This is a test message from NzbDrone"; - SendNotification(title, body, message.ApiKey, message.DeviceId); + SendNotification(title, body, settings.ApiKey, settings.DeviceId); + } + catch (RestException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.ErrorException("API Key is invalid: " + ex.Message, ex); + return new ValidationFailure("ApiKey", "API Key is invalid"); + } + + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("ApiKey", "Unable to send test message"); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("", "Unable to send test message"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/TestPushBulletCommand.cs b/src/NzbDrone.Core/Notifications/PushBullet/TestPushBulletCommand.cs deleted file mode 100644 index fd0f6732d..000000000 --- a/src/NzbDrone.Core/Notifications/PushBullet/TestPushBulletCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.PushBullet -{ - public class TestPushBulletCommand : Command - { - - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - public string ApiKey { get; set; } - public string DeviceId { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs b/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs index 1e14e34fb..8b6f7c3de 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs @@ -1,14 +1,17 @@ -using NzbDrone.Core.Tv; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Pushover { public class Pushover : NotificationBase { - private readonly IPushoverProxy _pushoverProxy; + private readonly IPushoverProxy _proxy; - public Pushover(IPushoverProxy pushoverProxy) + public Pushover(IPushoverProxy proxy) { - _pushoverProxy = pushoverProxy; + _proxy = proxy; } public override string Link @@ -20,18 +23,27 @@ namespace NzbDrone.Core.Notifications.Pushover { const string title = "Episode Grabbed"; - _pushoverProxy.SendNotification(title, message, Settings.ApiKey, Settings.UserKey, (PushoverPriority)Settings.Priority, Settings.Sound); + _proxy.SendNotification(title, message, Settings.ApiKey, Settings.UserKey, (PushoverPriority)Settings.Priority, Settings.Sound); } public override void OnDownload(DownloadMessage message) { const string title = "Episode Downloaded"; - _pushoverProxy.SendNotification(title, message.Message, Settings.ApiKey, Settings.UserKey, (PushoverPriority)Settings.Priority, Settings.Sound); + _proxy.SendNotification(title, message.Message, Settings.ApiKey, Settings.UserKey, (PushoverPriority)Settings.Priority, Settings.Sound); } public override void AfterRename(Series series) { } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_proxy.Test(Settings)); + + return new ValidationResult(failures); + } } } diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs index cf7b055f0..97739702b 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs @@ -1,5 +1,7 @@ -using NzbDrone.Common; -using NzbDrone.Core.Messaging.Commands; +using System; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common; using RestSharp; using NzbDrone.Core.Rest; @@ -8,15 +10,22 @@ namespace NzbDrone.Core.Notifications.Pushover public interface IPushoverProxy { void SendNotification(string title, string message, string apiKey, string userKey, PushoverPriority priority, string sound); + ValidationFailure Test(PushoverSettings settings); } - public class PushoverProxy : IPushoverProxy, IExecute + public class PushoverProxy : IPushoverProxy { + private readonly Logger _logger; private const string URL = "https://api.pushover.net/1/messages.json"; + public PushoverProxy(Logger logger) + { + _logger = logger; + } + public void SendNotification(string title, string message, string apiKey, string userKey, PushoverPriority priority, string sound) { - var client = new RestClient(URL); + var client = RestClientFactory.BuildClient(URL); var request = new RestRequest(Method.POST); request.AddParameter("token", apiKey); request.AddParameter("user", userKey); @@ -30,12 +39,22 @@ namespace NzbDrone.Core.Notifications.Pushover client.ExecuteAndValidate(request); } - public void Execute(TestPushoverCommand message) + public ValidationFailure Test(PushoverSettings settings) { - const string title = "Test Notification"; - const string body = "This is a test message from NzbDrone"; + try + { + const string title = "Test Notification"; + const string body = "This is a test message from NzbDrone"; - SendNotification(title, body, message.ApiKey, message.UserKey, (PushoverPriority)message.Priority, message.Sound); + SendNotification(title, body, settings.ApiKey, settings.UserKey, (PushoverPriority)settings.Priority, settings.Sound); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("ApiKey", "Unable to send test message"); + } + + return null; } } } diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs index db5197022..2bd17b79d 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Notifications.Pushover { get { - return !string.IsNullOrWhiteSpace(UserKey) && Priority != null & Priority >= -1 && Priority <= 2; + return !string.IsNullOrWhiteSpace(UserKey) && Priority >= -1 && Priority <= 2; } } diff --git a/src/NzbDrone.Core/Notifications/Pushover/TestPushoverCommand.cs b/src/NzbDrone.Core/Notifications/Pushover/TestPushoverCommand.cs deleted file mode 100644 index d216e08ce..000000000 --- a/src/NzbDrone.Core/Notifications/Pushover/TestPushoverCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Pushover -{ - public class TestPushoverCommand : Command - { - - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - public string ApiKey { get; set; } - public string UserKey { get; set; } - public int Priority { get; set; } - public string Sound { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/TestXbmcCommand.cs b/src/NzbDrone.Core/Notifications/Xbmc/TestXbmcCommand.cs deleted file mode 100644 index cb8acb361..000000000 --- a/src/NzbDrone.Core/Notifications/Xbmc/TestXbmcCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Notifications.Xbmc -{ - public class TestXbmcCommand : Command - { - public override bool SendUpdatesToClient - { - get - { - return true; - } - } - - public string Host { get; set; } - public int Port { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public int DisplayTime { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs b/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs index f111ae7b4..dcf616358 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs @@ -1,15 +1,18 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NzbDrone.Common; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Xbmc { public class Xbmc : NotificationBase { - private readonly IXbmcService _xbmcProvider; + private readonly IXbmcService _xbmcService; - public Xbmc(IXbmcService xbmcProvider) + public Xbmc(IXbmcService xbmcService) { - _xbmcProvider = xbmcProvider; + _xbmcService = xbmcService; } public override string Link @@ -23,7 +26,7 @@ namespace NzbDrone.Core.Notifications.Xbmc if (Settings.Notify) { - _xbmcProvider.Notify(Settings, header, message); + _xbmcService.Notify(Settings, header, message); } } @@ -33,7 +36,7 @@ namespace NzbDrone.Core.Notifications.Xbmc if (Settings.Notify) { - _xbmcProvider.Notify(Settings, header, message.Message); + _xbmcService.Notify(Settings, header, message.Message); } UpdateAndClean(message.Series, message.OldFiles.Any()); @@ -44,16 +47,25 @@ namespace NzbDrone.Core.Notifications.Xbmc UpdateAndClean(series); } + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_xbmcService.Test(Settings)); + + return new ValidationResult(failures); + } + private void UpdateAndClean(Series series, bool clean = true) { if (Settings.UpdateLibrary) { - _xbmcProvider.Update(Settings, series); + _xbmcService.Update(Settings, series); } if (clean && Settings.CleanLibrary) { - _xbmcProvider.Clean(Settings); + _xbmcService.Clean(Settings); } } } diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs index ce94da593..de38e5fed 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using FluentValidation.Results; using Newtonsoft.Json.Linq; using NLog; -using NzbDrone.Common; using NzbDrone.Common.Http; using NzbDrone.Common.Instrumentation; using NzbDrone.Common.Serializer; -using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Notifications.Xbmc.Model; using NzbDrone.Core.Tv; @@ -19,18 +18,20 @@ namespace NzbDrone.Core.Notifications.Xbmc void Update(XbmcSettings settings, Series series); void Clean(XbmcSettings settings); XbmcVersion GetJsonVersion(XbmcSettings settings); + ValidationFailure Test(XbmcSettings settings); } - public class XbmcService : IXbmcService, IExecute + public class XbmcService : IXbmcService { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(); private readonly IHttpProvider _httpProvider; private readonly IEnumerable _apiProviders; + private readonly Logger _logger; - public XbmcService(IHttpProvider httpProvider, IEnumerable apiProviders) + public XbmcService(IHttpProvider httpProvider, IEnumerable apiProviders, Logger logger) { _httpProvider = httpProvider; _apiProviders = apiProviders; + _logger = logger; } public void Notify(XbmcSettings settings, string title, string message) @@ -62,7 +63,7 @@ namespace NzbDrone.Core.Notifications.Xbmc var response = _httpProvider.PostCommand(settings.Address, settings.Username, settings.Password, postJson.ToString()); - Logger.Debug("Getting version from response: " + response); + _logger.Debug("Getting version from response: " + response); var result = Json.Deserialize>(response); var versionObject = result.Result.Property("version"); @@ -78,7 +79,7 @@ namespace NzbDrone.Core.Notifications.Xbmc catch (Exception ex) { - Logger.DebugException(ex.Message, ex); + _logger.DebugException(ex.Message, ex); } return new XbmcVersion(); @@ -98,27 +99,28 @@ namespace NzbDrone.Core.Notifications.Xbmc return apiProvider; } - public void Execute(TestXbmcCommand message) + public ValidationFailure Test(XbmcSettings settings) { - var settings = new XbmcSettings - { - Host = message.Host, - Port = message.Port, - Username = message.Username, - Password = message.Password, - DisplayTime = message.DisplayTime - }; - - Logger.Debug("Determining version of XBMC Host: {0}", settings.Address); - var version = GetJsonVersion(settings); - Logger.Debug("Version is: {0}", version); - - if (version == new XbmcVersion(0)) + try { - throw new InvalidXbmcVersionException("Verion received from XBMC is invalid, please correct your settings."); + _logger.Debug("Determining version of XBMC Host: {0}", settings.Address); + var version = GetJsonVersion(settings); + _logger.Debug("Version is: {0}", version); + + if (version == new XbmcVersion(0)) + { + throw new InvalidXbmcVersionException("Verion received from XBMC is invalid, please correct your settings."); + } + + Notify(settings, "Test Notification", "Success! XBMC has been successfully configured!"); + } + catch (Exception ex) + { + _logger.ErrorException("Unable to send test message: " + ex.Message, ex); + return new ValidationFailure("Host", "Unable to send test message"); } - Notify(settings, "Test Notification", "Success! XBMC has been successfully configured!"); + return null; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 97f8093a6..7e12f2d21 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -104,6 +104,9 @@ Properties\SharedAssemblyInfo.cs + + + @@ -118,10 +121,12 @@ + + @@ -198,6 +203,8 @@ + + @@ -216,6 +223,7 @@ + @@ -244,23 +252,21 @@ - - - - + - + + @@ -305,12 +311,14 @@ + + @@ -320,9 +328,13 @@ + + + + @@ -368,10 +380,13 @@ + + + @@ -403,12 +418,10 @@ - - @@ -429,6 +442,7 @@ + @@ -463,9 +477,7 @@ - - @@ -506,27 +518,22 @@ - - - - - @@ -552,6 +559,7 @@ + @@ -589,6 +597,7 @@ + @@ -719,6 +728,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/AbsoluteEpisodeFormat.cs b/src/NzbDrone.Core/Organizer/AbsoluteEpisodeFormat.cs new file mode 100644 index 000000000..c5d8fe5db --- /dev/null +++ b/src/NzbDrone.Core/Organizer/AbsoluteEpisodeFormat.cs @@ -0,0 +1,12 @@ +using System; + +namespace NzbDrone.Core.Organizer +{ + public class EpisodeFormat + { + public String Separator { get; set; } + public String EpisodePattern { get; set; } + public String EpisodeSeparator { get; set; } + public String SeasonEpisodePattern { get; set; } + } +} diff --git a/src/NzbDrone.Core/Organizer/EpisodeFormat.cs b/src/NzbDrone.Core/Organizer/EpisodeFormat.cs index c5d8fe5db..44f820fac 100644 --- a/src/NzbDrone.Core/Organizer/EpisodeFormat.cs +++ b/src/NzbDrone.Core/Organizer/EpisodeFormat.cs @@ -2,11 +2,9 @@ namespace NzbDrone.Core.Organizer { - public class EpisodeFormat + public class AbsoluteEpisodeFormat { public String Separator { get; set; } - public String EpisodePattern { get; set; } - public String EpisodeSeparator { get; set; } - public String SeasonEpisodePattern { get; set; } + public String AbsoluteEpisodePattern { get; set; } } } diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 142ec8bb4..7a65d7dfb 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using NLog; +using NzbDrone.Common; using NzbDrone.Common.Cache; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; @@ -38,9 +39,15 @@ namespace NzbDrone.Core.Organizer private static readonly Regex SeasonRegex = new Regex(@"(?\{season(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex AbsoluteEpisodeRegex = new Regex(@"(?\{absolute(?:\:0+)?})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?(?<=}).+?)?(?s?{season(?:\:0+)?}(?e|x)(?{episode(?:\:0+)?}))(?.+?(?={))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex AbsoluteEpisodePatternRegex = new Regex(@"(?(?<=}).+?)?(?{absolute(?:\:0+)?})(?.+?(?={))?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex AirDateRegex = new Regex(@"\{Air(\s|\W|_)Date\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex SeriesTitleRegex = new Regex(@"(?\{(?:Series)(?\s|\.|-|_)Title\})", @@ -118,6 +125,11 @@ namespace NzbDrone.Core.Organizer } } + if (series.SeriesType == SeriesTypes.Anime && episodes.All(e => e.AbsoluteEpisodeNumber > 0)) + { + pattern = namingConfig.AnimeEpisodeFormat; + } + var episodeFormat = GetEpisodeFormat(pattern); if (episodeFormat != null) @@ -154,6 +166,53 @@ namespace NzbDrone.Core.Organizer tokenValues.Add("{Season Episode}", seasonEpisodePattern); } + //TODO: Extract to another method + var absoluteEpisodeFormat = GetAbsoluteFormat(pattern); + + if (absoluteEpisodeFormat != null) + { + if (series.SeriesType != SeriesTypes.Anime) + { + pattern = pattern.Replace(absoluteEpisodeFormat.AbsoluteEpisodePattern, ""); + } + + else + { + pattern = pattern.Replace(absoluteEpisodeFormat.AbsoluteEpisodePattern, "{Absolute Pattern}"); + var absoluteEpisodePattern = absoluteEpisodeFormat.AbsoluteEpisodePattern; + + foreach (var episode in sortedEpisodes.Skip(1)) + { + switch ((MultiEpisodeStyle)namingConfig.MultiEpisodeStyle) + { + case MultiEpisodeStyle.Duplicate: + absoluteEpisodePattern += absoluteEpisodeFormat.Separator + + absoluteEpisodeFormat.AbsoluteEpisodePattern; + break; + + case MultiEpisodeStyle.Repeat: + absoluteEpisodePattern += absoluteEpisodeFormat.Separator + + absoluteEpisodeFormat.AbsoluteEpisodePattern; + break; + + case MultiEpisodeStyle.Scene: + absoluteEpisodePattern += "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; + break; + + //MultiEpisodeStyle.Extend + default: + absoluteEpisodePattern += "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; + break; + } + + episodeTitles.Add(episode.Title.TrimEnd(EpisodeTitleTrimCharaters)); + } + + absoluteEpisodePattern = ReplaceAbsoluteNumberTokens(absoluteEpisodePattern, sortedEpisodes); + tokenValues.Add("{Absolute Pattern}", absoluteEpisodePattern); + } + } + tokenValues.Add("{Episode Title}", GetEpisodeTitle(episodeTitles)); tokenValues.Add("{Quality Title}", GetQualityTitle(episodeFile.Quality)); @@ -310,10 +369,25 @@ namespace NzbDrone.Core.Organizer var episodeIndex = 0; pattern = EpisodeRegex.Replace(pattern, match => { - var episode = episodes[episodeIndex].EpisodeNumber; + var episode = episodes[episodeIndex]; episodeIndex++; - return ReplaceNumberToken(match.Groups["episode"].Value, episode); + return ReplaceNumberToken(match.Groups["episode"].Value, episode.EpisodeNumber); + }); + + return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); + } + + private string ReplaceAbsoluteNumberTokens(string pattern, List episodes) + { + var episodeIndex = 0; + pattern = AbsoluteEpisodeRegex.Replace(pattern, match => + { + var episode = episodes[episodeIndex]; + episodeIndex++; + + //TODO: We need to handle this null check somewhere, I think earlier is better... + return ReplaceNumberToken(match.Groups["absolute"].Value, episode.AbsoluteEpisodeNumber.Value); }); return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); @@ -354,6 +428,22 @@ namespace NzbDrone.Core.Organizer }); } + private AbsoluteEpisodeFormat GetAbsoluteFormat(string pattern) + { + var match = AbsoluteEpisodePatternRegex.Match(pattern); + + if (match.Success) + { + return new AbsoluteEpisodeFormat + { + Separator = match.Groups["separator"].Value, + AbsoluteEpisodePattern = match.Groups["absolute"].Value + }; + } + + return null; + } + private string GetEpisodeTitle(List episodeTitles) { if (episodeTitles.Count == 1) diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs index bd554d46a..6546ac29d 100644 --- a/src/NzbDrone.Core/Organizer/FileNameValidation.cs +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -22,6 +22,12 @@ namespace NzbDrone.Core.Organizer return ruleBuilder.SetValidator(new ValidDailyEpisodeFormatValidator()); } + public static IRuleBuilderOptions ValidAnimeEpisodeFormat(this IRuleBuilder ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + return ruleBuilder.SetValidator(new ValidAnimeEpisodeFormatValidator()); + } + public static IRuleBuilderOptions ValidSeriesFolderFormat(this IRuleBuilder ruleBuilder) { ruleBuilder.SetValidator(new NotEmptyValidator(null)); @@ -56,4 +62,26 @@ namespace NzbDrone.Core.Organizer return true; } } + + public class ValidAnimeEpisodeFormatValidator : PropertyValidator + { + public ValidAnimeEpisodeFormatValidator() + : base("Must contain Absolute Episode number or Season and Episode") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var value = context.PropertyValue as String; + + if (!FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) && + !FileNameBuilder.AbsoluteEpisodePatternRegex.IsMatch(value)) + { + return false; + } + + return true; + } + } } diff --git a/src/NzbDrone.Core/Organizer/FilenameSampleService.cs b/src/NzbDrone.Core/Organizer/FilenameSampleService.cs index 2857bdd3a..c4231e533 100644 --- a/src/NzbDrone.Core/Organizer/FilenameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FilenameSampleService.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Organizer SampleResult GetStandardSample(NamingConfig nameSpec); SampleResult GetMultiEpisodeSample(NamingConfig nameSpec); SampleResult GetDailySample(NamingConfig nameSpec); + SampleResult GetAnimeSample(NamingConfig nameSpec); String GetSeriesFolderSample(NamingConfig nameSpec); String GetSeasonFolderSample(NamingConfig nameSpec); } @@ -20,6 +21,7 @@ namespace NzbDrone.Core.Organizer private readonly IBuildFileNames _buildFileNames; private static Series _standardSeries; private static Series _dailySeries; + private static Series _animeSeries; private static Episode _episode1; private static Episode _episode2; private static List _singleEpisode; @@ -27,10 +29,12 @@ namespace NzbDrone.Core.Organizer private static EpisodeFile _singleEpisodeFile; private static EpisodeFile _multiEpisodeFile; private static EpisodeFile _dailyEpisodeFile; + private static EpisodeFile _animeEpisodeFile; public FilenameSampleService(IBuildFileNames buildFileNames) { _buildFileNames = buildFileNames; + _standardSeries = new Series { SeriesType = SeriesTypes.Standard, @@ -43,19 +47,27 @@ namespace NzbDrone.Core.Organizer Title = "Series Title" }; + _animeSeries = new Series + { + SeriesType = SeriesTypes.Anime, + Title = "Series Title" + }; + _episode1 = new Episode { SeasonNumber = 1, EpisodeNumber = 1, Title = "Episode Title (1)", - AirDate = "2013-10-30" + AirDate = "2013-10-30", + AbsoluteEpisodeNumber = 1 }; _episode2 = new Episode { SeasonNumber = 1, EpisodeNumber = 2, - Title = "Episode Title (2)" + Title = "Episode Title (2)", + AbsoluteEpisodeNumber = 2 }; _singleEpisode = new List { _episode1 }; @@ -81,6 +93,13 @@ namespace NzbDrone.Core.Organizer Path = @"C:\Test\Series.Title.2013.10.30.HDTV.x264-EVOLVE.mkv", ReleaseGroup = "RlsGrp" }; + + _animeEpisodeFile = new EpisodeFile + { + Quality = new QualityModel(Quality.HDTV720p), + Path = @"C:\Test\Series.Title.001.HDTV.x264-EVOLVE.mkv", + ReleaseGroup = "RlsGrp" + }; } public SampleResult GetStandardSample(NamingConfig nameSpec) @@ -122,6 +141,19 @@ namespace NzbDrone.Core.Organizer return result; } + public SampleResult GetAnimeSample(NamingConfig nameSpec) + { + var result = new SampleResult + { + Filename = BuildSample(_singleEpisode, _animeSeries, _animeEpisodeFile, nameSpec), + Series = _animeSeries, + Episodes = _singleEpisode, + EpisodeFile = _animeEpisodeFile + }; + + return result; + } + public string GetSeriesFolderSample(NamingConfig nameSpec) { return _buildFileNames.GetSeriesFolder(_standardSeries.Title, nameSpec); @@ -138,7 +170,7 @@ namespace NzbDrone.Core.Organizer { return _buildFileNames.BuildFilename(episodes, series, episodeFile, nameSpec); } - catch (NamingFormatException ex) + catch (NamingFormatException) { return String.Empty; } diff --git a/src/NzbDrone.Core/Organizer/FilenameValidationService.cs b/src/NzbDrone.Core/Organizer/FilenameValidationService.cs index 53f64bf86..5c2703cc1 100644 --- a/src/NzbDrone.Core/Organizer/FilenameValidationService.cs +++ b/src/NzbDrone.Core/Organizer/FilenameValidationService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using FluentValidation.Results; using NzbDrone.Core.Parser.Model; @@ -10,6 +11,7 @@ namespace NzbDrone.Core.Organizer { ValidationFailure ValidateStandardFilename(SampleResult sampleResult); ValidationFailure ValidateDailyFilename(SampleResult sampleResult); + ValidationFailure ValidateAnimeFilename(SampleResult sampleResult); } public class FilenameValidationService : IFilenameValidationService @@ -44,7 +46,7 @@ namespace NzbDrone.Core.Organizer return validationFailure; } - if (parsedEpisodeInfo.IsDaily()) + if (parsedEpisodeInfo.IsDaily) { if (!parsedEpisodeInfo.AirDate.Equals(sampleResult.Episodes.Single().AirDate)) { @@ -62,6 +64,34 @@ namespace NzbDrone.Core.Organizer return null; } + public ValidationFailure ValidateAnimeFilename(SampleResult sampleResult) + { + var validationFailure = new ValidationFailure("AnimeEpisodeFormat", ERROR_MESSAGE); + var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.Filename); + + if (parsedEpisodeInfo == null) + { + return validationFailure; + } + + if (parsedEpisodeInfo.AbsoluteEpisodeNumbers.Any()) + { + if (!parsedEpisodeInfo.AbsoluteEpisodeNumbers.First().Equals(sampleResult.Episodes.First().AbsoluteEpisodeNumber)) + { + return validationFailure; + } + + return null; + } + + if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) + { + return validationFailure; + } + + return null; + } + private bool ValidateSeasonAndEpisodeNumbers(List episodes, ParsedEpisodeInfo parsedEpisodeInfo) { if (parsedEpisodeInfo.SeasonNumber != episodes.First().SeasonNumber || diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs index fcfcdd940..4177fde6a 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -14,6 +14,7 @@ namespace NzbDrone.Core.Organizer MultiEpisodeStyle = 0, StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Title}", DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Title}", + AnimeEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Title}", SeriesFolderFormat = "{Series Title}", SeasonFolderFormat = "Season {season}" }; @@ -24,6 +25,7 @@ namespace NzbDrone.Core.Organizer public int MultiEpisodeStyle { get; set; } public string StandardEpisodeFormat { get; set; } public string DailyEpisodeFormat { get; set; } + public string AnimeEpisodeFormat { get; set; } public string SeriesFolderFormat { get; set; } public string SeasonFolderFormat { get; set; } } diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 47adfe26b..623a5b1b2 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -1,22 +1,24 @@ using System; using System.Linq; +using NzbDrone.Common; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.Parser.Model { public class ParsedEpisodeInfo { - public string SeriesTitle { get; set; } + public String SeriesTitle { get; set; } public SeriesTitleInfo SeriesTitleInfo { get; set; } public QualityModel Quality { get; set; } - public int SeasonNumber { get; set; } - public int[] EpisodeNumbers { get; set; } - public int[] AbsoluteEpisodeNumbers { get; set; } + public Int32 SeasonNumber { get; set; } + public Int32[] EpisodeNumbers { get; set; } + public Int32[] AbsoluteEpisodeNumbers { get; set; } public String AirDate { get; set; } public Language Language { get; set; } - public bool FullSeason { get; set; } - public string ReleaseGroup { get; set; } + public Boolean FullSeason { get; set; } + public Boolean Special { get; set; } + public String ReleaseGroup { get; set; } + public String ReleaseHash { get; set; } public ParsedEpisodeInfo() { @@ -24,42 +26,68 @@ namespace NzbDrone.Core.Parser.Model AbsoluteEpisodeNumbers = new int[0]; } - public bool IsDaily() + public bool IsDaily { - return !String.IsNullOrWhiteSpace(AirDate); + get + { + return !String.IsNullOrWhiteSpace(AirDate); + } + + //This prevents manually downloading a release from blowing up in mono + //TODO: Is there a better way? + private set { } } - public bool IsAbsoluteNumbering() + public bool IsAbsoluteNumbering { - return AbsoluteEpisodeNumbers.Any(); + get + { + return AbsoluteEpisodeNumbers.Any(); + } + + //This prevents manually downloading a release from blowing up in mono + //TODO: Is there a better way? + private set { } } - public bool IsPossibleSpecialEpisode() + public bool IsPossibleSpecialEpisode { - // if we dont have eny episode numbers we are likely a special episode and need to do a search by episode title - return String.IsNullOrWhiteSpace(AirDate) && - (EpisodeNumbers.Length == 0 || SeasonNumber == 0) && - String.IsNullOrWhiteSpace(SeriesTitle); + get + { + // if we don't have eny episode numbers we are likely a special episode and need to do a search by episode title + return (AirDate.IsNullOrWhiteSpace() && + SeriesTitle.IsNullOrWhiteSpace() && + (EpisodeNumbers.Length == 0 || SeasonNumber == 0) || + !SeriesTitle.IsNullOrWhiteSpace() && Special); + } + + //This prevents manually downloading a release from blowing up in mono + //TODO: Is there a better way? + private set {} } public override string ToString() { string episodeString = "[Unknown Episode]"; - if (IsDaily() && EpisodeNumbers == null) + if (IsDaily && EpisodeNumbers == null) { - episodeString = string.Format("{0}", AirDate); + episodeString = String.Format("{0}", AirDate); } else if (FullSeason) { - episodeString = string.Format("Season {0:00}", SeasonNumber); + episodeString = String.Format("Season {0:00}", SeasonNumber); } else if (EpisodeNumbers != null && EpisodeNumbers.Any()) { - episodeString = string.Format("S{0:00}E{1}", SeasonNumber, String.Join("-", EpisodeNumbers.Select(c => c.ToString("00")))); + episodeString = String.Format("S{0:00}E{1}", SeasonNumber, String.Join("-", EpisodeNumbers.Select(c => c.ToString("00")))); + } + else if (AbsoluteEpisodeNumbers != null && AbsoluteEpisodeNumbers.Any()) + { + episodeString = String.Format("{0}", String.Join("-", AbsoluteEpisodeNumbers.Select(c => c.ToString("000")))); } - return string.Format("{0} - {1} {2}", SeriesTitle, episodeString, Quality); + return String.Format("{0} - {1} {2}", SeriesTitle, episodeString, Quality); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs index 4fb3b2b7a..3aa30a432 100644 --- a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Parser.Model public string CommentUrl { get; set; } public String Indexer { get; set; } public DownloadProtocol DownloadProtocol { get; set; } - + public int TvRageId { get; set; } public DateTime PublishDate { get; set; } public Int32 Age @@ -24,10 +24,7 @@ namespace NzbDrone.Core.Parser.Model //This prevents manually downloading a release from blowing up in mono //TODO: Is there a better way? - private set - { - - } + private set { } } public Double AgeHours @@ -39,14 +36,9 @@ namespace NzbDrone.Core.Parser.Model //This prevents manually downloading a release from blowing up in mono //TODO: Is there a better way? - private set - { - - } + private set { } } - public int TvRageId { get; set; } - public override string ToString() { return String.Format("[{0}] {1} [{2}]", PublishDate, Title, Size); diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index f9345db2b..63576914b 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -23,15 +23,15 @@ namespace NzbDrone.Core.Parser // RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - [SubGroup] Title Absolute Episode Number + Season+Episode - new Regex(@"^(?:\[(?.+?)\](?:_|-|\s|\.))(?.+?)(?:(?:\W|_)+(?<absoluteepisode>\d{2,3}))+(?:_|-|\s|\.)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)", + new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.))(?<title>.+?)(?:(?:\W|_)+(?<absoluteepisode>\d{2,3}))+(?:_|-|\s|\.)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+).*?(?<hash>\[.{8}\])?(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - [SubGroup] Title Season+Episode + Absolute Episode Number - new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.))(?<title>.+?)(?:\W|_)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:\s|\.)(?:(?<absoluteepisode>\d{2,3})(?:_|-|\s|\.|$)+)+", + new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.))(?<title>.+?)(?:\W|_)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:\s|\.)(?:(?<absoluteepisode>\d{2,3})(?:_|-|\s|\.|$)+)+.*?(?<hash>\[.{8}\])?(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Anime - [SubGroup] Title Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\](?:_|-|\s|\.)?(?<title>.+?)(?:[ ._-]+(?<absoluteepisode>\d{2,}))+", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:[-_. ]+(?<absoluteepisode>\d{2,}))+.*?(?<hash>\[[a-z0-9]{8}\])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Multi-Part episodes without a title (S01E05.S01E06) @@ -54,16 +54,16 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?<title>.+?)(?:\W+S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:[ex]|\W[ex]){1,2}(?<episode>\d{2,3}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2,3}(?!\d+)))*)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Episodes with single digit episode number (S01E1, S01E5E6, etc) - new Regex(@"^(?<title>.*?)(?:\W?S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]){1,2}(?<episode>\d{1}))+)+(\W+|_|$)(?!\\)", + //Anime - Title Absolute Episode Number [SubGroup] + new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{3}(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\].*?(?<hash>\[.{8}\])?(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Anime - Title Absolute Episode Number [SubGroup] - new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{3}(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\](?:\.|$)", + //Anime - Title Absolute Episode Number Hash + new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{2,3}))+.*?(?<hash>\[.{8}\])(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 103/113 naming - new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)\d{1})(?<episode>[1-9][0-9]|[0][1-9])(?!\w|\d+))+", + new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)[1-9])(?<episode>[1-9][0-9]|[0][1-9])(?!\w|\d+))+", RegexOptions.IgnoreCase | RegexOptions.Compiled), //Mini-Series, treated as season 1, episodes are labelled as Part01, Part 01, Part.1 @@ -83,7 +83,7 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 1103/1113 naming - new Regex(@"^(?<title>.+?)?(?:\W(?<season>(?<!\d+|\(|\[|e|x)\d{2})(?<episode>(?<!e|x)\d{2}(?!p|i|\d+|\)|\]|\W\d+)))+(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+|\(|\[|e|x)\d{2})(?<episode>(?<!e|x)\d{2}(?!p|i|\d+|\)|\]|\W\d+)))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //4-digit episode number @@ -95,8 +95,16 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?<title>.+?)(?:(\W|_)+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{4}(?!\d+|i|p)))+)\W?(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Anime - Title Absolute Episode Number - new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+e(?<absoluteepisode>\d{2,3}))+", + //Episodes with single digit episode number (S01E1, S01E5E6, etc) + new Regex(@"^(?<title>.*?)(?:\W?S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]){1,2}(?<episode>\d{1}))+)+(\W+|_|$)(?!\\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Anime - Title Absolute Episode Number (e66) + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,3}))+.*?(?<hash>\[.{8}\])?(?:$|\.)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Anime - Title Absolute Episode Number + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{2,3}))+.*?(?<hash>\[.{8}\])?(?:$|\.)", RegexOptions.IgnoreCase | RegexOptions.Compiled) }; @@ -104,6 +112,9 @@ namespace NzbDrone.Core.Parser { // Generic match for md5 and mixed-case hashes. new Regex(@"^[0-9a-zA-Z]{32}", RegexOptions.Compiled), + + // Generic match for shorter lower-case hashes. + new Regex(@"^[a-z0-9]{24}$", RegexOptions.Compiled), // Format seen on some NZBGeek releases new Regex(@"^[A-Z]{11}\d{3}$", RegexOptions.Compiled) @@ -115,7 +126,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex NormalizeRegex = new Regex(@"((?:\b|_)(?<!^)(a|an|the|and|or|of)(?:\b|_))|\W|_", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex SimpleTitleRegex = new Regex(@"480[i|p]|720[i|p]|1080[i|p]|[xh][\W_]?264|DD\W?5\W1|\<|\>|\?|\*|\:|\|", + private static readonly Regex SimpleTitleRegex = new Regex(@"480[i|p]|720[i|p]|1080[i|p]|[xh][\W_]?264|DD\W?5\W1|\<|\>|\?|\*|\:|\||848x480|1280x720|1920x1080|8bit|10bit", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AirDateRegex = new Regex(@"^(.*?)(?<!\d)((?<airyear>\d{4})[_.-](?<airmonth>[0-1][0-9])[_.-](?<airday>[0-3][0-9])|(?<airmonth>[0-1][0-9])[_.-](?<airday>[0-3][0-9])[_.-](?<airyear>\d{4}))(?!\d)", @@ -179,7 +190,7 @@ namespace NzbDrone.Core.Parser var titleWithoutExtension = RemoveFileExtension(title).ToCharArray(); Array.Reverse(titleWithoutExtension); - title = new string(titleWithoutExtension) + title.Substring(titleWithoutExtension.Length); + title = new String(titleWithoutExtension) + title.Substring(titleWithoutExtension.Length); Logger.Debug("Reversed name detected. Converted to '{0}'", title); } @@ -202,8 +213,15 @@ namespace NzbDrone.Core.Parser try { var result = ParseMatchCollection(match); + if (result != null) { + if (result.FullSeason && title.ContainsIgnoreCase("Special")) + { + result.FullSeason = false; + result.Special = true; + } + result.Language = ParseLanguage(title); Logger.Debug("Language parsed: {0}", result.Language); @@ -211,8 +229,21 @@ namespace NzbDrone.Core.Parser Logger.Debug("Quality parsed: {0}", result.Quality); result.ReleaseGroup = ParseReleaseGroup(title); + + var subGroup = GetSubGroup(match); + if (!subGroup.IsNullOrWhiteSpace()) + { + result.ReleaseGroup = subGroup; + } + Logger.Debug("Release Group parsed: {0}", result.ReleaseGroup); + result.ReleaseHash = GetReleaseHash(match); + if (!result.ReleaseHash.IsNullOrWhiteSpace()) + { + Logger.Debug("Release Hash parsed: {0}", result.ReleaseHash); + } + return result; } } @@ -279,9 +310,7 @@ namespace NzbDrone.Core.Parser const string defaultReleaseGroup = "DRONE"; title = title.Trim(); - title = RemoveFileExtension(title); - title = title.TrimEnd("-RP"); var matches = ReleaseGroupRegex.Matches(title); @@ -357,12 +386,10 @@ namespace NzbDrone.Core.Parser } //If no season was found it should be treated as a mini series and season 1 - if (seasons.Count == 0) - seasons.Add(1); + if (seasons.Count == 0) seasons.Add(1); //If more than 1 season was parsed go to the next REGEX (A multi-season release is unlikely) - if (seasons.Distinct().Count() > 1) - return null; + if (seasons.Distinct().Count() > 1) return null; result = new ParsedEpisodeInfo { @@ -409,12 +436,12 @@ namespace NzbDrone.Core.Parser { //Check to see if this is an "Extras" or "SUBPACK" release, if it is, return NULL //Todo: Set a "Extras" flag in EpisodeParseResult if we want to download them ever - if (!String.IsNullOrWhiteSpace(matchCollection[0].Groups["extras"].Value)) - return null; + if (!matchCollection[0].Groups["extras"].Value.IsNullOrWhiteSpace()) return null; result.FullSeason = true; } } + if (result.AbsoluteEpisodeNumbers.Any() && !result.EpisodeNumbers.Any()) { result.SeasonNumber = 0; @@ -564,5 +591,36 @@ namespace NzbDrone.Core.Parser return true; } + + private static string GetSubGroup(MatchCollection matchCollection) + { + var subGroup = matchCollection[0].Groups["subgroup"]; + + if (subGroup.Success) + { + return subGroup.Value; + } + + return String.Empty; + } + + private static string GetReleaseHash(MatchCollection matchCollection) + { + var hash = matchCollection[0].Groups["hash"]; + + if (hash.Success) + { + var hashValue = hash.Value.Trim('[',']'); + + if (hashValue.Equals("1280x720")) + { + return String.Empty; + } + + return hashValue; + } + + return String.Empty; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 05f0359ab..5acc542f7 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -44,15 +44,13 @@ namespace NzbDrone.Core.Parser { var parsedEpisodeInfo = Parser.ParsePath(filename); - // do we have a possible special episode? - if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) { - // try to parse as a special episode var title = Path.GetFileNameWithoutExtension(filename); var specialEpisodeInfo = ParseSpecialEpisodeTitle(title, series); + if (specialEpisodeInfo != null) { - // use special episode parsedEpisodeInfo = specialEpisodeInfo; } } @@ -131,7 +129,7 @@ namespace NzbDrone.Core.Parser return _episodeService.GetEpisodesBySeason(series.Id, parsedEpisodeInfo.SeasonNumber); } - if (parsedEpisodeInfo.IsDaily()) + if (parsedEpisodeInfo.IsDaily) { if (series.SeriesType == SeriesTypes.Standard) { @@ -149,20 +147,52 @@ namespace NzbDrone.Core.Parser return result; } - if (parsedEpisodeInfo.IsAbsoluteNumbering()) + if (parsedEpisodeInfo.IsAbsoluteNumbering) { + var sceneSeasonNumber = _sceneMappingService.GetSeasonNumber(parsedEpisodeInfo.SeriesTitle); + foreach (var absoluteEpisodeNumber in parsedEpisodeInfo.AbsoluteEpisodeNumbers) { - var episodeInfo = _episodeService.FindEpisode(series.Id, absoluteEpisodeNumber); + Episode episode = null; - if (episodeInfo != null) + if (sceneSource) + { + if (sceneSeasonNumber.HasValue && sceneSeasonNumber > 1) + { + var episodes = _episodeService.FindEpisodesBySceneNumbering(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); + + if (episodes.Count == 1) + { + episode = episodes.First(); + } + + if (episode == null) + { + episode = _episodeService.FindEpisode(series.Id, sceneSeasonNumber.Value, + absoluteEpisodeNumber); + } + } + + else + { + episode = _episodeService.FindEpisodeBySceneNumbering(series.Id, absoluteEpisodeNumber); + } + } + + if (episode == null) + { + episode = _episodeService.FindEpisode(series.Id, absoluteEpisodeNumber); + } + + if (episode != null) { _logger.Info("Using absolute episode number {0} for: {1} - TVDB: {2}x{3:00}", absoluteEpisodeNumber, series.Title, - episodeInfo.SeasonNumber, - episodeInfo.EpisodeNumber); - result.Add(episodeInfo); + episode.SeasonNumber, + episode.EpisodeNumber); + + result.Add(episode); } } @@ -191,7 +221,7 @@ namespace NzbDrone.Core.Parser if (episodes != null && episodes.Any()) { - _logger.Info("Using Scene to TVDB Mapping for: {0} - Scene: {1}x{2:00} - TVDB: {3}", + _logger.Debug("Using Scene to TVDB Mapping for: {0} - Scene: {1}x{2:00} - TVDB: {3}", series.Title, episodes.First().SceneSeasonNumber, episodes.First().SceneEpisodeNumber, @@ -267,6 +297,7 @@ namespace NzbDrone.Core.Parser { // find special episode in series season 0 var episode = _episodeService.FindEpisodeByName(series.Id, 0, title); + if (episode != null) { // create parsed info from tv episode diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index fc87bfc39..156a17caf 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Parser private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly Regex SourceRegex = new Regex(@"\b(?: - (?<bluray>BluRay)| + (?<bluray>BluRay|Blu-Ray)| (?<webdl>WEB[-_. ]DL|WEBDL|WebRip)| (?<hdtv>HDTV)| (?<bdrip>BDRiP)| @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex ProperRegex = new Regex(@"\b(?<proper>proper|repack)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex ResolutionRegex = new Regex(@"\b(?:(?<_480p>480p)|(?<_576p>576p)|(?<_720p>720p)|(?<_1080p>1080p))\b", + private static readonly Regex ResolutionRegex = new Regex(@"\b(?:(?<_480p>480p|640x480)|(?<_576p>576p)|(?<_720p>720p|1280x720)|(?<_1080p>1080p|1920x1080))\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<x264>x264)|(?<h264>h264)|(?<xvidhd>XvidHD)|(?<xvid>Xvid)|(?<divx>divx))\b", @@ -39,6 +39,10 @@ namespace NzbDrone.Core.Parser private static readonly Regex OtherSourceRegex = new Regex(@"(?<hdtv>HD[-_. ]TV)|(?<sdtv>SD[-_. ]TV)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex AnimeBlurayRegex = new Regex(@"bd(?:720|1080)|(?<=\[|\(|\s)bd(?=\s|\)|\])", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex HighDefPdtvRegex = new Regex(@"hr[-_. ]ws", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static QualityModel ParseQuality(string name) { Logger.Debug("Trying to parse quality for {0}", name); @@ -161,10 +165,35 @@ namespace NzbDrone.Core.Parser sourceMatch.Groups["sdtv"].Success || sourceMatch.Groups["dsr"].Success) { + if (HighDefPdtvRegex.IsMatch(normalizedName)) + { + result.Quality = Quality.HDTV720p; + return result; + } + result.Quality = Quality.SDTV; return result; } + //Anime Bluray matching + if (AnimeBlurayRegex.Match(normalizedName).Success) + { + if (resolution == Resolution._480p || resolution == Resolution._576p || normalizedName.Contains("480p")) + { + result.Quality = Quality.DVD; + return result; + } + + if (resolution == Resolution._1080p || normalizedName.Contains("1080p")) + { + result.Quality = Quality.Bluray1080p; + return result; + } + + result.Quality = Quality.Bluray720p; + return result; + } + if (resolution == Resolution._1080p) { result.Quality = Quality.HDTV1080p; @@ -177,12 +206,48 @@ namespace NzbDrone.Core.Parser return result; } + if (resolution == Resolution._480p) + { + result.Quality = Quality.SDTV; + return result; + } + if (codecRegex.Groups["x264"].Success) { result.Quality = Quality.SDTV; return result; } + if (normalizedName.Contains("848x480")) + { + if (normalizedName.Contains("dvd")) + { + result.Quality = Quality.DVD; + } + + result.Quality = Quality.SDTV; + } + + if (normalizedName.Contains("1280x720")) + { + if (normalizedName.Contains("bluray")) + { + result.Quality = Quality.Bluray720p; + } + + result.Quality = Quality.HDTV720p; + } + + if (normalizedName.Contains("1920x1080")) + { + if (normalizedName.Contains("bluray")) + { + result.Quality = Quality.Bluray1080p; + } + + result.Quality = Quality.HDTV1080p; + } + if (normalizedName.Contains("bluray720p")) { result.Quality = Quality.Bluray720p; diff --git a/src/NzbDrone.Core/Properties/AssemblyInfo.cs b/src/NzbDrone.Core/Properties/AssemblyInfo.cs index 06c50c11f..4593d015a 100644 --- a/src/NzbDrone.Core/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Core/Properties/AssemblyInfo.cs @@ -11,10 +11,6 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("3C29FEF7-4B07-49ED-822E-1C29DC49BFAB")] -[assembly: InternalsVisibleTo("NzbDrone.Core.Test")] - -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: - [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] + +[assembly: InternalsVisibleTo("NzbDrone.Core.Test")] diff --git a/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs b/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs index 0b669f331..7e080c738 100644 --- a/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs +++ b/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Qualities { return Query.Where(q => (int) q.Quality == qualityId).Single(); } - catch (InvalidOperationException e) + catch (InvalidOperationException) { throw new ModelNotFoundException(typeof(QualityDefinition), qualityId); } diff --git a/src/NzbDrone.Core/Queue/Queue.cs b/src/NzbDrone.Core/Queue/Queue.cs index c8cde78d4..0cc67c2dc 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Queue public Decimal Sizeleft { get; set; } public TimeSpan? Timeleft { get; set; } public String Status { get; set; } + public String ErrorMessage { get; set; } public RemoteEpisode RemoteEpisode { get; set; } } } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index ead4f083c..20b57db4a 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -25,32 +25,37 @@ namespace NzbDrone.Core.Queue public List<Queue> GetQueue() { var queueItems = _downloadTrackingService.GetQueuedDownloads() - .Select(v => v.DownloadItem) - .OrderBy(v => v.RemainingTime) + .OrderBy(v => v.DownloadItem.RemainingTime) .ToList(); return MapQueue(queueItems); } - private List<Queue> MapQueue(IEnumerable<DownloadClientItem> queueItems) + private List<Queue> MapQueue(IEnumerable<TrackedDownload> queueItems) { var queued = new List<Queue>(); foreach (var queueItem in queueItems) { - foreach (var episode in queueItem.RemoteEpisode.Episodes) + foreach (var episode in queueItem.DownloadItem.RemoteEpisode.Episodes) { var queue = new Queue(); - queue.Id = queueItem.DownloadClientId.GetHashCode() + episode.Id; - queue.Series = queueItem.RemoteEpisode.Series; + queue.Id = queueItem.DownloadItem.DownloadClientId.GetHashCode() + episode.Id; + queue.Series = queueItem.DownloadItem.RemoteEpisode.Series; queue.Episode = episode; - queue.Quality = queueItem.RemoteEpisode.ParsedEpisodeInfo.Quality; - queue.Title = queueItem.Title; - queue.Size = queueItem.TotalSize; - queue.Sizeleft = queueItem.RemainingSize; - queue.Timeleft = queueItem.RemainingTime; - queue.Status = queueItem.Status.ToString(); - queue.RemoteEpisode = queueItem.RemoteEpisode; + queue.Quality = queueItem.DownloadItem.RemoteEpisode.ParsedEpisodeInfo.Quality; + queue.Title = queueItem.DownloadItem.Title; + queue.Size = queueItem.DownloadItem.TotalSize; + queue.Sizeleft = queueItem.DownloadItem.RemainingSize; + queue.Timeleft = queueItem.DownloadItem.RemainingTime; + queue.Status = queueItem.DownloadItem.Status.ToString(); + queue.RemoteEpisode = queueItem.DownloadItem.RemoteEpisode; + + if (queueItem.HasError) + { + queue.ErrorMessage = queueItem.StatusMessage; + } + queued.Add(queue); } } diff --git a/src/NzbDrone.Core/Rest/RestClientFactory.cs b/src/NzbDrone.Core/Rest/RestClientFactory.cs new file mode 100644 index 000000000..042bfb5f2 --- /dev/null +++ b/src/NzbDrone.Core/Rest/RestClientFactory.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using RestSharp; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Core.Rest +{ + public static class RestClientFactory + { + public static RestClient BuildClient(String baseUrl) + { + var restClient = new RestClient(baseUrl); + + restClient.UserAgent = String.Format("NzbDrone/{0} (RestSharp/{1}; {2}/{3})", + BuildInfo.Version, + restClient.GetType().Assembly.GetName().Version, + OsInfo.Os, OsInfo.Version.ToString(2)); + + return restClient; + } + } +} diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs index 170985718..e43fb7c64 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs @@ -7,6 +7,7 @@ namespace NzbDrone.Core.SeriesStats { 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; } @@ -21,5 +22,17 @@ namespace NzbDrone.Core.SeriesStats 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/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs index 6bb4c63d3..683736f77 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs @@ -56,7 +56,8 @@ namespace NzbDrone.Core.SeriesStats SeriesId, 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 + 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"; } diff --git a/src/NzbDrone.Core/ThingiProvider/IProvider.cs b/src/NzbDrone.Core/ThingiProvider/IProvider.cs index 4947c3d8b..bd30c0ab7 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProvider.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProvider.cs @@ -1,6 +1,6 @@ - -using System; +using System; using System.Collections.Generic; +using FluentValidation.Results; namespace NzbDrone.Core.ThingiProvider { @@ -10,5 +10,6 @@ namespace NzbDrone.Core.ThingiProvider IEnumerable<ProviderDefinition> DefaultDefinitions { get; } ProviderDefinition Definition { get; set; } + ValidationResult Test(); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index b70b1e06a..eb960f26a 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -1,5 +1,5 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using FluentValidation.Results; namespace NzbDrone.Core.ThingiProvider { @@ -10,10 +10,11 @@ namespace NzbDrone.Core.ThingiProvider List<TProviderDefinition> All(); List<TProvider> GetAvailableProviders(); TProviderDefinition Get(int id); - TProviderDefinition Create(TProviderDefinition indexer); - void Update(TProviderDefinition indexer); + TProviderDefinition Create(TProviderDefinition definition); + void Update(TProviderDefinition definition); void Delete(int id); IEnumerable<TProviderDefinition> GetDefaultDefinitions(); IEnumerable<TProviderDefinition> GetPresetDefinitions(TProviderDefinition providerDefinition); + ValidationResult Test(TProviderDefinition definition); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs b/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs index c9b20fe47..4ed723e4f 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs @@ -1,14 +1,16 @@ -using NzbDrone.Core.Datastore; +using System; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.ThingiProvider { public abstract class ProviderDefinition : ModelBase { private IProviderConfig _settings; - public string Name { get; set; } - public string Implementation { get; set; } - public string ConfigContract { get; set; } + public String Name { get; set; } + public String Implementation { get; set; } + public String ConfigContract { get; set; } + public virtual Boolean Enable { get; set; } public IProviderConfig Settings { @@ -26,4 +28,4 @@ namespace NzbDrone.Core.ThingiProvider } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index fa9cd32c2..a8dda0bec 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using FluentValidation.Results; using NLog; using NzbDrone.Common.Composition; using NzbDrone.Core.Lifecycle; @@ -75,6 +76,11 @@ namespace NzbDrone.Core.ThingiProvider return definitions; } + public ValidationResult Test(TProviderDefinition definition) + { + return GetInstance(definition).Test(); + } + public List<TProvider> GetAvailableProviders() { return Active().Select(GetInstance).ToList(); diff --git a/src/NzbDrone.Core/Tv/Episode.cs b/src/NzbDrone.Core/Tv/Episode.cs index a550709b0..e564ee519 100644 --- a/src/NzbDrone.Core/Tv/Episode.cs +++ b/src/NzbDrone.Core/Tv/Episode.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.Tv public string Overview { get; set; } public Boolean Monitored { get; set; } public Nullable<Int32> AbsoluteEpisodeNumber { get; set; } + public Nullable<Int32> SceneAbsoluteEpisodeNumber { get; set; } public int SceneSeasonNumber { get; set; } public int SceneEpisodeNumber { get; set; } public Ratings Ratings { get; set; } diff --git a/src/NzbDrone.Core/Tv/EpisodeRepository.cs b/src/NzbDrone.Core/Tv/EpisodeRepository.cs index db43e8f26..5ebb8c71d 100644 --- a/src/NzbDrone.Core/Tv/EpisodeRepository.cs +++ b/src/NzbDrone.Core/Tv/EpisodeRepository.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Linq; using Marr.Data.QGen; using NLog; +using NzbDrone.Common; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Messaging.Events; @@ -25,6 +26,7 @@ namespace NzbDrone.Core.Tv PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec, bool includeSpecials); PagingSpec<Episode> EpisodesWhereCutoffUnmet(PagingSpec<Episode> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, bool includeSpecials); List<Episode> FindEpisodesBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber); + Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber); List<Episode> EpisodesBetweenDates(DateTime startDate, DateTime endDate); void SetMonitoredFlat(Episode episode, bool monitored); void SetMonitoredBySeason(int seriesId, int seasonNumber, bool monitored); @@ -137,6 +139,20 @@ namespace NzbDrone.Core.Tv .AndWhere(s => s.SceneEpisodeNumber == episodeNumber); } + public Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) + { + var episodes = Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.SceneAbsoluteEpisodeNumber == sceneAbsoluteEpisodeNumber) + .ToList(); + + if (episodes.Empty() || episodes.Count > 1) + { + return null; + } + + return episodes.Single(); + } + public List<Episode> EpisodesBetweenDates(DateTime startDate, DateTime endDate) { return Query.Join<Episode, Series>(JoinType.Inner, e => e.Series, (e, s) => e.SeriesId == s.Id) diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index 09c073e56..a7a2326f0 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -7,7 +7,6 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tv.Events; -using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Tv { @@ -18,6 +17,7 @@ namespace NzbDrone.Core.Tv Episode FindEpisode(int seriesId, int absoluteEpisodeNumber); Episode FindEpisodeByName(int seriesId, int seasonNumber, string episodeTitle); List<Episode> FindEpisodesBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber); + Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber); Episode GetEpisode(int seriesId, String date); Episode FindEpisode(int seriesId, String date); List<Episode> GetEpisodeBySeries(int seriesId); @@ -71,6 +71,11 @@ namespace NzbDrone.Core.Tv return _episodeRepository.FindEpisodesBySceneNumbering(seriesId, seasonNumber, episodeNumber); } + public Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) + { + return _episodeRepository.FindEpisodeBySceneNumbering(seriesId, sceneAbsoluteEpisodeNumber); + } + public Episode GetEpisode(int seriesId, String date) { return _episodeRepository.Get(seriesId, date); diff --git a/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs index 33371a4e7..a37a6c902 100644 --- a/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs +++ b/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs @@ -5,10 +5,12 @@ namespace NzbDrone.Core.Tv.Events public class SeriesEditedEvent : IEvent { public Series Series { get; private set; } + public Series OldSeries { get; private set; } - public SeriesEditedEvent(Series series) + public SeriesEditedEvent(Series series, Series oldSeries) { Series = series; + OldSeries = oldSeries; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs index 357926f4e..8db1e864b 100644 --- a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs +++ b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Windows.Forms.VisualStyles; using NLog; using NzbDrone.Common; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.MetadataSource.Tvdb; using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.Tv @@ -17,12 +17,14 @@ namespace NzbDrone.Core.Tv public class RefreshEpisodeService : IRefreshEpisodeService { private readonly IEpisodeService _episodeService; + private readonly ITvdbProxy _tvdbProxy; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; - public RefreshEpisodeService(IEpisodeService episodeService, IEventAggregator eventAggregator, Logger logger) + public RefreshEpisodeService(IEpisodeService episodeService, ITvdbProxy tvdbProxy, IEventAggregator eventAggregator, Logger logger) { _episodeService = episodeService; + _tvdbProxy = tvdbProxy; _eventAggregator = eventAggregator; _logger = logger; } @@ -40,11 +42,16 @@ namespace NzbDrone.Core.Tv var newList = new List<Episode>(); var dupeFreeRemoteEpisodes = remoteEpisodes.DistinctBy(m => new { m.SeasonNumber, m.EpisodeNumber }).ToList(); - foreach (var episode in dupeFreeRemoteEpisodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber)) + if (series.SeriesType == SeriesTypes.Anime) + { + dupeFreeRemoteEpisodes = MapAbsoluteEpisodeNumbers(series, dupeFreeRemoteEpisodes); + } + + foreach (var episode in OrderEpsiodes(series, dupeFreeRemoteEpisodes)) { try { - var episodeToUpdate = existingEpisodes.FirstOrDefault(e => e.SeasonNumber == episode.SeasonNumber && e.EpisodeNumber == episode.EpisodeNumber); + var episodeToUpdate = GetEpisodeToUpdate(series, episode, existingEpisodes); if (episodeToUpdate != null) { @@ -61,6 +68,7 @@ namespace NzbDrone.Core.Tv episodeToUpdate.SeriesId = series.Id; episodeToUpdate.EpisodeNumber = episode.EpisodeNumber; episodeToUpdate.SeasonNumber = episode.SeasonNumber; + episodeToUpdate.AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber; episodeToUpdate.Title = episode.Title; episodeToUpdate.Overview = episode.Overview; episodeToUpdate.AirDate = episode.AirDate; @@ -82,7 +90,6 @@ namespace NzbDrone.Core.Tv allEpisodes.AddRange(updateList); AdjustMultiEpisodeAirTime(series, allEpisodes); - SetAbsoluteEpisodeNumber(allEpisodes); _episodeService.DeleteMany(existingEpisodes); _episodeService.UpdateMany(updateList); @@ -144,16 +151,62 @@ namespace NzbDrone.Core.Tv } } - private static void SetAbsoluteEpisodeNumber(IEnumerable<Episode> allEpisodes) + private List<Episode> MapAbsoluteEpisodeNumbers(Series series, List<Episode> traktEpisodes) { - var episodes = allEpisodes.Where(e => e.SeasonNumber > 0 && e.EpisodeNumber > 0) - .OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber) - .ToList(); + var tvdbEpisodes = _tvdbProxy.GetEpisodeInfo(series.TvdbId); - for (int i = 0; i < episodes.Count(); i++) + foreach (var episode in traktEpisodes) { - episodes[i].AbsoluteEpisodeNumber = i + 1; + //I'd use single, but then I'd have to trust the tvdb data... and I don't + var tvdbEpisode = tvdbEpisodes.FirstOrDefault(e => e.SeasonNumber == episode.SeasonNumber && + e.EpisodeNumber == episode.EpisodeNumber); + + if (tvdbEpisode == null) + { + _logger.Debug("Cannot find matching episode from the tvdb: {0}x{1:00}", episode.SeasonNumber, episode.EpisodeNumber); + continue; + } + + episode.AbsoluteEpisodeNumber = tvdbEpisode.AbsoluteEpisodeNumber; } + + //Return all episodes with abs 0, but distinct by abs for ones greater than 0 + return traktEpisodes.Where(e => e.AbsoluteEpisodeNumber > 0) + .DistinctBy(e => e.AbsoluteEpisodeNumber) + .Concat(traktEpisodes.Where(e => e.AbsoluteEpisodeNumber == 0)) + .ToList(); + } + + private Episode GetEpisodeToUpdate(Series series, Episode episode, List<Episode> existingEpisodes) + { + if (series.SeriesType == SeriesTypes.Anime) + { + if (episode.AbsoluteEpisodeNumber > 0) + { + var matchingEpisode = existingEpisodes.FirstOrDefault(e => e.AbsoluteEpisodeNumber == episode.AbsoluteEpisodeNumber); + + if (matchingEpisode != null) return matchingEpisode; + } + } + + return existingEpisodes.FirstOrDefault(e => e.SeasonNumber == episode.SeasonNumber && e.EpisodeNumber == episode.EpisodeNumber); + } + + private IEnumerable<Episode> OrderEpsiodes(Series series, List<Episode> episodes) + { + if (series.SeriesType == SeriesTypes.Anime) + { + var withAbs = episodes.Where(e => e.AbsoluteEpisodeNumber > 0) + .OrderBy(e => e.AbsoluteEpisodeNumber); + + var withoutAbs = episodes.Where(e => e.AbsoluteEpisodeNumber == 0) + .OrderBy(e => e.SeasonNumber) + .ThenBy(e => e.EpisodeNumber); + + return withAbs.Concat(withoutAbs); + } + + return episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 5390d0502..15a694fad 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -6,7 +6,6 @@ using NLog; using NzbDrone.Core.DataAugmentation.DailySeries; using NzbDrone.Core.Instrumentation.Extensions; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.MetadataSource; @@ -54,10 +53,13 @@ namespace NzbDrone.Core.Tv var seriesInfo = tuple.Item1; series.Title = seriesInfo.Title; + series.TitleSlug = seriesInfo.TitleSlug; + series.TvRageId = seriesInfo.TvRageId; series.AirTime = seriesInfo.AirTime; series.Overview = seriesInfo.Overview; series.Status = seriesInfo.Status; series.CleanTitle = seriesInfo.CleanTitle; + series.SortTitle = seriesInfo.SortTitle; series.LastInfoSync = DateTime.UtcNow; series.Runtime = seriesInfo.Runtime; series.Images = seriesInfo.Images; diff --git a/src/NzbDrone.Core/Tv/Series.cs b/src/NzbDrone.Core/Tv/Series.cs index ace922cb1..2d81a305b 100644 --- a/src/NzbDrone.Core/Tv/Series.cs +++ b/src/NzbDrone.Core/Tv/Series.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.Tv public string ImdbId { get; set; } public string Title { get; set; } public string CleanTitle { get; set; } + public string SortTitle { get; set; } public SeriesStatusType Status { get; set; } public string Overview { get; set; } public String AirTime { get; set; } diff --git a/src/NzbDrone.Core/Tv/SeriesEditedService.cs b/src/NzbDrone.Core/Tv/SeriesEditedService.cs new file mode 100644 index 000000000..faa6d6e35 --- /dev/null +++ b/src/NzbDrone.Core/Tv/SeriesEditedService.cs @@ -0,0 +1,27 @@ +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tv.Commands; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.Tv +{ + public class SeriesEditedService : IHandle<SeriesEditedEvent> + { + private readonly CommandExecutor _commandExecutor; + + public SeriesEditedService(CommandExecutor commandExecutor) + { + _commandExecutor = commandExecutor; + } + + public void Handle(SeriesEditedEvent message) + { + //TODO: Refresh if path has changed (also move files) + + if (message.Series.SeriesType != message.OldSeries.SeriesType) + { + _commandExecutor.PublishCommandAsync(new RefreshSeriesCommand(message.Series.Id)); + } + } + } +} diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 9de113351..f48e900b7 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.EnsureThat; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser; using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.Tv @@ -77,7 +78,8 @@ namespace NzbDrone.Core.Tv _logger.Info("Adding Series {0} Path: [{1}]", newSeries, newSeries.Path); newSeries.Monitored = true; - newSeries.CleanTitle = Parser.Parser.CleanSeriesTitle(newSeries.Title); + newSeries.CleanTitle = newSeries.Title.CleanSeriesTitle(); + newSeries.SortTitle = Parser.Parser.NormalizeEpisodeTitle(newSeries.Title).ToLower(); _seriesRepository.Insert(newSeries); _eventAggregator.PublishEvent(new SeriesAddedEvent(GetSeries(newSeries.Id))); @@ -97,8 +99,6 @@ namespace NzbDrone.Core.Tv public Series FindByTitle(string title) { - title = Parser.Parser.CleanSeriesTitle(title); - var tvdbId = _sceneMappingService.GetTvDbId(title); if (tvdbId.HasValue) @@ -106,13 +106,13 @@ namespace NzbDrone.Core.Tv return FindByTvdbId(tvdbId.Value); } - return _seriesRepository.FindByTitle(title); + return _seriesRepository.FindByTitle(title.CleanSeriesTitle()); } public Series FindByTitleInexact(string title) { // find any series clean title within the provided release title - string cleanTitle = Parser.Parser.CleanSeriesTitle(title); + string cleanTitle = title.CleanSeriesTitle(); var list = _seriesRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); if (!list.Any()) { @@ -191,7 +191,7 @@ namespace NzbDrone.Core.Tv } var updatedSeries = _seriesRepository.Update(series); - _eventAggregator.PublishEvent(new SeriesEditedEvent(updatedSeries)); + _eventAggregator.PublishEvent(new SeriesEditedEvent(updatedSeries, storedSeries)); return updatedSeries; } diff --git a/src/NzbDrone.Core/Update/InstallUpdateService.cs b/src/NzbDrone.Core/Update/InstallUpdateService.cs index 0e89f3116..8b473f989 100644 --- a/src/NzbDrone.Core/Update/InstallUpdateService.cs +++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs @@ -6,6 +6,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http; using NzbDrone.Common.Processes; +using NzbDrone.Core.Backup; using NzbDrone.Core.Configuration; using NzbDrone.Core.Instrumentation.Extensions; using NzbDrone.Core.Messaging.Commands; @@ -31,6 +32,7 @@ namespace NzbDrone.Core.Update private readonly IVerifyUpdates _updateVerifier; private readonly IConfigFileProvider _configFileProvider; private readonly IRuntimeInfo _runtimeInfo; + private readonly IBackupService _backupService; public InstallUpdateService(ICheckUpdateService checkUpdateService, IAppFolderInfo appFolderInfo, @@ -38,7 +40,9 @@ namespace NzbDrone.Core.Update IArchiveService archiveService, IProcessProvider processProvider, IVerifyUpdates updateVerifier, IConfigFileProvider configFileProvider, - IRuntimeInfo runtimeInfo, Logger logger) + IRuntimeInfo runtimeInfo, + IBackupService backupService, + Logger logger) { if (configFileProvider == null) { @@ -53,6 +57,7 @@ namespace NzbDrone.Core.Update _updateVerifier = updateVerifier; _configFileProvider = configFileProvider; _runtimeInfo = runtimeInfo; + _backupService = backupService; _logger = logger; } @@ -90,6 +95,8 @@ namespace NzbDrone.Core.Update _archiveService.Extract(packageDestination, updateSandboxFolder); _logger.Info("Update package extracted successfully"); + _backupService.Backup(BackupType.Update); + if (OsInfo.IsMono && _configFileProvider.UpdateMechanism == UpdateMechanism.Script) { InstallUpdateWithScript(updateSandboxFolder); diff --git a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs index 0b436adfb..5096a9788 100644 --- a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs +++ b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs @@ -17,7 +17,7 @@ namespace NzbDrone.Core.Update { public UpdatePackage GetLatestUpdate(string branch, Version currentVersion) { - var restClient = new RestClient(Services.RootUrl); + var restClient = RestClientFactory.BuildClient(Services.RootUrl); var request = new RestRequest("/v1/update/{branch}"); @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Update public List<UpdatePackage> GetRecentUpdates(string branch) { - var restClient = new RestClient(Services.RootUrl); + var restClient = RestClientFactory.BuildClient(Services.RootUrl); var request = new RestRequest("/v1/update/{branch}/changes"); diff --git a/src/NzbDrone.Core/Validation/NzbDroneValidationFailure.cs b/src/NzbDrone.Core/Validation/NzbDroneValidationFailure.cs new file mode 100644 index 000000000..5eaea8862 --- /dev/null +++ b/src/NzbDrone.Core/Validation/NzbDroneValidationFailure.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentValidation.Results; + +namespace NzbDrone.Core.Validation +{ + public class NzbDroneValidationFailure : ValidationFailure + { + public String DetailedDescription { get; set; } + public String InfoLink { get; set; } + + public NzbDroneValidationFailure(String propertyName, String error) + : base(propertyName, error) + { + + } + } +} diff --git a/src/NzbDrone.Host/PlatformValidation.cs b/src/NzbDrone.Host/PlatformValidation.cs index 703918aca..cfbffbf50 100644 --- a/src/NzbDrone.Host/PlatformValidation.cs +++ b/src/NzbDrone.Host/PlatformValidation.cs @@ -26,7 +26,7 @@ namespace NzbDrone.Host { Process.Start("http://www.microsoft.com/en-ca/download/details.aspx?id=30653"); } - catch (Exception e) + catch (Exception) { userAlert.Alert("Oops. can't start default browser. Please visit http://www.microsoft.com/en-ca/download/details.aspx?id=30653 to download .NET Framework 4.5."); } diff --git a/src/NzbDrone.Host/Properties/AssemblyInfo.cs b/src/NzbDrone.Host/Properties/AssemblyInfo.cs index 72143ef17..dd667bbdd 100644 --- a/src/NzbDrone.Host/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Host/Properties/AssemblyInfo.cs @@ -8,9 +8,4 @@ using System.Runtime.InteropServices; [assembly: AssemblyTitle("NzbDrone.exe")] [assembly: Guid("C2172AF4-F9A6-4D91-BAEE-C2E4EE680613")] - -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: - [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Integration.Test/NamingConfigTests.cs b/src/NzbDrone.Integration.Test/NamingConfigTests.cs index 5dc9a2fb0..a0cf82cf1 100644 --- a/src/NzbDrone.Integration.Test/NamingConfigTests.cs +++ b/src/NzbDrone.Integration.Test/NamingConfigTests.cs @@ -30,11 +30,13 @@ namespace NzbDrone.Integration.Test config.RenameEpisodes = false; config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; + config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; var result = NamingConfig.Put(config); result.RenameEpisodes.Should().BeFalse(); result.StandardEpisodeFormat.Should().Be(config.StandardEpisodeFormat); result.DailyEpisodeFormat.Should().Be(config.DailyEpisodeFormat); + result.AnimeEpisodeFormat.Should().Be(config.AnimeEpisodeFormat); } [Test] @@ -44,6 +46,7 @@ namespace NzbDrone.Integration.Test config.RenameEpisodes = true; config.StandardEpisodeFormat = ""; config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; + config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeEmpty(); @@ -56,6 +59,7 @@ namespace NzbDrone.Integration.Test config.RenameEpisodes = true; config.StandardEpisodeFormat = "{season}"; config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; + config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeEmpty(); @@ -68,6 +72,20 @@ namespace NzbDrone.Integration.Test config.RenameEpisodes = true; config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; config.DailyEpisodeFormat = "{Series Title} - {season} - {Episode Title}"; + config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; + + var errors = NamingConfig.InvalidPut(config); + errors.Should().NotBeEmpty(); + } + + [Test] + public void should_get_bad_request_if_anime_format_doesnt_contain_season_and_episode_or_absolute() + { + var config = NamingConfig.GetSingle(); + config.RenameEpisodes = false; + config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; + config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; + config.AnimeEpisodeFormat = "{Series Title} - {season} - {Episode Title}"; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeEmpty(); diff --git a/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs index 07eaa1d06..5183f6f7e 100644 --- a/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("8a49cb1d-87ac-42f9-a582-607365a6bd79")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Integration.Test/ReleaseIntegrationTest.cs b/src/NzbDrone.Integration.Test/ReleaseIntegrationTest.cs index a4e1e4d40..3804a90d2 100644 --- a/src/NzbDrone.Integration.Test/ReleaseIntegrationTest.cs +++ b/src/NzbDrone.Integration.Test/ReleaseIntegrationTest.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using System.Linq; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Api.Indexers; diff --git a/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs index fd8857541..8d91461ae 100644 --- a/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("32ec29e2-40ba-4050-917d-e295d85d4969")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs index a2dbbc810..e488817ab 100644 --- a/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs @@ -22,15 +22,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("45299d3c-34ff-48ca-9093-de2f037c38ac")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Mono/Properties/AssemblyInfo.cs b/src/NzbDrone.Mono/Properties/AssemblyInfo.cs index 6a7d1a9dc..1ac851bc6 100644 --- a/src/NzbDrone.Mono/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Mono/Properties/AssemblyInfo.cs @@ -22,15 +22,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("01493ea5-494f-43bf-be18-8ae4d0708fc6")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs b/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs index ffaa67fdd..7d5972415 100644 --- a/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs @@ -7,5 +7,4 @@ using System.Runtime.InteropServices; [assembly: AssemblyTitle("NzbDrone.SignalR")] [assembly: Guid("98bd985a-4f23-4201-8ed3-f6f3d7f2a5fe")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Test.Common/LoggingTest.cs b/src/NzbDrone.Test.Common/LoggingTest.cs index e706d3d04..dd0434b1a 100644 --- a/src/NzbDrone.Test.Common/LoggingTest.cs +++ b/src/NzbDrone.Test.Common/LoggingTest.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Test.Common LogManager.Configuration = new LoggingConfiguration(); var consoleTarget = new ConsoleTarget { Layout = "${level}: ${message} ${exception}" }; LogManager.Configuration.AddTarget(consoleTarget.GetType().Name, consoleTarget); - LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, consoleTarget)); + LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Trace, consoleTarget)); RegisterExceptionVerification(); } diff --git a/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs b/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs index bd7e4b61a..d82d940d5 100644 --- a/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("f3e91f6e-d01d-4f20-8255-147cc10f04e3")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs b/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs index 035fb0b76..d2e93dadf 100644 --- a/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("7b773a86-574d-48c3-9e89-6f2e0dff714b")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs index e26bd5efe..35dc227d7 100644 --- a/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs @@ -21,15 +21,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("b323e212-2d04-4c7f-9097-c356749ace4d")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Update.Test/StartNzbDroneService.cs b/src/NzbDrone.Update.Test/StartNzbDroneService.cs index 75fc9d917..801326c6e 100644 --- a/src/NzbDrone.Update.Test/StartNzbDroneService.cs +++ b/src/NzbDrone.Update.Test/StartNzbDroneService.cs @@ -32,7 +32,7 @@ namespace NzbDrone.Update.Test Subject.Start(AppType.Service, targetFolder); - Mocker.GetMock<IProcessProvider>().Verify(c => c.SpawnNewProcess("c:\\NzbDrone\\NzbDrone.Console.exe", StartupContext.NO_BROWSER), Times.Once()); + Mocker.GetMock<IProcessProvider>().Verify(c => c.SpawnNewProcess("c:\\NzbDrone\\NzbDrone.Console.exe", "--" + StartupContext.NO_BROWSER), Times.Once()); ExceptionVerification.ExpectedWarns(1); } diff --git a/src/NzbDrone.Update/Properties/AssemblyInfo.cs b/src/NzbDrone.Update/Properties/AssemblyInfo.cs index 69e6a5d5f..5a577baf3 100644 --- a/src/NzbDrone.Update/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Update/Properties/AssemblyInfo.cs @@ -9,8 +9,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("e4560a3d-8053-4d57-a260-bfe52f4cc357")] -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: - [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs index 238a3d68a..e4bc41c28 100644 --- a/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs @@ -22,15 +22,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("372cb8dc-5cdf-4fe4-9e1d-725827889bc7")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Windows/Properties/AssemblyInfo.cs b/src/NzbDrone.Windows/Properties/AssemblyInfo.cs index 8a24ac703..3d3d06ff5 100644 --- a/src/NzbDrone.Windows/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Windows/Properties/AssemblyInfo.cs @@ -22,15 +22,4 @@ using System.Runtime.InteropServices; // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("cea28fa9-43d0-4682-99f2-d364377adbdf")] -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.sln b/src/NzbDrone.sln index b6ba578db..d96a71dda 100644 --- a/src/NzbDrone.sln +++ b/src/NzbDrone.sln @@ -458,8 +458,7 @@ Global {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.Build.0 = Release|x86 {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.ActiveCfg = Debug|x86 {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.Build.0 = Debug|x86 {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Mono|Any CPU.ActiveCfg = Release|Any CPU @@ -473,8 +472,8 @@ Global {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.Build.0 = Release|x86 {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|Any CPU.Build.0 = Debug|Any CPU - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|Mixed Platforms.Build.0 = Debug|x86 {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.ActiveCfg = Debug|x86 {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.Build.0 = Debug|x86 {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Mono|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/NzbDrone/NzbDrone.csproj b/src/NzbDrone/NzbDrone.csproj index ef38699aa..5437239ca 100644 --- a/src/NzbDrone/NzbDrone.csproj +++ b/src/NzbDrone/NzbDrone.csproj @@ -15,6 +15,8 @@ <TargetFrameworkProfile> </TargetFrameworkProfile> <IsWebBootstrapper>false</IsWebBootstrapper> + <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> + <RestorePackages>true</RestorePackages> <PublishUrl>publish\</PublishUrl> <Install>true</Install> <InstallFrom>Disk</InstallFrom> @@ -29,8 +31,6 @@ <ApplicationVersion>1.0.0.%2a</ApplicationVersion> <UseApplicationTrust>false</UseApplicationTrust> <BootstrapperEnabled>true</BootstrapperEnabled> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> </PropertyGroup> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> <PlatformTarget>x86</PlatformTarget> diff --git a/src/NzbDrone/Properties/AssemblyInfo.cs b/src/NzbDrone/Properties/AssemblyInfo.cs index 35b5ef144..c1bca6872 100644 --- a/src/NzbDrone/Properties/AssemblyInfo.cs +++ b/src/NzbDrone/Properties/AssemblyInfo.cs @@ -8,8 +8,4 @@ using System.Runtime.InteropServices; [assembly: AssemblyTitle("NzbDrone.exe")] [assembly: Guid("67AADCD9-89AA-4D95-8281-3193740E70E5")] -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: - [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/NzbDrone/SysTray/SysTrayApp.cs b/src/NzbDrone/SysTray/SysTrayApp.cs index 2b730e180..87fc9763a 100644 --- a/src/NzbDrone/SysTray/SysTrayApp.cs +++ b/src/NzbDrone/SysTray/SysTrayApp.cs @@ -125,7 +125,7 @@ namespace NzbDrone.SysTray _trayIcon.Visible = false; _trayIcon.Dispose(); } - catch (Exception e) + catch (Exception) { } diff --git a/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs b/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs index b590cbc40..63a2e4bc0 100644 --- a/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs +++ b/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs @@ -8,4 +8,3 @@ using System.Runtime.InteropServices; [assembly: Guid("13976baa-e5ba-42b2-8ad7-8d568b68a53b")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs b/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs index 4962928b4..c5e087a13 100644 --- a/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs +++ b/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs @@ -5,4 +5,3 @@ using System.Runtime.InteropServices; [assembly: Guid("0a964b21-9de9-40b3-9378-0474fd5f21a8")] [assembly: AssemblyVersion("10.0.0.*")] -[assembly: AssemblyFileVersion("10.0.0.*")] diff --git a/src/UI/AddSeries/AddSeriesView.js b/src/UI/AddSeries/AddSeriesView.js index 199c823de..8ea8be65d 100644 --- a/src/UI/AddSeries/AddSeriesView.js +++ b/src/UI/AddSeries/AddSeriesView.js @@ -5,10 +5,11 @@ define( 'marionette', 'AddSeries/AddSeriesCollection', 'AddSeries/SearchResultCollectionView', + 'AddSeries/EmptyView', 'AddSeries/NotFoundView', 'Shared/LoadingView', 'underscore' - ], function (vent, Marionette, AddSeriesCollection, SearchResultCollectionView, NotFoundView, LoadingView, _) { + ], function (vent, Marionette, AddSeriesCollection, SearchResultCollectionView, EmptyView, NotFoundView, LoadingView, _) { return Marionette.Layout.extend({ template: 'AddSeries/AddSeriesViewTemplate', @@ -64,13 +65,14 @@ define( return; } - self.searchResult.close(); self._abortExistingSearch(); self.throttledSearch({ term: self.ui.seriesSearch.val() }); }); + this._clearResults(); + if (this.isExisting) { this.ui.searchBar.hide(); } @@ -104,7 +106,7 @@ define( else if (!this.isExisting) { this.collection.reset(); - this.searchResult.close(); + this._clearResults(); this.ui.seriesSearch.val(''); this.ui.seriesSearch.focus(); } @@ -118,6 +120,15 @@ define( this.ui.loadMore.hide(); } }, + + _clearResults: function () { + if (!this.isExisting) { + this.searchResult.show(new EmptyView()); + } + else { + this.searchResult.close(); + } + }, _showResults: function () { if (!this.isClosed) { @@ -139,6 +150,9 @@ define( console.log('aborting previous pending search request.'); this.currentSearchPromise.abort(); } + else { + this._clearResults(); + } } }); }); diff --git a/src/UI/AddSeries/EmptyView.js b/src/UI/AddSeries/EmptyView.js new file mode 100644 index 000000000..54aaa4af9 --- /dev/null +++ b/src/UI/AddSeries/EmptyView.js @@ -0,0 +1,11 @@ +'use strict'; + +define( + [ + 'marionette' + ], function (Marionette) { + + return Marionette.CompositeView.extend({ + template: 'AddSeries/EmptyViewTemplate' + }); + }); diff --git a/src/UI/AddSeries/EmptyViewTemplate.html b/src/UI/AddSeries/EmptyViewTemplate.html new file mode 100644 index 000000000..a56208736 --- /dev/null +++ b/src/UI/AddSeries/EmptyViewTemplate.html @@ -0,0 +1,3 @@ +<div class="text-center hint col-md-12"> + <span>You can also search by tvdbid and imdbid using the tvdb: and imdb: prefixes.</span> +</div> diff --git a/src/UI/AddSeries/SearchResultView.js b/src/UI/AddSeries/SearchResultView.js index 4450cee72..cb9e80612 100644 --- a/src/UI/AddSeries/SearchResultView.js +++ b/src/UI/AddSeries/SearchResultView.js @@ -32,19 +32,21 @@ define( template: 'AddSeries/SearchResultViewTemplate', ui: { - qualityProfile: '.x-quality-profile', - rootFolder : '.x-root-folder', - seasonFolder : '.x-season-folder', - addButton : '.x-add', - overview : '.x-overview', - startingSeason: '.x-starting-season' + qualityProfile : '.x-quality-profile', + rootFolder : '.x-root-folder', + seasonFolder : '.x-season-folder', + seriesType : '.x-series-type', + startingSeason : '.x-starting-season', + addButton : '.x-add', + overview : '.x-overview' }, events: { - 'click .x-add' : '_addSeries', - 'change .x-quality-profile': '_qualityProfileChanged', - 'change .x-root-folder' : '_rootFolderChanged', - 'change .x-season-folder' : '_seasonFolderChanged' + 'click .x-add' : '_addSeries', + 'change .x-quality-profile' : '_qualityProfileChanged', + 'change .x-root-folder' : '_rootFolderChanged', + 'change .x-season-folder' : '_seasonFolderChanged', + 'change .x-series-type' : '_seriesTypeChanged' }, initialize: function () { @@ -66,6 +68,7 @@ define( var defaultQuality = Config.getValue(Config.Keys.DefaultQualityProfileId); var defaultRoot = Config.getValue(Config.Keys.DefaultRootFolderId); var useSeasonFolder = Config.getValueBoolean(Config.Keys.UseSeasonFolder, true); + var defaultSeriesType = Config.getValueBoolean(Config.Keys.DefaultSeriesType, true); if (QualityProfiles.get(defaultQuality)) { this.ui.qualityProfile.val(defaultQuality); @@ -76,6 +79,7 @@ define( } this.ui.seasonFolder.prop('checked', useSeasonFolder); + this.ui.rootFolder.val(defaultSeriesType); var minSeasonNotZero = _.min(_.reject(this.model.get('seasons'), { seasonNumber: 0 }), 'seasonNumber'); @@ -116,6 +120,10 @@ define( else if (options.key === Config.Keys.UseSeasonFolder) { this.ui.seasonFolder.prop('checked', options.value); } + + else if (options.key === Config.Keys.DefaultSeriesType) { + this.ui.seriesType.val(options.value); + } }, _qualityProfileChanged: function () { @@ -138,6 +146,10 @@ define( } }, + _seriesTypeChanged: function () { + Config.setValue(Config.Keys.DefaultSeriesType, this.ui.seriesType.val()); + }, + _setRootFolder: function (options) { vent.trigger(vent.Commands.CloseModalCommand); this.ui.rootFolder.val(options.model.id); @@ -151,12 +163,17 @@ define( var quality = this.ui.qualityProfile.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('qualityProfileId', quality); - this.model.set('rootFolderPath', rootFolderPath); + this.model.set({ + qualityProfileId: quality, + rootFolderPath: rootFolderPath, + seasonFolder: seasonFolder, + seriesType: seriesType + }, { silent: true }); + this.model.setSeasonPass(startingSeason); - this.model.set('seasonFolder', seasonFolder); var self = this; var promise = this.model.save(); diff --git a/src/UI/AddSeries/SearchResultViewTemplate.html b/src/UI/AddSeries/SearchResultViewTemplate.html index 9fa74a539..bb56b167d 100644 --- a/src/UI/AddSeries/SearchResultViewTemplate.html +++ b/src/UI/AddSeries/SearchResultViewTemplate.html @@ -33,14 +33,22 @@ {{> RootFolderSelectionPartial rootFolders}} </div> {{/unless}} + <div class="form-group col-md-2"> <label>Starting Season</label> {{> StartingSeasonSelectionPartial seasons}} </div> + <div class="form-group col-md-2"> <label>Quality Profile</label> {{> QualityProfileSelectionPartial qualityProfiles}} </div> + + <div class="form-group col-md-2"> + <label>Series Type</label> + {{> SeriesTypeSelectionPartial}} + </div> + <div class="form-group col-md-2"> <label>Season Folders</label> @@ -55,20 +63,23 @@ </label> </div> </div> - <div class="form-group col-md-1 pull-right"> - <label> </label> - <button class="btn btn-success x-add add-series pull-right pull-none-xs"> Add + {{/unless}} + </div> + <div class="row"> + {{#unless existing}} + <div class="form-group col-md-2 col-md-offset-10"> + <!--Uncomment if we need to add even more controls to add series--> + <!--<label> </label>--> + <button class="btn btn-success x-add"> Add <i class="icon-plus"></i> </button> </div> - {{else}} - <div class="col-md-1 col-md-offset-11"> - <button class="btn add-series disabled pull-right pull-none-xs"> + <div class="col-md-2 col-md-offset-10"> + <button class="btn add-series disabled"> Already Exists </button> </div> - {{/unless}} </div> </div> diff --git a/src/UI/AddSeries/SeriesTypeSelectionPartial.html b/src/UI/AddSeries/SeriesTypeSelectionPartial.html new file mode 100644 index 000000000..8cfdb7344 --- /dev/null +++ b/src/UI/AddSeries/SeriesTypeSelectionPartial.html @@ -0,0 +1,5 @@ +<select class="form-control col-md-2 x-series-type" name="seriesType"> + <option value="standard">Standard</option> + <option value="daily">Daily</option> + <option value="anime">Anime</option> +</select> diff --git a/src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html b/src/UI/AddSeries/StartingSeasonSelectionPartial.html similarity index 81% rename from src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html rename to src/UI/AddSeries/StartingSeasonSelectionPartial.html index db07cdda2..0dcac5a9a 100644 --- a/src/UI/AddSeries/RootFolders/StartingSeasonSelectionPartial.html +++ b/src/UI/AddSeries/StartingSeasonSelectionPartial.html @@ -1,4 +1,4 @@ -<select class="form-control md-col-2 starting-season x-starting-season"> +<select class="form-control col-md-2 starting-season x-starting-season"> {{#each this}} {{#if_eq seasonNumber compare="0"}} <option value="{{seasonNumber}}">Specials</option> diff --git a/src/UI/AddSeries/addSeries.less b/src/UI/AddSeries/addSeries.less index d0fdf3579..3a6357677 100644 --- a/src/UI/AddSeries/addSeries.less +++ b/src/UI/AddSeries/addSeries.less @@ -85,21 +85,9 @@ font-size : 16px; } - .add-series { - margin-left : 20px; - } - .checkbox { - margin-top : 0px; - } - - .starting-season { - width: 140px; - - &.starting-season-label { - display: inline-block; + margin-top : 0px; } - } i { &:before { @@ -112,6 +100,11 @@ margin : 30px 0px; text-align: center; } + + .hint { + color : #999999; + font-style : italic; + } } li.add-new { diff --git a/src/UI/Calendar/CalendarFeedView.js b/src/UI/Calendar/CalendarFeedView.js index cf18cb711..c626a56ee 100644 --- a/src/UI/Calendar/CalendarFeedView.js +++ b/src/UI/Calendar/CalendarFeedView.js @@ -15,7 +15,7 @@ define( templateHelpers: { icalHttpUrl : window.location.protocol + '//' + window.location.host + StatusModel.get('urlBase') + '/feed/calendar/NzbDrone.ics?apikey=' + window.NzbDrone.ApiKey, - icalWebCalUrl : 'webcal://' + window.location.host + StatusModel.get('urlBase') + '/feed/calendar/NzbDrone.ics' + icalWebCalUrl : 'webcal://' + window.location.host + StatusModel.get('urlBase') + '/feed/calendar/NzbDrone.ics?apikey=' + window.NzbDrone.ApiKey }, onShow: function () { diff --git a/src/UI/Calendar/CalendarFeedViewTemplate.html b/src/UI/Calendar/CalendarFeedViewTemplate.html index 56ba9eb61..b217a953e 100644 --- a/src/UI/Calendar/CalendarFeedViewTemplate.html +++ b/src/UI/Calendar/CalendarFeedViewTemplate.html @@ -5,27 +5,19 @@ <h3>NzbDrone Calendar feed</h3> </div> <div class="modal-body edit-series-modal"> - <div class="row"> - <div class="col-md-12"> - <div class="form-horizontal"> - - <div class="form-group"> - <label class="col-sm-3 control-label">iCal feed</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-nd-form-info" title="Copy this url into your clients subscription form or use the subscribe button if your browser support webcal"/> + <div class="form-horizontal"> + <div class="form-group"> + <label class="col-sm-2 control-label">iCal feed</label> + <div class="col-sm-1 col-sm-push-9 help-inline"> + <i class="icon-nd-form-info" title="Copy this url into your clients subscription form or use the subscribe button if your browser support webcal" /> + </div> + <div class="col-sm-9 col-sm-pull-1"> + <div class="input-group ical-url"> + <input type="text" class="form-control x-ical-url" value="{{icalHttpUrl}}" readonly="readonly" /> + <div class="input-group-btn"> + <button class="btn btn-icon-only x-ical-copy" title="Copy to clipboard"><i class="icon-copy"></i></button> + <button class="btn btn-icon-only no-router" title="Subscribe" href="{{icalWebCalUrl}}" target="_blank" data-container=".modal-body"><i class="icon-calendar-empty"></i></button> </div> - - <div class="col-sm-8 col-sm-pull-1"> - <div class="input-group ical-url"> - <input type="text" class="form-control x-ical-url" value="{{icalHttpUrl}}" readonly="readonly" /> - <div class="input-group-btn"> - <button class="btn btn-icon-only x-ical-copy" title="Copy to clipboard"><i class="icon-copy"></i></button> - <button class="btn btn-icon-only no-router"><a title="Subscribe" href="{{icalWebCalUrl}}" target="_blank"><i class="icon-calendar-empty"></i></a></button> - </div> - </div> - </div> - </div> </div> </div> diff --git a/src/UI/Calendar/calendar.less b/src/UI/Calendar/calendar.less index 5b6b1faf7..865206cb8 100644 --- a/src/UI/Calendar/calendar.less +++ b/src/UI/Calendar/calendar.less @@ -177,7 +177,7 @@ .ical-url { - input { - cursor : text !important; + input, input[readonly] { + cursor : text; } } diff --git a/src/UI/Cells/ApprovalStatusCell.js b/src/UI/Cells/ApprovalStatusCell.js index 53ad8683f..fc1fcdbc8 100644 --- a/src/UI/Cells/ApprovalStatusCell.js +++ b/src/UI/Cells/ApprovalStatusCell.js @@ -11,7 +11,6 @@ define( className: 'approval-status-cell', template : 'Cells/ApprovalStatusCellTemplate', - render: function () { var rejections = this.model.get(this.column.get('name')); diff --git a/src/UI/Cells/SeriesTitleCell.js b/src/UI/Cells/SeriesTitleCell.js index 2421ab72a..16add6278 100644 --- a/src/UI/Cells/SeriesTitleCell.js +++ b/src/UI/Cells/SeriesTitleCell.js @@ -5,7 +5,7 @@ define( ], function (TemplatedCell) { return TemplatedCell.extend({ - className: 'series-title', + className: 'series-title-cell', template : 'Cells/SeriesTitleTemplate' }); diff --git a/src/UI/Cells/cells.less b/src/UI/Cells/cells.less index 42442e6af..67c567269 100644 --- a/src/UI/Cells/cells.less +++ b/src/UI/Cells/cells.less @@ -5,7 +5,7 @@ @import "../Content/mixins"; @import "../Content/variables"; -.series-title { +.series-title-cell { .text-overflow(); @media @sm { @@ -164,3 +164,20 @@ td.delete-episode-file-cell { .series-status-cell { width: 16px; } + +.episode-number-cell { + cursor : default; +} + +.backup-type-cell { + width : 20px; +} + +.table>tbody>tr>td, .table>thead>tr>th { + + &.episode-warning-cell { + width : 1px; + padding-left : 0px; + padding-right : 0px; + } +} diff --git a/src/UI/Config.js b/src/UI/Config.js index bbc6b54ed..245ac59ae 100644 --- a/src/UI/Config.js +++ b/src/UI/Config.js @@ -8,10 +8,11 @@ define( ConfigUpdatedEvent: 'ConfigUpdatedEvent' }, Keys : { - DefaultQualityProfileId: 'DefaultQualityProfileId', - DefaultRootFolderId : 'DefaultRootFolderId', - UseSeasonFolder : 'UseSeasonFolder', - AdvancedSettings : 'advancedSettings' + DefaultQualityProfileId : 'DefaultQualityProfileId', + DefaultRootFolderId : 'DefaultRootFolderId', + UseSeasonFolder : 'UseSeasonFolder', + DefaultSeriesType : 'DefaultSeriesType', + AdvancedSettings : 'advancedSettings' }, getValueBoolean: function (key, defaultValue) { @@ -32,15 +33,19 @@ define( setValue: function (key, value) { - console.log('Config: [{0}] => [{1}] '.format(key, value)); + console.log('Config: [{0}] => [{1}]'.format(key, value)); if (this.getValue(key) === value.toString()) { return; } - window.localStorage.setItem(key, value); - vent.trigger(this.Events.ConfigUpdatedEvent, {key: key, value: value}); - + try { + window.localStorage.setItem(key, value); + vent.trigger(this.Events.ConfigUpdatedEvent, {key: key, value: value}); + } + catch (error) { + console.error('Unable to save config: [{0}] => [{1}]'.format(key, value)); + } } }; }); diff --git a/src/UI/Content/form.less b/src/UI/Content/form.less index 66bf13a81..ee8112e89 100644 --- a/src/UI/Content/form.less +++ b/src/UI/Content/form.less @@ -88,6 +88,13 @@ h3 { } } +.validation-error { + i { + text-decoration: none; + color: #b94a48; + } +} + // Tooltips .help-inline-checkbox, .help-inline { diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less index 1a6ca1c24..0468bd894 100644 --- a/src/UI/Content/icons.less +++ b/src/UI/Content/icons.less @@ -163,6 +163,11 @@ color : purple; } +.icon-nd-import-failed:before { + .icon(@download-alt); + color: @brand-danger; +} + .icon-nd-download-failed:before { .icon(@cloud-download); color: @brand-danger; diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less index 47a0b323f..6c13b071b 100644 --- a/src/UI/Content/theme.less +++ b/src/UI/Content/theme.less @@ -95,6 +95,8 @@ } th { + cursor : default; + &.sortable { &:hover { background : @table-bg-hover; diff --git a/src/UI/Controller.js b/src/UI/Controller.js index 19d7c3760..87ef4e535 100644 --- a/src/UI/Controller.js +++ b/src/UI/Controller.js @@ -12,7 +12,6 @@ define( 'Release/ReleaseLayout', 'System/SystemLayout', 'SeasonPass/SeasonPassLayout', - 'System/Update/UpdateLayout', 'Series/Editor/SeriesEditorLayout' ], function (NzbDroneController, AppLayout, @@ -25,7 +24,6 @@ define( ReleaseLayout, SystemLayout, SeasonPassLayout, - UpdateLayout, SeriesEditorLayout) { return NzbDroneController.extend({ @@ -71,11 +69,6 @@ define( this.showMainRegion(new SeasonPassLayout()); }, - update: function () { - this.setTitle('Updates'); - this.showMainRegion(new UpdateLayout()); - }, - seriesEditor: function () { this.setTitle('Series Editor'); this.showMainRegion(new SeriesEditorLayout()); diff --git a/src/UI/Episode/Search/ManualLayout.js b/src/UI/Episode/Search/ManualLayout.js index 5e03caf88..d91d95007 100644 --- a/src/UI/Episode/Search/ManualLayout.js +++ b/src/UI/Episode/Search/ManualLayout.js @@ -20,48 +20,40 @@ define( columns: [ { - name : 'age', - label : 'Age', - sortable: true, - cell : AgeCell + name : 'age', + label : 'Age', + cell : AgeCell }, { - name : 'title', - label : 'Title', - sortable: true, - cell : Backgrid.StringCell.extend({ className: 'nzb-title-cell' }) + name : 'title', + label : 'Title', + cell : Backgrid.StringCell.extend({ className: 'nzb-title-cell' }) }, { - name : 'indexer', - label : 'Indexer', - sortable: true, - cell : Backgrid.StringCell + name : 'indexer', + label : 'Indexer', + cell : Backgrid.StringCell }, { - name : 'size', - label : 'Size', - sortable: true, - cell : FileSizeCell + name : 'size', + label : 'Size', + cell : FileSizeCell }, { name : 'quality', label : 'Quality', - sortable : true, - cell : QualityCell, - sortValue : function (model) { - return model.get('quality').quality.weight; - } + cell : QualityCell }, { - name : 'rejections', - label: '', - cell : ApprovalStatusCell + name : 'rejections', + label : '', + cell : ApprovalStatusCell }, { - name : 'download', - label: '', - cell : DownloadReportCell + name : 'download', + label : '', + cell : DownloadReportCell } ], diff --git a/src/UI/Form/CheckboxTemplate.html b/src/UI/Form/CheckboxTemplate.html index 1da8fa23f..9432392b0 100644 --- a/src/UI/Form/CheckboxTemplate.html +++ b/src/UI/Form/CheckboxTemplate.html @@ -1,4 +1,4 @@ -<div class="form-group"> +<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> <label class="col-sm-3 control-label">{{label}}</label> <div class="col-sm-5"> diff --git a/src/UI/Form/PasswordTemplate.html b/src/UI/Form/PasswordTemplate.html index 610eec1cc..619edb316 100644 --- a/src/UI/Form/PasswordTemplate.html +++ b/src/UI/Form/PasswordTemplate.html @@ -1,4 +1,4 @@ -<div class="form-group"> +<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> <label class="col-sm-3 control-label">{{label}}</label> <div class="col-sm-5"> diff --git a/src/UI/Form/PathTemplate.html b/src/UI/Form/PathTemplate.html index 93992c733..1bdeba6d4 100644 --- a/src/UI/Form/PathTemplate.html +++ b/src/UI/Form/PathTemplate.html @@ -1,4 +1,4 @@ -<div class="form-group"> +<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> <label class="col-sm-3 control-label">{{label}}</label> <div class="col-sm-5"> diff --git a/src/UI/Form/SelectTemplate.html b/src/UI/Form/SelectTemplate.html index 29274525d..978d432df 100644 --- a/src/UI/Form/SelectTemplate.html +++ b/src/UI/Form/SelectTemplate.html @@ -1,4 +1,4 @@ -<div class="form-group"> +<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> <label class="col-sm-3 control-label">{{label}}</label> <div class="col-sm-5"> diff --git a/src/UI/Form/TextboxTemplate.html b/src/UI/Form/TextboxTemplate.html index e607b466c..e7054cfac 100644 --- a/src/UI/Form/TextboxTemplate.html +++ b/src/UI/Form/TextboxTemplate.html @@ -1,4 +1,4 @@ -<div class="form-group"> +<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> <label class="col-sm-3 control-label">{{label}}</label> <div class="col-sm-5"> diff --git a/src/UI/Handlebars/Helpers/Quality.js b/src/UI/Handlebars/Helpers/Quality.js index d0050f577..292c04937 100644 --- a/src/UI/Handlebars/Helpers/Quality.js +++ b/src/UI/Handlebars/Helpers/Quality.js @@ -2,20 +2,18 @@ define( [ 'handlebars', - 'Quality/QualityProfileCollection', - 'underscore' - ], function (Handlebars, QualityProfileCollection, _) { + 'Quality/QualityProfileCollection' + ], function (Handlebars, QualityProfileCollection) { Handlebars.registerHelper('qualityProfile', function (profileId) { var profile = QualityProfileCollection.get(profileId); if (profile) { - return new Handlebars.SafeString('<span class="label label-default quality-profile-label">' + profile.get("name") + '</span>'); + return new Handlebars.SafeString('<span class="label label-default quality-profile-label">' + profile.get('name') + '</span>'); } return undefined; }); - }); diff --git a/src/UI/History/Blacklist/BlacklistCollection.js b/src/UI/History/Blacklist/BlacklistCollection.js index 3dcdb7cab..49d49a700 100644 --- a/src/UI/History/Blacklist/BlacklistCollection.js +++ b/src/UI/History/Blacklist/BlacklistCollection.js @@ -3,9 +3,10 @@ define( [ 'History/Blacklist/BlacklistModel', 'backbone.pageable', + 'Mixins/AsSortedCollection', 'Mixins/AsPersistedStateCollection' - ], function (BlacklistModel, PageableCollection, AsPersistedStateCollection) { - var collection = PageableCollection.extend({ + ], function (BlacklistModel, PageableCollection, AsSortedCollection, AsPersistedStateCollection) { + var Collection = PageableCollection.extend({ url : window.NzbDrone.ApiRoot + '/blacklist', model: BlacklistModel, @@ -27,6 +28,10 @@ define( } }, + sortMappings: { + 'series' : { sortKey: 'series.sortTitle' } + }, + parseState: function (resp) { return { totalRecords: resp.totalRecords }; }, @@ -40,5 +45,6 @@ define( } }); - return AsPersistedStateCollection.apply(collection); + Collection = AsSortedCollection.call(Collection); + return AsPersistedStateCollection.call(Collection); }); diff --git a/src/UI/History/Blacklist/BlacklistLayout.js b/src/UI/History/Blacklist/BlacklistLayout.js index d8b214ca2..d91abe620 100644 --- a/src/UI/History/Blacklist/BlacklistLayout.js +++ b/src/UI/History/Blacklist/BlacklistLayout.js @@ -35,33 +35,31 @@ define( columns: [ { - name : 'series', - label: 'Series', - cell : SeriesTitleCell, - sortValue: 'series.title' + name : 'series', + label : 'Series', + cell : SeriesTitleCell }, { - name : 'sourceTitle', - label: 'Source Title', - cell : 'string', - sortValue: 'sourceTitle' + name : 'sourceTitle', + label : 'Source Title', + cell : 'string' }, { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable: false + name : 'quality', + label : 'Quality', + cell : QualityCell, + sortable : false }, { - name : 'date', - label: 'Date', - cell : RelativeDateCell + name : 'date', + label : 'Date', + cell : RelativeDateCell }, { - name : 'this', - label : '', - cell : BlacklistActionsCell, - sortable: false + name : 'this', + label : '', + cell : BlacklistActionsCell, + sortable : false } ], diff --git a/src/UI/History/Details/HistoryDetailsAge.js b/src/UI/History/Details/HistoryDetailsAge.js new file mode 100644 index 000000000..d28dd317a --- /dev/null +++ b/src/UI/History/Details/HistoryDetailsAge.js @@ -0,0 +1,19 @@ +'use strict'; +define( + [ + 'handlebars' + ], function (Handlebars) { + + Handlebars.registerHelper('historyAge', function () { + + var unit = 'days'; + var age = this.age; + + if (age < 2) { + unit = 'hours'; + age = parseFloat(this.ageHours).toFixed(1); + } + + return new Handlebars.SafeString('<dt>Age (when grabbed):</dt><dd>{0} {1}</dd>'.format(age, unit)); + }); + }); diff --git a/src/UI/History/Details/HistoryDetailsView.js b/src/UI/History/Details/HistoryDetailsView.js index ba2177ac5..045189137 100644 --- a/src/UI/History/Details/HistoryDetailsView.js +++ b/src/UI/History/Details/HistoryDetailsView.js @@ -1,10 +1,11 @@ 'use strict'; define( [ + 'jquery', 'vent', 'marionette', - 'jquery' - ], function (vent, Marionette, $) { + 'History/Details/HistoryDetailsAge' + ], function ($, vent, Marionette) { return Marionette.ItemView.extend({ template: 'History/Details/HistoryDetailsViewTemplate', diff --git a/src/UI/History/Details/HistoryDetailsViewTemplate.html b/src/UI/History/Details/HistoryDetailsViewTemplate.html index 3cc969e84..19b6d3794 100644 --- a/src/UI/History/Details/HistoryDetailsViewTemplate.html +++ b/src/UI/History/Details/HistoryDetailsViewTemplate.html @@ -43,6 +43,10 @@ <dt>Download Client ID:</dt> <dd>{{downloadClientId}}</dd> {{/if}} + + {{#if age}} + {{historyAge}} + {{/if}} {{/with}} </dl> {{/if_eq}} diff --git a/src/UI/History/HistoryCollection.js b/src/UI/History/HistoryCollection.js index 623e38cee..82292715c 100644 --- a/src/UI/History/HistoryCollection.js +++ b/src/UI/History/HistoryCollection.js @@ -4,9 +4,10 @@ define( 'History/HistoryModel', 'backbone.pageable', 'Mixins/AsFilteredCollection', + 'Mixins/AsSortedCollection', 'Mixins/AsPersistedStateCollection' - ], function (HistoryModel, PageableCollection, AsFilteredCollection, AsPersistedStateCollection) { - var collection = PageableCollection.extend({ + ], function (HistoryModel, PageableCollection, AsFilteredCollection, AsSortedCollection, AsPersistedStateCollection) { + var Collection = PageableCollection.extend({ url : window.NzbDrone.ApiRoot + '/history', model: HistoryModel, @@ -35,6 +36,10 @@ define( 'failed' : ['eventType', '4'] }, + sortMappings: { + 'series' : { sortKey: 'series.sortTitle' } + }, + initialize: function (options) { delete this.queryParams.episodeId; @@ -58,6 +63,7 @@ define( } }); - collection = AsFilteredCollection.call(collection); - return AsPersistedStateCollection.call(collection); + Collection = AsFilteredCollection.call(Collection); + Collection = AsSortedCollection.call(Collection); + return AsPersistedStateCollection.call(Collection); }); diff --git a/src/UI/History/Queue/QueueLayout.js b/src/UI/History/Queue/QueueLayout.js index b20044fd8..886381c49 100644 --- a/src/UI/History/Queue/QueueLayout.js +++ b/src/UI/History/Queue/QueueLayout.js @@ -32,33 +32,34 @@ define( columns: [ { - name : 'status', - label: '', - cell : QueueStatusCell, - cellValue: 'this' + name : 'status', + label : '', + cell : QueueStatusCell, + cellValue : 'this' }, { - name : 'series', - label: 'Series', - cell : SeriesTitleCell + name : 'series', + label : 'Series', + cell : SeriesTitleCell, + sortable : false }, { - name : 'episode', - label : 'Episode', - sortable: false, - cell : EpisodeNumberCell + name : 'episode', + label : 'Episode', + cell : EpisodeNumberCell, + sortable : false }, { - name : 'episode', - label : 'Episode Title', - sortable: false, - cell : EpisodeTitleCell + name : 'episode', + label : 'Episode Title', + cell : EpisodeTitleCell, + sortable : false }, { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable: false + name : 'quality', + label : 'Quality', + cell : QualityCell, + sortable : false }, { name : 'timeleft', diff --git a/src/UI/History/Queue/QueueStatusCell.js b/src/UI/History/Queue/QueueStatusCell.js index 6a8903deb..580d904ce 100644 --- a/src/UI/History/Queue/QueueStatusCell.js +++ b/src/UI/History/Queue/QueueStatusCell.js @@ -13,6 +13,7 @@ define( if (this.cellValue) { var status = this.cellValue.get('status').toLowerCase(); + var errorMessage = (this.cellValue.get('errorMessage') || ''); var icon = 'icon-nd-downloading'; var title = 'Downloading'; @@ -31,7 +32,29 @@ define( title = 'Downloaded'; } - this.$el.html('<i class="{0}" title="{1}"></i>'.format(icon, title)); + if (errorMessage !== '') { + if (status === 'completed') { + icon = 'icon-nd-import-failed'; + title = "Import failed"; + } + else { + icon = 'icon-nd-download-failed'; + title = "Download failed"; + } + this.$el.html('<i class="{0}"></i>'.format(icon)); + + this.$el.popover({ + content : errorMessage.replace(new RegExp('\r\n', 'g'), '<br/>'), + html : true, + trigger : 'hover', + title : title, + placement: 'right', + container: this.$el + }); + } + else { + this.$el.html('<i class="{0}" title="{1}"></i>'.format(icon, title)); + } } return this; diff --git a/src/UI/History/Queue/TimeleftCell.js b/src/UI/History/Queue/TimeleftCell.js index a4d6e4544..cea11f12f 100644 --- a/src/UI/History/Queue/TimeleftCell.js +++ b/src/UI/History/Queue/TimeleftCell.js @@ -22,9 +22,8 @@ define( this.$el.html("-"); } else { - this.$el.html(timeleft); + this.$el.html('<span title="{1} / {2}">{0}</span>'.format(timeleft, remainingSize, totalSize)); } - this.$el.attr('title', '{0} / {1}'.format(remainingSize, totalSize)); } return this; diff --git a/src/UI/History/Table/HistoryTableLayout.js b/src/UI/History/Table/HistoryTableLayout.js index cb7c66331..d4df0fc71 100644 --- a/src/UI/History/Table/HistoryTableLayout.js +++ b/src/UI/History/Table/HistoryTableLayout.js @@ -39,45 +39,44 @@ define( columns: [ { - name : 'eventType', - label : '', - cell : EventTypeCell, - cellValue: 'this' + name : 'eventType', + label : '', + cell : EventTypeCell, + cellValue : 'this' }, { - name : 'series', - label: 'Series', - cell : SeriesTitleCell, - sortValue: 'series.title' + name : 'series', + label : 'Series', + cell : SeriesTitleCell }, { - name : 'episode', - label : 'Episode', - sortable: false, - cell : EpisodeNumberCell + name : 'episode', + label : 'Episode', + cell : EpisodeNumberCell, + sortable : false }, { - name : 'episode', - label : 'Episode Title', - sortable: false, - cell : EpisodeTitleCell + name : 'episode', + label : 'Episode Title', + cell : EpisodeTitleCell, + sortable : false }, { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable: false + name : 'quality', + label : 'Quality', + cell : QualityCell, + sortable : false }, { - name : 'date', - label: 'Date', - cell : RelativeDateCell + name : 'date', + label : 'Date', + cell : RelativeDateCell }, { - name : 'this', - label : '', - cell : HistoryDetailsCell, - sortable: false + name : 'this', + label : '', + cell : HistoryDetailsCell, + sortable : false } ], diff --git a/src/UI/History/history.less b/src/UI/History/history.less new file mode 100644 index 000000000..5ed1cd2ee --- /dev/null +++ b/src/UI/History/history.less @@ -0,0 +1,4 @@ + +.queue-status-cell .popover { + max-width: 800px; +} diff --git a/src/UI/Mixins/AsPersistedStateCollection.js b/src/UI/Mixins/AsPersistedStateCollection.js index d4177f1a4..e07c2b0d8 100644 --- a/src/UI/Mixins/AsPersistedStateCollection.js +++ b/src/UI/Mixins/AsPersistedStateCollection.js @@ -7,7 +7,6 @@ define( return function () { var originalInit = this.prototype.initialize; - this.prototype.initialize = function (options) { options = options || {}; @@ -30,18 +29,24 @@ define( } }; + if (!this.prototype._getSortMapping) { + this.prototype._getSortMapping = function(key) { + return { name: key, sortKey: key }; + }; + } + var _setInitialState = function () { var key = Config.getValue('{0}.sortKey'.format(this.tableName), this.state.sortKey); var direction = Config.getValue('{0}.sortDirection'.format(this.tableName), this.state.order); var order = parseInt(direction, 10); - this.state.sortKey = key; + this.state.sortKey = this._getSortMapping(key).sortKey; this.state.order = order; }; var _storeStateFromBackgrid = function (column, sortDirection) { var order = _convertDirectionToInt(sortDirection); - var sortKey = column.has('sortValue') && _.isString(column.get('sortValue')) ? column.get('sortValue') : column.get('name'); + var sortKey = this._getSortMapping(column.get('name')).sortKey; Config.setValue('{0}.sortKey'.format(this.tableName), sortKey); Config.setValue('{0}.sortDirection'.format(this.tableName), order); @@ -49,7 +54,7 @@ define( var _storeState = function (sortModel, sortDirection) { var order = _convertDirectionToInt(sortDirection); - var sortKey = sortModel.get('name'); + var sortKey = this._getSortMapping(sortModel.get('name')).sortKey; Config.setValue('{0}.sortKey'.format(this.tableName), sortKey); Config.setValue('{0}.sortDirection'.format(this.tableName), order); @@ -62,20 +67,6 @@ define( return '1'; }; - - var originalMakeComparator = this.prototype._makeComparator; - this.prototype._makeComparator = function (sortKey, order, sortValue) { - var state = this.state; - - sortKey = sortKey || state.sortKey; - order = order || state.order; - - if (!sortKey || !order) return; - - if (!sortValue && this[sortKey]) sortValue = this[sortKey]; - - return originalMakeComparator.call(this, sortKey, order, sortValue); - }; return this; }; diff --git a/src/UI/Mixins/AsSortedCollection.js b/src/UI/Mixins/AsSortedCollection.js new file mode 100644 index 000000000..d49aa216f --- /dev/null +++ b/src/UI/Mixins/AsSortedCollection.js @@ -0,0 +1,45 @@ +'use strict'; + +define( + ['underscore', 'Config'], + function (_, Config) { + + return function () { + + this.prototype._getSortMappings = function () { + var result = {}; + + if (this.sortMappings) { + _.each(this.sortMappings, function (values, key) { + var item = { + name: key, + sortKey: values.sortKey || key, + sortValue: values.sortValue + }; + result[key] = item; + result[item.sortKey] = item; + }); + } + + return result; + }; + + this.prototype._getSortMapping = function (key) { + var sortMappings = this._getSortMappings(); + + return sortMappings[key] || { name: key, sortKey: key }; + }; + + var originalSetSorting = this.prototype.setSorting; + this.prototype.setSorting = function (sortKey, order, options) { + var sortMapping = this._getSortMapping(sortKey); + + options = _.defaults({ sortValue: sortMapping.sortValue }, options || {}); + + return originalSetSorting.call(this, sortMapping.sortKey, order, options); + }; + + return this; + }; + } +); diff --git a/src/UI/Release/ReleaseCollection.js b/src/UI/Release/ReleaseCollection.js index c53ddccb7..796144502 100644 --- a/src/UI/Release/ReleaseCollection.js +++ b/src/UI/Release/ReleaseCollection.js @@ -1,19 +1,44 @@ 'use strict'; define( [ - 'backbone', - 'Release/ReleaseModel' - ], function (Backbone, ReleaseModel) { - return Backbone.Collection.extend({ + 'backbone.pageable', + 'Release/ReleaseModel', + 'Mixins/AsSortedCollection' + ], function (PagableCollection, ReleaseModel, AsSortedCollection) { + var Collection = PagableCollection.extend({ url : window.NzbDrone.ApiRoot + '/release', model: ReleaseModel, state: { - pageSize: 2000 + pageSize : 2000, + sortKey : 'download', + order : -1 + }, + + mode: 'client', + + sortMappings: { + 'quality' : { sortKey: 'qualityWeight' }, + 'rejections' : { sortValue: function (model, attr) { + var rejections = model.get('rejections'); + var releaseWeight = model.get('releaseWeight'); + + if (rejections.length !== 0) { + return releaseWeight + 1000000; + } + + return releaseWeight; + } + }, + 'download' : { sortKey: 'releaseWeight' } }, fetchEpisodeReleases: function (episodeId) { return this.fetch({ data: { episodeId: episodeId }}); } }); + + Collection = AsSortedCollection.call(Collection); + + return Collection; }); diff --git a/src/UI/SeasonPass/SeasonPassLayout.js b/src/UI/SeasonPass/SeasonPassLayout.js index 0a1e51d92..ed0615525 100644 --- a/src/UI/SeasonPass/SeasonPassLayout.js +++ b/src/UI/SeasonPass/SeasonPassLayout.js @@ -5,29 +5,93 @@ define( 'Series/SeriesCollection', 'Series/SeasonCollection', 'SeasonPass/SeriesCollectionView', - 'Shared/LoadingView' + 'Shared/LoadingView', + 'Shared/Toolbar/ToolbarLayout', + 'Mixins/backbone.signalr.mixin' ], function (Marionette, SeriesCollection, SeasonCollection, SeriesCollectionView, - LoadingView) { + LoadingView, + ToolbarLayout) { return Marionette.Layout.extend({ template: 'SeasonPass/SeasonPassLayoutTemplate', regions: { - series: '#x-series' + toolbar : '#x-toolbar', + series : '#x-series' }, - onShow: function () { - var self = this; + initialize: function () { - this.series.show(new LoadingView()); + this.seriesCollection = SeriesCollection.clone(); + this.seriesCollection.shadowCollection.bindSignalR(); - this.seriesCollection = SeriesCollection; + this.listenTo(this.seriesCollection, 'sync', this.render); - self.series.show(new SeriesCollectionView({ - collection: self.seriesCollection + this.filteringOptions = { + type : 'radio', + storeState : true, + menuKey : 'seasonpass.filterMode', + defaultAction: 'all', + items : + [ + { + key : 'all', + title : '', + tooltip : 'All', + icon : 'icon-circle-blank', + callback: this._setFilter + }, + { + key : 'monitored', + title : '', + tooltip : 'Monitored Only', + icon : 'icon-nd-monitored', + callback: this._setFilter + }, + { + key : 'continuing', + title : '', + tooltip : 'Continuing Only', + icon : 'icon-play', + callback: this._setFilter + }, + { + key : 'ended', + title : '', + tooltip : 'Ended Only', + icon : 'icon-stop', + callback: this._setFilter + } + ] + }; + }, + + onRender: function () { + + this.series.show(new SeriesCollectionView({ + collection: this.seriesCollection })); + + this._showToolbar(); + }, + + _showToolbar: function () { + + this.toolbar.show(new ToolbarLayout({ + right : + [ + this.filteringOptions + ], + context: this + })); + }, + + _setFilter: function(buttonContext) { + var mode = buttonContext.model.get('key'); + + this.seriesCollection.setFilterMode(mode); } }); }); diff --git a/src/UI/SeasonPass/SeasonPassLayoutTemplate.html b/src/UI/SeasonPass/SeasonPassLayoutTemplate.html index d64978a30..d7a86c6df 100644 --- a/src/UI/SeasonPass/SeasonPassLayoutTemplate.html +++ b/src/UI/SeasonPass/SeasonPassLayoutTemplate.html @@ -1,4 +1,6 @@ -<div class="row"> +<div id="x-toolbar"></div> + +<div class="row"> <div class="col-md-12"> <div class="alert alert-info">Season Pass allows you to quickly change the monitored status of seasons for all your series in one place</div> </div> diff --git a/src/UI/SeasonPass/SeriesLayoutTemplate.html b/src/UI/SeasonPass/SeriesLayoutTemplate.html index 24ce4e9b1..6ccbc1036 100644 --- a/src/UI/SeasonPass/SeriesLayoutTemplate.html +++ b/src/UI/SeasonPass/SeriesLayoutTemplate.html @@ -3,14 +3,14 @@ <div class="col-md-12"> <i class="icon-chevron-right x-expander expander pull-left"/> <i class="x-series-monitored series-monitor-toggle pull-left" title="Toggle monitored state for entire series"/> - <span class="title col-md-5"> + <div class="title col-md-5"> <a href="{{route}}"> {{title}} </a> - </span> + </div> - <span class="col-md-3"> - <select class="x-season-select season-select"> + <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"}} @@ -20,11 +20,13 @@ {{/if_eq}} {{/each}} </select> + </div> + <div class="col-md-1"> <span class="help-inline"> <i class="icon-nd-form-info" title="Selecting a season will unmonitor all previous seasons"/> </span> - </span> + </div> <span class="season-pass-button"> <button class="btn x-latest last">Latest Season Only</button> diff --git a/src/UI/Series/Details/EpisodeNumberCell.js b/src/UI/Series/Details/EpisodeNumberCell.js new file mode 100644 index 000000000..da0596a02 --- /dev/null +++ b/src/UI/Series/Details/EpisodeNumberCell.js @@ -0,0 +1,62 @@ + 'use strict'; + +define( + [ + 'marionette', + 'Cells/NzbDroneCell', + 'reqres' + ], function (Marionette, NzbDroneCell, reqres) { + return NzbDroneCell.extend({ + + className: 'episode-number-cell', + template : 'Series/Details/EpisodeNumberCellTemplate', + + render: function () { + + this.$el.empty(); + this.$el.html(this.model.get('episodeNumber')); + + var alternateTitles = []; + + if (reqres.hasHandler(reqres.Requests.GetAlternateNameBySeasonNumber)) { + + if (this.model.get('sceneSeasonNumber') > 0) { + alternateTitles = reqres.request(reqres.Requests.GetAlternateNameBySeasonNumber, + this.model.get('seriesId'), + this.model.get('sceneSeasonNumber')); + } + + if (alternateTitles.length === 0) { + alternateTitles = reqres.request(reqres.Requests.GetAlternateNameBySeasonNumber, + this.model.get('seriesId'), + this.model.get('seasonNumber')); + } + } + + if (this.model.get('sceneSeasonNumber') > 0 || + this.model.get('sceneEpisodeNumber') > 0 || + (this.model.has('sceneAbsoluteEpisodeNumber') && this.model.get('sceneAbsoluteEpisodeNumber') > 0) || + alternateTitles.length > 0) + { + this.templateFunction = Marionette.TemplateCache.get(this.template); + + var json = this.model.toJSON(); + json.alternateTitles = alternateTitles; + + var html = this.templateFunction(json); + + this.$el.popover({ + content : html, + html : true, + trigger : 'hover', + title : 'Scene Information', + placement: 'right', + container: this.$el + }); + } + + this.delegateEvents(); + return this; + } + }); + }); diff --git a/src/UI/Series/Details/EpisodeNumberCellTemplate.html b/src/UI/Series/Details/EpisodeNumberCellTemplate.html new file mode 100644 index 000000000..a9028a423 --- /dev/null +++ b/src/UI/Series/Details/EpisodeNumberCellTemplate.html @@ -0,0 +1,39 @@ +<div class="scene-info"> + {{#if sceneSeasonNumber}} + <div class="row"> + <div class="key">Season</div> + <div class="value">{{sceneSeasonNumber}}</div> + </div> + {{/if}} + + {{#if sceneEpisodeNumber}} + <div class="row"> + <div class="key">Episode</div> + <div class="value">{{sceneEpisodeNumber}}</div> + </div> + {{/if}} + + {{#if sceneAbsoluteEpisodeNumber}} + <div class="row"> + <div class="key">Absolute</div> + <div class="value">{{sceneAbsoluteEpisodeNumber}}</div> + </div> + {{/if}} + + {{#if alternateTitles}} + <div class="row"> + {{#if_gt alternateTitles.length compare="1"}} + <div class="key">Titles</div> + {{else}} + <div class="key">Title</div> + {{/if_gt}} + <div class="value"> + <ul> + {{#each alternateTitles}} + <li>{{title}}</li> + {{/each}} + </ul> + </div> + </div> + {{/if}} +</div> \ No newline at end of file diff --git a/src/UI/Series/Details/EpisodeWarningCell.js b/src/UI/Series/Details/EpisodeWarningCell.js new file mode 100644 index 000000000..ab80a9e4c --- /dev/null +++ b/src/UI/Series/Details/EpisodeWarningCell.js @@ -0,0 +1,27 @@ + 'use strict'; + +define( + [ + 'Cells/NzbDroneCell', + 'Series/SeriesCollection' + ], function (NzbDroneCell, SeriesCollection) { + return NzbDroneCell.extend({ + + className: 'episode-warning-cell', + + render: function () { + + this.$el.empty(); + + if (SeriesCollection.get(this.model.get('seriesId')).get('seriesType') === 'anime') { + + if (this.model.get('seasonNumber') > 0 && this.model.get('absoluteEpisodeNumber') === 0) { + this.$el.html('<i class="icon-nd-form-warning" title="Episode does not have an absolute episode number"></i>'); + } + } + + this.delegateEvents(); + return this; + } + }); + }); diff --git a/src/UI/Series/Details/InfoViewTemplate.html b/src/UI/Series/Details/InfoViewTemplate.html index 81fb8d4e5..5c962352c 100644 --- a/src/UI/Series/Details/InfoViewTemplate.html +++ b/src/UI/Series/Details/InfoViewTemplate.html @@ -30,8 +30,10 @@ </div> <div class="row"> <div class="col-md-12"> - {{#each alternativeTitles}} - <span class="label label-default">{{this}}</span> + {{#each alternateTitles}} + {{#if_eq seasonNumber compare="-1"}} + <span class="label label-default">{{title}}</span> + {{/if_eq}} {{/each}} </div> </div> \ No newline at end of file diff --git a/src/UI/Series/Details/SeasonLayout.js b/src/UI/Series/Details/SeasonLayout.js index f9f3f08b3..0a11225fb 100644 --- a/src/UI/Series/Details/SeasonLayout.js +++ b/src/UI/Series/Details/SeasonLayout.js @@ -9,6 +9,8 @@ define( 'Cells/RelativeDateCell', 'Cells/EpisodeStatusCell', 'Cells/EpisodeActionsCell', + 'Series/Details/EpisodeNumberCell', + 'Series/Details/EpisodeWarningCell', 'Commands/CommandController', 'moment', 'underscore', @@ -21,6 +23,8 @@ define( RelativeDateCell, EpisodeStatusCell, EpisodeActionsCell, + EpisodeNumberCell, + EpisodeWarningCell, CommandController, Moment, _, @@ -58,11 +62,16 @@ define( sortable : false }, { - name : 'episodeNumber', + name : 'this', label: '#', - cell : Backgrid.IntegerCell.extend({ - className: 'episode-number-cell' - }) + cell : EpisodeNumberCell + }, + { + name : 'this', + label : '', + cell : EpisodeWarningCell, + sortable : false, + className : 'episode-warning-cell' }, { name : 'this', diff --git a/src/UI/Series/Details/SeriesDetailsLayout.js b/src/UI/Series/Details/SeriesDetailsLayout.js index cceef6655..1f8e2e0da 100644 --- a/src/UI/Series/Details/SeriesDetailsLayout.js +++ b/src/UI/Series/Details/SeriesDetailsLayout.js @@ -191,6 +191,14 @@ define( return self.episodeFileCollection.get(episodeFileId); }); + reqres.setHandler(reqres.Requests.GetAlternateNameBySeasonNumber, function (seriesId, seasonNumber) { + if (self.model.get('id') !== seriesId) { + return []; + } + + return _.where(self.model.get('alternateTitles'), { seasonNumber: seasonNumber }); + }); + $.when(this.episodeCollection.fetch(), this.episodeFileCollection.fetch()).done(function () { var seasonCollectionView = new SeasonCollectionView({ collection : self.seasonCollection, diff --git a/src/UI/Series/Edit/EditSeriesViewTemplate.html b/src/UI/Series/Edit/EditSeriesViewTemplate.html index 777e7986f..d619ecbc5 100644 --- a/src/UI/Series/Edit/EditSeriesViewTemplate.html +++ b/src/UI/Series/Edit/EditSeriesViewTemplate.html @@ -58,10 +58,10 @@ </div> <div class="form-group"> - <label class="col-sm-4 control-label" for="inputQualityProfile">Quality Profile</label> + <label class="col-sm-4 control-label">Quality Profile</label> <div class="col-sm-4"> - <select class="form-control x-quality-profile" id="inputQualityProfile" name="qualityProfileId"> + <select class="form-control x-quality-profile" name="qualityProfileId"> {{#each qualityProfiles.models}} <option value="{{id}}">{{attributes.name}}</option> {{/each}} @@ -71,10 +71,17 @@ </div> <div class="form-group"> - <label class="col-sm-4 control-label" for="inputPath">Path</label> + <label class="col-sm-4 control-label">Series Type</label> + <div class="col-sm-4"> + {{> SeriesTypeSelectionPartial}} + </div> + </div> + + <div class="form-group"> + <label class="col-sm-4 control-label">Path</label> <div class="col-sm-6"> - <input type="text" id="inputPath" class="form-control x-path" placeholder="Path" name="path"> + <input type="text" class="form-control x-path" placeholder="Path" name="path"> </div> </div> </div> diff --git a/src/UI/Series/Editor/SeriesEditorFooterView.js b/src/UI/Series/Editor/SeriesEditorFooterView.js index 4a33d51f6..a111be913 100644 --- a/src/UI/Series/Editor/SeriesEditorFooterView.js +++ b/src/UI/Series/Editor/SeriesEditorFooterView.js @@ -5,7 +5,6 @@ define( 'marionette', 'backgrid', 'vent', - 'Series/SeriesCollection', 'Quality/QualityProfileCollection', 'AddSeries/RootFolders/RootFolderCollection', 'Shared/Toolbar/ToolbarLayout', @@ -16,7 +15,6 @@ define( Marionette, Backgrid, vent, - SeriesCollection, QualityProfiles, RootFolders, ToolbarLayout, @@ -51,12 +49,14 @@ define( }, initialize: function (options) { + this.seriesCollection = options.collection; + RootFolders.fetch().done(function () { RootFolders.synced = true; }); this.editorGrid = options.editorGrid; - this.listenTo(SeriesCollection, 'backgrid:selected', this._updateInfo); + this.listenTo(this.seriesCollection, 'backgrid:selected', this._updateInfo); this.listenTo(RootFolders, 'all', this.render); }, @@ -102,9 +102,7 @@ define( model.edited = true; }); - SeriesCollection.save(); - - this.listenTo(SeriesCollection, 'save', this._afterSave); + this.seriesCollection.save(); }, _updateInfo: function () { @@ -150,18 +148,6 @@ define( this._rootFolderChanged(); }, - _afterSave: function () { - this.ui.monitored.val('noChange'); - this.ui.qualityProfile.val('noChange'); - this.ui.seasonFolder.val('noChange'); - this.ui.rootFolder.val('noChange'); - - SeriesCollection.each(function (model) { - model.trigger('backgrid:select', model, false); - model.edited = false; - }); - }, - _organizeFiles: function () { var selected = this.editorGrid.getSelectedModels(); var updateFilesSeriesView = new UpdateFilesSeriesView({ series: selected }); diff --git a/src/UI/Series/Editor/SeriesEditorLayout.js b/src/UI/Series/Editor/SeriesEditorLayout.js index 84ee8bca7..1983444fb 100644 --- a/src/UI/Series/Editor/SeriesEditorLayout.js +++ b/src/UI/Series/Editor/SeriesEditorLayout.js @@ -11,7 +11,8 @@ define( 'Cells/SeriesStatusCell', 'Cells/SeasonFolderCell', 'Shared/Toolbar/ToolbarLayout', - 'Series/Editor/SeriesEditorFooterView' + 'Series/Editor/SeriesEditorFooterView', + 'Mixins/backbone.signalr.mixin' ], function (vent, Marionette, Backgrid, @@ -57,25 +58,25 @@ define( cell : SeriesStatusCell }, { - name : 'title', - label : 'Title', - cell : SeriesTitleCell, - cellValue: 'this' + name : 'title', + label : 'Title', + cell : SeriesTitleCell, + cellValue : 'this' }, { - name : 'qualityProfileId', - label: 'Quality', - cell : QualityProfileCell + name : 'qualityProfileId', + label : 'Quality', + cell : QualityProfileCell }, { - name : 'seasonFolder', - label: 'Season Folder', - cell : SeasonFolderCell + name : 'seasonFolder', + label : 'Season Folder', + cell : SeasonFolderCell }, { - name : 'path', - label: 'Path', - cell : 'string' + name : 'path', + label : 'Path', + cell : 'string' } ], @@ -99,11 +100,54 @@ define( ] }, + initialize: function () { + + this.seriesCollection = SeriesCollection.clone(); + this.seriesCollection.shadowCollection.bindSignalR(); + this.listenTo(this.seriesCollection, 'save', this.render); + + this.filteringOptions = { + type : 'radio', + storeState : true, + menuKey : 'serieseditor.filterMode', + defaultAction: 'all', + items : + [ + { + key : 'all', + title : '', + tooltip : 'All', + icon : 'icon-circle-blank', + callback: this._setFilter + }, + { + key : 'monitored', + title : '', + tooltip : 'Monitored Only', + icon : 'icon-nd-monitored', + callback: this._setFilter + }, + { + key : 'continuing', + title : '', + tooltip : 'Continuing Only', + icon : 'icon-play', + callback: this._setFilter + }, + { + key : 'ended', + title : '', + tooltip : 'Ended Only', + icon : 'icon-stop', + callback: this._setFilter + } + ] + }; + }, + onRender: function () { this._showToolbar(); this._showTable(); - - this._fetchCollection(); }, onClose: function () { @@ -111,14 +155,14 @@ define( }, _showTable: function () { - if (SeriesCollection.length === 0) { + if (this.seriesCollection.shadowCollection.length === 0) { this.seriesRegion.show(new EmptyView()); this.toolbar.close(); return; } this.editorGrid = new Backgrid.Grid({ - collection: SeriesCollection, + collection: this.seriesCollection, columns : this.columns, className : 'table table-hover' }); @@ -127,22 +171,28 @@ define( this._showFooter(); }, - _fetchCollection: function () { - SeriesCollection.fetch(); - }, - _showToolbar: function () { this.toolbar.show(new ToolbarLayout({ left : [ this.leftSideButtons ], + right : + [ + this.filteringOptions + ], context: this })); }, _showFooter: function () { - vent.trigger(vent.Commands.OpenControlPanelCommand, new FooterView({ editorGrid: this.editorGrid })); + vent.trigger(vent.Commands.OpenControlPanelCommand, new FooterView({ editorGrid: this.editorGrid, collection: this.seriesCollection })); + }, + + _setFilter: function(buttonContext) { + var mode = buttonContext.model.get('key'); + + this.seriesCollection.setFilterMode(mode); } }); }); diff --git a/src/UI/Series/Index/SeriesIndexLayout.js b/src/UI/Series/Index/SeriesIndexLayout.js index 292d7d37e..f0f773d58 100644 --- a/src/UI/Series/Index/SeriesIndexLayout.js +++ b/src/UI/Series/Index/SeriesIndexLayout.js @@ -53,43 +53,43 @@ define( cell : SeriesStatusCell }, { - name : 'title', - label : 'Title', - cell : SeriesTitleCell, - cellValue: 'this' + name : 'title', + label : 'Title', + cell : SeriesTitleCell, + cellValue : 'this', + sortValue : 'sortTitle' }, { - name : 'seasonCount', - label: 'Seasons', - cell : 'integer' + name : 'seasonCount', + label : 'Seasons', + cell : 'integer' }, { - name : 'qualityProfileId', - label: 'Quality', - cell : QualityProfileCell + name : 'qualityProfileId', + label : 'Quality', + cell : QualityProfileCell }, { - name : 'network', - label: 'Network', - cell : 'string' + name : 'network', + label : 'Network', + cell : 'string' }, { name : 'nextAiring', label : 'Next Airing', - cell : RelativeDateCell, - sortValue : SeriesCollection.nextAiring + cell : RelativeDateCell }, { - name : 'percentOfEpisodes', - label : 'Episodes', - cell : EpisodeProgressCell, - className: 'episode-progress-cell' + name : 'percentOfEpisodes', + label : 'Episodes', + cell : EpisodeProgressCell, + className : 'episode-progress-cell' }, { - name : 'this', - label : '', - sortable: false, - cell : SeriesActionsCell + name : 'this', + label : '', + sortable : false, + cell : SeriesActionsCell } ], @@ -172,9 +172,8 @@ define( name : 'network' }, { - title : 'Next Airing', - name : 'nextAiring', - sortValue : SeriesCollection.nextAiring + title: 'Next Airing', + name : 'nextAiring' }, { title: 'Episodes', diff --git a/src/UI/Series/SeriesCollection.js b/src/UI/Series/SeriesCollection.js index 8bc23630e..bc4183eb7 100644 --- a/src/UI/Series/SeriesCollection.js +++ b/src/UI/Series/SeriesCollection.js @@ -7,16 +7,17 @@ define( 'Series/SeriesModel', 'api!series', 'Mixins/AsFilteredCollection', + 'Mixins/AsSortedCollection', 'Mixins/AsPersistedStateCollection', 'moment' - ], function (_, Backbone, PageableCollection, SeriesModel, SeriesData, AsFilteredCollection, AsPersistedStateCollection, Moment) { + ], function (_, Backbone, PageableCollection, SeriesModel, SeriesData, AsFilteredCollection, AsSortedCollection, AsPersistedStateCollection, Moment) { var Collection = PageableCollection.extend({ url : window.NzbDrone.ApiRoot + '/series', model: SeriesModel, tableName: 'series', state: { - sortKey: 'title', + sortKey: 'sortTitle', order : -1, pageSize: 100000 }, @@ -56,21 +57,30 @@ define( 'monitored' : ['monitored', true] }, - //Sorters - nextAiring: function (model, attr) { - var nextAiring = model.get(attr); + sortMappings: { + 'title' : { sortKey: 'sortTitle' }, + 'nextAiring' : { sortValue: function (model, attr) { + var nextAiring = model.get(attr); + + if (nextAiring) { + return Moment(nextAiring).unix(); + } + + var previousAiring = model.get(attr.replace('nextAiring', 'previousAiring')); + + if (previousAiring) { + return 10000000000 - Moment(previousAiring).unix(); + } - if (!nextAiring) { - return Number.MAX_VALUE; + return Number.MAX_VALUE; + } } - - return Moment(nextAiring).unix(); } }); - var FilteredCollection = AsFilteredCollection.call(Collection); - var MixedIn = AsPersistedStateCollection.call(FilteredCollection); - var collection = new MixedIn(SeriesData, { full: true }); + Collection = AsFilteredCollection.call(Collection); + Collection = AsSortedCollection.call(Collection); + Collection = AsPersistedStateCollection.call(Collection); - return collection; + return new Collection(SeriesData, { full: true }); }); diff --git a/src/UI/Series/series.less b/src/UI/Series/series.less index 1420fe73e..139d92d65 100644 --- a/src/UI/Series/series.less +++ b/src/UI/Series/series.less @@ -296,7 +296,7 @@ i { .clickable(); font-size : 24px; - padding-left : 5px; + margin-left : 5px; } } @@ -333,14 +333,7 @@ .season-grid { margin-top : 10px; } -} -.season-status { - font-size : 16px; - vertical-align : middle !important; -} - -.seasonpass-series { .season-pass-button { display : inline-block; } @@ -349,6 +342,16 @@ font-size : 24px; margin-top : 3px; } + + .help-inline { + margin-top : 7px; + display : inline-block; + } +} + +.season-status { + font-size : 16px; + vertical-align : middle !important; } //Overview List @@ -404,4 +407,26 @@ margin-top : 5px; } } -} \ No newline at end of file +} + +.scene-info { + .key, .value { + display : inline-block; + } + + .key { + width : 80px; + margin-left : 10px; + vertical-align : top; + } + + .value { + margin-right : 10px; + max-width : 170px; + } + + ul { + padding-left : 0px; + list-style-type : none; + } +} diff --git a/src/UI/Settings/DownloadClient/DownloadClientModel.js b/src/UI/Settings/DownloadClient/DownloadClientModel.js index 3702cf7dc..9d88dd7ea 100644 --- a/src/UI/Settings/DownloadClient/DownloadClientModel.js +++ b/src/UI/Settings/DownloadClient/DownloadClientModel.js @@ -1,9 +1,9 @@ 'use strict'; define([ - 'backbone.deepmodel' -], function (DeepModel) { - return DeepModel.DeepModel.extend({ + 'Settings/ProviderSettingsModelBase' +], function (ProviderSettingsModelBase) { + return ProviderSettingsModelBase.extend({ }); }); diff --git a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js index 98fa8eea0..31bd5f194 100644 --- a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js +++ b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js @@ -78,14 +78,7 @@ define([ }, _test: function () { - var testCommand = 'test{0}'.format(this.model.get('implementation')); - var properties = {}; - - _.each(this.model.get('fields'), function (field) { - properties[field.name] = field.value; - }); - - CommandController.Execute(testCommand, properties); + this.model.test(); } }); diff --git a/src/UI/Settings/Indexers/Edit/IndexerEditView.js b/src/UI/Settings/Indexers/Edit/IndexerEditView.js index 4a77072d7..c67fb9260 100644 --- a/src/UI/Settings/Indexers/Edit/IndexerEditView.js +++ b/src/UI/Settings/Indexers/Edit/IndexerEditView.js @@ -82,14 +82,7 @@ define([ }, _test: function () { - var testCommand = 'test{0}'.format(this.model.get('implementation')); - var properties = {}; - - _.each(this.model.get('fields'), function (field) { - properties[field.name] = field.value; - }); - - CommandController.Execute(testCommand, properties); + this.model.test(); } }); diff --git a/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.html b/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.html index 75ec1d284..3dc1f3eda 100644 --- a/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.html +++ b/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.html @@ -46,8 +46,7 @@ <button class="btn pull-left x-back">back</button> {{/if}} - <!-- Testing is currently not yet supported for indexers, but leaving the infrastructure for later --> - <!-- <button class="btn x-test">test <i class="x-test-icon icon-nd-test"/></button> --> + <button class="btn x-test">test <i class="x-test-icon icon-nd-test"/></button> <button class="btn x-close">cancel</button> <div class="btn-group"> diff --git a/src/UI/Settings/Indexers/IndexerModel.js b/src/UI/Settings/Indexers/IndexerModel.js index 3702cf7dc..9d88dd7ea 100644 --- a/src/UI/Settings/Indexers/IndexerModel.js +++ b/src/UI/Settings/Indexers/IndexerModel.js @@ -1,9 +1,9 @@ 'use strict'; define([ - 'backbone.deepmodel' -], function (DeepModel) { - return DeepModel.DeepModel.extend({ + 'Settings/ProviderSettingsModelBase' +], function (ProviderSettingsModelBase) { + return ProviderSettingsModelBase.extend({ }); }); diff --git a/src/UI/Settings/MediaManagement/Naming/NamingView.js b/src/UI/Settings/MediaManagement/Naming/NamingView.js index 071339d59..bd86e1490 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingView.js +++ b/src/UI/Settings/MediaManagement/Naming/NamingView.js @@ -18,6 +18,7 @@ define( singleEpisodeExample : '.x-single-episode-example', multiEpisodeExample : '.x-multi-episode-example', dailyEpisodeExample : '.x-daily-episode-example', + animeEpisodeExample : '.x-anime-episode-example', namingTokenHelper : '.x-naming-token-helper', multiEpisodeStyle : '.x-multi-episode-style', seriesFolderExample : '.x-series-folder-example', @@ -68,6 +69,7 @@ define( this.ui.singleEpisodeExample.html(this.namingSampleModel.get('singleEpisodeExample')); this.ui.multiEpisodeExample.html(this.namingSampleModel.get('multiEpisodeExample')); this.ui.dailyEpisodeExample.html(this.namingSampleModel.get('dailyEpisodeExample')); + this.ui.animeEpisodeExample.html(this.namingSampleModel.get('animeEpisodeExample')); this.ui.seriesFolderExample.html(this.namingSampleModel.get('seriesFolderExample')); this.ui.seasonFolderExample.html(this.namingSampleModel.get('seasonFolderExample')); }, diff --git a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html index 125ec4c71..33c76d757 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html +++ b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html @@ -87,6 +87,37 @@ </div> </div> </div> + + <div class="form-group advanced-setting"> + <label class="col-sm-3 control-label">Anime Episode Format</label> + + <div class="col-sm-1 col-sm-push-8 help-inline"> + <i class="icon-nd-form-info" title="" data-original-title="All caps or all lower-case can also be used"></i> + <a href="https://github.com/NzbDrone/NzbDrone/wiki/Sorting-and-Renaming" class="help-link" title="More information"><i class="icon-nd-form-info-link"/></a> + </div> + + <div class="col-sm-8 col-sm-pull-1"> + <div class="input-group x-helper-input"> + <input type="text" class="form-control naming-format" name="animeEpisodeFormat" data-onkeyup="true" /> + <div class="input-group-btn btn-group x-naming-token-helper"> + <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> + <i class="icon-plus"></i> + </button> + <ul class="dropdown-menu"> + {{> SeriesTitleNamingPartial}} + {{> AbsoluteEpisodeNamingPartial}} + {{> SeasonNamingPartial}} + {{> EpisodeNamingPartial}} + {{> EpisodeTitleNamingPartial}} + {{> QualityTitleNamingPartial}} + {{> ReleaseGroupNamingPartial}} + {{> OriginalTitleNamingPartial}} + {{> SeparatorNamingPartial}} + </ul> + </div> + </div> + </div> + </div> </div> <div class="form-group advanced-setting"> @@ -170,6 +201,14 @@ </div> </div> + <div class="form-group"> + <label class="col-sm-3 control-label">Anime Episode Example</label> + + <div class="col-sm-8"> + <p class="form-control-static x-anime-episode-example naming-example"></p> + </div> + </div> + <div class="form-group"> <label class="col-sm-3 control-label">Series Folder Example</label> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/AbsoluteEpisodeNamingPartial.html b/src/UI/Settings/MediaManagement/Naming/Partials/AbsoluteEpisodeNamingPartial.html new file mode 100644 index 000000000..ba31a196e --- /dev/null +++ b/src/UI/Settings/MediaManagement/Naming/Partials/AbsoluteEpisodeNamingPartial.html @@ -0,0 +1,8 @@ +<li class="dropdown-submenu"> + <a href="#" tabindex="-1" data-token="absolute">Absolute</a> + <ul class="dropdown-menu"> + <li><a href="#" data-token="absolute">1</a></li> + <li><a href="#" data-token="absolute:00">01</a></li> + <li><a href="#" data-token="absolute:000">001</a></li> + </ul> +</li> diff --git a/src/UI/Settings/Metadata/MetadataModel.js b/src/UI/Settings/Metadata/MetadataModel.js index 5e08858af..c5b01ab1c 100644 --- a/src/UI/Settings/Metadata/MetadataModel.js +++ b/src/UI/Settings/Metadata/MetadataModel.js @@ -1,10 +1,10 @@ 'use strict'; -define( - [ - 'backbone.deepmodel' - ], function (DeepModel) { - return DeepModel.DeepModel.extend({ - }); +define([ + 'Settings/ProviderSettingsModelBase' +], function (ProviderSettingsModelBase) { + return ProviderSettingsModelBase.extend({ + }); +}); diff --git a/src/UI/Settings/Notifications/Edit/NotificationEditView.js b/src/UI/Settings/Notifications/Edit/NotificationEditView.js index 0855d5482..cb5ff873c 100644 --- a/src/UI/Settings/Notifications/Edit/NotificationEditView.js +++ b/src/UI/Settings/Notifications/Edit/NotificationEditView.js @@ -83,14 +83,7 @@ define([ }, _test: function () { - var testCommand = 'test{0}'.format(this.model.get('implementation')); - var properties = {}; - - _.each(this.model.get('fields'), function (field) { - properties[field.name] = field.value; - }); - - CommandController.Execute(testCommand, properties); + this.model.test(); }, _onDownloadChanged: function () { diff --git a/src/UI/Settings/Notifications/NotificationModel.js b/src/UI/Settings/Notifications/NotificationModel.js index 9eb8e5552..4aaabc948 100644 --- a/src/UI/Settings/Notifications/NotificationModel.js +++ b/src/UI/Settings/Notifications/NotificationModel.js @@ -1,10 +1,9 @@ 'use strict'; -define([ - 'Settings/SettingsModelBase' -], function (ModelBase) { - return ModelBase.extend({ - successMessage: 'Notification Saved', - errorMessage : 'Couldn\'t save notification' +define([ + 'Settings/ProviderSettingsModelBase' +], function (ProviderSettingsModelBase) { + return ProviderSettingsModelBase.extend({ + }); }); diff --git a/src/UI/Settings/ProviderSettingsModelBase.js b/src/UI/Settings/ProviderSettingsModelBase.js new file mode 100644 index 000000000..256caef80 --- /dev/null +++ b/src/UI/Settings/ProviderSettingsModelBase.js @@ -0,0 +1,36 @@ +'use strict'; + +define([ + 'jquery', + 'backbone.deepmodel', + 'Shared/Messenger' +], function ($, DeepModel, Messenger) { + return DeepModel.DeepModel.extend({ + + test: function () { + var self = this; + + this.trigger('validation:sync'); + + var params = {}; + + params.url = this.collection.url + '/test'; + params.contentType = 'application/json'; + params.data = JSON.stringify(this.toJSON()); + params.type = 'POST'; + params.isValidatedCall = true; + + var promise = $.ajax(params); + + Messenger.monitor({ + promise : promise, + successMessage : 'Testing \'{0}\' completed'.format(this.get('name')), + errorMessage : 'Testing \'{0}\' failed'.format(this.get('name')) + }); + + promise.fail(function (response) { + self.trigger('validation:failed', response); + }); + } + }); +}); diff --git a/src/UI/Shared/Grid/HeaderCell.js b/src/UI/Shared/Grid/HeaderCell.js index 45b402589..b1ac56279 100644 --- a/src/UI/Shared/Grid/HeaderCell.js +++ b/src/UI/Shared/Grid/HeaderCell.js @@ -35,17 +35,20 @@ define( //Do we need this? this.$el.addClass(column.get('name')); + if (column.has('className')) { + this.$el.addClass(column.get('className')); + } + this.delegateEvents(); this.direction(column.get('direction')); if (this.collection.state) { - var key = this.collection.state.sortKey; + var name = this._getSortMapping().name; var order = this.collection.state.order; - if (key === this.column.get('name')) { + if (name === column.get('name')) { this._setSortIcon(order); } - else { this._removeSortIcon(); } @@ -69,10 +72,10 @@ define( var columnDirection = this.column.get('direction'); if (!columnDirection && this.collection.state) { - var key = this.collection.state.sortKey; + var name = this._getSortMapping().name; var order = this.collection.state.order; - if (key === this.column.get('name')) { + if (name === this.column.get('name')) { columnDirection = order; } } @@ -80,31 +83,41 @@ define( return columnDirection; }, + _getSortMapping: function() { + var sortKey = this.collection.state.sortKey; + + if (this.collection._getSortMapping) { + return this.collection._getSortMapping(sortKey); + } + + return { name: sortKey, sortKey: sortKey }; + }, + onClick: function (e) { e.preventDefault(); var collection = this.collection; var event = 'backgrid:sort'; - function toggleSort(header, col) { - collection.state.sortKey = col.get('name'); - var direction = header.direction(); - if (direction === 'ascending' || direction === -1) - { - collection.state.order = 'descending'; - collection.trigger(event, col, 'descending'); - } - else - { - collection.state.order = 'ascending'; - collection.trigger(event, col, 'ascending'); - } - } - var column = this.column; - var sortable = Backgrid.callByNeed(column.sortable(), column, this.collection); + var sortable = Backgrid.callByNeed(column.sortable(), column, collection); if (sortable) { - toggleSort(this, column); + var direction = collection.state.order; + if (direction === 'ascending' || direction === -1) { + direction = 'descending'; + } + else { + direction = 'ascending'; + } + + if (collection.setSorting) { + collection.setSorting(column.get('name'), direction); + } + else { + collection.state.sortKey = column.get('name'); + collection.state.order = direction; + } + collection.trigger(event, column, direction); } }, diff --git a/src/UI/Shared/Toolbar/ButtonModel.js b/src/UI/Shared/Toolbar/ButtonModel.js index b7bd6d4dc..9ea428ed7 100644 --- a/src/UI/Shared/Toolbar/ButtonModel.js +++ b/src/UI/Shared/Toolbar/ButtonModel.js @@ -10,20 +10,6 @@ define( 'title' : '', 'active' : false, 'tooltip': undefined - }, - - sortValue: function () { - var sortValue = this.get('sortValue'); - if (_.isString(sortValue)) { - return this[sortValue]; - } - else if (_.isFunction(sortValue)) { - return sortValue; - } - - return function (model, colName) { - return model.get(colName); - }; } }); }); diff --git a/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js b/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js index a79abc2f0..f5e386db2 100644 --- a/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js +++ b/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js @@ -34,52 +34,11 @@ define( else { order = null; } - - var comparator = this.makeComparator(sortModel.get('name'), order, - order ? - sortModel.sortValue() : - function (model) { - return model.cid; - }); - - if (PageableCollection && - collection instanceof PageableCollection) { - - collection.setSorting(order && sortModel.get('name'), order, - {sortValue: sortModel.sortValue()}); - - if (collection.mode === 'client') { - if (collection.fullCollection.comparator === null) { - collection.fullCollection.comparator = comparator; - } - collection.fullCollection.sort(); - } - else { - collection.fetch({reset: true}); - } - } - else { - collection.comparator = comparator; - collection.sort(); - } + + collection.setSorting(sortModel.get('name'), order); + collection.fullCollection.sort(); return this; - }, - - makeComparator: function (attr, order, func) { - - return function (left, right) { - // extract the values from the models - var l = func(left, attr), r = func(right, attr), t; - - // if descending order, swap left and right - if (order === 1) t = l, l = r, r = t; - - // compare as usual - if (l === r) return 0; - else if (l < r) return -1; - return 1; - }; } }); }); diff --git a/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js b/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js index 7421e628f..2ecb00182 100644 --- a/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js +++ b/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js @@ -26,13 +26,13 @@ define( onRender: function () { if (this.viewCollection.state) { - var key = this.viewCollection.state.sortKey; + var sortKey = this.viewCollection.state.sortKey; + var name = this.viewCollection._getSortMapping(sortKey).name; var order = this.viewCollection.state.order; - if (key === this.model.get('name')) { + if (name === this.model.get('name')) { this._setSortIcon(order); } - else { this._removeSortIcon(); } @@ -45,19 +45,16 @@ define( var collection = this.viewCollection; var event = 'drone:sort'; - collection.state.sortKey = this.model.get('name'); var direction = collection.state.order; + if (direction === 'ascending' || direction === -1) { + direction = 'descending'; + } + else { + direction = 'ascending'; + } - if (direction === 'ascending' || direction === -1) - { - collection.state.order = 'descending'; - collection.trigger(event, this.model, 'descending'); - } - else - { - collection.state.order = 'ascending'; - collection.trigger(event, this.model, 'ascending'); - } + collection.setSorting(this.model.get('name'), direction); + collection.trigger(event, this.model, direction); }, _convertDirectionToIcon: function (dir) { diff --git a/src/UI/System/Backup/BackupCollection.js b/src/UI/System/Backup/BackupCollection.js new file mode 100644 index 000000000..6b52ca3b9 --- /dev/null +++ b/src/UI/System/Backup/BackupCollection.js @@ -0,0 +1,19 @@ +'use strict'; +define( + [ + 'backbone.pageable', + 'System/Backup/BackupModel' + ], function (PageableCollection, BackupModel) { + return PageableCollection.extend({ + url : window.NzbDrone.ApiRoot + '/system/backup', + model: BackupModel, + + state: { + sortKey : 'time', + order : 1, + pageSize : 100000 + }, + + mode: 'client' + }); + }); diff --git a/src/UI/System/Backup/BackupEmptyView.js b/src/UI/System/Backup/BackupEmptyView.js new file mode 100644 index 000000000..51e72eafd --- /dev/null +++ b/src/UI/System/Backup/BackupEmptyView.js @@ -0,0 +1,10 @@ +'use strict'; + +define( + [ + 'marionette' + ], function (Marionette) { + return Marionette.ItemView.extend({ + template: 'System/Backup/BackupEmptyViewTemplate' + }); + }); diff --git a/src/UI/System/Backup/BackupEmptyViewTemplate.html b/src/UI/System/Backup/BackupEmptyViewTemplate.html new file mode 100644 index 000000000..5a480c62a --- /dev/null +++ b/src/UI/System/Backup/BackupEmptyViewTemplate.html @@ -0,0 +1 @@ +<div>No backups are available</div> \ No newline at end of file diff --git a/src/UI/System/Backup/BackupFilenameCell.js b/src/UI/System/Backup/BackupFilenameCell.js new file mode 100644 index 000000000..d5582855e --- /dev/null +++ b/src/UI/System/Backup/BackupFilenameCell.js @@ -0,0 +1,12 @@ +'use strict'; +define( + [ + 'Cells/TemplatedCell' + ], function (TemplatedCell) { + return TemplatedCell.extend({ + + className: 'series-title-cell', + template : 'System/Backup/BackupFilenameCellTemplate' + + }); + }); diff --git a/src/UI/System/Backup/BackupFilenameCellTemplate.html b/src/UI/System/Backup/BackupFilenameCellTemplate.html new file mode 100644 index 000000000..9dc32e9f5 --- /dev/null +++ b/src/UI/System/Backup/BackupFilenameCellTemplate.html @@ -0,0 +1,2 @@ +<a href="{{urlBack}}/backup/{{type}}/{{name}}" class="no-router">{{name}}</a> + diff --git a/src/UI/System/Backup/BackupLayout.js b/src/UI/System/Backup/BackupLayout.js new file mode 100644 index 000000000..98840384d --- /dev/null +++ b/src/UI/System/Backup/BackupLayout.js @@ -0,0 +1,106 @@ +'use strict'; +define( + [ + 'vent', + 'marionette', + 'backgrid', + 'System/Backup/BackupCollection', + 'Cells/RelativeDateCell', + 'System/Backup/BackupFilenameCell', + 'System/Backup/BackupTypeCell', + 'System/Backup/BackupEmptyView', + 'Shared/LoadingView', + 'Shared/Toolbar/ToolbarLayout' + ], function (vent, Marionette, Backgrid, BackupCollection, RelativeDateCell, BackupFilenameCell, BackupTypeCell, EmptyView, LoadingView, ToolbarLayout) { + return Marionette.Layout.extend({ + template: 'System/Backup/BackupLayoutTemplate', + + regions: { + backups : '#x-backups', + toolbar : '#x-backup-toolbar' + }, + + columns: [ + { + name : 'type', + label : '', + sortable : false, + cell : BackupTypeCell + }, + { + name : 'this', + label : 'Name', + sortable : false, + cell : BackupFilenameCell + }, + { + name : 'time', + label : 'Time', + sortable : false, + cell : RelativeDateCell + } + ], + + leftSideButtons: { + type : 'default', + storeState: false, + collapse : false, + items : + [ + { + title : 'Backup', + icon : 'icon-file-text', + command : 'backup', + properties : { type: 'manual' }, + successMessage: 'Database and settings were backed up successfully', + errorMessage : 'Backup Failed!' + } + ] + }, + + initialize: function () { + this.backupCollection = new BackupCollection(); + + this.listenTo(this.backupCollection, 'sync', this._showBackups); + this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); + }, + + onRender: function () { + this._showToolbar(); + this.backups.show(new LoadingView()); + + this.backupCollection.fetch(); + }, + + _showBackups: function () { + + if (this.backupCollection.length === 0) { + this.backups.show(new EmptyView()); + } + + else { + this.backups.show(new Backgrid.Grid({ + columns : this.columns, + collection: this.backupCollection, + className : 'table table-hover' + })); + } + }, + + _showToolbar : function () { + this.toolbar.show(new ToolbarLayout({ + left : + [ + this.leftSideButtons + ], + context: this + })); + }, + + _commandComplete: function (options) { + if (options.command.get('name') === 'backup') { + this.backupCollection.fetch(); + } + } + }); + }); diff --git a/src/UI/System/Backup/BackupLayoutTemplate.html b/src/UI/System/Backup/BackupLayoutTemplate.html new file mode 100644 index 000000000..b2b9f91f9 --- /dev/null +++ b/src/UI/System/Backup/BackupLayoutTemplate.html @@ -0,0 +1,10 @@ +<div class="row"> + <div class="col-md-12"> + <div id="x-backup-toolbar"/> + </div> +</div> +<div class="row"> + <div class="col-md-12"> + <div id="x-backups" class="table-responsive"/> + </div> +</div> diff --git a/src/UI/System/Backup/BackupModel.js b/src/UI/System/Backup/BackupModel.js new file mode 100644 index 000000000..530a080c6 --- /dev/null +++ b/src/UI/System/Backup/BackupModel.js @@ -0,0 +1,9 @@ +'use strict'; +define( + [ + 'backbone' + ], function (Backbone) { + return Backbone.Model.extend({ + + }); + }); diff --git a/src/UI/System/Backup/BackupTypeCell.js b/src/UI/System/Backup/BackupTypeCell.js new file mode 100644 index 000000000..d836299bb --- /dev/null +++ b/src/UI/System/Backup/BackupTypeCell.js @@ -0,0 +1,33 @@ +'use strict'; +define( + [ + 'Cells/NzbDroneCell' + ], function (NzbDroneCell) { + return NzbDroneCell.extend({ + + className: 'backup-type-cell', + + render: function () { + this.$el.empty(); + + var icon = 'icon-time'; + var title = 'Scheduled'; + + var type = this.model.get(this.column.get('name')); + + if (type === 'manual') { + icon = 'icon-book'; + title = 'Manual'; + } + + else if (type === 'update') { + icon = 'icon-retweet'; + title = 'Before update'; + } + + this.$el.html('<i class="{0}" title="{1}"></i>'.format(icon, title)); + + return this; + } + }); + }); diff --git a/src/UI/System/Logs/Files/LogFileLayout.js b/src/UI/System/Logs/Files/LogFileLayout.js index 75280515a..d6ce0cf7a 100644 --- a/src/UI/System/Logs/Files/LogFileLayout.js +++ b/src/UI/System/Logs/Files/LogFileLayout.js @@ -38,12 +38,14 @@ define( { name : 'filename', label: 'Filename', - cell : FilenameCell + cell : FilenameCell, + sortable: false }, { name : 'lastWriteTime', label: 'Last Write Time', - cell : RelativeDateCell + cell : RelativeDateCell, + sortable: false }, { name : 'downloadUrl', diff --git a/src/UI/System/Logs/Table/LogTimeCell.js b/src/UI/System/Logs/Table/LogTimeCell.js index 20ee23641..dbabc1246 100644 --- a/src/UI/System/Logs/Table/LogTimeCell.js +++ b/src/UI/System/Logs/Table/LogTimeCell.js @@ -11,8 +11,7 @@ define( render: function () { var date = Moment(this._getValue()); - this.$el.html(date.format('LT')); - this.$el.attr('title', date.format('LLLL')); + this.$el.html('<span title="{1}">{0}</span>'.format(date.format('LT'), date.format('LLLL'))); return this; } diff --git a/src/UI/System/SystemLayout.js b/src/UI/System/SystemLayout.js index 3500681c7..29e8562f7 100644 --- a/src/UI/System/SystemLayout.js +++ b/src/UI/System/SystemLayout.js @@ -7,6 +7,7 @@ define( 'System/Info/SystemInfoLayout', 'System/Logs/LogsLayout', 'System/Update/UpdateLayout', + 'System/Backup/BackupLayout', 'Shared/Messenger' ], function ($, Backbone, @@ -14,26 +15,30 @@ define( SystemInfoLayout, LogsLayout, UpdateLayout, + BackupLayout, Messenger) { return Marionette.Layout.extend({ template: 'System/SystemLayoutTemplate', regions: { - info : '#info', + info : '#info', logs : '#logs', - updates: '#updates' + updates : '#updates', + backup : '#backup' }, ui: { - infoTab : '.x-info-tab', - logsTab : '.x-logs-tab', - updatesTab: '.x-updates-tab' + infoTab : '.x-info-tab', + logsTab : '.x-logs-tab', + updatesTab : '.x-updates-tab', + backupTab : '.x-backup-tab' }, events: { 'click .x-info-tab' : '_showInfo', 'click .x-logs-tab' : '_showLogs', 'click .x-updates-tab': '_showUpdates', + 'click .x-backup-tab': '_showBackup', 'click .x-shutdown' : '_shutdown', 'click .x-restart' : '_restart' }, @@ -52,6 +57,9 @@ define( case 'updates': this._showUpdates(); break; + case 'backup': + this._showBackup(); + break; default: this._showInfo(); } @@ -91,6 +99,16 @@ define( this._navigate('system/updates'); }, + _showBackup: function (e) { + if (e) { + e.preventDefault(); + } + + this.backup.show(new BackupLayout()); + this.ui.backupTab.tab('show'); + this._navigate('system/backup'); + }, + _shutdown: function () { $.ajax({ url: window.NzbDrone.ApiRoot + '/system/shutdown', diff --git a/src/UI/System/SystemLayoutTemplate.html b/src/UI/System/SystemLayoutTemplate.html index e3b245715..aa6996850 100644 --- a/src/UI/System/SystemLayoutTemplate.html +++ b/src/UI/System/SystemLayoutTemplate.html @@ -2,6 +2,7 @@ <li><a href="#info" class="x-info-tab no-router">Info</a></li> <li><a href="#logs" class="x-logs-tab no-router">Logs</a></li> <li><a href="#updates" class="x-updates-tab no-router">Updates</a></li> + <li><a href="#backup" class="x-backup-tab no-router">Backup</a></li> <li class="lifecycle-controls pull-right"> <div class="btn-group"> <button class="btn btn-default btn-icon-only x-shutdown" title="Shutdown" data-container="body"> @@ -18,4 +19,5 @@ <div class="tab-pane" id="info"></div> <div class="tab-pane" id="logs"></div> <div class="tab-pane" id="updates"></div> + <div class="tab-pane" id="backup"></div> </div> \ No newline at end of file diff --git a/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js b/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js index a42c12dba..ad2e40ac9 100644 --- a/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js +++ b/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js @@ -5,9 +5,10 @@ define( 'Series/EpisodeModel', 'backbone.pageable', 'Mixins/AsFilteredCollection', + 'Mixins/AsSortedCollection', 'Mixins/AsPersistedStateCollection' - ], function (_, EpisodeModel, PagableCollection, AsFilteredCollection, AsPersistedStateCollection) { - var collection = PagableCollection.extend({ + ], function (_, EpisodeModel, PagableCollection, AsFilteredCollection, AsSortedCollection, AsPersistedStateCollection) { + var Collection = PagableCollection.extend({ url : window.NzbDrone.ApiRoot + '/wanted/cutoff', model: EpisodeModel, tableName: 'wanted.cutoff', @@ -36,6 +37,10 @@ define( 'unmonitored' : ['monitored', 'false'], }, + sortMappings: { + 'series' : { sortKey: 'series.sortTitle' } + }, + parseState: function (resp) { return {totalRecords: resp.totalRecords}; }, @@ -49,6 +54,7 @@ define( } }); - collection = AsFilteredCollection.call(collection); - return AsPersistedStateCollection.call(collection); + Collection = AsFilteredCollection.call(Collection); + Collection = AsSortedCollection.call(Collection); + return AsPersistedStateCollection.call(Collection); }); diff --git a/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js b/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js index df889f4f5..00503c2d4 100644 --- a/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js +++ b/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js @@ -45,39 +45,39 @@ define([ columns : [ { - name : '', - cell : 'select-row', - headerCell : 'select-all', - sortable : false + name : '', + cell : 'select-row', + headerCell: 'select-all', + sortable : false }, { - name : 'series', - label : 'Series Title', - sortable : false, - cell : SeriesTitleCell + name : 'series', + label : 'Series Title', + cell : SeriesTitleCell, + sortValue : 'series.sortTitle' }, { - name : 'this', - label : 'Episode', - sortable : false, - cell : EpisodeNumberCell + name : 'this', + label : 'Episode', + cell : EpisodeNumberCell, + sortable : false }, { - name : 'this', - label : 'Episode Title', - sortable : false, - cell : EpisodeTitleCell + name : 'this', + label : 'Episode Title', + cell : EpisodeTitleCell, + sortable : false }, { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell + name : 'airDateUtc', + label : 'Air Date', + cell : RelativeDateCell }, { - name : 'status', - label : 'Status', - cell : EpisodeStatusCell, - sortable : false + name : 'status', + label : 'Status', + cell : EpisodeStatusCell, + sortable : false } ], diff --git a/src/UI/Wanted/Missing/MissingCollection.js b/src/UI/Wanted/Missing/MissingCollection.js index 17a79995f..7f589e957 100644 --- a/src/UI/Wanted/Missing/MissingCollection.js +++ b/src/UI/Wanted/Missing/MissingCollection.js @@ -5,9 +5,10 @@ define( 'Series/EpisodeModel', 'backbone.pageable', 'Mixins/AsFilteredCollection', + 'Mixins/AsSortedCollection', 'Mixins/AsPersistedStateCollection' - ], function (_, EpisodeModel, PagableCollection, AsFilteredCollection, AsPersistedStateCollection) { - var collection = PagableCollection.extend({ + ], function (_, EpisodeModel, PagableCollection, AsFilteredCollection, AsSortedCollection, AsPersistedStateCollection) { + var Collection = PagableCollection.extend({ url : window.NzbDrone.ApiRoot + '/wanted/missing', model: EpisodeModel, tableName: 'wanted.missing', @@ -35,6 +36,10 @@ define( 'unmonitored' : ['monitored', 'false'] }, + sortMappings: { + 'series' : { sortKey: 'series.sortTitle' } + }, + parseState: function (resp) { return {totalRecords: resp.totalRecords}; }, @@ -48,6 +53,7 @@ define( } }); - collection = AsFilteredCollection.call(collection); - return AsPersistedStateCollection.call(collection); + Collection = AsFilteredCollection.call(Collection); + Collection = AsSortedCollection.call(Collection); + return AsPersistedStateCollection.call(Collection); }); diff --git a/src/UI/Wanted/Missing/MissingLayout.js b/src/UI/Wanted/Missing/MissingLayout.js index 7e1cf6ae2..a85f867fc 100644 --- a/src/UI/Wanted/Missing/MissingLayout.js +++ b/src/UI/Wanted/Missing/MissingLayout.js @@ -45,39 +45,39 @@ define([ columns : [ { - name : '', - cell : 'select-row', - headerCell : 'select-all', - sortable : false + name : '', + cell : 'select-row', + headerCell: 'select-all', + sortable : false }, { - name : 'series', - label : 'Series Title', - sortable : false, - cell : SeriesTitleCell + name : 'series', + label : 'Series Title', + cell : SeriesTitleCell, + sortValue : 'series.sortTitle' }, { - name : 'this', - label : 'Episode', - sortable : false, - cell : EpisodeNumberCell + name : 'this', + label : 'Episode', + cell : EpisodeNumberCell, + sortable : false }, { - name : 'this', - label : 'Episode Title', - sortable : false, - cell : EpisodeTitleCell + name : 'this', + label : 'Episode Title', + cell : EpisodeTitleCell, + sortable : false }, { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell + name : 'airDateUtc', + label : 'Air Date', + cell : RelativeDateCell }, { - name : 'status', - label : 'Status', - cell : EpisodeStatusCell, - sortable : false + name : 'status', + label : 'Status', + cell : EpisodeStatusCell, + sortable : false } ], diff --git a/src/UI/index.html b/src/UI/index.html index 43eb62eb9..2dcdd72c8 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -12,6 +12,7 @@ <link href="/Content/theme.css" rel='stylesheet' type='text/css'/> <link href="/Cells/cells.css" rel='stylesheet' type='text/css'> <link href="/Series/series.css" rel='stylesheet' type='text/css'/> + <link href="/History/history.css" rel='stylesheet' type='text/css'/> <link href="/System/Logs/logs.css" rel='stylesheet' type='text/css'/> <link href="/Settings/settings.css" rel='stylesheet' type='text/css'/> <link href="/AddSeries/addSeries.css" rel='stylesheet' type='text/css'/> diff --git a/src/UI/jQuery/jquery.validation.js b/src/UI/jQuery/jquery.validation.js index 210f94d69..ba1f80d90 100644 --- a/src/UI/jQuery/jquery.validation.js +++ b/src/UI/jQuery/jquery.validation.js @@ -8,9 +8,16 @@ define( var validationName = error.propertyName.toLowerCase(); + var errorMessage = this.formatErrorMessage(error); + this.find('.validation-errors') .addClass('alert alert-danger') - .append('<div><i class="icon-exclamation-sign"></i>' + error.errorMessage + '</div>'); + .append('<div><i class="icon-exclamation-sign"></i>' + errorMessage + '</div>'); + + if (!validationName || validationName === '') { + this.addFormError(error); + return this; + } var input = this.find('[name]').filter(function () { return this.name.toLowerCase() === validationName; @@ -38,11 +45,11 @@ define( var inputGroup = controlGroup.find('.input-group'); if (inputGroup.length === 0) { - controlGroup.append('<span class="help-inline error-message">' + error.errorMessage + '</span>'); + controlGroup.append('<span class="help-inline validation-error">' + errorMessage + '</span>'); } else { - inputGroup.parent().append('<span class="help-block error-message">' + error.errorMessage + '</span>'); + inputGroup.parent().append('<span class="help-block validation-error">' + errorMessage + '</span>'); } } @@ -57,17 +64,42 @@ define( }; $.fn.addFormError = function (error) { - var t1 = this.find('.form-horizontal'); - var t2 = this.find('.form-horizontal').parent(); - this.prepend('<div class="alert alert-danger validation-error">' + error.errorMessage + '</div>'); + var errorMessage = this.formatErrorMessage(error); + + if (this.find('.modal-body')) { + this.find('.modal-body').prepend('<div class="alert alert-danger validation-error">' + errorMessage + '</div>'); + } + + else { + this.prepend('<div class="alert alert-danger validation-error">' + errorMessage + '</div>'); + } }; $.fn.removeAllErrors = function () { + this.find('.has-error').removeClass('has-error'); this.find('.error').removeClass('error'); this.find('.validation-errors').removeClass('alert').removeClass('alert-danger').html(''); this.find('.validation-error').remove(); return this.find('.help-inline.error-message').remove(); }; + $.fn.formatErrorMessage = function (error) { + + var errorMessage = error.errorMessage; + + if (error.infoLink) { + if (error.detailedDescription) { + errorMessage += ' <a class="no-router" target="_blank" href="' + error.infoLink + '"><i class="icon-external-link" title="' + error.detailedDescription + '"></i></a>'; + } + else { + errorMessage += ' <a class="no-router" target="_blank" href="' + error.infoLink + '"><i class="icon-external-link"></i></a>'; + } + } + else if (error.detailedDescription) { + errorMessage += ' <i class="icon-nd-form-info" title="' + error.detailedDescription + '"></i>'; + } + + return errorMessage; + }; }); diff --git a/src/UI/reqres.js b/src/UI/reqres.js index 786b5dc26..88c237908 100644 --- a/src/UI/reqres.js +++ b/src/UI/reqres.js @@ -7,7 +7,8 @@ define( var reqres = new Backbone.Wreqr.RequestResponse(); reqres.Requests = { - GetEpisodeFileById: 'GetEpisodeFileById' + GetEpisodeFileById : 'GetEpisodeFileById', + GetAlternateNameBySeasonNumber : 'GetAlternateNameBySeasonNumber' }; return reqres;