From d727840fbf2fe2a771d4ae245a221a919d24fb4f Mon Sep 17 00:00:00 2001 From: Icer Addis Date: Tue, 7 Jan 2014 00:21:05 -0800 Subject: [PATCH 1/5] Indexer searching for special episodes using query string Added SpecialEpisodeSearchCriteria criteria to handle special episode search queries Added method NzbSearchService.SearchSpecial() for season0 episodes Added IIndexer GetSearchUrls() for doing text based queries --- .../Definitions/SearchCriteriaBase.cs | 2 +- .../SpecialEpisodeSearchCriteria.cs | 28 +++++++++++++++++++ .../IndexerSearch/NzbSearchService.cs | 23 +++++++++++++++ src/NzbDrone.Core/Indexers/Eztv/Eztv.cs | 5 ++++ src/NzbDrone.Core/Indexers/IIndexer.cs | 1 + src/NzbDrone.Core/Indexers/IndexerBase.cs | 1 + .../Indexers/IndexerFetchService.cs | 18 ++++++++++-- src/NzbDrone.Core/Indexers/Newznab/Newznab.cs | 9 ++++++ .../Indexers/Omgwtfnzbs/Omgwtfnzbs.cs | 6 ++++ src/NzbDrone.Core/Indexers/Wombles/Wombles.cs | 5 ++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + 11 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index c86256099..689d36ab3 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions } } - private static string GetQueryTitle(string title) + public static string GetQueryTitle(string title) { Ensure.That(title,() => title).IsNotNullOrWhiteSpace(); diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs new file mode 100644 index 000000000..d4f034e44 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.IndexerSearch.Definitions +{ + public class SpecialEpisodeSearchCriteria : SearchCriteriaBase + { + public string[] EpisodeQueryTitles { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + bool delimiter = false; + foreach (var title in EpisodeQueryTitles) + { + if (delimiter) + { + sb.Append(','); + } + sb.Append(title); + delimiter = true; + } + return string.Format("[{0} : {1}]", SceneTitle, sb.ToString()); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 0981c5eb9..cafc2cd32 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -64,6 +64,12 @@ namespace NzbDrone.Core.IndexerSearch return SearchDaily(series, episode); } + if (episode.SeasonNumber == 0) + { + // search for special episodes in season 0 + return SearchSpecial(series, new List{episode}); + } + return SearchSingle(series, episode); } @@ -103,11 +109,28 @@ namespace NzbDrone.Core.IndexerSearch return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); } + private List SearchSpecial(Series series, List episodes) + { + var searchSpec = Get(series, episodes); + // build list of queries for each episode in the form: " " + searchSpec.EpisodeQueryTitles = episodes.Where(e => !String.IsNullOrWhiteSpace(e.Title)) + .Select(e => searchSpec.QueryTitle + "+" + SearchCriteriaBase.GetQueryTitle(e.Title)) + .ToArray(); + + return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + } + public List SeasonSearch(int seriesId, int seasonNumber) { var series = _seriesService.GetSeries(seriesId); var episodes = _episodeService.GetEpisodesBySeason(seriesId, seasonNumber); + if (seasonNumber == 0) + { + // search for special episodes in season 0 + return SearchSpecial(series, episodes); + } + var searchSpec = Get(series, episodes); searchSpec.SeasonNumber = seasonNumber; diff --git a/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs index 52c55df60..315178c54 100644 --- a/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs +++ b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs @@ -46,5 +46,10 @@ namespace NzbDrone.Core.Indexers.Eztv //EZTV doesn't support searching based on actual episode airdate. they only support release date. return new string[0]; } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index 34daa6a26..4ec97efb2 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -13,5 +13,6 @@ namespace NzbDrone.Core.Indexers IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date); IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset); + IEnumerable 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 7d42847cd..b89b0538d 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers public abstract IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); public abstract IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date); public abstract IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset); + public abstract IEnumerable 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 2de0c51b0..924b6689a 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Indexers IList Fetch(IIndexer indexer, SeasonSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, SingleEpisodeSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, DailyEpisodeSearchCriteria searchCriteria); + IList Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria); } public class FetchFeedService : IFetchFeedFromIndexers @@ -77,9 +78,8 @@ namespace NzbDrone.Core.Indexers var searchUrls = indexer.GetEpisodeSearchUrls(searchCriteria.QueryTitle, 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); + return result; } @@ -94,6 +94,20 @@ namespace NzbDrone.Core.Indexers return result; } + public IList Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria) + { + var queryUrls = new List(); + foreach (var episodeQueryTitle in searchCriteria.EpisodeQueryTitles) + { + _logger.Debug("Performing query of {0} for {1}", indexer, episodeQueryTitle); + queryUrls.AddRange(indexer.GetSearchUrls(episodeQueryTitle)); + } + + var result = Fetch(indexer, queryUrls); + _logger.Info("Finished searching {0} for {1}. Found {2}", indexer, searchCriteria, result.Count); + return result; + } + private List Fetch(IIndexer indexer, IEnumerable urls) { var result = new List(); diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 8a1be6f66..d14df1540 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -104,6 +104,15 @@ namespace NzbDrone.Core.Indexers.Newznab return RecentFeed.Select(url => String.Format("{0}&limit=100&q={1}&season={2}&ep={3}", url, NewsnabifyTitle(seriesTitle), seasonNumber, episodeNumber)); } + public override IEnumerable GetSearchUrls(string query, int offset) + { + // encode query (replace the + with spaces first) + query = query.Replace("+", " "); + query = System.Web.HttpUtility.UrlEncode(query); + 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) diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs index c4daeab66..20f0953af 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs @@ -66,5 +66,11 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs return searchUrls; } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } + } } diff --git a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs index 5355d853d..f111db688 100644 --- a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs +++ b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs @@ -41,5 +41,10 @@ namespace NzbDrone.Core.Indexers.Wombles { return new List(); } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 599e2cdb1..2e2f74d42 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -259,6 +259,7 @@ + From 6ee08af1112dbd78ba60660035256aa4d729be5e Mon Sep 17 00:00:00 2001 From: Icer Addis Date: Tue, 7 Jan 2014 00:24:50 -0800 Subject: [PATCH 2/5] Special Episode parsing support in ParsingService Added ParsingService.ParseSpecialEpisodeTitle Added SeriesService.FindByNameInexact Added EpisodeService.FindSpecialEpisodeByName Added IsPossibleSpecialEpisode method to parse info DownloadDecisionMaker will try to find special episodes if a parse fails or is a possible special episode --- .../DecisionEngine/DownloadDecisionMaker.cs | 20 +++++++ .../Parser/Model/ParsedEpisodeInfo.cs | 6 ++ src/NzbDrone.Core/Parser/Parser.cs | 14 +++++ src/NzbDrone.Core/Parser/ParsingService.cs | 59 +++++++++++++++++++ src/NzbDrone.Core/Tv/EpisodeService.cs | 16 +++++ src/NzbDrone.Core/Tv/SeriesService.cs | 50 ++++++++++++++++ 6 files changed, 165 insertions(+) diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 32e7087c6..093e16a89 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -52,6 +52,13 @@ namespace NzbDrone.Core.DecisionEngine _logger.ProgressInfo("No reports found"); } + // get series from search criteria + Tv.Series series = null; + if (searchCriteria != null) + { + series = searchCriteria.Series; + } + var reportNumber = 1; foreach (var report in reports) @@ -61,8 +68,21 @@ namespace NzbDrone.Core.DecisionEngine try { + // use parsing service to parse episode info (this allows us to do episode title searches against the episode repository) var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); + // do we have a possible special episode? + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + { + // try to parse as a special episode + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, series); + if (specialEpisodeInfo != null) + { + // use special episode + parsedEpisodeInfo = specialEpisodeInfo; + } + } + if (parsedEpisodeInfo != null && !string.IsNullOrWhiteSpace(parsedEpisodeInfo.SeriesTitle)) { var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvRageId, searchCriteria); diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 7ae94f647..2b6a808af 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -33,6 +33,12 @@ namespace NzbDrone.Core.Parser.Model return AbsoluteEpisodeNumbers.Any(); } + public bool IsPossibleSpecialEpisode() + { + // if we dont have eny episode numbers we are likely a special episode and need to do a search by episode title + return string.IsNullOrEmpty(AirDate) && (EpisodeNumbers.Length == 0 || SeasonNumber == 0 || String.IsNullOrWhiteSpace(SeriesTitle)); + } + public override string ToString() { string episodeString = "[Unknown Episode]"; diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 7797a61cd..d59ce4e80 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -114,6 +114,11 @@ namespace NzbDrone.Core.Parser private static readonly Regex YearInTitleRegex = new Regex(@"^(?.+?)(?:\W|_)?(?<year>\d{4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex NonWordRegex = new Regex(@"\W+", RegexOptions.Compiled); + private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of|part)\b\s?", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static ParsedEpisodeInfo ParsePath(string path) { var fileInfo = new FileInfo(path); @@ -220,6 +225,15 @@ namespace NzbDrone.Core.Parser return MultiPartCleanupRegex.Replace(title, string.Empty).Trim(); } + public static string NormalizeEpisodeTitle(string title) + { + // convert any non-word characters to a single space + string normalizedSpaces = NonWordRegex.Replace(title, " ").ToLower(); + // remove common words + string normalized = CommonWordRegex.Replace(normalizedSpaces, String.Empty); + return normalized; + } + public static string ParseReleaseGroup(string title) { const string defaultReleaseGroup = "DRONE"; diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 0134bbe4a..a2dfee939 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Parser { public interface IParsingService { + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series); LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource); Series GetSeries(string title); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvRageId, SearchCriteriaBase searchCriteria = null); @@ -39,10 +40,68 @@ namespace NzbDrone.Core.Parser _logger = logger; } + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + { + try + { + if (series == null) + { + // find series if we dont have it already + // we use an inexact match here since the series name is often mangled with the episode title + series = _seriesService.FindByTitleInexact(title); + if (series == null) + { + // no series matched + return null; + } + } + + // find special episode in series season 0 + Episode episode = _episodeService.FindEpisodeByName(series.Id, 0, title); + if (episode != null) + { + // created parsed info from tv episode that we found + var info = new ParsedEpisodeInfo(); + info.SeriesTitle = series.Title; + info.SeriesTitleInfo = new SeriesTitleInfo(); + info.SeriesTitleInfo.Title = info.SeriesTitle; + info.SeasonNumber = episode.SeasonNumber; + info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; + info.FullSeason = false; + info.Quality = QualityParser.ParseQuality(title); + info.ReleaseGroup = Parser.ParseReleaseGroup(title); + + _logger.Info("Found special episode {0} for title '{1}'", info, title); + return info; + } + } + catch (Exception e) + { + _logger.ErrorException("An error has occurred while trying to parse special episode " + title, e); + } + + return null; + } + + + public LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource) { var parsedEpisodeInfo = Parser.ParsePath(filename); + // do we have a possible special episode? + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + { + // try to parse as a special episode + var title = System.IO.Path.GetFileNameWithoutExtension(filename); + var specialEpisodeInfo = ParseSpecialEpisodeTitle(title, series); + if (specialEpisodeInfo != null) + { + // use special episode + parsedEpisodeInfo = specialEpisodeInfo; + } + } + if (parsedEpisodeInfo == null) { return null; diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index 96bfb1e52..5d8064ec5 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -15,6 +15,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 FindEpisodeByName(int seriesId, int seasonNumber, string episodeTitle); Episode GetEpisode(int seriesId, String date); Episode FindEpisode(int seriesId, String date); List<Episode> GetEpisodeBySeries(int seriesId); @@ -88,6 +89,21 @@ namespace NzbDrone.Core.Tv return _episodeRepository.GetEpisodes(seriesId, seasonNumber); } + public Episode FindEpisodeByName(int seriesId, int seasonNumber, string episodeTitle) + { + // TODO: can replace this search mechanism with something smarter/faster/better + var search = Parser.Parser.NormalizeEpisodeTitle(episodeTitle); + return _episodeRepository.GetEpisodes(seriesId, seasonNumber) + .FirstOrDefault(e => + { + // normalize episode title + string title = Parser.Parser.NormalizeEpisodeTitle(e.Title); + // find episode title within search string + return (title.Length > 0) && search.Contains(title); + }); + } + + public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec) { var episodeResult = _episodeRepository.EpisodesWithoutFiles(pagingSpec, false); diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 18b67a732..60937f7a9 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.Tv Series FindByTvRageId(int tvRageId); Series FindByTitle(string title); Series FindByTitle(string title, int year); + Series FindByTitleInexact(string title); void SetSeriesType(int seriesId, SeriesTypes seriesTypes); void DeleteSeries(int seriesId, bool deleteFiles); List<Series> GetAllSeries(); @@ -100,6 +101,55 @@ namespace NzbDrone.Core.Tv return _seriesRepository.FindByTitle(Parser.Parser.CleanSeriesTitle(title)); } + public Series FindByTitleInexact(string title) + { + // perform fuzzy matching of series name + // TODO: can replace this search mechanism with something smarter/faster/better + + // find any series clean title within the provided release title + string cleanTitle = Parser.Parser.CleanSeriesTitle(title); + var list = _seriesRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); + if (!list.Any()) + { + // no series matched + return null; + } + else if (list.Count == 1) + { + // return the first series if there is only one + return list.Single(); + } + else + { + // build ordered list of series by position in the search string + var query = + list.Select(series => new + { + position = cleanTitle.IndexOf(series.CleanTitle), + length = series.CleanTitle.Length, + series = series + }) + .Where(s => (s.position>=0)) + .ToList() + .OrderBy(s => s.position) + .ThenByDescending(s => s.length) + .ToList(); + + // get the leftmost series that is the longest + // series are usually the first thing in release title, so we select the leftmost and longest match + // we could have multiple matches for series which have a common prefix like "Love it", "Love it Too" so we pick the longest one + var match = query.First().series; + + _logger.Trace("Multiple series matched {0} from title {1}", match.Title, title); + foreach (var entry in list) + { + _logger.Trace("Multiple series match candidate: {0} cleantitle: {1}", entry.Title, entry.CleanTitle); + } + + return match; + } + } + public Series FindByTitle(string title, int year) { return _seriesRepository.FindByTitle(title, year); From c459cdf168679a91c053bbd9b5614e739c15c0c6 Mon Sep 17 00:00:00 2001 From: Icer Addis <iceraddis@gmail.com> Date: Tue, 7 Jan 2014 21:54:23 -0800 Subject: [PATCH 3/5] Fixes in response to code review ParseSpecialEpisode now follows similar pattern to Map() method and accepts TvRageId and SearchCriteria Fixed normalize episode title to handle punctuation separately from spaces and removed special episode words Removed comments --- .../DecisionEngine/DownloadDecisionMaker.cs | 13 +--- src/NzbDrone.Core/Parser/Parser.cs | 17 +++-- src/NzbDrone.Core/Parser/ParsingService.cs | 71 +++++++++++-------- src/NzbDrone.Core/Tv/SeriesService.cs | 4 -- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 093e16a89..b8b77c5a3 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -52,13 +52,6 @@ namespace NzbDrone.Core.DecisionEngine _logger.ProgressInfo("No reports found"); } - // get series from search criteria - Tv.Series series = null; - if (searchCriteria != null) - { - series = searchCriteria.Series; - } - var reportNumber = 1; foreach (var report in reports) @@ -68,17 +61,13 @@ namespace NzbDrone.Core.DecisionEngine try { - // use parsing service to parse episode info (this allows us to do episode title searches against the episode repository) var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); - // do we have a possible special episode? if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) { - // try to parse as a special episode - var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, series); + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, report.TvRageId, searchCriteria); if (specialEpisodeInfo != null) { - // use special episode parsedEpisodeInfo = specialEpisodeInfo; } } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index d59ce4e80..f307e421e 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -114,8 +114,11 @@ namespace NzbDrone.Core.Parser private static readonly Regex YearInTitleRegex = new Regex(@"^(?<title>.+?)(?:\W|_)?(?<year>\d{4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex NonWordRegex = new Regex(@"\W+", RegexOptions.Compiled); - private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of|part)\b\s?", + private static readonly Regex WordDelimiterRegex = new Regex(@"(\s|\.|,|_|-|=|\|)+", RegexOptions.Compiled); + private static readonly Regex PunctuationRegex = new Regex(@"[^\w\s]", RegexOptions.Compiled); + private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of)\b\s?", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SpecialEpisodeWordRegex = new Regex(@"\b(part|special|edition)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -227,11 +230,11 @@ namespace NzbDrone.Core.Parser public static string NormalizeEpisodeTitle(string title) { - // convert any non-word characters to a single space - string normalizedSpaces = NonWordRegex.Replace(title, " ").ToLower(); - // remove common words - string normalized = CommonWordRegex.Replace(normalizedSpaces, String.Empty); - return normalized; + string singleSpaces = WordDelimiterRegex.Replace(title, " "); + string noPunctuation = PunctuationRegex.Replace(singleSpaces, String.Empty); + string noCommonWords = CommonWordRegex.Replace(noPunctuation, String.Empty); + string normalized = SpecialEpisodeWordRegex.Replace(noCommonWords, String.Empty); + return normalized.Trim().ToLower(); } public static string ParseReleaseGroup(string title) diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index a2dfee939..a0682a138 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Parser { public interface IParsingService { + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null); ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series); LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource); Series GetSeries(string title); @@ -40,49 +41,63 @@ namespace NzbDrone.Core.Parser _logger = logger; } - public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null) { - try + if (searchCriteria != null) { - if (series == null) + var tvdbId = _sceneMappingService.GetTvDbId(title); + if (tvdbId.HasValue) { - // find series if we dont have it already - // we use an inexact match here since the series name is often mangled with the episode title - series = _seriesService.FindByTitleInexact(title); - if (series == null) + if (searchCriteria.Series.TvdbId == tvdbId) { - // no series matched - return null; + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); } } - // find special episode in series season 0 - Episode episode = _episodeService.FindEpisodeByName(series.Id, 0, title); - if (episode != null) + if (tvRageId == searchCriteria.Series.TvRageId) { - // created parsed info from tv episode that we found - var info = new ParsedEpisodeInfo(); - info.SeriesTitle = series.Title; - info.SeriesTitleInfo = new SeriesTitleInfo(); - info.SeriesTitleInfo.Title = info.SeriesTitle; - info.SeasonNumber = episode.SeasonNumber; - info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; - info.FullSeason = false; - info.Quality = QualityParser.ParseQuality(title); - info.ReleaseGroup = Parser.ParseReleaseGroup(title); - - _logger.Info("Found special episode {0} for title '{1}'", info, title); - return info; + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); } } - catch (Exception e) + + var series = _seriesService.FindByTitleInexact(title); + if (series == null && tvRageId > 0) { - _logger.ErrorException("An error has occurred while trying to parse special episode " + title, e); + series = _seriesService.FindByTvRageId(tvRageId); } - return null; + if (series == null) + { + _logger.Trace("No matching series {0}", title); + return null; + } + + return ParseSpecialEpisodeTitle(title, series); } + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + { + // find special episode in series season 0 + var episode = _episodeService.FindEpisodeByName(series.Id, 0, title); + if (episode != null) + { + // create parsed info from tv episode + var info = new ParsedEpisodeInfo(); + info.SeriesTitle = series.Title; + info.SeriesTitleInfo = new SeriesTitleInfo(); + info.SeriesTitleInfo.Title = info.SeriesTitle; + info.SeasonNumber = episode.SeasonNumber; + info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; + info.FullSeason = false; + info.Quality = QualityParser.ParseQuality(title); + info.ReleaseGroup = Parser.ParseReleaseGroup(title); + + _logger.Info("Found special episode {0} for title '{1}'", info, title); + return info; + } + + return null; + } public LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource) diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 60937f7a9..93405f8f5 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -103,9 +103,6 @@ namespace NzbDrone.Core.Tv public Series FindByTitleInexact(string title) { - // perform fuzzy matching of series name - // TODO: can replace this search mechanism with something smarter/faster/better - // find any series clean title within the provided release title string cleanTitle = Parser.Parser.CleanSeriesTitle(title); var list = _seriesRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); @@ -137,7 +134,6 @@ namespace NzbDrone.Core.Tv // get the leftmost series that is the longest // series are usually the first thing in release title, so we select the leftmost and longest match - // we could have multiple matches for series which have a common prefix like "Love it", "Love it Too" so we pick the longest one var match = query.First().series; _logger.Trace("Multiple series matched {0} from title {1}", match.Title, title); From 2dbf0ecc8240c138ddc53a9d571fa4a911ba58e0 Mon Sep 17 00:00:00 2001 From: Icer Addis <iceraddis@gmail.com> Date: Mon, 13 Jan 2014 21:20:29 -0800 Subject: [PATCH 4/5] Fixes for code review --- .../Definitions/SpecialEpisodeSearchCriteria.cs | 13 +------------ src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs | 7 ++++++- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs index d4f034e44..93bdfd0e0 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs @@ -11,18 +11,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - var sb = new StringBuilder(); - bool delimiter = false; - foreach (var title in EpisodeQueryTitles) - { - if (delimiter) - { - sb.Append(','); - } - sb.Append(title); - delimiter = true; - } - return string.Format("[{0} : {1}]", SceneTitle, sb.ToString()); + return string.Format("[{0} : {1}]", SceneTitle, String.Join(",", EpisodeQueryTitles)); } } } diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 2b6a808af..db7d3abb5 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -36,7 +36,12 @@ namespace NzbDrone.Core.Parser.Model public bool IsPossibleSpecialEpisode() { // if we dont have eny episode numbers we are likely a special episode and need to do a search by episode title - return string.IsNullOrEmpty(AirDate) && (EpisodeNumbers.Length == 0 || SeasonNumber == 0 || String.IsNullOrWhiteSpace(SeriesTitle)); + return string.IsNullOrEmpty(AirDate) && + ( + EpisodeNumbers.Length == 0 || + SeasonNumber == 0 || + String.IsNullOrWhiteSpace(SeriesTitle) + ); } public override string ToString() From 502ddceea2fe06d59e4cdbd691a1640553016704 Mon Sep 17 00:00:00 2001 From: Icer Addis <iceraddis@gmail.com> Date: Tue, 14 Jan 2014 00:35:22 -0800 Subject: [PATCH 5/5] Replaced + with space in special episode query string builder --- src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index cafc2cd32..006f2b9a8 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -114,7 +114,7 @@ namespace NzbDrone.Core.IndexerSearch var searchSpec = Get<SpecialEpisodeSearchCriteria>(series, episodes); // build list of queries for each episode in the form: "<series> <episode-title>" searchSpec.EpisodeQueryTitles = episodes.Where(e => !String.IsNullOrWhiteSpace(e.Title)) - .Select(e => searchSpec.QueryTitle + "+" + SearchCriteriaBase.GetQueryTitle(e.Title)) + .Select(e => searchSpec.QueryTitle + " " + SearchCriteriaBase.GetQueryTitle(e.Title)) .ToArray(); return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec);