Browse Source

#680 - episode organization

Luke Pulverenti 11 năm trước cách đây
mục cha
commit
9d40b684bf
21 tập tin đã thay đổi với 822 bổ sung644 xóa
  1. 7 5
      MediaBrowser.Common.Implementations/MediaBrowser.Common.Implementations.csproj
  2. 1 1
      MediaBrowser.Common.Implementations/packages.config
  3. 28 8
      MediaBrowser.Controller/FileOrganization/IFileOrganizationService.cs
  4. 6 0
      MediaBrowser.Controller/Persistence/IFileOrganizationRepository.cs
  5. 15 0
      MediaBrowser.Model/FileOrganization/FileOrganizationQuery.cs
  6. 1 7
      MediaBrowser.Providers/TV/SeriesPostScanTask.cs
  7. 357 0
      MediaBrowser.Server.Implementations/FileOrganization/EpisodeFileOrganizer.cs
  8. 28 34
      MediaBrowser.Server.Implementations/FileOrganization/FileOrganizationService.cs
  9. 92 0
      MediaBrowser.Server.Implementations/FileOrganization/NameUtils.cs
  10. 11 10
      MediaBrowser.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs
  11. 0 563
      MediaBrowser.Server.Implementations/FileOrganization/TvFileSorter.cs
  12. 176 0
      MediaBrowser.Server.Implementations/FileOrganization/TvFolderOrganizer.cs
  13. 8 5
      MediaBrowser.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
  14. 3 1
      MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj
  15. 1 1
      MediaBrowser.Server.Implementations/MediaEncoder/MediaEncoder.cs
  16. 52 1
      MediaBrowser.Server.Implementations/Persistence/SqliteFileOrganizationRepository.cs
  17. 1 1
      MediaBrowser.ServerApplication/ApplicationHost.cs
  18. 5 4
      MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj
  19. 1 1
      MediaBrowser.ServerApplication/packages.config
  20. 28 1
      MediaBrowser.WebDashboard/ApiClient.js
  21. 1 1
      MediaBrowser.WebDashboard/packages.config

+ 7 - 5
MediaBrowser.Common.Implementations/MediaBrowser.Common.Implementations.csproj

@@ -48,8 +48,13 @@
     <RunPostBuildEvent>Always</RunPostBuildEvent>
   </PropertyGroup>
   <ItemGroup>
-    <Reference Include="SimpleInjector.Diagnostics">
-      <HintPath>..\packages\SimpleInjector.2.4.0\lib\net45\SimpleInjector.Diagnostics.dll</HintPath>
+    <Reference Include="SimpleInjector, Version=2.4.1.0, Culture=neutral, PublicKeyToken=984cb50dea722e99, processorArchitecture=MSIL">
+      <SpecificVersion>False</SpecificVersion>
+      <HintPath>..\packages\SimpleInjector.2.4.1\lib\net45\SimpleInjector.dll</HintPath>
+    </Reference>
+    <Reference Include="SimpleInjector.Diagnostics, Version=2.4.1.0, Culture=neutral, PublicKeyToken=984cb50dea722e99, processorArchitecture=MSIL">
+      <SpecificVersion>False</SpecificVersion>
+      <HintPath>..\packages\SimpleInjector.2.4.1\lib\net45\SimpleInjector.Diagnostics.dll</HintPath>
     </Reference>
     <Reference Include="System" />
     <Reference Include="System.Configuration" />
@@ -67,9 +72,6 @@
     <Reference Include="ServiceStack.Text">
       <HintPath>..\ThirdParty\ServiceStack.Text\ServiceStack.Text.dll</HintPath>
     </Reference>
-    <Reference Include="SimpleInjector">
-      <HintPath>..\packages\SimpleInjector.2.4.0\lib\net45\SimpleInjector.dll</HintPath>
-    </Reference>
   </ItemGroup>
   <ItemGroup>
     <Compile Include="..\SharedVersion.cs">

+ 1 - 1
MediaBrowser.Common.Implementations/packages.config

@@ -2,5 +2,5 @@
 <packages>
   <package id="NLog" version="2.1.0" targetFramework="net45" />
   <package id="sharpcompress" version="0.10.2" targetFramework="net45" />
-  <package id="SimpleInjector" version="2.4.0" targetFramework="net45" />
+  <package id="SimpleInjector" version="2.4.1" targetFramework="net45" />
 </packages>

+ 28 - 8
MediaBrowser.Controller/FileOrganization/IFileOrganizationService.cs

@@ -12,14 +12,6 @@ namespace MediaBrowser.Controller.FileOrganization
         /// </summary>
         void BeginProcessNewFiles();
 
-        /// <summary>
-        /// Saves the result.
-        /// </summary>
-        /// <param name="result">The result.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns>Task.</returns>
-        Task SaveResult(FileOrganizationResult result, CancellationToken cancellationToken);
-
         /// <summary>
         /// Deletes the original file.
         /// </summary>
@@ -27,12 +19,25 @@ namespace MediaBrowser.Controller.FileOrganization
         /// <returns>Task.</returns>
         Task DeleteOriginalFile(string resultId);
 
+        /// <summary>
+        /// Clears the log.
+        /// </summary>
+        /// <returns>Task.</returns>
+        Task ClearLog();
+        
         /// <summary>
         /// Performs the organization.
         /// </summary>
         /// <param name="resultId">The result identifier.</param>
         /// <returns>Task.</returns>
         Task PerformOrganization(string resultId);
+
+        /// <summary>
+        /// Performs the episode organization.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        /// <returns>Task.</returns>
+        Task PerformEpisodeOrganization(EpisodeFileOrganizationRequest request);
         
         /// <summary>
         /// Gets the results.
@@ -40,5 +45,20 @@ namespace MediaBrowser.Controller.FileOrganization
         /// <param name="query">The query.</param>
         /// <returns>IEnumerable{FileOrganizationResult}.</returns>
         QueryResult<FileOrganizationResult> GetResults(FileOrganizationResultQuery query);
+
+        /// <summary>
+        /// Gets the result.
+        /// </summary>
+        /// <param name="id">The identifier.</param>
+        /// <returns>FileOrganizationResult.</returns>
+        FileOrganizationResult GetResult(string id);
+
+        /// <summary>
+        /// Saves the result.
+        /// </summary>
+        /// <param name="result">The result.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        Task SaveResult(FileOrganizationResult result, CancellationToken cancellationToken);
     }
 }

+ 6 - 0
MediaBrowser.Controller/Persistence/IFileOrganizationRepository.cs

@@ -35,5 +35,11 @@ namespace MediaBrowser.Controller.Persistence
         /// <param name="query">The query.</param>
         /// <returns>IEnumerable{FileOrganizationResult}.</returns>
         QueryResult<FileOrganizationResult> GetResults(FileOrganizationResultQuery query);
+
+        /// <summary>
+        /// Deletes all.
+        /// </summary>
+        /// <returns>Task.</returns>
+        Task DeleteAll();
     }
 }

+ 15 - 0
MediaBrowser.Model/FileOrganization/FileOrganizationQuery.cs

@@ -15,4 +15,19 @@ namespace MediaBrowser.Model.FileOrganization
         /// <value>The limit.</value>
         public int? Limit { get; set; }
     }
+
+    public class EpisodeFileOrganizationRequest
+    {
+        public string ResultId { get; set; }
+        
+        public string SeriesId { get; set; }
+
+        public int SeasonNumber { get; set; }
+
+        public int EpisodeNumber { get; set; }
+
+        public int? EndingEpisodeNumber { get; set; }
+
+        public bool RememberCorrection { get; set; }
+    }
 }

+ 1 - 7
MediaBrowser.Providers/TV/SeriesPostScanTask.cs

@@ -39,12 +39,6 @@ namespace MediaBrowser.Providers.TV
 
         private async Task RunInternal(IProgress<double> progress, CancellationToken cancellationToken)
         {
-            if (!_config.Configuration.EnableInternetProviders)
-            {
-                progress.Report(100);
-                return;
-            }
-
             var seriesList = _libraryManager.RootFolder
                 .RecursiveChildren
                 .OfType<Series>()
@@ -288,7 +282,7 @@ namespace MediaBrowser.Providers.TV
             return hasChanges;
         }
 
-        private Series DetermineAppropriateSeries(IEnumerable<Series> series, int seasonNumber)
+        private Series DetermineAppropriateSeries(List<Series> series, int seasonNumber)
         {
             return series.FirstOrDefault(s => s.RecursiveChildren.OfType<Season>().Any(season => season.IndexNumber == seasonNumber)) ??
                     series.FirstOrDefault(s => s.RecursiveChildren.OfType<Season>().Any(season => season.IndexNumber == 1)) ??

+ 357 - 0
MediaBrowser.Server.Implementations/FileOrganization/EpisodeFileOrganizer.cs

@@ -0,0 +1,357 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using System.Globalization;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.Server.Implementations.FileOrganization
+{
+    public class EpisodeFileOrganizer
+    {
+        private readonly IDirectoryWatchers _directoryWatchers;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ILogger _logger;
+        private readonly IFileSystem _fileSystem;
+        private readonly IFileOrganizationService _organizationService;
+        private readonly IServerConfigurationManager _config;
+
+        private  readonly CultureInfo _usCulture = new CultureInfo("en-US");
+
+        public EpisodeFileOrganizer(IFileOrganizationService organizationService, IServerConfigurationManager config, IFileSystem fileSystem, ILogger logger, ILibraryManager libraryManager, IDirectoryWatchers directoryWatchers)
+        {
+            _organizationService = organizationService;
+            _config = config;
+            _fileSystem = fileSystem;
+            _logger = logger;
+            _libraryManager = libraryManager;
+            _directoryWatchers = directoryWatchers;
+        }
+
+        public async Task<FileOrganizationResult> OrganizeEpisodeFile(string path, TvFileOrganizationOptions options, bool overwriteExisting)
+        {
+            _logger.Info("Sorting file {0}", path);
+
+            var result = new FileOrganizationResult
+            {
+                Date = DateTime.UtcNow,
+                OriginalPath = path,
+                OriginalFileName = Path.GetFileName(path),
+                Type = FileOrganizerType.Episode
+            };
+
+            var seriesName = TVUtils.GetSeriesNameFromEpisodeFile(path);
+
+            if (!string.IsNullOrEmpty(seriesName))
+            {
+                var season = TVUtils.GetSeasonNumberFromEpisodeFile(path);
+
+                result.ExtractedSeasonNumber = season;
+
+                if (season.HasValue)
+                {
+                    // Passing in true will include a few extra regex's
+                    var episode = TVUtils.GetEpisodeNumberFromFile(path, true);
+
+                    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 = TVUtils.GetEndingEpisodeNumberFromFile(path);
+
+                        result.ExtractedEndingEpisodeNumber = endingEpisodeNumber;
+
+                        OrganizeEpisode(path, seriesName, season.Value, episode.Value, endingEpisodeNumber, options, overwriteExisting, result);
+                    }
+                    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);
+            }
+
+            await _organizationService.SaveResult(result, CancellationToken.None).ConfigureAwait(false);
+
+            return result;
+        }
+
+        public async Task<FileOrganizationResult> OrganizeWithCorrection(EpisodeFileOrganizationRequest request, TvFileOrganizationOptions options)
+        {
+            var result = _organizationService.GetResult(request.ResultId);
+
+            var series = (Series)_libraryManager.GetItemById(new Guid(request.SeriesId));
+
+            OrganizeEpisode(result.OriginalPath, series, request.SeasonNumber, request.EpisodeNumber, request.EndingEpisodeNumber, _config.Configuration.TvFileOrganizationOptions, true, result);
+
+            await _organizationService.SaveResult(result, CancellationToken.None).ConfigureAwait(false);
+
+            return result;
+        }
+
+        private void OrganizeEpisode(string sourcePath, string seriesName, int seasonNumber, int episodeNumber, int? endingEpiosdeNumber, TvFileOrganizationOptions options, bool overwriteExisting, FileOrganizationResult result)
+        {
+            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;
+            }
+
+            OrganizeEpisode(sourcePath, series, seasonNumber, episodeNumber, endingEpiosdeNumber, options, overwriteExisting, result);
+        }
+
+        private void OrganizeEpisode(string sourcePath, Series series, int seasonNumber, int episodeNumber, int? endingEpiosdeNumber, TvFileOrganizationOptions options, bool overwriteExisting, FileOrganizationResult result)
+        {
+            _logger.Info("Sorting file {0} into series {1}", sourcePath, series.Path);
+
+            // Proceed to sort the file
+            var newPath = GetNewPath(sourcePath, series, seasonNumber, episodeNumber, endingEpiosdeNumber, options);
+
+            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 existing = GetDuplicatePaths(result.TargetPath, series, seasonNumber, episodeNumber);
+
+            if (!overwriteExisting && existing.Count > 0)
+            {
+                result.Status = FileSortingStatus.SkippedExisting;
+                result.StatusMessage = string.Empty;
+                return;
+            }
+
+            PerformFileSorting(options, result);
+        }
+
+        private List<string> GetDuplicatePaths(string targetPath, Series series, int seasonNumber, int episodeNumber)
+        {
+            var list = new List<string>();
+
+            if (File.Exists(targetPath))
+            {
+                list.Add(targetPath);
+            }
+
+            return list;
+        }
+
+        private void PerformFileSorting(TvFileOrganizationOptions options, FileOrganizationResult result)
+        {
+            _directoryWatchers.TemporarilyIgnore(result.TargetPath);
+
+            Directory.CreateDirectory(Path.GetDirectoryName(result.TargetPath));
+
+            var copy = File.Exists(result.TargetPath);
+            
+            try
+            {
+                if (copy)
+                {
+                    File.Copy(result.OriginalPath, result.TargetPath, true);
+                }
+                else
+                {
+                    File.Move(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
+            {
+                _directoryWatchers.RemoveTempIgnore(result.TargetPath);
+            }
+
+            if (copy)
+            {
+                try
+                {
+                    File.Delete(result.OriginalPath);
+                }
+                catch (Exception ex)
+                {
+                    _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath);
+                }
+            }
+        }
+
+        private Series GetMatchingSeries(string seriesName, FileOrganizationResult result)
+        {
+            int? yearInName;
+            var nameWithoutYear = seriesName;
+            NameParser.ParseName(nameWithoutYear, out nameWithoutYear, out yearInName);
+
+            result.ExtractedName = nameWithoutYear;
+            result.ExtractedYear = yearInName;
+
+            return _libraryManager.RootFolder.RecursiveChildren
+                .OfType<Series>()
+                .Select(i => NameUtils.GetMatchScore(nameWithoutYear, yearInName, i))
+                .Where(i => i.Item2 > 0)
+                .OrderByDescending(i => i.Item2)
+                .Select(i => i.Item1)
+                .FirstOrDefault();
+        }
+
+        /// <summary>
+        /// Gets the new path.
+        /// </summary>
+        /// <param name="sourcePath">The source path.</param>
+        /// <param name="series">The series.</param>
+        /// <param name="seasonNumber">The season number.</param>
+        /// <param name="episodeNumber">The episode number.</param>
+        /// <param name="endingEpisodeNumber">The ending episode number.</param>
+        /// <param name="options">The options.</param>
+        /// <returns>System.String.</returns>
+        private string GetNewPath(string sourcePath, Series series, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, TvFileOrganizationOptions options)
+        {
+            // If season and episode numbers match
+            var currentEpisodes = series.RecursiveChildren.OfType<Episode>()
+                .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 = GetSeasonFolderPath(series, seasonNumber, options);
+
+            var episode = currentEpisodes.First();
+
+            var episodeFileName = GetEpisodeFileName(sourcePath, series.Name, seasonNumber, episodeNumber, endingEpisodeNumber, episode.Name, options);
+
+            newPath = Path.Combine(newPath, episodeFileName);
+
+            return newPath;
+        }
+
+        /// <summary>
+        /// Gets the season folder path.
+        /// </summary>
+        /// <param name="series">The series.</param>
+        /// <param name="seasonNumber">The season number.</param>
+        /// <param name="options">The options.</param>
+        /// <returns>System.String.</returns>
+        private string GetSeasonFolderPath(Series series, int seasonNumber, TvFileOrganizationOptions options)
+        {
+            // If there's already a season folder, use that
+            var season = series
+                .RecursiveChildren
+                .OfType<Season>()
+                .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));
+        }
+
+        private string GetEpisodeFileName(string sourcePath, string seriesName, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, string episodeTitle, TvFileOrganizationOptions options)
+        {
+            seriesName = _fileSystem.GetValidFilename(seriesName);
+            episodeTitle = _fileSystem.GetValidFilename(episodeTitle);
+
+            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", episodeTitle)
+                .Replace("%e.n", episodeTitle.Replace(" ", "."))
+                .Replace("%e_n", episodeTitle.Replace(" ", "_"));
+
+            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));
+            }
+
+            return result.Replace("%e", episodeNumber.ToString(_usCulture))
+                .Replace("%0e", episodeNumber.ToString("00", _usCulture))
+                .Replace("%00e", episodeNumber.ToString("000", _usCulture));
+        }
+    }
+}

+ 28 - 34
MediaBrowser.Server.Implementations/FileOrganization/FileOrganizationService.cs

@@ -1,5 +1,7 @@
 using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.IO;
 using MediaBrowser.Common.ScheduledTasks;
+using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.FileOrganization;
 using MediaBrowser.Controller.IO;
 using MediaBrowser.Controller.Library;
@@ -21,14 +23,18 @@ namespace MediaBrowser.Server.Implementations.FileOrganization
         private readonly ILogger _logger;
         private readonly IDirectoryWatchers _directoryWatchers;
         private readonly ILibraryManager _libraryManager;
+        private readonly IServerConfigurationManager _config;
+        private readonly IFileSystem _fileSystem;
 
-        public FileOrganizationService(ITaskManager taskManager, IFileOrganizationRepository repo, ILogger logger, IDirectoryWatchers directoryWatchers, ILibraryManager libraryManager)
+        public FileOrganizationService(ITaskManager taskManager, IFileOrganizationRepository repo, ILogger logger, IDirectoryWatchers directoryWatchers, ILibraryManager libraryManager, IServerConfigurationManager config, IFileSystem fileSystem)
         {
             _taskManager = taskManager;
             _repo = repo;
             _logger = logger;
             _directoryWatchers = directoryWatchers;
             _libraryManager = libraryManager;
+            _config = config;
+            _fileSystem = fileSystem;
         }
 
         public void BeginProcessNewFiles()
@@ -53,6 +59,11 @@ namespace MediaBrowser.Server.Implementations.FileOrganization
             return _repo.GetResults(query);
         }
 
+        public FileOrganizationResult GetResult(string id)
+        {
+            return _repo.GetResult(id);
+        }
+
         public Task DeleteOriginalFile(string resultId)
         {
             var result = _repo.GetResult(resultId);
@@ -79,44 +90,27 @@ namespace MediaBrowser.Server.Implementations.FileOrganization
                 throw new ArgumentException("No target path available.");
             }
 
