diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 32e7087c6..b8b77c5a3 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -63,6 +63,15 @@ namespace NzbDrone.Core.DecisionEngine { var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + { + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, report.TvRageId, searchCriteria); + if (specialEpisodeInfo != null) + { + parsedEpisodeInfo = specialEpisodeInfo; + } + } + if (parsedEpisodeInfo != null && !string.IsNullOrWhiteSpace(parsedEpisodeInfo.SeriesTitle)) { var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvRageId, searchCriteria); 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..93bdfd0e0 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs @@ -0,0 +1,17 @@ +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() + { + return string.Format("[{0} : {1}]", SceneTitle, String.Join(",", EpisodeQueryTitles)); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 0981c5eb9..006f2b9a8 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 5f141eacb..f926d911e 100644 --- a/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs +++ b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs @@ -54,5 +54,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 2f850d8de..a4a9f1c6c 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -14,5 +14,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 0aead1b89..9c6527a17 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -50,6 +50,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 57158fec0..7009e748c 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs @@ -16,6 +16,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 @@ -76,9 +77,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; } @@ -93,6 +93,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 985ee208b..7e6f8caad 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -111,6 +111,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 4869b7c2b..689138c03 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs @@ -67,8 +67,13 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs return searchUrls; } - public override bool SupportsPaging + public override IEnumerable GetSearchUrls(string query, int offset) { + return new List(); + } + + public override bool SupportsPaging + { get { return false; diff --git a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs index 17180ed99..9b7ade53f 100644 --- a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs +++ b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs @@ -49,5 +49,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 6de48e0c3..671d53bcb 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -289,6 +289,7 @@ + diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index fffc79c96..beea7b190 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -34,6 +34,17 @@ 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 d62f12f3d..564232ceb 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -114,6 +114,14 @@ namespace NzbDrone.Core.Parser private static readonly Regex YearInTitleRegex = new Regex(@"^(?.+?)(?:\W|_)?(?<year>\d{4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); + 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); + + public static ParsedEpisodeInfo ParsePath(string path) { var fileInfo = new FileInfo(path); @@ -219,6 +227,15 @@ namespace NzbDrone.Core.Parser return MultiPartCleanupRegex.Replace(title, string.Empty).Trim(); } + public static string NormalizeEpisodeTitle(string title) + { + 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) { const string defaultReleaseGroup = "DRONE"; diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index f8053db3d..a0ac387ef 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -13,6 +13,8 @@ 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); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvRageId, SearchCriteriaBase searchCriteria = null); @@ -40,10 +42,82 @@ namespace NzbDrone.Core.Parser _logger = logger; } + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null) + { + if (searchCriteria != null) + { + var tvdbId = _sceneMappingService.GetTvDbId(title); + if (tvdbId.HasValue) + { + if (searchCriteria.Series.TvdbId == tvdbId) + { + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + } + } + + if (tvRageId == searchCriteria.Series.TvRageId) + { + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + } + } + + var series = _seriesService.FindByTitleInexact(title); + if (series == null && tvRageId > 0) + { + series = _seriesService.FindByTvRageId(tvRageId); + } + + 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) { 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 47e5fedf9..2dcd44283 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(); @@ -102,6 +103,51 @@ namespace NzbDrone.Core.Tv return _seriesRepository.FindByTitle(Parser.Parser.CleanSeriesTitle(title)); } + public Series FindByTitleInexact(string title) + { + // 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 + 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);