using MediaBrowser.Common.IO; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; using MediaBrowser.Model.Logging; namespace MediaBrowser.Controller.Library { /// /// Class TVUtils /// public static class TVUtils { /// /// The TVDB API key /// public static readonly string TvdbApiKey = "B89CE93890E9419B"; /// /// The banner URL /// public static readonly string BannerUrl = "http://www.thetvdb.com/banners/"; /// /// A season folder must contain one of these somewhere in the name /// private static readonly string[] SeasonFolderNames = { "season", "sæson", "temporada", "saison", "staffel", "series", "сезон" }; /// /// Used to detect paths that represent episodes, need to make sure they don't also /// match movie titles like "2001 A Space..." /// Currently we limit the numbers here to 2 digits to try and avoid this /// private static readonly Regex[] EpisodeExpressions = { new Regex( @".*(\\|\/)[sS]?(?\d{1,4})[xX](?\d{1,3})[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)[sS](?\d{1,4})[x,X]?[eE](?\d{1,3})[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?\d{1,4})[xX](?\d{1,3}))[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?[^\\\/]*)[sS](?\d{1,4})[xX\.]?[eE](?\d{1,3})[^\\\/]*$", RegexOptions.Compiled) }; private static readonly Regex[] MultipleEpisodeExpressions = { new Regex( @".*(\\|\/)[sS]?(?\d{1,4})[xX](?\d{1,3})((-| - )\d{1,4}[eExX](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)[sS]?(?\d{1,4})[xX](?\d{1,3})((-| - )\d{1,4}[xX][eE](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)[sS]?(?\d{1,4})[xX](?\d{1,3})((-| - )?[xXeE](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)[sS]?(?\d{1,4})[xX](?\d{1,3})(-[xE]?[eE]?(?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?\d{1,4})[xX](?\d{1,3}))((-| - )\d{1,4}[xXeE](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?\d{1,4})[xX](?\d{1,3}))((-| - )\d{1,4}[xX][eE](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?\d{1,4})[xX](?\d{1,3}))((-| - )?[xXeE](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?((?![sS]?\d{1,4}[xX]\d{1,3})[^\\\/])*)?([sS]?(?\d{1,4})[xX](?\d{1,3}))(-[xX]?[eE]?(?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?[^\\\/]*)[sS](?\d{1,4})[xX\.]?[eE](?\d{1,3})((-| - )?[xXeE](?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled), new Regex( @".*(\\|\/)(?[^\\\/]*)[sS](?\d{1,4})[xX\.]?[eE](?\d{1,3})(-[xX]?[eE]?(?\d{1,3}))+[^\\\/]*$", RegexOptions.Compiled) }; /// /// To avoid the following matching movies they are only valid when contained in a folder which has been matched as a being season, or the media type is TV series /// private static readonly Regex[] EpisodeExpressionsWithoutSeason = { new Regex( @".*[\\\/](?\d{1,3})(-(?\d{2,3}))*\.\w+$", RegexOptions.Compiled), // "01.avi" new Regex( @".*(\\|\/)(?\d{1,3})(-(?\d{2,3}))*\s?-\s?[^\\\/]*$", RegexOptions.Compiled), // "01 - blah.avi", "01-blah.avi" new Regex( @".*(\\|\/)(?\d{1,3})(-(?\d{2,3}))*\.[^\\\/]+$", RegexOptions.Compiled), // "01.blah.avi" new Regex( @".*[\\\/][^\\\/]* - (?\d{1,3})(-(?\d{2,3}))*[^\\\/]*$", RegexOptions.Compiled), // "blah - 01.avi", "blah 2 - 01.avi", "blah - 01 blah.avi", "blah 2 - 01 blah", "blah - 01 - blah.avi", "blah 2 - 01 - blah" }; /// /// Gets the season number from path. /// /// The path. /// System.Nullable{System.Int32}. public static int? GetSeasonNumberFromPath(string path) { var filename = Path.GetFileName(path); if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase)) { return 0; } int val; if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val)) { return val; } if (filename.StartsWith("s", StringComparison.OrdinalIgnoreCase)) { var testFilename = filename.Substring(1); if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val)) { return val; } } // Look for one of the season folder names foreach (var name in SeasonFolderNames) { var index = filename.IndexOf(name, StringComparison.OrdinalIgnoreCase); if (index != -1) { return GetSeasonNumberFromPathSubstring(filename.Substring(index + name.Length)); } } return null; } /// /// Extracts the season number from the second half of the Season folder name (everything after "Season", or "Staffel") /// /// The path. /// System.Nullable{System.Int32}. private static int? GetSeasonNumberFromPathSubstring(string path) { var numericStart = -1; var length = 0; // Find out where the numbers start, and then keep going until they end for (var i = 0; i < path.Length; i++) { if (char.IsNumber(path, i)) { if (numericStart == -1) { numericStart = i; } length++; } else if (numericStart != -1) { break; } } if (numericStart == -1) { return null; } return int.Parse(path.Substring(numericStart, length), CultureInfo.InvariantCulture); } /// /// Determines whether [is season folder] [the specified path]. /// /// The path. /// The directory service. /// The file system. /// true if [is season folder] [the specified path]; otherwise, false. private static bool IsSeasonFolder(string path, IDirectoryService directoryService, IFileSystem fileSystem) { var seasonNumber = GetSeasonNumberFromPath(path); var hasSeasonNumber = seasonNumber != null; if (!hasSeasonNumber) { return false; } // It's a season folder if it's named as such and does not contain any audio files, apart from theme.mp3 foreach (var fileSystemInfo in directoryService.GetFileSystemEntries(path)) { var attributes = fileSystemInfo.Attributes; if ((attributes & FileAttributes.Hidden) == FileAttributes.Hidden) { continue; } // Can't enforce this because files saved by Bitcasa are always marked System //if ((attributes & FileAttributes.System) == FileAttributes.System) //{ // continue; //} if ((attributes & FileAttributes.Directory) == FileAttributes.Directory) { //if (IsBadFolder(fileSystemInfo.Name)) //{ // return false; //} } else { if (EntityResolutionHelper.IsAudioFile(fileSystemInfo.FullName) && !string.Equals(fileSystem.GetFileNameWithoutExtension(fileSystemInfo), BaseItem.ThemeSongFilename)) { return false; } } } return true; } /// /// Determines whether [is series folder] [the specified path]. /// /// The path. /// if set to true [consider seasonless entries]. /// The file system children. /// The directory service. /// The file system. /// true if [is series folder] [the specified path]; otherwise, false. public static bool IsSeriesFolder(string path, bool considerSeasonlessEntries, IEnumerable fileSystemChildren, IDirectoryService directoryService, IFileSystem fileSystem, ILogger logger) { // A folder with more than 3 non-season folders in will not becounted as a series var nonSeriesFolders = 0; foreach (var child in fileSystemChildren) { var attributes = child.Attributes; if ((attributes & FileAttributes.Hidden) == FileAttributes.Hidden) { //logger.Debug("Igoring series file or folder marked hidden: {0}", child.FullName); continue; } // Can't enforce this because files saved by Bitcasa are always marked System //if ((attributes & FileAttributes.System) == FileAttributes.System) //{ // logger.Debug("Igoring series subfolder marked system: {0}", child.FullName); // continue; //} if ((attributes & FileAttributes.Directory) == FileAttributes.Directory) { if (IsSeasonFolder(child.FullName, directoryService, fileSystem)) { logger.Debug("{0} is a series because of season folder {1}.", path, child.FullName); return true; } if (IsBadFolder(child.Name)) { logger.Debug("Invalid folder under series: {0}", child.FullName); nonSeriesFolders++; } if (nonSeriesFolders >= 3) { logger.Debug("{0} not a series due to 3 or more invalid folders.", path); return false; } } else { var fullName = child.FullName; if (EntityResolutionHelper.IsVideoFile(fullName) || EntityResolutionHelper.IsVideoPlaceHolder(fullName)) { if (GetEpisodeNumberFromFile(fullName, considerSeasonlessEntries).HasValue) { return true; } } } } logger.Debug("{0} is not a series folder.", path); return false; } private static bool IsBadFolder(string name) { if (string.Equals(name, BaseItem.ThemeSongsFolderName, StringComparison.OrdinalIgnoreCase)) { return false; } if (string.Equals(name, BaseItem.ThemeVideosFolderName, StringComparison.OrdinalIgnoreCase)) { return false; } if (string.Equals(name, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase)) { return false; } return !EntityResolutionHelper.IgnoreFolders.Contains(name, StringComparer.OrdinalIgnoreCase); } /// /// Episodes the number from file. /// /// The full path. /// if set to true [is in season]. /// System.String. public static int? GetEpisodeNumberFromFile(string fullPath, bool considerSeasonlessNames) { string fl = fullPath.ToLower(); foreach (var r in EpisodeExpressions) { Match m = r.Match(fl); if (m.Success) return ParseEpisodeNumber(m.Groups["epnumber"].Value); } if (considerSeasonlessNames) { var match = EpisodeExpressionsWithoutSeason.Select(r => r.Match(fl)) .FirstOrDefault(m => m.Success); if (match != null) { return ParseEpisodeNumber(match.Groups["epnumber"].Value); } } return null; } public static int? GetEndingEpisodeNumberFromFile(string fullPath) { var fl = fullPath.ToLower(); foreach (var r in MultipleEpisodeExpressions) { var m = r.Match(fl); if (m.Success && !string.IsNullOrEmpty(m.Groups["endingepnumber"].Value)) return ParseEpisodeNumber(m.Groups["endingepnumber"].Value); } foreach (var r in EpisodeExpressionsWithoutSeason) { var m = r.Match(fl); if (m.Success && !string.IsNullOrEmpty(m.Groups["endingepnumber"].Value)) return ParseEpisodeNumber(m.Groups["endingepnumber"].Value); } return null; } private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); private static int? ParseEpisodeNumber(string val) { int num; if (!string.IsNullOrEmpty(val) && int.TryParse(val, NumberStyles.Integer, UsCulture, out num)) { return num; } return null; } /// /// Seasons the number from episode file. /// /// The full path. /// System.String. public static int? GetSeasonNumberFromEpisodeFile(string fullPath) { string fl = fullPath.ToLower(); foreach (var r in EpisodeExpressions) { Match m = r.Match(fl); if (m.Success) { Group g = m.Groups["seasonnumber"]; if (g != null) { var val = g.Value; if (!string.IsNullOrWhiteSpace(val)) { int num; if (int.TryParse(val, NumberStyles.Integer, UsCulture, out num)) { return num; } } } return null; } } return null; } public static string GetSeriesNameFromEpisodeFile(string fullPath) { var fl = fullPath.ToLower(); foreach (var r in EpisodeExpressions) { var m = r.Match(fl); if (m.Success) { var g = m.Groups["seriesname"]; if (g != null) { var val = g.Value; if (!string.IsNullOrWhiteSpace(val)) { return val; } } return null; } } return null; } /// /// Gets the air days. /// /// The day. /// List{DayOfWeek}. public static List GetAirDays(string day) { if (!string.IsNullOrWhiteSpace(day)) { if (day.Equals("Daily", StringComparison.OrdinalIgnoreCase)) { return new List { DayOfWeek.Sunday, DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday }; } DayOfWeek value; if (Enum.TryParse(day, true, out value)) { return new List { value }; } return new List(); } return null; } } }