-            _logger.Info("Moving {0} to {1}", result.OriginalPath, result.TargetPath);
+            var organizer = new EpisodeFileOrganizer(this, _config, _fileSystem, _logger, _libraryManager,
+                _directoryWatchers);
 
-            _directoryWatchers.TemporarilyIgnore(result.TargetPath);
-
-            var copy = File.Exists(result.TargetPath);
+            await organizer.OrganizeEpisodeFile(result.OriginalPath, _config.Configuration.TvFileOrganizationOptions, true)
+                    .ConfigureAwait(false);
 
-            try
-            {
-                if (copy)
-                {
-                    File.Copy(result.OriginalPath, result.TargetPath, true);
-                }
-                else
-                {
-                    File.Move(result.OriginalPath, result.TargetPath);
-                }
-            }
-            finally
-            {
-                _directoryWatchers.RemoveTempIgnore(result.TargetPath);
-            }
+            await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None)
+                    .ConfigureAwait(false);
+        }
 
-            if (copy)
-            {
-                try
-                {
-                    File.Delete(result.OriginalPath);
-                }
-                catch (Exception ex)
-                {
-                    _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath);
-                }
-            }
+        public Task ClearLog()
+        {
+            return _repo.DeleteAll();
+        }
 
-            result.Status = FileSortingStatus.Success;
-            result.StatusMessage = string.Empty;
+        public async Task PerformEpisodeOrganization(EpisodeFileOrganizationRequest request)
+        {
+            var organizer = new EpisodeFileOrganizer(this, _config, _fileSystem, _logger, _libraryManager,
+                _directoryWatchers);
 
-            await SaveResult(result, CancellationToken.None).ConfigureAwait(false);
+            await organizer.OrganizeWithCorrection(request, _config.Configuration.TvFileOrganizationOptions).ConfigureAwait(false);
 
             await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None)
                     .ConfigureAwait(false);

+ 92 - 0
MediaBrowser.Server.Implementations/FileOrganization/NameUtils.cs

@@ -0,0 +1,92 @@
+using MediaBrowser.Controller.Entities;
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+namespace MediaBrowser.Server.Implementations.FileOrganization
+{
+    public static class NameUtils
+    {
+        private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
+
+        internal static Tuple<T, int> GetMatchScore<T>(string sortedName, int? year, T series)
+            where T : BaseItem
+        {
+            var score = 0;
+
+            var seriesNameWithoutYear = series.Name;
+            if (series.ProductionYear.HasValue)
+            {
+                seriesNameWithoutYear = seriesNameWithoutYear.Replace(series.ProductionYear.Value.ToString(UsCulture), String.Empty);
+            }
+
+            if (IsNameMatch(sortedName, seriesNameWithoutYear))
+            {
+                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<T, int>(series, 0);
+                    }
+                }
+            }
+
+            return new Tuple<T, int>(series, score);
+        }
+
+
+        private static bool IsNameMatch(string name1, string name2)
+        {
+            name1 = GetComparableName(name1);
+            name2 = GetComparableName(name2);
+
+            return String.Equals(name1, name2, StringComparison.OrdinalIgnoreCase);
+        }
+
+        private static string GetComparableName(string name)
+        {
+            // TODO: Improve this - should ignore spaces, periods, underscores, most likely all symbols and 
+            // possibly remove sorting words like "the", "and", etc.
+
+            name = RemoveDiacritics(name);
+
+            name = " " + name.ToLower() + " ";
+
+            name = name.Replace(".", " ")
+            .Replace("_", " ")
+            .Replace("&", " ")
+            .Replace("!", " ")
+            .Replace("(", " ")
+            .Replace(")", " ")
+            .Replace(",", " ")
+            .Replace("-", " ")
+            .Replace(" a ", String.Empty)
+            .Replace(" the ", String.Empty)
+            .Replace(" ", String.Empty);
+
+            return name.Trim();
+        }
+
+        /// <summary>
+        /// Removes the diacritics.
+        /// </summary>
+        /// <param name="text">The text.</param>
+        /// <returns>System.String.</returns>
+        private static string RemoveDiacritics(string text)
+        {
+            return String.Concat(
+                text.Normalize(NormalizationForm.FormD)
+                .Where(ch => CharUnicodeInfo.GetUnicodeCategory(ch) !=
+                                              UnicodeCategory.NonSpacingMark)
+              ).Normalize(NormalizationForm.FormC);
+        }
+    }
+}

+ 11 - 10
MediaBrowser.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs

@@ -14,21 +14,21 @@ namespace MediaBrowser.Server.Implementations.FileOrganization
 {
     public class OrganizerScheduledTask : IScheduledTask, IConfigurableScheduledTask
     {
-        private readonly IServerConfigurationManager _config;
-        private readonly ILogger _logger;
+        private readonly IDirectoryWatchers _directoryWatchers;
         private readonly ILibraryManager _libraryManager;
+        private readonly ILogger _logger;
         private readonly IFileSystem _fileSystem;
-        private readonly IFileOrganizationService _iFileSortingRepository;
-        private readonly IDirectoryWatchers _directoryWatchers;
+        private readonly IServerConfigurationManager _config;
+        private readonly IFileOrganizationService _organizationService;
 
-        public OrganizerScheduledTask(IServerConfigurationManager config, ILogger logger, ILibraryManager libraryManager, IFileSystem fileSystem, IFileOrganizationService iFileSortingRepository, IDirectoryWatchers directoryWatchers)
+        public OrganizerScheduledTask(IDirectoryWatchers directoryWatchers, ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, IServerConfigurationManager config, IFileOrganizationService organizationService)
         {
-            _config = config;
-            _logger = logger;
+            _directoryWatchers = directoryWatchers;
             _libraryManager = libraryManager;
+            _logger = logger;
             _fileSystem = fileSystem;
-            _iFileSortingRepository = iFileSortingRepository;
-            _directoryWatchers = directoryWatchers;
+            _config = config;
+            _organizationService = organizationService;
         }
 
         public string Name
@@ -48,7 +48,8 @@ namespace MediaBrowser.Server.Implementations.FileOrganization
 
         public Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
         {
-            return new TvFileSorter(_libraryManager, _logger, _fileSystem, _iFileSortingRepository, _directoryWatchers).Sort(_config.Configuration.TvFileOrganizationOptions, cancellationToken, progress);
+            return new TvFolderOrganizer(_libraryManager, _logger, _fileSystem, _directoryWatchers, _organizationService, _config)
+                .Organize(_config.Configuration.TvFileOrganizationOptions, cancellationToken, progress);
         }
 
         public IEnumerable<ITaskTrigger> GetDefaultTriggers()

+ 0 - 563
MediaBrowser.Server.Implementations/FileOrganization/TvFileSorter.cs

@@ -1,563 +0,0 @@
-using MediaBrowser.Common.IO;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.FileOrganization;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.FileOrganization;
-using MediaBrowser.Model.Logging;
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Server.Implementations.FileOrganization
-{
-    public class TvFileSorter
-    {
-        private readonly IDirectoryWatchers _directoryWatchers;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILogger _logger;
-        private readonly IFileSystem _fileSystem;
-        private readonly IFileOrganizationService _iFileSortingRepository;
-
-        private static readonly CultureInfo UsCulture = new CultureInfo("en-US");
-
-        public TvFileSorter(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, IFileOrganizationService iFileSortingRepository, IDirectoryWatchers directoryWatchers)
-        {
-            _libraryManager = libraryManager;
-            _logger = logger;
-            _fileSystem = fileSystem;
-            _iFileSortingRepository = iFileSortingRepository;
-            _directoryWatchers = directoryWatchers;
-        }
-
-        public async Task Sort(TvFileOrganizationOptions options, CancellationToken cancellationToken, IProgress<double> 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);
-
-            var scanLibrary = false;
-
-            if (eligibleFiles.Count > 0)
-            {
-                var allSeries = _libraryManager.RootFolder
-                    .RecursiveChildren.OfType<Series>()
-                    .Where(i => i.LocationType == LocationType.FileSystem)
-                    .ToList();
-
-                var numComplete = 0;
-
-                foreach (var file in eligibleFiles)
-                {
-                    var result = await SortFile(file.FullName, options, allSeries).ConfigureAwait(false);
-
-                    if (result.Status == FileSortingStatus.Success)
-                    {
-                        scanLibrary = true;
-                    }
-
-                    numComplete++;
-                    double percent = numComplete;
-                    percent /= eligibleFiles.Count;
-
-                    progress.Report(10 + (89 * percent));
-                }
-            }
-
-            cancellationToken.ThrowIfCancellationRequested();
-            progress.Report(99);
-
-            foreach (var path in watchLocations)
-            {
-                if (options.LeftOverFileExtensionsToDelete.Length > 0)
-                {
-                    DeleteLeftOverFiles(path, options.LeftOverFileExtensionsToDelete);
-                }
-
-                if (options.DeleteEmptyFolders)
-                {
-                    DeleteEmptyFolders(path);
-                }
-            }
-
-            if (scanLibrary)
-            {
-                await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None)
-                        .ConfigureAwait(false);
-            }
-
-            progress.Report(100);
-        }
-
-        /// <summary>
-        /// Gets the eligible files.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>IEnumerable{FileInfo}.</returns>
-        private IEnumerable<FileInfo> 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<FileInfo>();
-            }
-        }
-
-        /// <summary>
-        /// Sorts the file.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="options">The options.</param>
-        /// <param name="allSeries">All series.</param>
-        private async Task<FileOrganizationResult> SortFile(string path, TvFileOrganizationOptions options, IEnumerable<Series> allSeries)
-        {
-            _logger.Info("Sorting file {0}", path);
-
-            var result = new FileOrganizationResult
-            {
-                Date = DateTime.UtcNow,
-                OriginalPath = path,
-                OriginalFileName = Path.GetFileName(path),
-                Type = FileOrganizerType.Episode
-            };
-
-            var seriesName = TVUtils.GetSeriesNameFromEpisodeFile(path);
-
-            if (!string.IsNullOrEmpty(seriesName))
-            {
-                var season = TVUtils.GetSeasonNumberFromEpisodeFile(path);
-
-                result.ExtractedSeasonNumber = season;
-                
-                if (season.HasValue)
-                {
-                    // Passing in true will include a few extra regex's
-                    var episode = TVUtils.GetEpisodeNumberFromFile(path, true);
-
-                    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 = TVUtils.GetEndingEpisodeNumberFromFile(path);
-
-                        result.ExtractedEndingEpisodeNumber = endingEpisodeNumber;
-                        
-                        SortFile(path, seriesName, season.Value, episode.Value, endingEpisodeNumber, options, allSeries, result);
-                    }
-                    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);
-            }
-
-            await LogResult(result).ConfigureAwait(false);
-
-            return result;
-        }
-
-        /// <summary>
-        /// Sorts the file.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="seriesName">Name of the series.</param>
-        /// <param name="seasonNumber">The season number.</param>
-        /// <param name="episodeNumber">The episode number.</param>
-        /// <param name="endingEpiosdeNumber">The ending epiosde number.</param>
-        /// <param name="options">The options.</param>
-        /// <param name="allSeries">All series.</param>
-        /// <param name="result">The result.</param>
-        private void SortFile(string path, string seriesName, int seasonNumber, int episodeNumber, int? endingEpiosdeNumber, TvFileOrganizationOptions options, IEnumerable<Series> allSeries, FileOrganizationResult result)
-        {
-            var series = GetMatchingSeries(seriesName, allSeries, 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;
-            }
-
-            _logger.Info("Sorting file {0} into series {1}", path, series.Path);
-
-            // Proceed to sort the file
-            var newPath = GetNewPath(path, series, seasonNumber, episodeNumber, endingEpiosdeNumber, 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.StatusMessage = msg;
-                _logger.Warn(msg);
-                return;
-            }
-
-            _logger.Info("Sorting file {0} to new path {1}", path, newPath);
-            result.TargetPath = newPath;
-
-            var targetExists = File.Exists(result.TargetPath);
-            if (!options.OverwriteExistingEpisodes && targetExists)
-            {
-                result.Status = FileSortingStatus.SkippedExisting;
-                return;
-            }
-
-            PerformFileSorting(options, result, targetExists);
-        }
-
-        /// <summary>
-        /// Performs the file sorting.
-        /// </summary>
-        /// <param name="options">The options.</param>
-        /// <param name="result">The result.</param>
-        /// <param name="copy">if set to <c>true</c> [copy].</param>
-        private void PerformFileSorting(TvFileOrganizationOptions options, FileOrganizationResult result, bool copy)
-        {
-            _directoryWatchers.TemporarilyIgnore(result.TargetPath);
-
-            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.StatusMessage = errorMsg;
-                _logger.ErrorException(errorMsg, ex);
-
-                return;
-            }
-            finally
-            {
-                _directoryWatchers.RemoveTempIgnore(result.TargetPath);
-            }
-
-            if (copy)
-            {
-                try
-                {
-                    File.Delete(result.OriginalPath);
-                }
-                catch (Exception ex)
-                {
-                    _logger.ErrorException("Error deleting {0}", ex, result.OriginalPath);
-                }
-            }
-        }
-
-        /// <summary>
-        /// Logs the result.
-        /// </summary>
-        /// <param name="result">The result.</param>
-        /// <returns>Task.</returns>
-        private Task LogResult(FileOrganizationResult result)
-        {
-            return _iFileSortingRepository.SaveResult(result, CancellationToken.None);
-        }
-
-        /// <summary>
-        /// Gets the new path.
-        /// </summary>
-        /// <param name="sourcePath">The source path.</param>
-        /// <param name="series">The series.</param>
-        /// <param name="seasonNumber">The season number.</param>
-        /// <param name="episodeNumber">The episode number.</param>
-        /// <param name="endingEpisodeNumber">The ending episode number.</param>
-        /// <param name="options">The options.</param>
-        /// <returns>System.String.</returns>
-        private string GetNewPath(string sourcePath, Series series, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, TvFileOrganizationOptions options)
-        {
-            // If season and episode numbers match
-            var currentEpisodes = series.RecursiveChildren.OfType<Episode>()
-                .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 = GetSeasonFolderPath(series, seasonNumber, options);
-
-            var episode = currentEpisodes.First();
-
-            var episodeFileName = GetEpisodeFileName(sourcePath, series.Name, seasonNumber, episodeNumber, endingEpisodeNumber, episode.Name, options);
-
-            newPath = Path.Combine(newPath, episodeFileName);
-
-            return newPath;
-        }
-
-        private string GetEpisodeFileName(string sourcePath, string seriesName, int seasonNumber, int episodeNumber, int? endingEpisodeNumber, string episodeTitle, TvFileOrganizationOptions options)
-        {
-            seriesName = _fileSystem.GetValidFilename(seriesName);
-            episodeTitle = _fileSystem.GetValidFilename(episodeTitle);
-
-            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", episodeTitle)
-                .Replace("%e.n", episodeTitle.Replace(" ", "."))
-                .Replace("%e_n", episodeTitle.Replace(" ", "_"));
-
-            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));
-            }
-
-            return result.Replace("%e", episodeNumber.ToString(UsCulture))
-                .Replace("%0e", episodeNumber.ToString("00", UsCulture))
-                .Replace("%00e", episodeNumber.ToString("000", UsCulture));
-        }
-
-        /// <summary>
-        /// Gets the season folder path.
-        /// </summary>
-        /// <param name="series">The series.</param>
-        /// <param name="seasonNumber">The season number.</param>
-        /// <param name="options">The options.</param>
-        /// <returns>System.String.</returns>
-        private string GetSeasonFolderPath(Series series, int seasonNumber, TvFileOrganizationOptions options)
-        {
-            // If there's already a season folder, use that
-            var season = series
-                .RecursiveChildren
-                .OfType<Season>()
-                .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));
-        }
-
-        /// <summary>
-        /// Gets the matching series.
-        /// </summary>
-        /// <param name="seriesName">Name of the series.</param>
-        /// <param name="allSeries">All series.</param>
-        /// <returns>Series.</returns>
-        private Series GetMatchingSeries(string seriesName, IEnumerable<Series> allSeries, FileOrganizationResult result)
-        {
-            int? yearInName;
-            var nameWithoutYear = seriesName;
-            NameParser.ParseName(nameWithoutYear, out nameWithoutYear, out yearInName);
-
-            result.ExtractedName = nameWithoutYear;
-            result.ExtractedYear = 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<Series, int> GetMatchScore(string sortedName, int? year, Series series)
-        {
-            var score = 0;
-
-            if (IsNameMatch(sortedName, series.Name))
-            {
-                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, int>(series, 0);
-                    }
-                }
-            }
-
-            return new Tuple<Series, int>(series, score);
-        }
-
-        private bool IsNameMatch(string name1, string name2)
-        {
-            name1 = GetComparableName(name1);
-            name2 = GetComparableName(name2);
-
-            return string.Equals(name1, name2, StringComparison.OrdinalIgnoreCase);
-        }
-
-        private string GetComparableName(string name)
-        {
-            // TODO: Improve this - should ignore spaces, periods, underscores, most likely all symbols and 
-            // possibly remove sorting words like "the", "and", etc.
-
-            name = RemoveDiacritics(name);
-
-            name = " " + name.ToLower() + " ";
-
-            name = name.Replace(".", " ")
-            .Replace("_", " ")
-            .Replace("&", " ")
-            .Replace("!", " ")
-            .Replace(",", " ")
-            .Replace("-", " ")
-            .Replace(" a ", string.Empty)
-            .Replace(" the ", string.Empty)
-            .Replace(" ", string.Empty);
-
-            return name.Trim();
-        }
-
-        /// <summary>
-        /// Removes the diacritics.
-        /// </summary>
-        /// <param name="text">The text.</param>
-        /// <returns>System.String.</returns>
-        private string RemoveDiacritics(string text)
-        {
-            return string.Concat(
-                text.Normalize(NormalizationForm.FormD)
-                .Where(ch => CharUnicodeInfo.GetUnicodeCategory(ch) !=
-                                              UnicodeCategory.NonSpacingMark)
-              ).Normalize(NormalizationForm.FormC);
-        }
-
-        /// <summary>
-        /// Deletes the left over files.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="extensions">The extensions.</param>
-        private void DeleteLeftOverFiles(string path, IEnumerable<string> 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);
-                }
-            }
-        }
-
-        /// <summary>
-        /// Deletes the empty folders.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        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) { }
-        }
-    }
-}

+ 176 - 0
MediaBrowser.Server.Implementations/FileOrganization/TvFolderOrganizer.cs

@@ -0,0 +1,176 @@
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.FileOrganization;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.FileOrganization;
+using MediaBrowser.Model.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.Server.Implementations.FileOrganization
+{
+    public class TvFolderOrganizer
+    {
+        private readonly IDirectoryWatchers _directoryWatchers;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ILogger _logger;
+        private readonly IFileSystem _fileSystem;
+        private readonly IFileOrganizationService _organizationService;
+        private readonly IServerConfigurationManager _config;
+
+        public TvFolderOrganizer(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, IDirectoryWatchers directoryWatchers, IFileOrganizationService organizationService, IServerConfigurationManager config)
+        {
+            _libraryManager = libraryManager;
+            _logger = logger;
+            _fileSystem = fileSystem;
+            _directoryWatchers = directoryWatchers;
+            _organizationService = organizationService;
+            _config = config;
+        }
+
+        public async Task Organize(TvFileOrganizationOptions options, CancellationToken cancellationToken, IProgress<double> progress)
+        {
+            var minFileBytes = options.MinFileSizeMb * 1024 * 1024;
+
+            var watchLocations = options.WatchLocations.ToList();
+
+            var eligibleFiles = watchLocations.SelectMany(GetFilesToOrganize)
+                .OrderBy(_fileSystem.GetCreationTimeUtc)
+                .Where(i => EntityResolutionHelper.IsVideoFile(i.FullName) && i.Length >= minFileBytes)
+                .ToList();
+
+            progress.Report(10);
+
+            var scanLibrary = false;
+
+            if (eligibleFiles.Count > 0)
+            {
+                var numComplete = 0;
+
+                foreach (var file in eligibleFiles)
+                {
+                    var organizer = new EpisodeFileOrganizer(_organizationService, _config, _fileSystem, _logger, _libraryManager,
+                        _directoryWatchers);
+
+                    var result = await organizer.OrganizeEpisodeFile(file.FullName, options, false).ConfigureAwait(false);
+
+                    if (result.Status == FileSortingStatus.Success)
+                    {
+                        scanLibrary = true;
+                    }
+
+                    numComplete++;
+                    double percent = numComplete;
+                    percent /= eligibleFiles.Count;
+
+                    progress.Report(10 + (89 * percent));
+                }
+            }
+
+            cancellationToken.ThrowIfCancellationRequested();
+            progress.Report(99);
+
+            foreach (var path in watchLocations)
+            {
+                if (options.LeftOverFileExtensionsToDelete.Length > 0)
+                {
+                    DeleteLeftOverFiles(path, options.LeftOverFileExtensionsToDelete);
+                }
+
+                if (options.DeleteEmptyFolders)
+                {
+                    DeleteEmptyFolders(path);
+                }
+            }
+
+            if (scanLibrary)
+            {
+                await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None)
+                        .ConfigureAwait(false);
+            }
+
+            progress.Report(100);
+        }
+
+        /// <summary>
+        /// Gets the files to organize.
+        /// </summary>
+        /// <param name="path">The path.</param>
+        /// <returns>IEnumerable{FileInfo}.</returns>
+        private IEnumerable<FileInfo> GetFilesToOrganize(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<FileInfo>();
+            }
+        }
+
+        /// <summary>
+        /// Deletes the left over files.
+        /// </summary>
+        /// <param name="path">The path.</param>
+        /// <param name="extensions">The extensions.</param>
+        private void DeleteLeftOverFiles(string path, IEnumerable<string> 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);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Deletes the empty folders.
+        /// </summary>
+        /// <param name="path">The path.</param>
+        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) { }
+        }
+    }
+}

