using MediaBrowser.Common.Extensions; using MediaBrowser.Common.IO; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; namespace MediaBrowser.Server.Implementations.Library.Resolvers.TV { /// /// Class SeriesResolver /// public class SeriesResolver : FolderResolver { private readonly IFileSystem _fileSystem; private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; public SeriesResolver(IFileSystem fileSystem, ILogger logger, ILibraryManager libraryManager) { _fileSystem = fileSystem; _logger = logger; _libraryManager = libraryManager; } /// /// Gets the priority. /// /// The priority. public override ResolverPriority Priority { get { return ResolverPriority.Second; } } /// /// Resolves the specified args. /// /// The args. /// Series. protected override Series Resolve(ItemResolveArgs args) { if (args.IsDirectory) { // Avoid expensive tests against VF's and all their children by not allowing this if (args.Parent == null || args.Parent.IsRoot) { return null; } // Optimization to avoid running these tests against Seasons if (args.Parent is Series || args.Parent is Season || args.Parent is MusicArtist || args.Parent is MusicAlbum) { return null; } var collectionType = args.GetCollectionType(); var isTvShowsFolder = string.Equals(collectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase); // If there's a collection type and it's not tv, it can't be a series if (!string.IsNullOrEmpty(collectionType) && !isTvShowsFolder && !string.Equals(collectionType, CollectionType.BoxSets, StringComparison.OrdinalIgnoreCase)) { return null; } if (IsSeriesFolder(args.Path, isTvShowsFolder, args.FileSystemChildren, args.DirectoryService, _fileSystem, _logger, _libraryManager)) { return new Series { Path = args.Path, Name = ResolverHelper.StripBrackets(Path.GetFileName(args.Path)) }; } } return null; } /// /// 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, ILibraryManager libraryManager) { // 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 (libraryManager.IsVideoFile(fullName) || IsVideoPlaceHolder(fullName)) { if (GetEpisodeNumberFromFile(fullName, considerSeasonlessEntries).HasValue) { return true; } } } } logger.Debug("{0} is not a series folder.", path); return false; } /// /// Determines whether [is place holder] [the specified path]. /// /// The path. /// true if [is place holder] [the specified path]; otherwise, false. /// path private static bool IsVideoPlaceHolder(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); } var extension = Path.GetExtension(path); return string.Equals(extension, ".disc", StringComparison.OrdinalIgnoreCase); } 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); } /// /// 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; } /// /// 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); } /// /// 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; } /// /// 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; } 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; } /// /// Sets the initial item values. /// /// The item. /// The args. protected override void SetInitialItemValues(Series item, ItemResolveArgs args) { base.SetInitialItemValues(item, args); SetProviderIdFromPath(item, args.Path); } /// /// Sets the provider id from path. /// /// The item. /// The path. private void SetProviderIdFromPath(Series item, string path) { var justName = Path.GetFileName(path); var id = justName.GetAttributeValue("tvdbid"); if (!string.IsNullOrEmpty(id)) { item.SetProviderId(MetadataProviders.Tvdb, id); } } } }