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|_)?(?\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 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 EpisodesWithoutFiles(PagingSpec 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 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);