Merge branch 'special-episode-search' of https://github.com/iaddis/NzbDrone into special-episode-search

Conflicts:
	src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs
This commit is contained in:
Mark McDowall 2014-02-19 23:23:20 -08:00
commit faa24c5bb6
17 changed files with 258 additions and 4 deletions

View File

@ -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);

View File

@ -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();

View File

@ -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));
}
}
}

View File

@ -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>{episode});
}
return SearchSingle(series, episode);
}
@ -103,11 +109,28 @@ namespace NzbDrone.Core.IndexerSearch
return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec);
}
private List<DownloadDecision> SearchSpecial(Series series, List<Episode> episodes)
{
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))
.ToArray();
return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec);
}
public List<DownloadDecision> 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<SeasonSearchCriteria>(series, episodes);
searchSpec.SeasonNumber = seasonNumber;

View File

@ -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<string> GetSearchUrls(string query, int offset)
{
return new List<string>();
}
}
}

View File

@ -14,5 +14,6 @@ namespace NzbDrone.Core.Indexers
IEnumerable<string> GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber);
IEnumerable<string> GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date);
IEnumerable<string> GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset);
IEnumerable<string> GetSearchUrls(string query, int offset = 0);
}
}

View File

@ -50,6 +50,7 @@ namespace NzbDrone.Core.Indexers
public abstract IEnumerable<string> GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber);
public abstract IEnumerable<string> GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date);
public abstract IEnumerable<string> GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset);
public abstract IEnumerable<string> GetSearchUrls(string query, int offset);
public override string ToString()
{

View File

@ -16,6 +16,7 @@ namespace NzbDrone.Core.Indexers
IList<ReleaseInfo> Fetch(IIndexer indexer, SeasonSearchCriteria searchCriteria);
IList<ReleaseInfo> Fetch(IIndexer indexer, SingleEpisodeSearchCriteria searchCriteria);
IList<ReleaseInfo> Fetch(IIndexer indexer, DailyEpisodeSearchCriteria searchCriteria);
IList<ReleaseInfo> 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<ReleaseInfo> Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria)
{
var queryUrls = new List<String>();
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<ReleaseInfo> Fetch(IIndexer indexer, IEnumerable<string> urls)
{
var result = new List<ReleaseInfo>();

View File

@ -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<string> 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<string> GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date)
{
if (tvRageId > 0)

View File

@ -67,8 +67,13 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs
return searchUrls;
}
public override bool SupportsPaging
public override IEnumerable<string> GetSearchUrls(string query, int offset)
{
return new List<string>();
}
public override bool SupportsPaging
{
get
{
return false;

View File

@ -49,5 +49,10 @@ namespace NzbDrone.Core.Indexers.Wombles
{
return new List<string>();
}
public override IEnumerable<string> GetSearchUrls(string query, int offset)
{
return new List<string>();
}
}
}

View File

@ -289,6 +289,7 @@
<Compile Include="Housekeeping\HousekeepingCommand.cs" />
<Compile Include="Housekeeping\HousekeepingService.cs" />
<Compile Include="Housekeeping\IHousekeepingTask.cs" />
<Compile Include="IndexerSearch\Definitions\SpecialEpisodeSearchCriteria.cs" />
<Compile Include="IndexerSearch\SeriesSearchService.cs" />
<Compile Include="IndexerSearch\SeriesSearchCommand.cs" />
<Compile Include="IndexerSearch\EpisodeSearchService.cs" />

View File

@ -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]";

View File

@ -114,6 +114,14 @@ namespace NzbDrone.Core.Parser
private static readonly Regex YearInTitleRegex = new Regex(@"^(?<title>.+?)(?:\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";

View File

@ -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;

View File

@ -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);

View File

@ -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);