diff --git a/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index d23106266..8f3f9b0a6 100644 --- a/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -313,6 +313,13 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks { var trigger = (ITaskTrigger)sender; + var configurableTask = ScheduledTask as IConfigurableScheduledTask; + + if (configurableTask != null && !configurableTask.IsEnabled) + { + return; + } + Logger.Info("{0} fired for task: {1}", trigger.GetType().Name, Name); trigger.Stop(); diff --git a/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs index d02984bad..a5b8de554 100644 --- a/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -169,5 +169,10 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks.Tasks { get { return true; } } + + public bool IsEnabled + { + get { return true; } + } } } diff --git a/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs b/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs index e5cb7aa10..e5c5638c0 100644 --- a/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs +++ b/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs @@ -124,5 +124,10 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks.Tasks { get { return true; } } + + public bool IsEnabled + { + get { return true; } + } } } diff --git a/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/ReloadLoggerTask.cs b/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/ReloadLoggerTask.cs index 9a65046bf..78f60632f 100644 --- a/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/ReloadLoggerTask.cs +++ b/MediaBrowser.Common.Implementations/ScheduledTasks/Tasks/ReloadLoggerTask.cs @@ -96,5 +96,10 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks.Tasks { get { return true; } } + + public bool IsEnabled + { + get { return true; } + } } } diff --git a/MediaBrowser.Common/ScheduledTasks/IScheduledTask.cs b/MediaBrowser.Common/ScheduledTasks/IScheduledTask.cs index 2ee4fb4b5..b38e6357e 100644 --- a/MediaBrowser.Common/ScheduledTasks/IScheduledTask.cs +++ b/MediaBrowser.Common/ScheduledTasks/IScheduledTask.cs @@ -45,6 +45,15 @@ namespace MediaBrowser.Common.ScheduledTasks public interface IConfigurableScheduledTask { + /// + /// Gets a value indicating whether this instance is hidden. + /// + /// true if this instance is hidden; otherwise, false. bool IsHidden { get; } + /// + /// Gets a value indicating whether this instance is enabled. + /// + /// true if this instance is enabled; otherwise, false. + bool IsEnabled { get; } } } diff --git a/MediaBrowser.Controller/Library/TVUtils.cs b/MediaBrowser.Controller/Library/TVUtils.cs index 67d78fa89..54ebf8914 100644 --- a/MediaBrowser.Controller/Library/TVUtils.cs +++ b/MediaBrowser.Controller/Library/TVUtils.cs @@ -331,6 +331,30 @@ namespace MediaBrowser.Controller.Library return null; } + public static string GetSeriesNameFromEpisodeFile(string fullPath) + { + var fl = fullPath.ToLower(); + foreach (var r in EpisodeExpressions) + { + var m = r.Match(fl); + if (m.Success) + { + var g = m.Groups["seriesname"]; + if (g != null) + { + var val = g.Value; + + if (!string.IsNullOrWhiteSpace(val)) + { + return val; + } + } + return null; + } + } + return null; + } + /// /// Gets the air days. /// diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index ad65f79b6..b5afab3e5 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -224,7 +224,7 @@ namespace MediaBrowser.Model.Configuration public bool EnableAutomaticRestart { get; set; } - + public FileSortingOptions FileSortingOptions { get; set; } public LiveTvOptions LiveTvOptions { get; set; } /// @@ -288,10 +288,12 @@ namespace MediaBrowser.Model.Configuration BookOptions = new MetadataOptions { - MaxBackdrops = 1 + MaxBackdrops = 1 }; LiveTvOptions = new LiveTvOptions(); + + FileSortingOptions = new FileSortingOptions(); } } @@ -313,4 +315,35 @@ namespace MediaBrowser.Model.Configuration { public int? GuideDays { get; set; } } + + public class FileSortingOptions + { + public bool IsEnabled { get; set; } + public int MinFileSizeMb { get; set; } + public string[] LeftOverFileExtensionsToDelete { get; set; } + public string[] TvWatchLocations { get; set; } + + public string SeasonFolderPattern { get; set; } + + public string SeasonZeroFolderName { get; set; } + + public bool OverwriteExistingEpisodes { get; set; } + + public bool DeleteEmptyFolders { get; set; } + + public FileSortingOptions() + { + MinFileSizeMb = 50; + + LeftOverFileExtensionsToDelete = new[] { + ".nfo", + ".txt" + }; + + TvWatchLocations = new string[] { }; + + SeasonFolderPattern = "Season %s"; + SeasonZeroFolderName = "Season 0"; + } + } } diff --git a/MediaBrowser.Server.Implementations/FileSorting/SortingScheduledTask.cs b/MediaBrowser.Server.Implementations/FileSorting/SortingScheduledTask.cs new file mode 100644 index 000000000..201e282c0 --- /dev/null +++ b/MediaBrowser.Server.Implementations/FileSorting/SortingScheduledTask.cs @@ -0,0 +1,96 @@ +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.FileSorting +{ + public class SortingScheduledTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + + public SortingScheduledTask(IServerConfigurationManager config, ILogger logger, ILibraryManager libraryManager) + { + _config = config; + _logger = logger; + _libraryManager = libraryManager; + } + + public string Name + { + get { return "Sort new files"; } + } + + public string Description + { + get { return "Processes new files available in the configured sorting location."; } + } + + public string Category + { + get { return "Library"; } + } + + public Task Execute(CancellationToken cancellationToken, IProgress progress) + { + return Task.Run(() => SortFiles(cancellationToken, progress), cancellationToken); + } + + private void SortFiles(CancellationToken cancellationToken, IProgress progress) + { + var numComplete = 0; + + var paths = _config.Configuration.FileSortingOptions.TvWatchLocations.ToList(); + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + SortFiles(path); + } + catch (Exception ex) + { + _logger.ErrorException("Error sorting files from {0}", ex, path); + } + + numComplete++; + double percent = numComplete; + percent /= paths.Count; + + progress.Report(100 * percent); + } + } + + private void SortFiles(string path) + { + new TvFileSorter(_libraryManager, _logger).Sort(path, _config.Configuration.FileSortingOptions); + } + + public IEnumerable GetDefaultTriggers() + { + return new ITaskTrigger[] + { + new IntervalTrigger{ Interval = TimeSpan.FromMinutes(5)} + }; + } + + public bool IsHidden + { + get { return !_config.Configuration.FileSortingOptions.IsEnabled; } + } + + public bool IsEnabled + { + get { return _config.Configuration.FileSortingOptions.IsEnabled; } + } + } +} diff --git a/MediaBrowser.Server.Implementations/FileSorting/TvFileSorter.cs b/MediaBrowser.Server.Implementations/FileSorting/TvFileSorter.cs new file mode 100644 index 000000000..e2a967ef3 --- /dev/null +++ b/MediaBrowser.Server.Implementations/FileSorting/TvFileSorter.cs @@ -0,0 +1,186 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Server.Implementations.FileSorting +{ + public class TvFileSorter + { + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + + public TvFileSorter(ILibraryManager libraryManager, ILogger logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + public void Sort(string path, FileSortingOptions options) + { + var minFileBytes = options.MinFileSizeMb * 1024 * 1024; + + var allSeries = _libraryManager.RootFolder + .RecursiveChildren.OfType() + .Where(i => i.LocationType == LocationType.FileSystem) + .ToList(); + + var eligibleFiles = new DirectoryInfo(path) + .EnumerateFiles("*", SearchOption.AllDirectories) + .Where(i => EntityResolutionHelper.IsVideoFile(i.FullName) && i.Length >= minFileBytes) + .ToList(); + + foreach (var file in eligibleFiles) + { + SortFile(file.FullName, options, allSeries); + } + + if (options.LeftOverFileExtensionsToDelete.Length > 0) + { + DeleteLeftOverFiles(path, options.LeftOverFileExtensionsToDelete); + } + + if (options.DeleteEmptyFolders) + { + DeleteEmptyFolders(path); + } + } + + private void SortFile(string path, FileSortingOptions options, IEnumerable allSeries) + { + _logger.Info("Sorting file {0}", 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); + } + else + { + _logger.Warn("Unable to determine episode number from {0}", path); + } + } + else + { + _logger.Warn("Unable to determine season number from {0}", path); + } + } + else + { + _logger.Warn("Unable to determine series name from {0}", path); + } + } + + private void SortFile(string path, string seriesName, int seasonNumber, int episodeNumber, FileSortingOptions options, IEnumerable allSeries) + { + var series = GetMatchingSeries(seriesName, allSeries); + + if (series == null) + { + _logger.Warn("Unable to find series in library matching name {0}", seriesName); + return; + } + + _logger.Info("Sorting file {0} into series {1}", path, series.Path); + + // Proceed to sort the file + } + + 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; + + if (year.HasValue) + { + if (series.ProductionYear.HasValue && year.Value == series.ProductionYear.Value) + { + score++; + } + } + + // TODO: Improve this + if (string.Equals(sortedName, series.Name, StringComparison.OrdinalIgnoreCase)) + { + score++; + } + + return new Tuple(series, score); + } + + 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); + } + } + } + + 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) { } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs index 1edd79d69..1f61d1a4f 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs @@ -54,5 +54,10 @@ namespace MediaBrowser.Server.Implementations.LiveTv { get { return _liveTvManager.ActiveService == null; } } + + public bool IsEnabled + { + get { return true; } + } } } diff --git a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj index 855183b97..9c1be678d 100644 --- a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj +++ b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj @@ -117,9 +117,11 @@ + + diff --git a/MediaBrowser.Server.Implementations/Udp/UdpServer.cs b/MediaBrowser.Server.Implementations/Udp/UdpServer.cs index 900299667..455bdeb9c 100644 --- a/MediaBrowser.Server.Implementations/Udp/UdpServer.cs +++ b/MediaBrowser.Server.Implementations/Udp/UdpServer.cs @@ -135,6 +135,10 @@ namespace MediaBrowser.Server.Implementations.Udp { break; } + catch (Exception ex) + { + _logger.ErrorException("Error in StartListening", ex); + } } }