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/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/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/Indexers/ReleaseModule.cs b/src/NzbDrone.Api/Indexers/ReleaseModule.cs index 0f91ad102..8ea20ed8b 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; @@ -23,18 +25,21 @@ namespace NzbDrone.Api.Indexers private readonly IMakeDownloadDecision _downloadDecisionMaker; private readonly IDownloadService _downloadService; private readonly IParsingService _parsingService; + private readonly Logger _logger; public ReleaseModule(IFetchAndParseRss rssFetcherAndParser, ISearchForNzb nzbSearchService, IMakeDownloadDecision downloadDecisionMaker, IDownloadService downloadService, - IParsingService parsingService) + IParsingService parsingService, + Logger logger) { _rssFetcherAndParser = rssFetcherAndParser; _nzbSearchService = nzbSearchService; _downloadDecisionMaker = downloadDecisionMaker; _downloadService = downloadService; _parsingService = parsingService; + _logger = logger; GetResourceAll = GetReleases; Post["/"] = x=> DownloadRelease(this.Bind()); @@ -62,9 +67,17 @@ namespace NzbDrone.Api.Indexers private List GetEpisodeReleases(int episodeId) { - var decisions = _nzbSearchService.EpisodeSearch(episodeId); + try + { + var decisions = _nzbSearchService.EpisodeSearch(episodeId); + return MapDecisions(decisions); + } + catch (Exception ex) + { + _logger.ErrorException("Episode search failed: " + ex.Message, ex); + } - return MapDecisions(decisions); + return new List(); } private List GetRss() diff --git a/src/NzbDrone.Api/Indexers/ReleaseResource.cs b/src/NzbDrone.Api/Indexers/ReleaseResource.cs index 859399588..9700c7b18 100644 --- a/src/NzbDrone.Api/Indexers/ReleaseResource.cs +++ b/src/NzbDrone.Api/Indexers/ReleaseResource.cs @@ -15,6 +15,8 @@ namespace NzbDrone.Api.Indexers 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,9 +25,10 @@ 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; } diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 21a84886e..f0ab8561f 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -164,6 +164,7 @@ + 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/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs index 0439fdb27..5ecd6881c 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; } @@ -179,6 +165,23 @@ namespace NzbDrone.Api.Series resource.NextAiring = seriesStatistics.NextAiring; } + 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) { BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.SeriesId); diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index 6fdff13df..6ee15f35b 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Api.Series //View Only public String Title { get; set; } - public List AlternativeTitles { get; set; } + public List AlternateTitles { get; set; } public Int32 SeasonCount { 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/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.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/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/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..1737c7bb0 100644 --- a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs @@ -56,6 +56,14 @@ 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(); 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..1b71b5e0f 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -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 85% rename from src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs rename to src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderFixture.cs index 6e1258888..95d27ef02 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,67 @@ 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_replace_multiple_absolute_numbering_when_series_is_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"); + } } } \ 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..cadd276d8 100644 --- a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs @@ -34,6 +34,37 @@ 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", 100, 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)] 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/QualityParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs index dfa5b5989..98e0a5bf0 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,11 @@ 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)] public void should_parse_hdtv720p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.HDTV720p, proper); @@ -106,6 +116,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 +155,10 @@ 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)] public void should_parse_bluray720p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.Bluray720p, proper); @@ -152,6 +167,10 @@ 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)] public void should_parse_bluray1080p_quality(string title, bool proper) { ParseAndVerifyQuality(title, Quality.Bluray1080p, proper); diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs index b375ed4a2..8ef10beac 100644 --- a/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs @@ -157,8 +157,22 @@ namespace NzbDrone.Core.Test.TvTests } [Test] + public void should_not_set_absolute_episode_number_for_non_anime() + { + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) + .Returns(new List()); + + Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); + + _insertedEpisodes.All(e => e.AbsoluteEpisodeNumber == 0).Should().BeTrue(); + } + + [Test] + [Ignore] public void should_set_absolute_episode_number() { + //TODO: Only run this against an anime series + Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(new List()); 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/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..8da47ab05 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using NLog; +using NzbDrone.Common; using NzbDrone.Common.Cache; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Commands; @@ -12,45 +13,52 @@ 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, + 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,60 +68,87 @@ 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) + { + //TODO: we should be able to override xem aliases with ones from services + //Example Fairy Tail - Alias is assigned to season 2 (anidb), but we're still using tvdb for everything + + 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) { 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..faf04ac99 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 @@ -65,6 +68,47 @@ namespace NzbDrone.Core.DataAugmentation.Xem return response.Data.Where(c => c.Scene != null).ToList(); } + public List GetSceneTvdbNames() + { + _logger.Debug("Fetching alternate names"); + var restClient = new RestClient(XEM_BASE_URL); + + var request = BuildRequest("allNames"); + request.AddParameter("origin", "tvdb"); + //request.AddParameter("language", "us"); + 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; + } + + 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 +117,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/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/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 index 7e1c57bf4..089740d6f 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - return string.Format("[{0} : {1:00}]", SceneTitle, AbsoluteEpisodeNumber); + 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 97206d1f0..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; @@ -84,6 +86,71 @@ namespace NzbDrone.Core.IndexerSearch return SearchSingle(series, episode); } + 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); + } + + 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(); + + foreach (var sceneSeasonEpisodes in sceneSeasonGroups) + { + if (sceneSeasonEpisodes.Count() == 1) + { + var episode = sceneSeasonEpisodes.First(); + var searchSpec = Get(series, sceneSeasonEpisodes.ToList()); + searchSpec.SeasonNumber = sceneSeasonEpisodes.Key; + if (episode.SceneSeasonNumber == 0 && episode.SceneEpisodeNumber == 0) + searchSpec.EpisodeNumber = episode.EpisodeNumber; + else + searchSpec.EpisodeNumber = episode.SceneEpisodeNumber; + + var decisions = Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + downloadDecisions.AddRange(decisions); + } + else + { + var searchSpec = Get(series, sceneSeasonEpisodes.ToList()); + searchSpec.SeasonNumber = sceneSeasonEpisodes.Key; + + var decisions = Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + downloadDecisions.AddRange(decisions); + } + } + } + else + { + var searchSpec = Get(series, episodes); + searchSpec.SeasonNumber = seasonNumber; + + var decisions = Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + downloadDecisions.AddRange(decisions); + } + + return downloadDecisions; + } + private List SearchSingle(Series series, Episode episode) { var searchSpec = Get(series, new List{episode}); @@ -123,10 +190,17 @@ namespace NzbDrone.Core.IndexerSearch private List SearchAnime(Series series, Episode episode) { var searchSpec = Get(series, new List { episode }); - // TODO: Get the scene title from TheXEM - searchSpec.SceneTitle = series.Title; - // TODO: Calculate the Absolute Episode Number on the fly (if I have to) - searchSpec.AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber.GetValueOrDefault(0); + 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); } @@ -136,67 +210,19 @@ namespace NzbDrone.Core.IndexerSearch 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)) + .SelectMany(e => searchSpec.QueryTitles.Select(title => title + " " + SearchCriteriaBase.GetQueryTitle(e.Title))) .ToArray(); return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); } - public List SeasonSearch(int seriesId, int seasonNumber) + private List SearchAnimeSeason(Series series, List episodes) { - var series = _seriesService.GetSeries(seriesId); - var episodes = _episodeService.GetEpisodesBySeason(seriesId, seasonNumber); + var downloadDecisions = new List(); - if (seasonNumber == 0) + foreach (var episode in episodes) { - // search for special episodes in season 0 - return SearchSpecial(series, episodes); - } - - List 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(); - - foreach (var sceneSeasonEpisodes in sceneSeasonGroups) - { - if (sceneSeasonEpisodes.Count() == 1) - { - var episode = sceneSeasonEpisodes.First(); - var searchSpec = Get(series, sceneSeasonEpisodes.ToList()); - searchSpec.SeasonNumber = sceneSeasonEpisodes.Key; - if (episode.SceneSeasonNumber == 0 && episode.SceneEpisodeNumber == 0) - searchSpec.EpisodeNumber = episode.EpisodeNumber; - else - searchSpec.EpisodeNumber = episode.SceneEpisodeNumber; - - var decisions = Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); - downloadDecisions.AddRange(decisions); - } - else - { - var searchSpec = Get(series, sceneSeasonEpisodes.ToList()); - searchSpec.SeasonNumber = sceneSeasonEpisodes.Key; - - var decisions = Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); - downloadDecisions.AddRange(decisions); - } - } - } - else - { - var searchSpec = Get(series, episodes); - searchSpec.SeasonNumber = seasonNumber; - - var decisions = Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); - downloadDecisions.AddRange(decisions); + downloadDecisions.AddRange(SearchAnime(series, episode)); } return downloadDecisions; @@ -207,13 +233,14 @@ namespace NzbDrone.Core.IndexerSearch 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..f8a17ab71 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Animezb/Animezb.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +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(); + } + + 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 index 2b914ef49..de1765319 100644 --- a/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs +++ b/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs @@ -1,12 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; 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 @@ -15,14 +18,6 @@ namespace NzbDrone.Core.Indexers.Fanzub } } - public override bool SupportsPaging - { - get - { - return false; - } - } - public override bool SupportsSearching { get @@ -43,33 +38,47 @@ namespace NzbDrone.Core.Indexers.Fanzub { get { - yield return "http://fanzub.com/rss/?cat=anime"; + yield return "https://fanzub.com/rss/?cat=anime&max=100"; } } - 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(string seriesTitle, int tvRageId, int absoluteEpisodeNumber) + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) { - return RecentFeed.Select(url => String.Format("{0}&q={1}%20{2}", url, seriesTitle, 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(); } + + 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/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index cc13c9b62..d183ef7a3 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -13,10 +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 GetAnimeEpisodeSearchUrls(string seriesTitle, int tvRageId, int absoluteEpisodeNumber); - 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 ac52df408..c82c5a51b 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -50,10 +50,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 GetAnimeEpisodeSearchUrls(string seriesTitle, int tvRageId, int absoluteEpisodeNumber); - 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/IndexerFetchService.cs b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs index fa0a8b271..22255865a 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs @@ -59,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); @@ -76,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); @@ -87,7 +87,7 @@ 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); @@ -98,7 +98,7 @@ namespace NzbDrone.Core.Indexers { _logger.Debug("Searching for {0}", searchCriteria); - var searchUrls = indexer.GetAnimeEpisodeSearchUrls(searchCriteria.QueryTitle, searchCriteria.Series.TvRageId, searchCriteria.AbsoluteEpisodeNumber); + 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); diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 41570f5b6..f2b70e2ee 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; using System.Linq; +using NzbDrone.Common; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers.Newznab @@ -79,13 +81,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 +94,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,33 +169,6 @@ 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) - { - 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 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 GetAnimeEpisodeSearchUrls(string seriesTitle, int tvRageId, int absoluteEpisodeNumber) - { - // TODO: Implement - return new List(); - } - - 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)); - } - private static string NewsnabifyTitle(string title) { return title.Replace("+", "%20"); 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 277f70dbb..be81e3116 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs @@ -24,43 +24,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 GetAnimeEpisodeSearchUrls(string seriesTitle, int tvRageId, int absoluteEpisodeNumber) + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) { // TODO: Implement 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) { 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; diff --git a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs index 82fa0bcbd..a4dac0d2b 100644 --- a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs +++ b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs @@ -22,24 +22,24 @@ 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(string seriesTitle, int tvRageId, int absoluteEpisodeNumber) + public override IEnumerable GetAnimeEpisodeSearchUrls(List titles, int tvRageId, int absoluteEpisodeNumber) { - return new List(); + return new string[0]; } public override IEnumerable GetSearchUrls(string query, int offset) diff --git a/src/NzbDrone.Core/Indexers/XElementExtensions.cs b/src/NzbDrone.Core/Indexers/XElementExtensions.cs index fb24e526f..d6f4a5222 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 @@ -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/Metadata/MetadataBase.cs b/src/NzbDrone.Core/Metadata/MetadataBase.cs index a68d60174..8aa0e2764 100644 --- a/src/NzbDrone.Core/Metadata/MetadataBase.cs +++ b/src/NzbDrone.Core/Metadata/MetadataBase.cs @@ -1,10 +1,5 @@ using System; using System.Collections.Generic; -using System.Net; -using NLog; -using NzbDrone.Common; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Metadata.Files; using NzbDrone.Core.ThingiProvider; 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..be80d8e3a 100644 --- a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs @@ -80,10 +80,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(); @@ -111,7 +111,7 @@ namespace NzbDrone.Core.MetadataSource 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 +131,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 +272,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..6be24fc18 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Tvdb/TvdbProxy.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +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 new RestClient(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/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 14ce01160..77023a186 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -118,10 +118,12 @@ + + @@ -198,6 +200,7 @@ + @@ -305,6 +308,7 @@ + @@ -321,10 +325,11 @@ + + - @@ -372,6 +377,7 @@ + @@ -433,6 +439,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..c7e78581e 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,34 @@ 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)) + { + absoluteEpisodePattern += absoluteEpisodeFormat.Separator + + absoluteEpisodeFormat.AbsoluteEpisodePattern; + + 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 +350,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 +409,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..6258a2908 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); diff --git a/src/NzbDrone.Core/Organizer/FilenameValidationService.cs b/src/NzbDrone.Core/Organizer/FilenameValidationService.cs index 53f64bf86..2beed616e 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 @@ -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..1b4c3cf33 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Parser.Model public Language Language { get; set; } public bool FullSeason { get; set; } public string ReleaseGroup { get; set; } + public string ReleaseHash { get; set; } public ParsedEpisodeInfo() { @@ -58,6 +59,10 @@ namespace NzbDrone.Core.Parser.Model { 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); } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index f9345db2b..8abafb430 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) }; @@ -115,7 +123,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)", @@ -211,8 +219,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 +300,7 @@ namespace NzbDrone.Core.Parser const string defaultReleaseGroup = "DRONE"; title = title.Trim(); - title = RemoveFileExtension(title); - title = title.TrimEnd("-RP"); var matches = ReleaseGroupRegex.Matches(title); @@ -564,5 +583,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..6699ffcc1 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -151,18 +151,50 @@ namespace NzbDrone.Core.Parser 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); } } diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index fc87bfc39..edc8755d6 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -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)|(?<_1080p>1080p))\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,8 @@ 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); + public static QualityModel ParseQuality(string name) { Logger.Debug("Trying to parse quality for {0}", name); @@ -165,6 +167,25 @@ namespace NzbDrone.Core.Parser 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 +198,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/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/RefreshEpisodeService.cs b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs index 357926f4e..190538b48 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; } @@ -68,6 +70,13 @@ namespace NzbDrone.Core.Tv episodeToUpdate.Ratings = episode.Ratings; episodeToUpdate.Images = episode.Images; + //Reset the absolute episode number to zero if the series is not anime + if (series.SeriesType != SeriesTypes.Anime) + { + episodeToUpdate.AbsoluteEpisodeNumber = 0; + } + + successCount++; } catch (Exception e) @@ -82,7 +91,7 @@ namespace NzbDrone.Core.Tv allEpisodes.AddRange(updateList); AdjustMultiEpisodeAirTime(series, allEpisodes); - SetAbsoluteEpisodeNumber(allEpisodes); + SetAbsoluteEpisodeNumber(series, allEpisodes); _episodeService.DeleteMany(existingEpisodes); _episodeService.UpdateMany(updateList); @@ -144,15 +153,30 @@ namespace NzbDrone.Core.Tv } } - private static void SetAbsoluteEpisodeNumber(IEnumerable<Episode> allEpisodes) + private void SetAbsoluteEpisodeNumber(Series series, IEnumerable<Episode> allEpisodes) { - var episodes = allEpisodes.Where(e => e.SeasonNumber > 0 && e.EpisodeNumber > 0) - .OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber) - .ToList(); - - for (int i = 0; i < episodes.Count(); i++) + if (series.SeriesType != SeriesTypes.Anime) { - episodes[i].AbsoluteEpisodeNumber = i + 1; + _logger.Debug("Skipping absolute number lookup for non-anime"); + + return; + } + + var tvdbEpisodes = _tvdbProxy.GetEpisodeInfo(series.TvdbId); + + foreach (var episode in allEpisodes) + { + //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; } } } diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 5390d0502..d3ad1428a 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -54,6 +54,7 @@ namespace NzbDrone.Core.Tv var seriesInfo = tuple.Item1; series.Title = seriesInfo.Title; + series.TitleSlug = seriesInfo.TitleSlug; series.AirTime = seriesInfo.AirTime; series.Overview = seriesInfo.Overview; series.Status = seriesInfo.Status; diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 9de113351..736ba2fbc 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,7 @@ 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(); _seriesRepository.Insert(newSeries); _eventAggregator.PublishEvent(new SeriesAddedEvent(GetSeries(newSeries.Id))); @@ -97,8 +98,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 +105,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()) { 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/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.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.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/UI/AddSeries/SearchResultView.js b/src/UI/AddSeries/SearchResultView.js index 4450cee72..cd07a7502 100644 --- a/src/UI/AddSeries/SearchResultView.js +++ b/src/UI/AddSeries/SearchResultView.js @@ -32,12 +32,13 @@ 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: { @@ -151,12 +152,14 @@ 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.setSeasonPass(startingSeason); this.model.set('seasonFolder', seasonFolder); + this.model.set('seriesType', seriesType); + this.model.setSeasonPass(startingSeason); 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..eef54fb51 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 { 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/cells.less b/src/UI/Cells/cells.less index 42442e6af..8d591460b 100644 --- a/src/UI/Cells/cells.less +++ b/src/UI/Cells/cells.less @@ -164,3 +164,7 @@ td.delete-episode-file-cell { .series-status-cell { width: 16px; } + +.episode-number-cell { + cursor : default; +} 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/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/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..3fb874894 100644 --- a/src/UI/Series/Details/SeasonLayout.js +++ b/src/UI/Series/Details/SeasonLayout.js @@ -9,6 +9,7 @@ define( 'Cells/RelativeDateCell', 'Cells/EpisodeStatusCell', 'Cells/EpisodeActionsCell', + 'Series/Details/EpisodeNumberCell', 'Commands/CommandController', 'moment', 'underscore', @@ -21,6 +22,7 @@ define( RelativeDateCell, EpisodeStatusCell, EpisodeActionsCell, + EpisodeNumberCell, CommandController, Moment, _, @@ -58,11 +60,9 @@ define( sortable : false }, { - name : 'episodeNumber', + name : 'this', label: '#', - cell : Backgrid.IntegerCell.extend({ - className: 'episode-number-cell' - }) + cell : EpisodeNumberCell }, { 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/series.less b/src/UI/Series/series.less index 1420fe73e..506bf06f9 100644 --- a/src/UI/Series/series.less +++ b/src/UI/Series/series.less @@ -404,4 +404,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/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/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;