mirror of
https://github.com/Radarr/Radarr
synced 2026-01-26 09:23:39 +01:00
feat(tv): add TV parser with anime support
Phase 5 Part 4: Comprehensive episode parsing with anime from day 1. Parsing formats supported: - Standard: S01E01, S01E01E02, Season 1 Episode 1 - Multi-episode: S01E01-E03, 1x01-03 - Daily: 2024.01.15, 15-01-2024 - Anime: [SubGroup] Title - 01v2, batch ranges - Season packs: S01.COMPLETE, Season 1 Complete - Specials: SP01, OVA, OAD, Pilot Components: - ParsedEpisodeInfo model with anime fields - TVParser static class with regex patterns - TVParsingService for show/episode lookup - StreamingSource detection (AMZN, NF, CR, etc.) Also updated: - EpisodeService/Repository with air date and batch lookups - ParserCommon.SimplifyTitle() for title normalization
This commit is contained in:
parent
88a7436a18
commit
3ce561b195
6 changed files with 656 additions and 0 deletions
77
src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs
Normal file
77
src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs
Normal file
|
|
@ -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<Language>();
|
||||
EpisodeNumbers = Array.Empty<int>();
|
||||
AbsoluteEpisodeNumbers = Array.Empty<int>();
|
||||
}
|
||||
|
||||
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<Language> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RegexReplace>();
|
||||
|
||||
// Valid TLDs http://data.iana.org/TLD/tlds-alpha-by-domain.txt
|
||||
|
|
|
|||
416
src/NzbDrone.Core/Parser/TVParser.cs
Normal file
416
src/NzbDrone.Core/Parser/TVParser.cs
Normal file
|
|
@ -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(?<season>\d{1,2})(?:[.-])?e(?<episode>\d{1,3})(?:(?:[-e])+(?<episode2>\d{1,3}))*(?:\W|$)", StandardOptions, RegexTimeout),
|
||||
|
||||
// S01 (season only, no episode)
|
||||
new Regex(@"(?:^|[^\w])s(?<season>\d{1,2})(?:[^\w]|$)(?!e\d)", StandardOptions, RegexTimeout),
|
||||
|
||||
// Season 01 Episode 01 / Season 1 Episode 1
|
||||
new Regex(@"(?:Season|Series)\W*(?<season>\d{1,2})\W*(?:Episode|Ep)\W*(?<episode>\d{1,3})(?:(?:[-e])+(?<episode2>\d{1,3}))*", StandardOptions, RegexTimeout),
|
||||
|
||||
// Season only (Season 01, Season 1)
|
||||
new Regex(@"(?:Season|Series)\W*(?<season>\d{1,2})(?:[^\w]|$)", StandardOptions, RegexTimeout),
|
||||
|
||||
// 1x01 format
|
||||
new Regex(@"(?:^|[^\w])(?<season>\d{1,2})x(?<episode>\d{1,3})(?:(?:[-x])+(?<episode2>\d{1,3}))*(?:\W|$)", StandardOptions, RegexTimeout),
|
||||
|
||||
// Part format - Part 1, Part I, Part.1
|
||||
new Regex(@"(?:^|[^\w])(?:Part|Pt)[.\s-]*(?<episode>\d{1,3}|[IVXLC]+)(?:\W|$)", StandardOptions, RegexTimeout),
|
||||
};
|
||||
|
||||
private static readonly Regex[] DailyEpisodeRegex = new[]
|
||||
{
|
||||
// 2024.01.15 or 2024-01-15
|
||||
new Regex(@"(?:^|[^\d])(?<airyear>\d{4})[.-](?<airmonth>\d{2})[.-](?<airday>\d{2})(?:[^\d]|$)", StandardOptions, RegexTimeout),
|
||||
|
||||
// 15.01.2024 or 15-01-2024
|
||||
new Regex(@"(?:^|[^\d])(?<airday>\d{2})[.-](?<airmonth>\d{2})[.-](?<airyear>\d{4})(?:[^\d]|$)", StandardOptions, RegexTimeout),
|
||||
};
|
||||
|
||||
private static readonly Regex[] AnimeEpisodeRegex = new[]
|
||||
{
|
||||
// [SubGroup] Title - 01v2 or [SubGroup] Title - 01 (version detection)
|
||||
new Regex(@"^\[(?<subgroup>[^\]]+)\][\s._-]*(?<title>.+?)[\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);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/NzbDrone.Core/Parser/TVParsingService.cs
Normal file
109
src/NzbDrone.Core/Parser/TVParsingService.cs
Normal file
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue