diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs new file mode 100644 index 0000000000..0fde46d164 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.TV; + +namespace NzbDrone.Core.Parser.Model +{ + public class ParsedEpisodeInfo + { + public ParsedEpisodeInfo() + { + Languages = new List(); + EpisodeNumbers = Array.Empty(); + AbsoluteEpisodeNumbers = Array.Empty(); + } + + public string SeriesTitle { get; set; } + public string OriginalTitle { get; set; } + public string ReleaseTitle { get; set; } + public string SimpleReleaseTitle { get; set; } + public SeriesTitleInfo SeriesTitleInfo { get; set; } + public QualityModel Quality { get; set; } + public List Languages { get; set; } + public string ReleaseGroup { get; set; } + public string ReleaseHash { get; set; } + + public int SeasonNumber { get; set; } + public int[] EpisodeNumbers { get; set; } + public int[] AbsoluteEpisodeNumbers { get; set; } + public string AirDate { get; set; } + + public bool FullSeason { get; set; } + public bool IsPartialSeason { get; set; } + public bool IsMultiSeason { get; set; } + public bool IsSeasonExtra { get; set; } + public bool IsSplitEpisode { get; set; } + public bool IsDaily { get; set; } + public bool IsAbsoluteNumbering { get; set; } + public bool IsPossibleSpecialEpisode { get; set; } + + public int? ReleaseVersion { get; set; } + public StreamingSource StreamingSource { get; set; } + + public bool IsPossibleSceneSeasonSpecial => SeasonNumber != 0 && + (ReleaseTitle?.Contains("Special") == true || + ReleaseTitle?.Contains("Specials") == true); + + public bool IsSpecialEpisode => SeasonNumber == 0 || + EpisodeNumbers?.Any(e => e == 0) == true || + IsPossibleSpecialEpisode; + + public override string ToString() + { + var episodeNumbers = EpisodeNumbers?.Any() == true + ? string.Format("E{0}", string.Join("-", EpisodeNumbers.Select(e => e.ToString("D2")))) + : string.Empty; + + var absoluteNumbers = AbsoluteEpisodeNumbers?.Any() == true + ? string.Format(" ({0})", string.Join("-", AbsoluteEpisodeNumbers)) + : string.Empty; + + if (IsDaily) + { + return string.Format("{0} - {1} {2}", SeriesTitle, AirDate, Quality); + } + + return string.Format("{0} - S{1:D2}{2}{3} {4}", + SeriesTitle, + SeasonNumber, + episodeNumbers, + absoluteNumbers, + Quality); + } + } +} diff --git a/src/NzbDrone.Core/Parser/ParserCommon.cs b/src/NzbDrone.Core/Parser/ParserCommon.cs index 40dcaa8f2d..07fdf6486e 100644 --- a/src/NzbDrone.Core/Parser/ParserCommon.cs +++ b/src/NzbDrone.Core/Parser/ParserCommon.cs @@ -1,3 +1,4 @@ +using System; using System.Text.RegularExpressions; namespace NzbDrone.Core.Parser; @@ -6,6 +7,18 @@ namespace NzbDrone.Core.Parser; // they are not intended to be used outside of them parsing. internal static class ParserCommon { + private static readonly Regex SimpleTitleRegex = new Regex(@"[\W_]+", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + internal static string SimplifyTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return string.Empty; + } + + return SimpleTitleRegex.Replace(title, string.Empty).ToLowerInvariant(); + } + internal static readonly RegexReplace[] PreSubstitutionRegex = System.Array.Empty(); // Valid TLDs http://data.iana.org/TLD/tlds-alpha-by-domain.txt diff --git a/src/NzbDrone.Core/Parser/TVParser.cs b/src/NzbDrone.Core/Parser/TVParser.cs new file mode 100644 index 0000000000..00bf7d01b4 --- /dev/null +++ b/src/NzbDrone.Core/Parser/TVParser.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.TV; + +namespace NzbDrone.Core.Parser +{ + public static class TVParser + { + private const RegexOptions StandardOptions = RegexOptions.IgnoreCase | RegexOptions.Compiled; + + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(TVParser)); + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + + private static readonly Regex[] EpisodeRegex = new[] + { + // Standard S01E01 format with optional multi-episode (S01E01E02 or S01E01-E02) + new Regex(@"(?:^|[^\w])s(?\d{1,2})(?:[.-])?e(?\d{1,3})(?:(?:[-e])+(?\d{1,3}))*(?:\W|$)", StandardOptions, RegexTimeout), + + // S01 (season only, no episode) + new Regex(@"(?:^|[^\w])s(?\d{1,2})(?:[^\w]|$)(?!e\d)", StandardOptions, RegexTimeout), + + // Season 01 Episode 01 / Season 1 Episode 1 + new Regex(@"(?:Season|Series)\W*(?\d{1,2})\W*(?:Episode|Ep)\W*(?\d{1,3})(?:(?:[-e])+(?\d{1,3}))*", StandardOptions, RegexTimeout), + + // Season only (Season 01, Season 1) + new Regex(@"(?:Season|Series)\W*(?\d{1,2})(?:[^\w]|$)", StandardOptions, RegexTimeout), + + // 1x01 format + new Regex(@"(?:^|[^\w])(?\d{1,2})x(?\d{1,3})(?:(?:[-x])+(?\d{1,3}))*(?:\W|$)", StandardOptions, RegexTimeout), + + // Part format - Part 1, Part I, Part.1 + new Regex(@"(?:^|[^\w])(?:Part|Pt)[.\s-]*(?\d{1,3}|[IVXLC]+)(?:\W|$)", StandardOptions, RegexTimeout), + }; + + private static readonly Regex[] DailyEpisodeRegex = new[] + { + // 2024.01.15 or 2024-01-15 + new Regex(@"(?:^|[^\d])(?\d{4})[.-](?\d{2})[.-](?\d{2})(?:[^\d]|$)", StandardOptions, RegexTimeout), + + // 15.01.2024 or 15-01-2024 + new Regex(@"(?:^|[^\d])(?\d{2})[.-](?\d{2})[.-](?\d{4})(?:[^\d]|$)", StandardOptions, RegexTimeout), + }; + + private static readonly Regex[] AnimeEpisodeRegex = new[] + { + // [SubGroup] Title - 01v2 or [SubGroup] Title - 01 (version detection) + new Regex(@"^\[(?[^\]]+)\][\s._-]*(?.+?)[\s._-]+(?<episode>\d{1,4})(?:v(?<version>\d{1,2}))?(?:[\s._-]*\[|\(|$)", StandardOptions, RegexTimeout), + + // [SubGroup] Title - 01-12 (batch range) + new Regex(@"^\[(?<subgroup>[^\]]+)\][\s._-]*(?<title>.+?)[\s._-]+(?<episode>\d{1,4})[\s._-]*-[\s._-]*(?<episode2>\d{1,4})(?:[\s._-]*\[|\(|$)", StandardOptions, RegexTimeout), + + // Show Title - S01E01 - Episode Title [Subgroup] (hybrid format) + new Regex(@"^(?<title>.+?)[\s._-]+S(?<season>\d{1,2})E(?<episode>\d{1,3})[\s._-]+.+?\[(?<subgroup>[^\]]+)\]", StandardOptions, RegexTimeout), + + // Show - 01 format (no brackets, absolute numbering) + new Regex(@"^(?<title>[^\[\]]+?)[\s._-]+(?<episode>\d{2,4})(?:v(?<version>\d{1,2}))?(?:[\s._-]+|$)", StandardOptions, RegexTimeout), + }; + + private static readonly Regex SeasonPackRegex = new Regex( + @"(?:^|[^\w])(?:Complete|COMPLETE|Full|Season|S)[\s._-]*(?<season>\d{1,2})[\s._-]*(?:Complete|COMPLETE)?(?:[^\w]|$)", + StandardOptions, + RegexTimeout); + + private static readonly Regex SpecialEpisodeRegex = new Regex( + @"(?:^|[^\w])(?:SP|Special|OVA|OAD|ONA|OAV|Pilot)[\s._-]*(?<episode>\d{1,3})?(?:[^\w]|$)", + StandardOptions, + RegexTimeout); + + private static readonly Dictionary<string, StreamingSource> StreamingSourceMap = new Dictionary<string, StreamingSource>(StringComparer.OrdinalIgnoreCase) + { + { "AMZN", StreamingSource.Amazon }, + { "Amazon", StreamingSource.Amazon }, + { "NF", StreamingSource.Netflix }, + { "Netflix", StreamingSource.Netflix }, + { "DSNP", StreamingSource.Disney }, + { "DisneyPlus", StreamingSource.Disney }, + { "Disney+", StreamingSource.Disney }, + { "ATVP", StreamingSource.AppleTV }, + { "AppleTV", StreamingSource.AppleTV }, + { "HULU", StreamingSource.Hulu }, + { "HBO", StreamingSource.HBO }, + { "HMAX", StreamingSource.HBO }, + { "HBOMax", StreamingSource.HBO }, + { "MAX", StreamingSource.HBO }, + { "PCOK", StreamingSource.Peacock }, + { "Peacock", StreamingSource.Peacock }, + { "PMTP", StreamingSource.Paramount }, + { "ParamountPlus", StreamingSource.Paramount }, + { "CR", StreamingSource.CrunchyRoll }, + { "CrunchyRoll", StreamingSource.CrunchyRoll }, + { "FUNI", StreamingSource.Funimation }, + { "Funimation", StreamingSource.Funimation }, + { "HIDV", StreamingSource.Hidive }, + { "Hidive", StreamingSource.Hidive }, + { "VRV", StreamingSource.VRV }, + { "RKTN", StreamingSource.Rakuten }, + { "iTunes", StreamingSource.ITunes }, + { "VUDU", StreamingSource.Vudu }, + { "STAN", StreamingSource.Stan }, + { "iP", StreamingSource.BBC }, + { "BBC", StreamingSource.BBC }, + { "ITV", StreamingSource.ITV }, + { "4OD", StreamingSource.All4 }, + { "NOW", StreamingSource.Now }, + { "CANAL", StreamingSource.Canal }, + { "WAKA", StreamingSource.Wakanim }, + { "Wakanim", StreamingSource.Wakanim }, + { "DCU", StreamingSource.DCUniverse }, + { "QIBI", StreamingSource.Quibi }, + { "SPEC", StreamingSource.Spectrum }, + { "SHO", StreamingSource.Showtime }, + { "Showtime", StreamingSource.Showtime }, + { "STRP", StreamingSource.Starz }, + { "Starz", StreamingSource.Starz }, + { "BRTBX", StreamingSource.BritBox } + }; + + public static ParsedEpisodeInfo ParseEpisodeTitle(string title, bool isSpecial = false) + { + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + var simpleTitle = ParserCommon.SimplifyTitle(title); + var normalizedTitle = title.Replace('_', ' ').Replace('.', ' ').Trim(); + + Logger.Debug("Parsing episode: {0}", title); + + var result = new ParsedEpisodeInfo + { + OriginalTitle = title, + ReleaseTitle = title, + SimpleReleaseTitle = simpleTitle + }; + + // Try anime format first (most specific) + if (TryParseAnime(normalizedTitle, result)) + { + result.IsAbsoluteNumbering = true; + ParseAdditionalInfo(title, result); + return result; + } + + // Try daily format + if (TryParseDaily(normalizedTitle, result)) + { + result.IsDaily = true; + ParseAdditionalInfo(title, result); + return result; + } + + // Try standard format + if (TryParseStandard(normalizedTitle, result)) + { + ParseAdditionalInfo(title, result); + return result; + } + + // Check for special episodes + if (isSpecial || TryParseSpecial(normalizedTitle, result)) + { + result.IsPossibleSpecialEpisode = true; + result.SeasonNumber = 0; + ParseAdditionalInfo(title, result); + return result; + } + + Logger.Debug("Unable to parse episode info from: {0}", title); + return null; + } + + private static bool TryParseStandard(string title, ParsedEpisodeInfo result) + { + foreach (var regex in EpisodeRegex) + { + var match = regex.Match(title); + if (match.Success) + { + var seasonGroup = match.Groups["season"]; + var episodeGroup = match.Groups["episode"]; + var episode2Group = match.Groups["episode2"]; + + if (seasonGroup.Success) + { + result.SeasonNumber = int.Parse(seasonGroup.Value); + } + + if (episodeGroup.Success) + { + var episodes = new List<int> { int.Parse(episodeGroup.Value) }; + + if (episode2Group.Success && episode2Group.Captures.Count > 0) + { + foreach (Capture capture in episode2Group.Captures) + { + episodes.Add(int.Parse(capture.Value)); + } + } + + result.EpisodeNumbers = episodes.Distinct().OrderBy(e => e).ToArray(); + } + else if (seasonGroup.Success) + { + result.FullSeason = true; + result.EpisodeNumbers = Array.Empty<int>(); + } + + result.SeriesTitle = ExtractSeriesTitle(title, match); + return true; + } + } + + // Check for season packs + var seasonPackMatch = SeasonPackRegex.Match(title); + if (seasonPackMatch.Success) + { + result.SeasonNumber = int.Parse(seasonPackMatch.Groups["season"].Value); + result.FullSeason = true; + result.EpisodeNumbers = Array.Empty<int>(); + result.SeriesTitle = ExtractSeriesTitle(title, seasonPackMatch); + return true; + } + + return false; + } + + private static bool TryParseDaily(string title, ParsedEpisodeInfo result) + { + foreach (var regex in DailyEpisodeRegex) + { + var match = regex.Match(title); + if (match.Success) + { + var year = int.Parse(match.Groups["airyear"].Value); + var month = int.Parse(match.Groups["airmonth"].Value); + var day = int.Parse(match.Groups["airday"].Value); + + if (year >= 1900 && year <= 2100 && month >= 1 && month <= 12 && day >= 1 && day <= 31) + { + try + { + var airDate = new DateTime(year, month, day); + result.AirDate = airDate.ToString("yyyy-MM-dd"); + result.SeriesTitle = ExtractSeriesTitle(title, match); + return true; + } + catch (ArgumentOutOfRangeException) + { + continue; + } + } + } + } + + return false; + } + + private static bool TryParseAnime(string title, ParsedEpisodeInfo result) + { + foreach (var regex in AnimeEpisodeRegex) + { + var match = regex.Match(title); + if (match.Success) + { + var titleGroup = match.Groups["title"]; + var subgroupGroup = match.Groups["subgroup"]; + var episodeGroup = match.Groups["episode"]; + var episode2Group = match.Groups["episode2"]; + var versionGroup = match.Groups["version"]; + var seasonGroup = match.Groups["season"]; + + if (titleGroup.Success) + { + result.SeriesTitle = titleGroup.Value.Trim(); + } + + if (subgroupGroup.Success) + { + result.ReleaseGroup = subgroupGroup.Value.Trim(); + } + + if (seasonGroup.Success) + { + result.SeasonNumber = int.Parse(seasonGroup.Value); + } + + if (episodeGroup.Success) + { + var startEpisode = int.Parse(episodeGroup.Value); + + if (episode2Group.Success) + { + var endEpisode = int.Parse(episode2Group.Value); + result.AbsoluteEpisodeNumbers = Enumerable.Range(startEpisode, endEpisode - startEpisode + 1).ToArray(); + } + else + { + result.AbsoluteEpisodeNumbers = new[] { startEpisode }; + } + + if (!seasonGroup.Success) + { + result.EpisodeNumbers = result.AbsoluteEpisodeNumbers; + } + } + + if (versionGroup.Success) + { + result.ReleaseVersion = int.Parse(versionGroup.Value); + } + + return true; + } + } + + return false; + } + + private static bool TryParseSpecial(string title, ParsedEpisodeInfo result) + { + var match = SpecialEpisodeRegex.Match(title); + if (match.Success) + { + var episodeGroup = match.Groups["episode"]; + if (episodeGroup.Success) + { + result.EpisodeNumbers = new[] { int.Parse(episodeGroup.Value) }; + } + else + { + result.EpisodeNumbers = new[] { 0 }; + } + + result.SeasonNumber = 0; + result.SeriesTitle = ExtractSeriesTitle(title, match); + return true; + } + + return false; + } + + private static string ExtractSeriesTitle(string title, Match match) + { + var index = match.Index; + if (index <= 0) + { + return title.Trim(); + } + + var seriesTitle = title.Substring(0, index).Trim(); + seriesTitle = Regex.Replace(seriesTitle, @"[\._-]+$", string.Empty); + seriesTitle = seriesTitle.Replace('.', ' ').Replace('_', ' ').Trim(); + + return seriesTitle; + } + + private static void ParseAdditionalInfo(string title, ParsedEpisodeInfo result) + { + result.Quality = QualityParser.ParseQuality(title); + result.Languages = LanguageParser.ParseLanguages(title); + result.StreamingSource = ParseStreamingSource(title); + + if (result.ReleaseGroup.IsNullOrWhiteSpace()) + { + result.ReleaseGroup = ReleaseGroupParser.ParseReleaseGroup(title); + } + + var hashMatch = Regex.Match(title, @"\[(?<hash>[a-f0-9]{8})\]", RegexOptions.IgnoreCase); + if (hashMatch.Success) + { + result.ReleaseHash = hashMatch.Groups["hash"].Value; + } + } + + public static StreamingSource ParseStreamingSource(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return StreamingSource.Unknown; + } + + foreach (var kvp in StreamingSourceMap) + { + if (Regex.IsMatch(title, $@"(?:^|[\s._-]){Regex.Escape(kvp.Key)}(?:[\s._-]|$)", RegexOptions.IgnoreCase)) + { + return kvp.Value; + } + } + + return StreamingSource.Unknown; + } + + public static bool IsFullSeason(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return false; + } + + var result = ParseEpisodeTitle(title); + return result?.FullSeason == true; + } + + public static bool IsSeasonPack(string title) + { + return IsFullSeason(title); + } + } +} diff --git a/src/NzbDrone.Core/Parser/TVParsingService.cs b/src/NzbDrone.Core/Parser/TVParsingService.cs new file mode 100644 index 0000000000..34456dbe6e --- /dev/null +++ b/src/NzbDrone.Core/Parser/TVParsingService.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.TV; + +namespace NzbDrone.Core.Parser +{ + public interface ITVParsingService + { + ParsedEpisodeInfo ParseEpisodeTitle(string title); + ParsedEpisodeInfo ParseMinimalPathEpisodeInfo(string path); + TVShow GetTVShow(string title); + List<Episode> GetEpisodes(ParsedEpisodeInfo parsedInfo, TVShow tvShow); + } + + public class TVParsingService : ITVParsingService + { + private readonly ITVShowService _tvShowService; + private readonly IEpisodeService _episodeService; + private readonly Logger _logger; + + public TVParsingService( + ITVShowService tvShowService, + IEpisodeService episodeService, + Logger logger) + { + _tvShowService = tvShowService; + _episodeService = episodeService; + _logger = logger; + } + + public ParsedEpisodeInfo ParseEpisodeTitle(string title) + { + return TVParser.ParseEpisodeTitle(title); + } + + public ParsedEpisodeInfo ParseMinimalPathEpisodeInfo(string path) + { + var fileInfo = new FileInfo(path); + + var result = TVParser.ParseEpisodeTitle(fileInfo.Name); + + if (result == null) + { + _logger.Debug("Attempting to parse episode info using directory and file names. '{0}'", fileInfo.Directory?.Name); + result = TVParser.ParseEpisodeTitle(fileInfo.Directory?.Name + " " + fileInfo.Name); + } + + if (result == null) + { + _logger.Debug("Attempting to parse episode info using directory name. '{0}'", fileInfo.Directory?.Name); + result = TVParser.ParseEpisodeTitle(fileInfo.Directory?.Name + fileInfo.Extension); + } + + return result; + } + + public TVShow GetTVShow(string title) + { + var parsedInfo = TVParser.ParseEpisodeTitle(title); + + if (parsedInfo?.SeriesTitle.IsNullOrWhiteSpace() == false) + { + return _tvShowService.FindByTitle(parsedInfo.SeriesTitle); + } + + return _tvShowService.FindByTitle(title); + } + + public List<Episode> GetEpisodes(ParsedEpisodeInfo parsedInfo, TVShow tvShow) + { + if (parsedInfo == null || tvShow == null) + { + return new List<Episode>(); + } + + if (parsedInfo.FullSeason) + { + return _episodeService.GetEpisodesBySeason(tvShow.Id, parsedInfo.SeasonNumber); + } + + if (parsedInfo.IsDaily && !parsedInfo.AirDate.IsNullOrWhiteSpace()) + { + var episode = _episodeService.FindByAirDate(tvShow.Id, parsedInfo.AirDate); + if (episode != null) + { + return new List<Episode> { episode }; + } + + return new List<Episode>(); + } + + if (parsedInfo.IsAbsoluteNumbering && parsedInfo.AbsoluteEpisodeNumbers?.Any() == true) + { + return _episodeService.FindByAbsoluteEpisodeNumber(tvShow.Id, parsedInfo.AbsoluteEpisodeNumbers); + } + + if (parsedInfo.EpisodeNumbers?.Any() == true) + { + return _episodeService.FindBySeasonAndEpisode(tvShow.Id, parsedInfo.SeasonNumber, parsedInfo.EpisodeNumbers); + } + + return new List<Episode>(); + } + } +} diff --git a/src/NzbDrone.Core/TV/EpisodeRepository.cs b/src/NzbDrone.Core/TV/EpisodeRepository.cs index 159268943b..eb84f5887e 100644 --- a/src/NzbDrone.Core/TV/EpisodeRepository.cs +++ b/src/NzbDrone.Core/TV/EpisodeRepository.cs @@ -13,6 +13,9 @@ public interface IEpisodeRepository : IBasicRepository<Episode> List<Episode> FindByTVShowIdAndSeasonNumber(int tvShowId, int seasonNumber); Episode FindByTVShowIdAndEpisode(int tvShowId, int seasonNumber, int episodeNumber); Episode FindByTVShowIdAndAbsoluteNumber(int tvShowId, int absoluteNumber); + Episode FindByAirDate(int tvShowId, string airDate); + List<Episode> FindBySeasonAndEpisode(int tvShowId, int seasonNumber, int[] episodeNumbers); + List<Episode> FindByAbsoluteEpisodeNumber(int tvShowId, int[] absoluteEpisodeNumbers); List<Episode> EpisodesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored); Episode FindByPath(string path); Dictionary<int, string> AllEpisodePaths(); @@ -54,6 +57,32 @@ public Episode FindByTVShowIdAndAbsoluteNumber(int tvShowId, int absoluteNumber) e.AbsoluteEpisodeNumber == absoluteNumber).FirstOrDefault(); } + public Episode FindByAirDate(int tvShowId, string airDate) + { + if (!DateTime.TryParse(airDate, out var date)) + { + return null; + } + + return Query(e => e.TVShowId == tvShowId && + e.AirDate.HasValue && + e.AirDate.Value.Date == date.Date).FirstOrDefault(); + } + + public List<Episode> FindBySeasonAndEpisode(int tvShowId, int seasonNumber, int[] episodeNumbers) + { + return Query(e => e.TVShowId == tvShowId && + e.SeasonNumber == seasonNumber && + episodeNumbers.Contains(e.EpisodeNumber)); + } + + public List<Episode> FindByAbsoluteEpisodeNumber(int tvShowId, int[] absoluteEpisodeNumbers) + { + return Query(e => e.TVShowId == tvShowId && + e.AbsoluteEpisodeNumber.HasValue && + absoluteEpisodeNumbers.Contains(e.AbsoluteEpisodeNumber.Value)); + } + public List<Episode> EpisodesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored) { var query = Query(e => e.AirDateUtc >= start && e.AirDateUtc <= end); diff --git a/src/NzbDrone.Core/TV/EpisodeService.cs b/src/NzbDrone.Core/TV/EpisodeService.cs index 65fff75b3c..4d1f414463 100644 --- a/src/NzbDrone.Core/TV/EpisodeService.cs +++ b/src/NzbDrone.Core/TV/EpisodeService.cs @@ -16,6 +16,10 @@ public interface IEpisodeService List<Episode> GetEpisodesByTVShowIdAndSeasonNumber(int tvShowId, int seasonNumber); Episode FindByTVShowIdAndEpisode(int tvShowId, int seasonNumber, int episodeNumber); Episode FindByTVShowIdAndAbsoluteNumber(int tvShowId, int absoluteNumber); + Episode FindByAirDate(int tvShowId, string airDate); + List<Episode> GetEpisodesBySeason(int tvShowId, int seasonNumber); + List<Episode> FindBySeasonAndEpisode(int tvShowId, int seasonNumber, int[] episodeNumbers); + List<Episode> FindByAbsoluteEpisodeNumber(int tvShowId, int[] absoluteEpisodeNumbers); Episode AddEpisode(Episode newEpisode); List<Episode> AddEpisodes(List<Episode> newEpisodes); void DeleteEpisode(int episodeId, bool deleteFiles); @@ -52,6 +56,14 @@ public Episode FindByTVShowIdAndEpisode(int tvShowId, int seasonNumber, int epis => _episodeRepository.FindByTVShowIdAndEpisode(tvShowId, seasonNumber, episodeNumber); public Episode FindByTVShowIdAndAbsoluteNumber(int tvShowId, int absoluteNumber) => _episodeRepository.FindByTVShowIdAndAbsoluteNumber(tvShowId, absoluteNumber); + public Episode FindByAirDate(int tvShowId, string airDate) + => _episodeRepository.FindByAirDate(tvShowId, airDate); + public List<Episode> GetEpisodesBySeason(int tvShowId, int seasonNumber) + => GetEpisodesByTVShowIdAndSeasonNumber(tvShowId, seasonNumber); + public List<Episode> FindBySeasonAndEpisode(int tvShowId, int seasonNumber, int[] episodeNumbers) + => _episodeRepository.FindBySeasonAndEpisode(tvShowId, seasonNumber, episodeNumbers); + public List<Episode> FindByAbsoluteEpisodeNumber(int tvShowId, int[] absoluteEpisodeNumbers) + => _episodeRepository.FindByAbsoluteEpisodeNumber(tvShowId, absoluteEpisodeNumbers); public Episode FindByPath(string path) => _episodeRepository.FindByPath(path); public Dictionary<int, string> AllEpisodePaths() => _episodeRepository.AllEpisodePaths(); public List<Episode> GetEpisodesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored)