using MediaBrowser.Common.IO; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.FileSorting; using MediaBrowser.Model.Logging; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Server.Implementations.FileSorting { public class TvFileSorter { private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; private readonly IFileSystem _fileSystem; private readonly IFileSortingRepository _iFileSortingRepository; private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); public TvFileSorter(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, IFileSortingRepository iFileSortingRepository) { _libraryManager = libraryManager; _logger = logger; _fileSystem = fileSystem; _iFileSortingRepository = iFileSortingRepository; } public async Task Sort(TvFileSortingOptions options, CancellationToken cancellationToken, IProgress progress) { var minFileBytes = options.MinFileSizeMb * 1024 * 1024; var watchLocations = options.WatchLocations.ToList(); var eligibleFiles = watchLocations.SelectMany(GetFilesToSort) .OrderBy(_fileSystem.GetCreationTimeUtc) .Where(i => EntityResolutionHelper.IsVideoFile(i.FullName) && i.Length >= minFileBytes) .ToList(); progress.Report(10); if (eligibleFiles.Count > 0) { var allSeries = _libraryManager.RootFolder .RecursiveChildren.OfType() .Where(i => i.LocationType == LocationType.FileSystem) .ToList(); var numComplete = 0; foreach (var file in eligibleFiles) { await SortFile(file.FullName, options, allSeries).ConfigureAwait(false); numComplete++; double percent = numComplete; percent /= eligibleFiles.Count; progress.Report(10 + (89 * percent)); } } cancellationToken.ThrowIfCancellationRequested(); progress.Report(99); if (!options.EnableTrialMode) { foreach (var path in watchLocations) { if (options.LeftOverFileExtensionsToDelete.Length > 0) { DeleteLeftOverFiles(path, options.LeftOverFileExtensionsToDelete); } if (options.DeleteEmptyFolders) { DeleteEmptyFolders(path); } } } progress.Report(100); } /// /// Gets the eligible files. /// /// The path. /// IEnumerable{FileInfo}. private IEnumerable GetFilesToSort(string path) { try { return new DirectoryInfo(path) .EnumerateFiles("*", SearchOption.AllDirectories) .ToList(); } catch (IOException ex) { _logger.ErrorException("Error getting files from {0}", ex, path); return new List(); } } /// /// Sorts the file. /// /// The path. /// The options. /// All series. private Task SortFile(string path, TvFileSortingOptions options, IEnumerable allSeries) { _logger.Info("Sorting file {0}", path); var result = new FileSortingResult { Date = DateTime.UtcNow, OriginalPath = path }; var seriesName = TVUtils.GetSeriesNameFromEpisodeFile(path); if (!string.IsNullOrEmpty(seriesName)) { var season = TVUtils.GetSeasonNumberFromEpisodeFile(path); if (season.HasValue) { // Passing in true will include a few extra regex's var episode = TVUtils.GetEpisodeNumberFromFile(path, true); if (episode.HasValue) { _logger.Debug("Extracted information from {0}. Series name {1}, Season {2}, Episode {3}", path, seriesName, season, episode); SortFile(path, seriesName, season.Value, episode.Value, options, allSeries, result); } else { var msg = string.Format("Unable to determine episode number from {0}", path); result.Status = FileSortingStatus.Failure; result.ErrorMessage = msg; _logger.Warn(msg); } } else { var msg = string.Format("Unable to determine season number from {0}", path); result.Status = FileSortingStatus.Failure; result.ErrorMessage = msg; _logger.Warn(msg); } } else { var msg = string.Format("Unable to determine series name from {0}", path); result.Status = FileSortingStatus.Failure; result.ErrorMessage = msg; _logger.Warn(msg); } return LogResult(result); } /// /// Sorts the file. /// /// The path. /// Name of the series. /// The season number. /// The episode number. /// The options. /// All series. /// The result. private void SortFile(string path, string seriesName, int seasonNumber, int episodeNumber, TvFileSortingOptions options, IEnumerable allSeries, FileSortingResult result) { var series = GetMatchingSeries(seriesName, allSeries); if (series == null) { var msg = string.Format("Unable to find series in library matching name {0}", seriesName); result.Status = FileSortingStatus.Failure; result.ErrorMessage = msg; _logger.Warn(msg); return; } _logger.Info("Sorting file {0} into series {1}", path, series.Path); // Proceed to sort the file var newPath = GetNewPath(series, seasonNumber, episodeNumber, options); if (string.IsNullOrEmpty(newPath)) { var msg = string.Format("Unable to sort {0} because target path could not be determined.", path); result.Status = FileSortingStatus.Failure; result.ErrorMessage = msg; _logger.Warn(msg); return; } _logger.Info("Sorting file {0} to new path {1}", path, newPath); result.TargetPath = newPath; if (options.EnableTrialMode) { result.Status = FileSortingStatus.SkippedTrial; return; } var targetExists = File.Exists(result.TargetPath); if (!options.OverwriteExistingEpisodes && targetExists) { result.Status = FileSortingStatus.SkippedExisting; return; } PerformFileSorting(options, result, targetExists); } /// /// Performs the file sorting. /// /// The options. /// The result. /// if set to true [copy]. private void PerformFileSorting(TvFileSortingOptions options, FileSortingResult result, bool copy) { try { if (copy) { File.Copy(result.OriginalPath, result.TargetPath, true); } else { File.Move(result.OriginalPath, result.TargetPath); } } catch (Exception ex) { var errorMsg = string.Format("Failed to move file from {0} to {1}", result.OriginalPath, result.TargetPath); result.Status = FileSortingStatus.Failure; result.ErrorMessage = errorMsg; _logger.ErrorException(errorMsg, ex); return; } if (copy) { try { File.Delete(result.OriginalPath); } catch (Exception ex) { _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath); } } } /// /// Logs the result. /// /// The result. /// Task. private Task LogResult(FileSortingResult result) { return _iFileSortingRepository.SaveResult(result, CancellationToken.None); } /// /// Gets the new path. /// /// The series. /// The season number. /// The episode number. /// The options. /// System.String. private string GetNewPath(Series series, int seasonNumber, int episodeNumber, TvFileSortingOptions options) { var currentEpisodes = series.RecursiveChildren.OfType() .Where(i => i.IndexNumber.HasValue && i.IndexNumber.Value == episodeNumber && i.ParentIndexNumber.HasValue && i.ParentIndexNumber.Value == seasonNumber) .ToList(); if (currentEpisodes.Count == 0) { return null; } var newPath = currentEpisodes .Where(i => i.LocationType == LocationType.FileSystem) .Select(i => i.Path) .FirstOrDefault(); if (string.IsNullOrEmpty(newPath)) { newPath = GetSeasonFolderPath(series, seasonNumber, options); var episode = currentEpisodes.First(); var episodeFileName = string.Format("{0} - {1}x{2} - {3}", _fileSystem.GetValidFilename(series.Name), seasonNumber.ToString(UsCulture), episodeNumber.ToString("00", UsCulture), _fileSystem.GetValidFilename(episode.Name) ); newPath = Path.Combine(newPath, episodeFileName); } return newPath; } /// /// Gets the season folder path. /// /// The series. /// The season number. /// The options. /// System.String. private string GetSeasonFolderPath(Series series, int seasonNumber, TvFileSortingOptions options) { // If there's already a season folder, use that var season = series .RecursiveChildren .OfType() .FirstOrDefault(i => i.LocationType == LocationType.FileSystem && i.IndexNumber.HasValue && i.IndexNumber.Value == seasonNumber); if (season != null) { return season.Path; } var path = series.Path; if (series.ContainsEpisodesWithoutSeasonFolders) { return path; } if (seasonNumber == 0) { return Path.Combine(path, _fileSystem.GetValidFilename(options.SeasonZeroFolderName)); } var seasonFolderName = options.SeasonFolderPattern .Replace("%s", seasonNumber.ToString(UsCulture)) .Replace("%0s", seasonNumber.ToString("00", UsCulture)) .Replace("%00s", seasonNumber.ToString("000", UsCulture)); return Path.Combine(path, _fileSystem.GetValidFilename(seasonFolderName)); } /// /// Gets the matching series. /// /// Name of the series. /// All series. /// Series. private Series GetMatchingSeries(string seriesName, IEnumerable allSeries) { int? yearInName; var nameWithoutYear = seriesName; NameParser.ParseName(nameWithoutYear, out nameWithoutYear, out yearInName); return allSeries.Select(i => GetMatchScore(nameWithoutYear, yearInName, i)) .Where(i => i.Item2 > 0) .OrderByDescending(i => i.Item2) .Select(i => i.Item1) .FirstOrDefault(); } private Tuple GetMatchScore(string sortedName, int? year, Series series) { var score = 0; // TODO: Improve this if (string.Equals(sortedName, series.Name, StringComparison.OrdinalIgnoreCase)) { score++; if (year.HasValue && series.ProductionYear.HasValue) { if (year.Value == series.ProductionYear.Value) { score++; } else { // Regardless of name, return a 0 score if the years don't match return new Tuple(series, 0); } } } return new Tuple(series, score); } /// /// Deletes the left over files. /// /// The path. /// The extensions. private void DeleteLeftOverFiles(string path, IEnumerable extensions) { var eligibleFiles = new DirectoryInfo(path) .EnumerateFiles("*", SearchOption.AllDirectories) .Where(i => extensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase)) .ToList(); foreach (var file in eligibleFiles) { try { File.Delete(file.FullName); } catch (IOException ex) { _logger.ErrorException("Error deleting file {0}", ex, file.FullName); } } } /// /// Deletes the empty folders. /// /// The path. private void DeleteEmptyFolders(string path) { try { foreach (var d in Directory.EnumerateDirectories(path)) { DeleteEmptyFolders(d); } var entries = Directory.EnumerateFileSystemEntries(path); if (!entries.Any()) { try { Directory.Delete(path); } catch (UnauthorizedAccessException) { } catch (DirectoryNotFoundException) { } } } catch (UnauthorizedAccessException) { } } } }