diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index 5652f3b6c..c80d578b9 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -120,7 +120,7 @@ namespace NzbDrone.Core.Test.ParserTests ExceptionVerification.IgnoreWarns(); } - [TestCase("[DmonHiro] The Severing Crime Edge - Cut 02 - Portrait Of Heresy [BD, 720p] [BE36E9E0]")] + [TestCase("THIS SHOULD NEVER PARSE")] public void unparsable_title_should_log_warn_and_return_null(string title) { Parser.Parser.ParseTitle(title).Should().BeNull(); @@ -147,7 +147,9 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Hell.on.Wheels.S02E09-E10.720p.HDTV.x264-EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] [TestCase("Grey's Anatomy - 8x01_02 - Free Falling", "Grey's Anatomy", 8, new [] { 1,2 })] [TestCase("8x01_02 - Free Falling", "", 8, new[] { 1, 2 })] - [TestCase("Kaamelott.S01E91-E100", "Kaamelott", 1,new[] { 91, 92, 93, 94, 95, 96, 97, 98, 99, 100 })] + [TestCase("Kaamelott.S01E91-E100", "Kaamelott", 1, new[] { 91, 92, 93, 94, 95, 96, 97, 98, 99, 100 })] + [TestCase("Neighbours.S29E161-E165.PDTV.x264-FQM", "Neighbours", 29, new[] { 161, 162, 163, 164, 165 })] + [TestCase("Shortland.Street.S22E5363-E5366.HDTV.x264-FiHTV", "Shortland Street", 22, new[] { 5363, 5364, 5365, 5366 })] public void TitleParse_multi(string postTitle, string title, int season, int[] episodes) { var result = Parser.Parser.ParseTitle(postTitle); @@ -174,7 +176,39 @@ namespace NzbDrone.Core.Test.ParserTests result.Should().NotBeNull(); result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); result.AirDate.Should().Be(airDate.ToString(Episode.AIR_DATE_FORMAT)); - result.EpisodeNumbers.Should().BeNull(); + result.EpisodeNumbers.Should().BeEmpty(); + } + + [TestCase("[SubDESU]_High_School_DxD_07_(1280x720_x264-AAC)_[6B7FD717]", "High School DxD", 7, 0, 0)] + [TestCase("[Chihiro]_Working!!_-_06_[848x480_H.264_AAC][859EEAFA]", "Working!!", 6, 0, 0)] + [TestCase("[Commie]_Senki_Zesshou_Symphogear_-_11_[65F220B4]", "Senki_Zesshou_Symphogear", 11, 0, 0)] + [TestCase("[Underwater]_Rinne_no_Lagrange_-_12_(720p)_[5C7BC4F9]", "Rinne_no_Lagrange", 12, 0, 0)] + [TestCase("[Commie]_Rinne_no_Lagrange_-_15_[E76552EA]", "Rinne_no_Lagrange", 15, 0, 0)] + [TestCase("[HorribleSubs]_Hunter_X_Hunter_-_33_[720p]", "Hunter_X_Hunter", 33, 0, 0)] + [TestCase("[HorribleSubs]_Fairy_Tail_-_145_[720p]", "Fairy_Tail", 145, 0, 0)] + [TestCase("[HorribleSubs] Tonari no Kaibutsu-kun - 13 [1080p].mkv", "Tonari no Kaibutsu-kun", 13, 0, 0)] + [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", "Yes.Pretty.Cure.5.Go.Go!", 31, 0, 0)] + [TestCase("[K-F] One Piece 214", "One Piece", 214, 0, 0)] + [TestCase("[K-F] One Piece S10E14 214", "One Piece", 214, 10, 14)] + [TestCase("[K-F] One Piece 10x14 214", "One Piece", 214, 10, 14)] + [TestCase("[K-F] One Piece 214 10x14", "One Piece", 214, 10, 14)] + [TestCase("One Piece S10E14 214", "One Piece", 214, 10, 14)] + [TestCase("One Piece 10x14 214", "One Piece", 214, 10, 14)] + [TestCase("One Piece 214 10x14", "One Piece", 214, 10, 14)] + [TestCase("214 One Piece 10x14", "One Piece", 214, 10, 14)] + [TestCase("Bleach - 031 - The Resolution to Kill [Lunar].avi", "Bleach", 31, 0, 0)] + [TestCase("Bleach - 031 - The Resolution to Kill [Lunar]", "Bleach", 31, 0, 0)] + [TestCase("[ACX]Hack Sign 01 Role Play [Kosaka] [9C57891E].mkv", "Hack Sign", 1, 0, 0)] + [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)] + public void parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.Should().NotBeNull(); + result.AbsoluteEpisodeNumbers.First().Should().Be(absoluteEpisodeNumber); + result.SeasonNumber.Should().Be(seasonNumber); + result.EpisodeNumbers.FirstOrDefault().Should().Be(episodeNumber); + result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); } diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs index b1d235dee..59f6d031f 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs @@ -38,7 +38,8 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests { SeriesTitle = _series.Title, SeasonNumber = 1, - EpisodeNumbers = new[] { 1 } + EpisodeNumbers = new[] { 1 }, + AbsoluteEpisodeNumbers = new int[0] }; _singleEpisodeSearchCriteria = new SingleEpisodeSearchCriteria @@ -69,6 +70,11 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests _series.UseSceneNumbering = true; } + private void GivenAbsoluteNumberingSeries() + { + _parsedEpisodeInfo.AbsoluteEpisodeNumbers = new[] { 1 }; + } + [Test] public void should_get_daily_episode_episode_when_search_criteria_is_null() { @@ -105,6 +111,17 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny()), Times.Once()); } + [Test] + public void should_use_search_criteria_episode_when_it_matches_absolute() + { + GivenAbsoluteNumberingSeries(); + + Subject.Map(_parsedEpisodeInfo, _series.TvRageId, _singleEpisodeSearchCriteria); + + Mocker.GetMock() + .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny()), Times.Never()); + } + [Test] public void should_use_scene_numbering_when_series_uses_scene_numbering() { diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs index 21fa4c91a..217e23ab8 100644 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests .With(e => e.SeasonNumber = 1) .With(e => e.SceneSeasonNumber = 2) .With(e => e.EpisodeNumber = 3) + .With(e => e.AbsoluteEpisodeNumber = 3) .With(e => e.SceneEpisodeNumber = 4) .Build(); @@ -51,5 +52,14 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests .Should() .BeNull(); } + + [Test] + public void should_find_episode_by_absolute_numbering() + { + Subject.Find(_episode.SeriesId, _episode.AbsoluteEpisodeNumber.Value) + .Id + .Should() + .Be(_episode.Id); + } } } diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 14fd53a80..d4bbefaac 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -11,11 +11,27 @@ namespace NzbDrone.Core.Parser.Model public QualityModel Quality { get; set; } public int SeasonNumber { get; set; } public int[] EpisodeNumbers { get; set; } + public int[] AbsoluteEpisodeNumbers { get; set; } public String AirDate { get; set; } public Language Language { get; set; } - public bool FullSeason { get; set; } + public ParsedEpisodeInfo() + { + EpisodeNumbers = new int[0]; + AbsoluteEpisodeNumbers = new int[0]; + } + + public bool IsDaily() + { + return !String.IsNullOrWhiteSpace(AirDate); + } + + public bool IsAbsoluteNumbering() + { + return AbsoluteEpisodeNumbers.Any(); + } + public override string ToString() { string episodeString = "[Unknown Episode]"; @@ -35,10 +51,5 @@ namespace NzbDrone.Core.Parser.Model return string.Format("{0} - {1} {2}", SeriesTitle, episodeString, Quality); } - - public bool IsDaily() - { - return !String.IsNullOrWhiteSpace(AirDate); - } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 9122245ab..ef62d9034 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -20,6 +21,22 @@ namespace NzbDrone.Core.Parser new Regex(@"^(?.+?)?\W*(?<airyear>\d{4})\W+(?<airmonth>[0-1][0-9])\W+(?<airday>[0-3][0-9])(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Anime - Absolute Episode Number + Title + Season+Episode + new Regex(@"^(?:(?<absoluteepisode>\d{2,3})(?:_|-|\s|\.)+)+(?<title>.+?)(?:\W|_)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Anime - [SubGroup] Title Absolute Episode Number + Season+Episode + 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+)))+)", + 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|\.|$)+)+", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Anime - [SubGroup] Title Absolute Episode Number + new Regex(@"^\[(?<subgroup>.+?)\](?:_|-|\s|\.)?(?<title>.+?)(?:(?:\W|_)+(?<absoluteepisode>\d{2,}))+", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Multi-Part episodes without a title (S01E05.S01E06) new Regex(@"^(?:\W*S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -44,6 +61,10 @@ namespace NzbDrone.Core.Parser 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 [SubGroup] + new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{2,3}))+(?:.+?)\[(?<subgroup>.+?)\](?:\.|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Supports 103/113 naming new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+)\d{1})(?<episode>\d{2}(?!p|i|\d+)))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -53,7 +74,7 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 1103/1113 naming - new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+|\(|\[)\d{2})(?<episode>\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), //Supports Season 01 Episode 03 @@ -62,7 +83,20 @@ namespace NzbDrone.Core.Parser //Supports Season only releases new Regex(@"^(?<title>.+?)\W(?:S|Season)\W?(?<season>\d{1,2}(?!\d+))(\W+|_|$)(?<extras>EXTRAS|SUBPACK)?(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled) + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //4-digit episode number + //Episodes without a title, Single (S01E05, 1x05) AND Multi (S01E04E05, 1x04x05, etc) + new Regex(@"^(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{4}(?!\d+|i|p)))+)(\W+|_|$)(?!\\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Episodes with a title, Single episodes (S01E05, 1x05, etc) & Multi-episode (S01E05E06, S01E05-06, S01E05 E06, etc) + 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}))+", + RegexOptions.IgnoreCase | RegexOptions.Compiled), }; private static readonly Regex NormalizeRegex = new Regex(@"((^|\W|_)(a|an|the|and|or|of)($|\W|_))|\W|_|(?:(?<=[^0-9]+)|\b)(?!(?:19\d{2}|20\d{2}))\d+(?=[^0-9ip]+|\b)", @@ -114,6 +148,7 @@ namespace NzbDrone.Core.Parser if (match.Count != 0) { + Debug.WriteLine(regex); try { var result = ParseMatchCollection(match); @@ -226,11 +261,13 @@ namespace NzbDrone.Core.Parser { SeasonNumber = seasons.First(), EpisodeNumbers = new int[0], + AbsoluteEpisodeNumbers = new int[0] }; foreach (Match matchGroup in matchCollection) { var episodeCaptures = matchGroup.Groups["episode"].Captures.Cast<Capture>().ToList(); + var absoluteEpisodeCaptures = matchGroup.Groups["absoluteepisode"].Captures.Cast<Capture>().ToList(); //Allows use to return a list of 0 episodes (We can handle that as a full season release) if (episodeCaptures.Any()) @@ -246,6 +283,20 @@ namespace NzbDrone.Core.Parser var count = last - first + 1; result.EpisodeNumbers = Enumerable.Range(first, count).ToArray(); } + + if (absoluteEpisodeCaptures.Any()) + { + var first = Convert.ToInt32(absoluteEpisodeCaptures.First().Value); + var last = Convert.ToInt32(absoluteEpisodeCaptures.Last().Value); + + if (first > last) + { + return null; + } + + var count = last - first + 1; + result.AbsoluteEpisodeNumbers = Enumerable.Range(first, count).ToArray(); + } else { //Check to see if this is an "Extras" or "SUBPACK" release, if it is, return NULL @@ -256,6 +307,10 @@ namespace NzbDrone.Core.Parser result.FullSeason = true; } } + if (result.AbsoluteEpisodeNumbers.Any() && !result.EpisodeNumbers.Any()) + { + result.SeasonNumber = 0; + } } else diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 3cd3aa407..ee544d789 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -129,6 +129,26 @@ namespace NzbDrone.Core.Parser return result; } + if (parsedEpisodeInfo.IsAbsoluteNumbering()) + { + foreach (var absoluteEpisodeNumber in parsedEpisodeInfo.AbsoluteEpisodeNumbers) + { + var episodeInfo = _episodeService.FindEpisode(series.Id, absoluteEpisodeNumber); + + if (episodeInfo != 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); + } + } + + return result; + } + if (parsedEpisodeInfo.EpisodeNumbers == null) return result; diff --git a/src/NzbDrone.Core/Tv/EpisodeRepository.cs b/src/NzbDrone.Core/Tv/EpisodeRepository.cs index daba4d5e0..58099d8c3 100644 --- a/src/NzbDrone.Core/Tv/EpisodeRepository.cs +++ b/src/NzbDrone.Core/Tv/EpisodeRepository.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Tv public interface IEpisodeRepository : IBasicRepository<Episode> { Episode Find(int seriesId, int season, int episodeNumber); + Episode Find(int seriesId, int absoluteEpisodeNumber); Episode Get(int seriesId, String date); Episode Find(int seriesId, String date); List<Episode> GetEpisodes(int seriesId); @@ -39,6 +40,11 @@ namespace NzbDrone.Core.Tv return Query.SingleOrDefault(s => s.SeriesId == seriesId && s.SeasonNumber == season && s.EpisodeNumber == episodeNumber); } + public Episode Find(int seriesId, int absoluteEpisodeNumber) + { + return Query.SingleOrDefault(s => s.SeriesId == seriesId && s.AbsoluteEpisodeNumber == absoluteEpisodeNumber); + } + public Episode Get(int seriesId, String date) { return Query.Single(s => s.SeriesId == seriesId && s.AirDate == date); diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index 9ef46761f..96bfb1e52 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -14,6 +14,7 @@ namespace NzbDrone.Core.Tv { Episode GetEpisode(int id); Episode FindEpisode(int seriesId, int seasonNumber, int episodeNumber, bool useScene = false); + Episode FindEpisode(int seriesId, int absoluteEpisodeNumber); Episode GetEpisode(int seriesId, String date); Episode FindEpisode(int seriesId, String date); List<Episode> GetEpisodeBySeries(int seriesId); @@ -62,6 +63,11 @@ namespace NzbDrone.Core.Tv return _episodeRepository.Find(seriesId, seasonNumber, episodeNumber); } + public Episode FindEpisode(int seriesId, int absoluteEpisodeNumber) + { + return _episodeRepository.Find(seriesId, absoluteEpisodeNumber); + } + public Episode GetEpisode(int seriesId, String date) { return _episodeRepository.Get(seriesId, date);