using MediaBrowser.Common.IO; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.FileOrganization; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Extensions; using MediaBrowser.Model.FileOrganization; using MediaBrowser.Model.Logging; using MediaBrowser.Server.Implementations.Library; using MediaBrowser.Server.Implementations.Logging; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using CommonIO; namespace MediaBrowser.Server.Implementations.FileOrganization { public class EpisodeFileOrganizer { private readonly ILibraryMonitor _libraryMonitor; private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; private readonly IFileSystem _fileSystem; private readonly IFileOrganizationService _organizationService; private readonly IServerConfigurationManager _config; private readonly IProviderManager _providerManager; private readonly CultureInfo _usCulture = new CultureInfo("en-US"); public EpisodeFileOrganizer(IFileOrganizationService organizationService, IServerConfigurationManager config, IFileSystem fileSystem, ILogger logger, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager) { _organizationService = organizationService; _config = config; _fileSystem = fileSystem; _logger = logger; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _providerManager = providerManager; } public Task OrganizeEpisodeFile(string path, CancellationToken cancellationToken) { var options = _config.GetAutoOrganizeOptions().TvOptions; return OrganizeEpisodeFile(path, options, false, cancellationToken); } public async Task OrganizeEpisodeFile(string path, TvFileOrganizationOptions options, bool overwriteExisting, CancellationToken cancellationToken) { _logger.Info("Sorting file {0}", path); var result = new FileOrganizationResult { Date = DateTime.UtcNow, OriginalPath = path, OriginalFileName = Path.GetFileName(path), Type = FileOrganizerType.Episode, FileSize = new FileInfo(path).Length }; var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); var resolver = new Naming.TV.EpisodeResolver(namingOptions, new PatternsLogger()); var episodeInfo = resolver.Resolve(path, false) ?? new Naming.TV.EpisodeInfo(); var seriesName = episodeInfo.SeriesName; if (!string.IsNullOrEmpty(seriesName)) { var season = episodeInfo.SeasonNumber; result.ExtractedSeasonNumber = season; if (season.HasValue) { // Passing in true will include a few extra regex's var episode = episodeInfo.EpisodeNumber; result.ExtractedEpisodeNumber = episode; if (episode.HasValue) { _logger.Debug("Extracted information from {0}. Series name {1}, Season {2}, Episode {3}", path, seriesName, season, episode); var endingEpisodeNumber = episodeInfo.EndingEpsiodeNumber; result.ExtractedEndingEpisodeNumber = endingEpisodeNumber; await OrganizeEpisode(path, seriesName, season.Value, episode.Value, endingEpisodeNumber, options, overwriteExisting, result, cancellationToken).ConfigureAwait(false); } else { var msg = string.Format("Unable to determine episode number from {0}", path); result.Status = FileSortingStatus.Failure; result.StatusMessage = msg; _logger.Warn(msg); } } else { var msg = string.Format("Unable to determine season number from {0}", path); result.Status = FileSortingStatus.Failure; result.StatusMessage = msg; _logger.Warn(msg); } } else { var msg = string.Format("Unable to determine series name from {0}", path); result.Status = FileSortingStatus.Failure; result.StatusMessage = msg; _logger.Warn(msg); } var previousResult = _organizationService.GetResultBySourcePath(path); if (previousResult != null) { // Don't keep saving the same result over and over if nothing has changed if (previousResult.Status == result.Status && result.Status != FileSortingStatus.Success) { return previousResult; } } await _organizationService.SaveResult(result, CancellationToken.None).ConfigureAwait(false); return result; } public async Task OrganizeWithCorrection(EpisodeFileOrganizationRequest request, TvFileOrganizationOptions options, CancellationToken cancellationToken) { var result = _organizationService.GetResult(request.ResultId); var series = (Series)_libraryManager.GetItemById(new Guid(request.SeriesId)); await OrganizeEpisode(result.OriginalPath, series, request.SeasonNumber, request.EpisodeNumber, request.EndingEpisodeNumber, options, true, result, cancellationToken).ConfigureAwait(false); await _organizationService.SaveResult(result, CancellationToken.None).ConfigureAwait(false); return result; } private Task OrganizeEpisode(string sourcePath, string seriesName, int seasonNumber, int episodeNumber, int? endingEpiosdeNumber, TvFileOrganizationOptions options, bool overwriteExisting, FileOrganizationResult result, CancellationToken cancellationToken) { var series = GetMatchingSeries(seriesName, result); if (series == null) { var msg = string.Format("Unable to find series in library matching name {0}", seriesName); result.Status = FileSortingStatus.Failure; result.StatusMessage = msg; _logger.Warn(msg); return Task.FromResult(true); } return OrganizeEpisode(sourcePath, series, seasonNumber, episodeNumber, endingEpiosdeNumber, options, overwriteExisting, result, cancellationToken); } private async Task OrganizeEpisode(string sourcePath, Series series, int seasonNumber, int episodeNumber, int? endingEpiosdeNumber, TvFileOrganizationOptions options, bool overwriteExisting, FileOrganizationResult result, CancellationToken cancellationToken) { _logger.Info("Sorting file {0} into series {1}", sourcePath, series.Path); // Proceed to sort the file var newPath = await GetNewPath(sourcePath, series, seasonNumber, episodeNumber, endingEpiosdeNumber, options, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(newPath)) { var msg = string.Format("Unable to sort {0} because target path could not be determined.", sourcePath); result.Status = FileSortingStatus.Failure; result.StatusMessage = msg; _logger.Warn(msg); return; } _logger.Info("Sorting file {0} to new path {1}", sourcePath, newPath); result.TargetPath = newPath; var fileExists = _fileSystem.FileExists(result.TargetPath); var otherDuplicatePaths = GetOtherDuplicatePaths(result.TargetPath, series, seasonNumber, episodeNumber, endingEpiosdeNumber); if (!overwriteExisting) { if (options.CopyOriginalFile && fileExists && IsSameEpisode(sourcePath, newPath)) { _logger.Info("File {0} already copied to new path {1}, stopping organization", sourcePath, newPath); result.Status = FileSortingStatus.SkippedExisting; result.StatusMessage = string.Empty; return; } if (fileExists || otherDuplicatePaths.Count > 0) { result.Status = FileSortingStatus.SkippedExisting; result.StatusMessage = string.Empty; result.DuplicatePaths = otherDuplicatePaths; return; } } PerformFileSorting(options, result); if (overwriteExisting) { var hasRenamedFiles = false; foreach (var path in otherDuplicatePaths) { _logger.Debug("Removing duplicate episode {0}", path); _libraryMonitor.ReportFileSystemChangeBeginning(path); var renameRelatedFiles = !hasRenamedFiles && string.Equals(Path.GetDirectoryName(path), Path.GetDirectoryName(result.TargetPath), StringComparison.OrdinalIgnoreCase); if (renameRelatedFiles) { hasRenamedFiles = true; } try { DeleteLibraryFile(path, renameRelatedFiles, result.TargetPath); } catch (IOException ex) { _logger.ErrorException("Error removing duplicate episode", ex, path); } finally { _libraryMonitor.ReportFileSystemChangeComplete(path, true); } } } } private void DeleteLibraryFile(string path, bool renameRelatedFiles, string targetPath) { _fileSystem.DeleteFile(path); if (!renameRelatedFiles) { return; } // Now find other files var originalFilenameWithoutExtension = Path.GetFileNameWithoutExtension(path); var directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(originalFilenameWithoutExtension) && !string.IsNullOrWhiteSpace(directory)) { // Get all related files, e.g. metadata, images, etc var files = _fileSystem.GetFilePaths(directory) .Where(i => (Path.GetFileNameWithoutExtension(i) ?? string.Empty).StartsWith(originalFilenameWithoutExtension, StringComparison.OrdinalIgnoreCase)) .ToList(); var targetFilenameWithoutExtension = Path.GetFileNameWithoutExtension(targetPath); foreach (var file in files) { directory = Path.GetDirectoryName(file); var filename = Path.GetFileName(file); filename = filename.Replace(originalFilenameWithoutExtension, targetFilenameWithoutExtension, StringComparison.OrdinalIgnoreCase); var destination = Path.Combine(directory, filename); _fileSystem.MoveFile(file, destination); } } } private List GetOtherDuplicatePaths(string targetPath, Series series, int seasonNumber, int episodeNumber, int? endingEpisodeNumber) { var episodePaths = series.GetRecursiveChildren() .OfType() .Where(i => { var locationType = i.LocationType; // Must be file system based and match exactly if (locationType != LocationType.Remote && locationType != LocationType.Virtual && i.ParentIndexNumber.HasValue && i.ParentIndexNumber.Value == seasonNumber && i.IndexNumber.HasValue && i.IndexNumber.Value == episodeNumber) { if (endingEpisodeNumber.HasValue || i.IndexNumberEnd.HasValue) { return endingEpisodeNumber.HasValue && i.IndexNumberEnd.HasValue && endingEpisodeNumber.Value == i.IndexNumberEnd.Value; } return true; } return false; }) .Select(i => i.Path) .ToList(); var folder = Path.GetDirectoryName(targetPath); var targetFileNameWithoutExtension = _fileSystem.GetFileNameWithoutExtension(targetPath); try { var filesOfOtherExtensions = _fileSystem.GetFilePaths(folder) .Where(i => _libraryManager.IsVideoFile(i) && string.Equals(_fileSystem.GetFileNameWithoutExtension(i), targetFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)); episodePaths.AddRange(filesOfOtherExtensions); } catch (DirectoryNotFoundException) { // No big deal. Maybe the season folder doesn't already exist. } return episodePaths.Where(i => !string.Equals(i, targetPath, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } private void PerformFileSorting(TvFileOrganizationOptions options, FileOrganizationResult result) { _libraryMonitor.ReportFileSystemChangeBeginning(result.TargetPath); _fileSystem.CreateDirectory(Path.GetDirectoryName(result.TargetPath)); var targetAlreadyExists = _fileSystem.FileExists(result.TargetPath); try { if (targetAlreadyExists || options.CopyOriginalFile) { _fileSystem.CopyFile(result.OriginalPath, result.TargetPath, true); } else { _fileSystem.MoveFile(result.OriginalPath, result.TargetPath); } result.Status = FileSortingStatus.Success; result.StatusMessage = string.Empty; } catch (Exception ex) { var errorMsg = string.Format("Failed to move file from {0} to {1}", result.OriginalPath, result.TargetPath); result.Status = FileSortingStatus.Failure; result.StatusMessage = errorMsg; _logger.ErrorException(errorMsg, ex); return; } finally { _libraryMonitor.ReportFileSystemChangeComplete(result.TargetPath, true); } if (targetAlreadyExists && !options.CopyOriginalFile) { try { _fileSystem.DeleteFile(result.OriginalPath); } catch (Exception ex) { _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath); } } } private Series GetMatchingSeries(string seriesName, FileOrganizationResult result) { var parsedName = _libraryManager.ParseName(seriesName); var yearInName = parsedName.Year; var nameWithoutYear = parsedName.Name; result.ExtractedName = nameWithoutYear; result.ExtractedYear = yearInName; return _libraryManager.RootFolder.GetRecursiveChildren(i => i is Series) .Cast() .Select(i => NameUtils.GetMatchScore(nameWithoutYear, yearInName, i)) .Where(i => i.Item2 > 0) .OrderByDescending(i => i.Item2) .Select(i => i.Item1) .FirstOrDefault(); } /// /// Gets the new path. /// /// The source path. /// The series. /// The season number. /// The episode number. /// The ending episode number. /// The options. /// System.String. private async Task GetNewPath(string sourcePath, Series series, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, TvFileOrganizationOptions options, CancellationToken cancellationToken) { var episodeInfo = new EpisodeInfo { IndexNumber = episodeNumber, IndexNumberEnd = endingEpisodeNumber, MetadataCountryCode = series.GetPreferredMetadataCountryCode(), MetadataLanguage = series.GetPreferredMetadataLanguage(), ParentIndexNumber = seasonNumber, SeriesProviderIds = series.ProviderIds }; var searchResults = await _providerManager.GetRemoteSearchResults(new RemoteSearchQuery { SearchInfo = episodeInfo }, cancellationToken).ConfigureAwait(false); var episode = searchResults.FirstOrDefault(); if (episode == null) { _logger.Warn("No provider metadata found for {0} season {1} episode {2}", series.Name, seasonNumber, episodeNumber); return null; } var newPath = GetSeasonFolderPath(series, seasonNumber, options); // MAX_PATH - trailing charachter - drive component: 260 - 1 - 3 = 256 // Usually newPath would include the drive component, but use 256 to be sure var maxFilenameLength = 256 - newPath.Length; if (!newPath.EndsWith(@"\")) { // Remove 1 for missing backslash combining path and filename maxFilenameLength--; } // Remove additional 4 chars to prevent PathTooLongException for downloaded subtitles (eg. filename.ext.eng.srt) maxFilenameLength -= 4; var episodeFileName = GetEpisodeFileName(sourcePath, series.Name, seasonNumber, episodeNumber, endingEpisodeNumber, episode.Name, options, maxFilenameLength); if (string.IsNullOrEmpty(episodeFileName)) { // cause failure return string.Empty; } 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, TvFileOrganizationOptions options) { // If there's already a season folder, use that var season = series .GetRecursiveChildren(i => i is Season && i.LocationType == LocationType.FileSystem && i.IndexNumber.HasValue && i.IndexNumber.Value == seasonNumber) .FirstOrDefault(); 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)); } private string GetEpisodeFileName(string sourcePath, string seriesName, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, string episodeTitle, TvFileOrganizationOptions options, int? maxLength) { seriesName = _fileSystem.GetValidFilename(seriesName).Trim(); if (episodeTitle == null) { episodeTitle = string.Empty; } else { episodeTitle = _fileSystem.GetValidFilename(episodeTitle).Trim(); } var sourceExtension = (Path.GetExtension(sourcePath) ?? string.Empty).TrimStart('.'); var pattern = endingEpisodeNumber.HasValue ? options.MultiEpisodeNamePattern : options.EpisodeNamePattern; var result = pattern.Replace("%sn", seriesName) .Replace("%s.n", seriesName.Replace(" ", ".")) .Replace("%s_n", seriesName.Replace(" ", "_")) .Replace("%s", seasonNumber.ToString(_usCulture)) .Replace("%0s", seasonNumber.ToString("00", _usCulture)) .Replace("%00s", seasonNumber.ToString("000", _usCulture)) .Replace("%ext", sourceExtension) .Replace("%en", "%#1") .Replace("%e.n", "%#2") .Replace("%e_n", "%#3"); if (endingEpisodeNumber.HasValue) { result = result.Replace("%ed", endingEpisodeNumber.Value.ToString(_usCulture)) .Replace("%0ed", endingEpisodeNumber.Value.ToString("00", _usCulture)) .Replace("%00ed", endingEpisodeNumber.Value.ToString("000", _usCulture)); } result = result.Replace("%e", episodeNumber.ToString(_usCulture)) .Replace("%0e", episodeNumber.ToString("00", _usCulture)) .Replace("%00e", episodeNumber.ToString("000", _usCulture)); if (maxLength.HasValue && result.Contains("%#")) { // Substract 3 for the temp token length (%#1, %#2 or %#3) int maxRemainingTitleLength = maxLength.Value - result.Length + 3; string shortenedEpisodeTitle = string.Empty; if (maxRemainingTitleLength > 5) { // A title with fewer than 5 letters wouldn't be of much value shortenedEpisodeTitle = episodeTitle.Substring(0, Math.Min(maxRemainingTitleLength, episodeTitle.Length)); } result = result.Replace("%#1", shortenedEpisodeTitle) .Replace("%#2", shortenedEpisodeTitle.Replace(" ", ".")) .Replace("%#3", shortenedEpisodeTitle.Replace(" ", "_")); } if (maxLength.HasValue && result.Length > maxLength.Value) { // There may be cases where reducing the title length may still not be sufficient to // stay below maxLength var msg = string.Format("Unable to generate an episode file name shorter than {0} characters to constrain to the max path limit", maxLength); _logger.Warn(msg); return string.Empty; } return result; } private bool IsSameEpisode(string sourcePath, string newPath) { try { var sourceFileInfo = new FileInfo(sourcePath); var destinationFileInfo = new FileInfo(newPath); if (sourceFileInfo.Length == destinationFileInfo.Length) { return true; } } catch (FileNotFoundException) { return false; } catch (DirectoryNotFoundException) { return false; } return false; } } }