+ 8 - 5
MediaBrowser.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs

@@ -19,20 +19,23 @@ namespace MediaBrowser.Server.Implementations.Library.Resolvers.TV
         protected override Episode Resolve(ItemResolveArgs args)
         {
             var parent = args.Parent;
+
+            if (parent == null)
+            {
+                return null;
+            }
+
             var season = parent as Season;
 
             // Just in case the user decided to nest episodes. 
             // Not officially supported but in some cases we can handle it.
             if (season == null)
             {
-                if (parent != null)
-                {
-                    season = parent.Parents.OfType<Season>().FirstOrDefault();
-                }
+                season = parent.Parents.OfType<Season>().FirstOrDefault();
             }
 
             // If the parent is a Season or Series, then this is an Episode if the VideoResolver returns something
-            if (season != null || args.Parent is Series)
+            if (season != null || parent.Parents.OfType<Series>().Any())
             {
                 Episode episode = null;
 

+ 3 - 1
MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj

@@ -117,8 +117,10 @@
     <Compile Include="EntryPoints\Notifications\RemoteNotifications.cs" />
     <Compile Include="EntryPoints\Notifications\WebSocketNotifier.cs" />
     <Compile Include="EntryPoints\RefreshUsersMetadata.cs" />
+    <Compile Include="FileOrganization\EpisodeFileOrganizer.cs" />
     <Compile Include="FileOrganization\FileOrganizationService.cs" />
-    <Compile Include="FileOrganization\TvFileSorter.cs" />
+    <Compile Include="FileOrganization\NameUtils.cs" />
+    <Compile Include="FileOrganization\TvFolderOrganizer.cs" />
     <Compile Include="EntryPoints\UdpServerEntryPoint.cs" />
     <Compile Include="EntryPoints\ServerEventNotifier.cs" />
     <Compile Include="EntryPoints\UserDataChangeNotifier.cs" />

+ 1 - 1
MediaBrowser.Server.Implementations/MediaEncoder/MediaEncoder.cs

@@ -956,7 +956,7 @@ namespace MediaBrowser.Server.Implementations.MediaEncoder
         /// <param name="process">The process.</param>
         /// <param name="timeout">The timeout.</param>
         /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns>
-        private bool StartAndWaitForProcess(Process process, int timeout = 10000)
+        private bool StartAndWaitForProcess(Process process, int timeout = 12000)
         {
             process.Start();
 

+ 52 - 1
MediaBrowser.Server.Implementations/Persistence/SqliteFileOrganizationRepository.cs

@@ -27,6 +27,7 @@ namespace MediaBrowser.Server.Implementations.Persistence
 
         private IDbCommand _saveResultCommand;
         private IDbCommand _deleteResultCommand;
+        private IDbCommand _deleteAllCommand;
 
         public SqliteFileOrganizationRepository(ILogManager logManager, IServerApplicationPaths appPaths)
         {
@@ -85,6 +86,9 @@ namespace MediaBrowser.Server.Implementations.Persistence
             _deleteResultCommand.CommandText = "delete from organizationresults where ResultId = @ResultId";
 
             _deleteResultCommand.Parameters.Add(_saveResultCommand, "@ResultId");
+
+            _deleteAllCommand = _connection.CreateCommand();
+            _deleteAllCommand.CommandText = "delete from organizationresults";
         }
 
         public async Task SaveResult(FileOrganizationResult result, CancellationToken cancellationToken)
@@ -188,7 +192,7 @@ namespace MediaBrowser.Server.Implementations.Persistence
             }
             catch (Exception e)
             {
-                _logger.ErrorException("Failed to save FileOrganizationResult:", e);
+                _logger.ErrorException("Failed to delete FileOrganizationResult:", e);
 
                 if (transaction != null)
                 {
@@ -208,6 +212,53 @@ namespace MediaBrowser.Server.Implementations.Persistence
             }
         }
 
+        public async Task DeleteAll()
+        {
+            await _writeLock.WaitAsync().ConfigureAwait(false);
+
+            IDbTransaction transaction = null;
+
+            try
+            {
+                transaction = _connection.BeginTransaction();
+                
+                _deleteAllCommand.Transaction = transaction;
+
+                _deleteAllCommand.ExecuteNonQuery();
+
+                transaction.Commit();
+            }
+            catch (OperationCanceledException)
+            {
+                if (transaction != null)
+                {
+                    transaction.Rollback();
+                }
+
+                throw;
+            }
+            catch (Exception e)
+            {
+                _logger.ErrorException("Failed to delete results", e);
+
+                if (transaction != null)
+                {
+                    transaction.Rollback();
+                }
+
+                throw;
+            }
+            finally
+            {
+                if (transaction != null)
+                {
+                    transaction.Dispose();
+                }
+
+                _writeLock.Release();
+            }
+        }
+        
         public QueryResult<FileOrganizationResult> GetResults(FileOrganizationResultQuery query)
         {
             if (query == null)

+ 1 - 1
MediaBrowser.ServerApplication/ApplicationHost.cs

@@ -294,7 +294,7 @@ namespace MediaBrowser.ServerApplication
             var newsService = new Server.Implementations.News.NewsService(ApplicationPaths, JsonSerializer);
             RegisterSingleInstance<INewsService>(newsService);
 
-            var fileOrganizationService = new FileOrganizationService(TaskManager, FileOrganizationRepository, Logger, DirectoryWatchers, LibraryManager);
+            var fileOrganizationService = new FileOrganizationService(TaskManager, FileOrganizationRepository, Logger, DirectoryWatchers, LibraryManager, ServerConfigurationManager, FileSystemManager);
             RegisterSingleInstance<IFileOrganizationService>(fileOrganizationService);
 
             progress.Report(15);

+ 5 - 4
MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj

@@ -134,12 +134,13 @@
     <Reference Include="ServiceStack.Interfaces">
       <HintPath>..\ThirdParty\ServiceStack\ServiceStack.Interfaces.dll</HintPath>
     </Reference>
-    <Reference Include="SimpleInjector, Version=2.4.0.0, Culture=neutral, PublicKeyToken=984cb50dea722e99, processorArchitecture=MSIL">
+    <Reference Include="SimpleInjector, Version=2.4.1.0, Culture=neutral, PublicKeyToken=984cb50dea722e99, processorArchitecture=MSIL">
       <SpecificVersion>False</SpecificVersion>
-      <HintPath>..\packages\SimpleInjector.2.4.0\lib\net45\SimpleInjector.dll</HintPath>
+      <HintPath>..\packages\SimpleInjector.2.4.1\lib\net45\SimpleInjector.dll</HintPath>
     </Reference>
-    <Reference Include="SimpleInjector.Diagnostics">
-      <HintPath>..\packages\SimpleInjector.2.4.0\lib\net45\SimpleInjector.Diagnostics.dll</HintPath>
+    <Reference Include="SimpleInjector.Diagnostics, Version=2.4.1.0, Culture=neutral, PublicKeyToken=984cb50dea722e99, processorArchitecture=MSIL">
+      <SpecificVersion>False</SpecificVersion>
+      <HintPath>..\packages\SimpleInjector.2.4.1\lib\net45\SimpleInjector.Diagnostics.dll</HintPath>
     </Reference>
     <Reference Include="System" />
     <Reference Include="System.Configuration.Install" />

+ 1 - 1
MediaBrowser.ServerApplication/packages.config

@@ -3,5 +3,5 @@
   <package id="Hardcodet.Wpf.TaskbarNotification" version="1.0.4.0" targetFramework="net45" />
   <package id="MediaBrowser.IsoMounting" version="3.0.65" targetFramework="net45" />
   <package id="NLog" version="2.1.0" targetFramework="net45" />
-  <package id="SimpleInjector" version="2.4.0" targetFramework="net45" />
+  <package id="SimpleInjector" version="2.4.1" targetFramework="net45" />
 </packages>

+ 28 - 1
MediaBrowser.WebDashboard/ApiClient.js

@@ -687,6 +687,16 @@ MediaBrowser.ApiClient = function ($, navigator, JSON, WebSocket, setTimeout, wi
             });
         };
 
+        self.clearOrganizationLog = function () {
+
+            var url = self.getUrl("Library/FileOrganizations");
+
+            return self.ajax({
+                type: "DELETE",
+                url: url
+            });
+        };
+
         self.performOrganization = function (id) {
 
             var url = self.getUrl("Library/FileOrganizations/" + id + "/Organize");
@@ -697,6 +707,16 @@ MediaBrowser.ApiClient = function ($, navigator, JSON, WebSocket, setTimeout, wi
             });
         };
 
+        self.performEpisodeOrganization = function (id, options) {
+
+            var url = self.getUrl("Library/FileOrganizations/" + id + "/Episode/Organize", options || {});
+
+            return self.ajax({
+                type: "POST",
+                url: url
+            });
+        };
+
         self.getLiveTvSeriesTimer = function (id) {
 
             if (!id) {
@@ -2984,7 +3004,14 @@ MediaBrowser.ApiClient = function ($, navigator, JSON, WebSocket, setTimeout, wi
                 throw new Error("null userId");
             }
 
-            var url = self.getUrl("Users/" + userId + "/Items", options);
+            var url;
+
+            if ((typeof userId).toString().toLowerCase() == 'string') {
+                url = self.getUrl("Users/" + userId + "/Items", options);
+            } else {
+                options = userId;
+                url = self.getUrl("Items", options || {});
+            }
 
             return self.ajax({
                 type: "GET",

+ 1 - 1
MediaBrowser.WebDashboard/packages.config

@@ -1,4 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <packages>
-  <package id="MediaBrowser.ApiClient.Javascript" version="3.0.240" targetFramework="net45" />
+  <package id="MediaBrowser.ApiClient.Javascript" version="3.0.243" targetFramework="net45" />
 </packages>