From ad95fbfd4a81c97f29d41bd65577b0280b99b916 Mon Sep 17 00:00:00 2001 From: Leonardo Galli Date: Tue, 3 Jan 2017 20:24:55 +0100 Subject: [PATCH] Implemented importing movies. This is still in early stages, however it should work pretty well. --- package.sh | 6 +- src/NzbDrone.Console/ConsoleApp.cs | 2 +- .../Configuration/ConfigFileProvider.cs | 6 +- .../Migration/104_add_moviefiles_table.cs | 31 ++ .../Framework/MigrationController.cs | 1 - src/NzbDrone.Core/Datastore/TableMapping.cs | 20 +- .../Clients/Blackhole/UsenetBlackhole.cs | 2 +- .../Download/CompletedDownloadService.cs | 71 +++-- .../Download/DownloadClientBase.cs | 4 +- .../Download/TorrentClientBase.cs | 4 +- .../DownloadedMovieCommandService.cs | 265 ++++++++++++++++++ .../MediaFiles/EpisodeImport/DetectSample.cs | 57 ++++ .../IImportDecisionEngineSpecification.cs | 2 + .../EpisodeImport/ImportApprovedMovie.cs | 172 ++++++++++++ .../EpisodeImport/ImportDecision.cs | 16 ++ .../EpisodeImport/ImportDecisionMaker.cs | 101 +++++-- .../Specifications/FreeSpaceSpecification.cs | 43 +++ .../Specifications/FullSeasonSpecification.cs | 9 +- .../MatchesFolderSpecification.cs | 34 ++- .../Specifications/NotSampleSpecification.cs | 22 ++ .../NotUnpackingSpecification.cs | 35 +++ .../SameEpisodesImportSpecification.cs | 6 + .../UnverifiedSceneNumberingSpecification.cs | 8 +- .../Specifications/UpgradeSpecification.cs | 5 + .../MediaFiles/Events/MovieDownloadedEvent.cs | 20 ++ .../MediaFiles/Events/MovieFileAddedEvent.cs | 14 + .../Events/MovieFileDeletedEvent.cs | 16 ++ .../Events/MovieFolderCreatedEvent.cs | 20 ++ .../MediaFiles/Events/MovieImportedEvent.cs | 32 +++ .../MediaFiles/MediaFileService.cs | 37 ++- src/NzbDrone.Core/MediaFiles/MovieFile.cs | 29 ++ .../MediaFiles/MovieFileMoveResult.cs | 15 + .../MediaFiles/MovieFileMovingService.cs | 191 +++++++++++++ .../MediaFiles/MovieFileRepository.cs | 32 +++ .../MediaFiles/UpdateMovieFileService.cs | 147 ++++++++++ .../MediaFiles/UpgradeMediaFileService.cs | 38 +++ src/NzbDrone.Core/NzbDrone.Core.csproj | 14 + .../Organizer/FileNameBuilder.cs | 93 +++++- src/NzbDrone.Core/Parser/Model/LocalMovie.cs | 29 ++ src/NzbDrone.Core/Parser/ParsingService.cs | 42 +++ src/NzbDrone.Core/Tv/Movie.cs | 3 + src/NzbDrone.Core/Tv/MovieRepository.cs | 17 +- src/NzbDrone.Core/Tv/MovieService.cs | 25 +- 43 files changed, 1673 insertions(+), 63 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs create mode 100644 src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs create mode 100644 src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs create mode 100644 src/NzbDrone.Core/MediaFiles/MovieFile.cs create mode 100644 src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs create mode 100644 src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs create mode 100644 src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs create mode 100644 src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs create mode 100644 src/NzbDrone.Core/Parser/Model/LocalMovie.cs diff --git a/package.sh b/package.sh index 207006c97..65357f083 100644 --- a/package.sh +++ b/package.sh @@ -22,9 +22,9 @@ rm $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr chmod +x $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr2 mv $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr2 $outputFolderOsxApp/Sonarr.app/Contents/MacOS/Sonarr >& error.log -cp -r $outputFolder Radarr_Windows_$VERSION -cp -r $outputFolderMono Radarr_Mono_$VERSION -cp -r $outputFolderOsxApp Radarr_OSX_$VERSION +cp -r $outputFolder/ Radarr_Windows_$VERSION +cp -r $outputFolderMono/ Radarr_Mono_$VERSION +cp -r $outputFolderOsxApp/ Radarr_OSX_$VERSION zip -r Radarr_Windows_$VERSION.zip Radarr_Windows_$VERSION >& /dev/null zip -r Radarr_Mono_$VERSION.zip Radarr_Mono_$VERSION >& /dev/null diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index 8740b4298..83040c67c 100644 --- a/src/NzbDrone.Console/ConsoleApp.cs +++ b/src/NzbDrone.Console/ConsoleApp.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Console { System.Console.WriteLine(""); System.Console.WriteLine(""); - Logger.Fatal(exception.Message + ". This can happen if another instance of Sonarr is already running another application is using the same port (default: 7878) or the user has insufficient permissions"); + Logger.Fatal(exception.Message + ". This can happen if another instance of Radarr is already running another application is using the same port (default: 7878) or the user has insufficient permissions"); System.Console.WriteLine("Press enter to exit..."); System.Console.ReadLine(); Environment.Exit(1); diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index fc429c91b..2eeaf6463 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -303,12 +303,12 @@ namespace NzbDrone.Core.Configuration if (contents.IsNullOrWhiteSpace()) { - throw new InvalidConfigFileException($"{_configFile} is empty. Please delete the config file and Sonarr will recreate it."); + throw new InvalidConfigFileException($"{_configFile} is empty. Please delete the config file and Radarr will recreate it."); } if (contents.All(char.IsControl)) { - throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Sonarr will recreate it."); + throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Radarr will recreate it."); } return XDocument.Parse(_diskProvider.ReadAllText(_configFile)); @@ -323,7 +323,7 @@ namespace NzbDrone.Core.Configuration catch (XmlException ex) { - throw new InvalidConfigFileException($"{_configFile} is corrupt is invalid. Please delete the config file and Sonarr will recreate it.", ex); + throw new InvalidConfigFileException($"{_configFile} is corrupt is invalid. Please delete the config file and Radarr will recreate it.", ex); } } diff --git a/src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs b/src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs new file mode 100644 index 000000000..34a455683 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/104_add_moviefiles_table.cs @@ -0,0 +1,31 @@ +using FluentMigrator; +using Marr.Data.Mapping; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Datastore.Extensions; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(104)] + public class add_moviefiles_table : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("MovieFiles") + .WithColumn("MovieId").AsInt32() + .WithColumn("Path").AsString().Unique() + .WithColumn("Quality").AsString() + .WithColumn("Size").AsInt64() + .WithColumn("DateAdded").AsDateTime() + .WithColumn("SceneName").AsString().Nullable() + .WithColumn("MediaInfo").AsString().Nullable() + .WithColumn("ReleaseGroup").AsString().Nullable() + .WithColumn("RelativePath").AsString().Nullable(); + + Alter.Table("Movies").AddColumn("MovieFileId").AsInt32().WithDefaultValue(0); + + + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs index 793725e9f..310628715 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs @@ -60,7 +60,6 @@ namespace NzbDrone.Core.Datastore.Migration.Framework sw.Stop(); - _announcer.ElapsedTime(sw.Elapsed); } } diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 397703127..65a82948b 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -76,10 +76,7 @@ namespace NzbDrone.Core.Datastore .Relationship() .HasOne(s => s.Profile, s => s.ProfileId); - Mapper.Entity().RegisterModel("Movies") - .Ignore(s => s.RootFolderPath) - .Relationship() - .HasOne(s => s.Profile, s => s.ProfileId); + Mapper.Entity().RegisterModel("EpisodeFiles") .Ignore(f => f.Path) @@ -89,6 +86,21 @@ namespace NzbDrone.Core.Datastore query: (db, parent) => db.Query().Where(c => c.EpisodeFileId == parent.Id).ToList()) .HasOne(file => file.Series, file => file.SeriesId); + Mapper.Entity().RegisterModel("MovieFiles") + .Ignore(f => f.Path) + .Relationships.AutoMapICollectionOrComplexProperties() + .For("Movie") + .LazyLoad(condition: parent => parent.Id > 0, + query: (db, parent) => db.Query().Where(c => c.MovieFileId == parent.Id).ToList()) + .HasOne(file => file.Movie, file => file.MovieId); + + Mapper.Entity().RegisterModel("Movies") + .Ignore(s => s.RootFolderPath) + .Relationship() + .HasOne(s => s.Profile, s => s.ProfileId) + .HasOne(m => m.MovieFile, m => m.MovieFileId); + + Mapper.Entity().RegisterModel("Episodes") .Ignore(e => e.SeriesTitle) .Ignore(e => e.Series) diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs index 2cc13a235..8a82fe93a 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { DownloadClient = Definition.Name, DownloadId = Definition.Name + "_" + item.DownloadId, - Category = "sonarr", + Category = "Radarr", Title = item.Title, TotalSize = item.TotalSize, diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 024a41c8b..4e2bebe59 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -27,6 +27,7 @@ namespace NzbDrone.Core.Download private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; + private readonly IDownloadedMovieImportService _downloadedMovieImportService; private readonly IParsingService _parsingService; private readonly IMovieService _movieService; private readonly Logger _logger; @@ -36,6 +37,7 @@ namespace NzbDrone.Core.Download IEventAggregator eventAggregator, IHistoryService historyService, IDownloadedEpisodesImportService downloadedEpisodesImportService, + IDownloadedMovieImportService downloadedMovieImportService, IParsingService parsingService, ISeriesService seriesService, IMovieService movieService, @@ -45,6 +47,7 @@ namespace NzbDrone.Core.Download _eventAggregator = eventAggregator; _historyService = historyService; _downloadedEpisodesImportService = downloadedEpisodesImportService; + _downloadedMovieImportService = downloadedMovieImportService; _parsingService = parsingService; _movieService = movieService; _logger = logger; @@ -64,7 +67,7 @@ namespace NzbDrone.Core.Download if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) { - trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); + trackedDownload.Warn("Download wasn't grabbed by Radarr and not in a category, Skipping."); return; } @@ -126,29 +129,59 @@ namespace NzbDrone.Core.Download private void Import(TrackedDownload trackedDownload) { var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; - var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); - - if (importResults.Empty()) + if (trackedDownload.RemoteMovie.Movie != null) { - trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); - return; - } + var importResults = _downloadedMovieImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteMovie.Movie, trackedDownload.DownloadItem); - if (importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) - { - trackedDownload.State = TrackedDownloadStage.Imported; - _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); - return; - } + if (importResults.Empty()) + { + trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + return; + } + + if (importResults.Count(c => c.Result == ImportResultType.Imported) >= 1) + { + trackedDownload.State = TrackedDownloadStage.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + return; + } + + if (importResults.Any(c => c.Result != ImportResultType.Imported)) + { + var statusMessages = importResults + .Where(v => v.Result != ImportResultType.Imported) + .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) + .ToArray(); - if (importResults.Any(c => c.Result != ImportResultType.Imported)) + trackedDownload.Warn(statusMessages); + } + } + else { - var statusMessages = importResults - .Where(v => v.Result != ImportResultType.Imported) - .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) - .ToArray(); + var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); + + if (importResults.Empty()) + { + trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + return; + } - trackedDownload.Warn(statusMessages); + if (importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) + { + trackedDownload.State = TrackedDownloadStage.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + return; + } + + if (importResults.Any(c => c.Result != ImportResultType.Imported)) + { + var statusMessages = importResults + .Where(v => v.Result != ImportResultType.Imported) + .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) + .ToArray(); + + trackedDownload.Warn(statusMessages); + } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 0e48207ba..14f7f1b71 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -133,7 +133,7 @@ namespace NzbDrone.Core.Download { return new NzbDroneValidationFailure(propertyName, "Folder does not exist") { - DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName) + DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Radarr.", Environment.UserName) }; } @@ -142,7 +142,7 @@ namespace NzbDrone.Core.Download _logger.Error("Folder '{0}' is not writable.", folder); return new NzbDroneValidationFailure(propertyName, "Unable to write to folder") { - DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName) + DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Radarr.", Environment.UserName) }; } diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index ce3767c39..70681f992 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -371,7 +371,7 @@ namespace NzbDrone.Core.Download if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", + "{0} did not return the expected InfoHash for '{1}', Radarr could potentially lose track of the download in progress.", Definition.Implementation, remoteEpisode.Release.DownloadUrl); } @@ -402,7 +402,7 @@ namespace NzbDrone.Core.Download if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", + "{0} did not return the expected InfoHash for '{1}', Radarr could potentially lose track of the download in progress.", Definition.Implementation, remoteEpisode.Release.DownloadUrl); } diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs new file mode 100644 index 000000000..fcda97d24 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/DownloadedMovieCommandService.cs @@ -0,0 +1,265 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IDownloadedMovieImportService + { + List ProcessRootFolder(DirectoryInfo directoryInfo); + List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Movie movie = null, DownloadClientItem downloadClientItem = null); + bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Movie movie); + } + + public class DownloadedMovieImportService : IDownloadedMovieImportService + { + private readonly IDiskProvider _diskProvider; + private readonly IDiskScanService _diskScanService; + private readonly IMovieService _movieService; + private readonly IParsingService _parsingService; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly IImportApprovedMovie _importApprovedMovie; + private readonly IDetectSample _detectSample; + private readonly Logger _logger; + + public DownloadedMovieImportService(IDiskProvider diskProvider, + IDiskScanService diskScanService, + IMovieService movieService, + IParsingService parsingService, + IMakeImportDecision importDecisionMaker, + IImportApprovedMovie importApprovedMovie, + IDetectSample detectSample, + Logger logger) + { + _diskProvider = diskProvider; + _diskScanService = diskScanService; + _movieService = movieService; + _parsingService = parsingService; + _importDecisionMaker = importDecisionMaker; + _importApprovedMovie = importApprovedMovie; + _detectSample = detectSample; + _logger = logger; + } + + public List ProcessRootFolder(DirectoryInfo directoryInfo) + { + var results = new List(); + + foreach (var subFolder in _diskProvider.GetDirectories(directoryInfo.FullName)) + { + var folderResults = ProcessFolder(new DirectoryInfo(subFolder), ImportMode.Auto, null); + results.AddRange(folderResults); + } + + foreach (var videoFile in _diskScanService.GetVideoFiles(directoryInfo.FullName, false)) + { + var fileResults = ProcessFile(new FileInfo(videoFile), ImportMode.Auto, null); + results.AddRange(fileResults); + } + + return results; + } + + public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Movie movie = null, DownloadClientItem downloadClientItem = null) + { + if (_diskProvider.FolderExists(path)) + { + var directoryInfo = new DirectoryInfo(path); + + if (movie == null) + { + return ProcessFolder(directoryInfo, importMode, downloadClientItem); + } + + return ProcessFolder(directoryInfo, importMode, movie, downloadClientItem); + } + + if (_diskProvider.FileExists(path)) + { + var fileInfo = new FileInfo(path); + + if (movie == null) + { + return ProcessFile(fileInfo, importMode, downloadClientItem); + } + + return ProcessFile(fileInfo, importMode, movie, downloadClientItem); + } + + _logger.Error("Import failed, path does not exist or is not accessible by Sonarr: {0}", path); + return new List(); + } + + public bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Movie movie) + { + var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); + var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f) == ".rar"); + + foreach (var videoFile in videoFiles) + { + var episodeParseResult = Parser.Parser.ParseTitle(Path.GetFileName(videoFile)); + + if (episodeParseResult == null) + { + _logger.Warn("Unable to parse file on import: [{0}]", videoFile); + return false; + } + + var size = _diskProvider.GetFileSize(videoFile); + var quality = QualityParser.ParseQuality(videoFile); + + if (!_detectSample.IsSample(movie, quality, videoFile, size, episodeParseResult.IsPossibleSpecialEpisode)) + { + _logger.Warn("Non-sample file detected: [{0}]", videoFile); + return false; + } + } + + if (rarFiles.Any(f => _diskProvider.GetFileSize(f) > 10.Megabytes())) + { + _logger.Warn("RAR file detected, will require manual cleanup"); + return false; + } + + return true; + } + + private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, DownloadClientItem downloadClientItem) + { + var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); + var movie = _parsingService.GetMovie(cleanedUpName); + + if (movie == null) + { + _logger.Debug("Unknown Movie {0}", cleanedUpName); + + return new List + { + UnknownMovieResult("Unknown Movie") + }; + } + + return ProcessFolder(directoryInfo, importMode, movie, downloadClientItem); + } + + private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, Movie movie, DownloadClientItem downloadClientItem) + { + if (_movieService.MoviePathExists(directoryInfo.FullName)) + { + _logger.Warn("Unable to process folder that is mapped to an existing show"); + return new List(); + } + + var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); + var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); + + if (folderInfo != null) + { + _logger.Debug("{0} folder quality: {1}", cleanedUpName, folderInfo.Quality); + } + + var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); + + if (downloadClientItem == null) + { + foreach (var videoFile in videoFiles) + { + if (_diskProvider.IsFileLocked(videoFile)) + { + return new List + { + FileIsLockedResult(videoFile) + }; + } + } + } + + var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), movie, folderInfo, true); + var importResults = _importApprovedMovie.Import(decisions, true, downloadClientItem, importMode); + + if ((downloadClientItem == null || !downloadClientItem.IsReadOnly) && + importResults.Any(i => i.Result == ImportResultType.Imported) && + ShouldDeleteFolder(directoryInfo, movie)) + { + _logger.Debug("Deleting folder after importing valid files"); + _diskProvider.DeleteFolder(directoryInfo.FullName, true); + } + + return importResults; + } + + private List ProcessFile(FileInfo fileInfo, ImportMode importMode, DownloadClientItem downloadClientItem) + { + var movie = _parsingService.GetMovie(Path.GetFileNameWithoutExtension(fileInfo.Name)); + + if (movie == null) + { + _logger.Debug("Unknown Movie for file: {0}", fileInfo.Name); + + return new List + { + UnknownMovieResult(string.Format("Unknown Movie for file: {0}", fileInfo.Name), fileInfo.FullName) + }; + } + + return ProcessFile(fileInfo, importMode, movie, downloadClientItem); + } + + private List ProcessFile(FileInfo fileInfo, ImportMode importMode, Movie movie, DownloadClientItem downloadClientItem) + { + if (Path.GetFileNameWithoutExtension(fileInfo.Name).StartsWith("._")) + { + _logger.Debug("[{0}] starts with '._', skipping", fileInfo.FullName); + + return new List + { + new ImportResult(new ImportDecision(new LocalEpisode { Path = fileInfo.FullName }, new Rejection("Invalid video file, filename starts with '._'")), "Invalid video file, filename starts with '._'") + }; + } + + if (downloadClientItem == null) + { + if (_diskProvider.IsFileLocked(fileInfo.FullName)) + { + return new List + { + FileIsLockedResult(fileInfo.FullName) + }; + } + } + + var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, movie, null, true); + + return _importApprovedMovie.Import(decisions, true, downloadClientItem, importMode); + } + + private string GetCleanedUpFolderName(string folder) + { + folder = folder.Replace("_UNPACK_", "") + .Replace("_FAILED_", ""); + + return folder; + } + + private ImportResult FileIsLockedResult(string videoFile) + { + _logger.Debug("[{0}] is currently locked by another process, skipping", videoFile); + return new ImportResult(new ImportDecision(new LocalEpisode { Path = videoFile }, new Rejection("Locked file, try again later")), "Locked file, try again later"); + } + + private ImportResult UnknownMovieResult(string message, string videoFile = null) + { + var localEpisode = videoFile == null ? null : new LocalEpisode { Path = videoFile }; + + return new ImportResult(new ImportDecision(localEpisode, new Rejection("Unknown Movie")), message); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs index b517cd76c..27756cf4f 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public interface IDetectSample { bool IsSample(Series series, QualityModel quality, string path, long size, bool isSpecial); + bool IsSample(Movie movie, QualityModel quality, string path, long size, bool isSpecial); } public class DetectSample : IDetectSample @@ -79,6 +80,57 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return false; } + public bool IsSample(Movie movie, QualityModel quality, string path, long size, bool isSpecial) + { + if (isSpecial) + { + _logger.Debug("Special, skipping sample check"); + return false; + } + + var extension = Path.GetExtension(path); + + if (extension != null && extension.Equals(".flv", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Skipping sample check for .flv file"); + return false; + } + + if (extension != null && extension.Equals(".strm", StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Skipping sample check for .strm file"); + return false; + } + + try + { + var runTime = _videoFileInfoReader.GetRunTime(path); + var minimumRuntime = GetMinimumAllowedRuntime(movie); + + if (runTime.TotalMinutes.Equals(0)) + { + _logger.Error("[{0}] has a runtime of 0, is it a valid video file?", path); + return true; + } + + if (runTime.TotalSeconds < minimumRuntime) + { + _logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, runTime, minimumRuntime); + return true; + } + } + + catch (DllNotFoundException) + { + _logger.Debug("Falling back to file size detection"); + + return CheckSize(size, quality); + } + + _logger.Debug("Runtime is over 90 seconds"); + return false; + } + private bool CheckSize(long size, QualityModel quality) { if (_largeSampleSizeQualities.Contains(quality.Quality)) @@ -99,6 +151,11 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return false; } + private int GetMinimumAllowedRuntime(Movie movie) + { + return 120; //2 minutes + } + private int GetMinimumAllowedRuntime(Series series) { //Webisodes - 90 seconds diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs index 86abb87b7..4dc6bcaf6 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs @@ -6,5 +6,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public interface IImportDecisionEngineSpecification { Decision IsSatisfiedBy(LocalEpisode localEpisode); + + Decision IsSatisfiedBy(LocalMovie localMovie); } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs new file mode 100644 index 000000000..6d766a0e3 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedMovie.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Download; +using NzbDrone.Core.Extras; + + +namespace NzbDrone.Core.MediaFiles.EpisodeImport +{ + public interface IImportApprovedMovie + { + List Import(List decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); + } + + public class ImportApprovedMovie : IImportApprovedMovie + { + private readonly IUpgradeMediaFiles _episodeFileUpgrader; + private readonly IMediaFileService _mediaFileService; + private readonly IExtraService _extraService; + private readonly IDiskProvider _diskProvider; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public ImportApprovedMovie(IUpgradeMediaFiles episodeFileUpgrader, + IMediaFileService mediaFileService, + IExtraService extraService, + IDiskProvider diskProvider, + IEventAggregator eventAggregator, + Logger logger) + { + _episodeFileUpgrader = episodeFileUpgrader; + _mediaFileService = mediaFileService; + _extraService = extraService; + _diskProvider = diskProvider; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public List Import(List decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) + { + var qualifiedImports = decisions.Where(c => c.Approved) + .GroupBy(c => c.LocalMovie.Movie.Id, (i, s) => s + .OrderByDescending(c => c.LocalMovie.Quality, new QualityModelComparer(s.First().LocalMovie.Movie.Profile)) + .ThenByDescending(c => c.LocalMovie.Size)) + .SelectMany(c => c) + .ToList(); + + var importResults = new List(); + + foreach (var importDecision in qualifiedImports.OrderBy(e => e.LocalMovie.Size) + .ThenByDescending(e => e.LocalMovie.Size)) + { + var localMovie = importDecision.LocalMovie; + var oldFiles = new List(); + + try + { + //check if already imported + if (importResults.Select(r => r.ImportDecision.LocalMovie.Movie) + .Select(e => e.Id).Contains(localMovie.Movie.Id)) + { + importResults.Add(new ImportResult(importDecision, "Movie has already been imported")); + continue; + } + + var episodeFile = new MovieFile(); + episodeFile.DateAdded = DateTime.UtcNow; + episodeFile.MovieId = localMovie.Movie.Id; + episodeFile.Path = localMovie.Path.CleanFilePath(); + episodeFile.Size = _diskProvider.GetFileSize(localMovie.Path); + episodeFile.Quality = localMovie.Quality; + episodeFile.MediaInfo = localMovie.MediaInfo; + episodeFile.Movie = localMovie.Movie; + episodeFile.ReleaseGroup = localMovie.ParsedEpisodeInfo.ReleaseGroup; + + bool copyOnly; + switch (importMode) + { + default: + case ImportMode.Auto: + copyOnly = downloadClientItem != null && downloadClientItem.IsReadOnly; + break; + case ImportMode.Move: + copyOnly = false; + break; + case ImportMode.Copy: + copyOnly = true; + break; + } + + if (newDownload) + { + episodeFile.SceneName = GetSceneName(downloadClientItem, localMovie); + + var moveResult = _episodeFileUpgrader.UpgradeMovieFile(episodeFile, localMovie, copyOnly); + oldFiles = moveResult.OldFiles; + } + else + { + episodeFile.RelativePath = localMovie.Movie.Path.GetRelativePath(episodeFile.Path); + } + + _mediaFileService.Add(episodeFile); + importResults.Add(new ImportResult(importDecision)); + + if (newDownload) + { + //_extraService.ImportExtraFiles(localMovie, episodeFile, copyOnly); TODO update for movie + } + + if (downloadClientItem != null) + { + _eventAggregator.PublishEvent(new MovieImportedEvent(localMovie, episodeFile, newDownload, downloadClientItem.DownloadClient, downloadClientItem.DownloadId, downloadClientItem.IsReadOnly)); + } + else + { + _eventAggregator.PublishEvent(new MovieImportedEvent(localMovie, episodeFile, newDownload)); + } + + if (newDownload) + { + _eventAggregator.PublishEvent(new MovieDownloadedEvent(localMovie, episodeFile, oldFiles)); + } + } + catch (Exception e) + { + _logger.Warn(e, "Couldn't import episode " + localMovie); + importResults.Add(new ImportResult(importDecision, "Failed to import episode")); + } + } + + //Adding all the rejected decisions + importResults.AddRange(decisions.Where(c => !c.Approved) + .Select(d => new ImportResult(d, d.Rejections.Select(r => r.Reason).ToArray()))); + + return importResults; + } + + private string GetSceneName(DownloadClientItem downloadClientItem, LocalMovie localMovie) + { + if (downloadClientItem != null) + { + var title = Parser.Parser.RemoveFileExtension(downloadClientItem.Title); + + var parsedTitle = Parser.Parser.ParseTitle(title); + + if (parsedTitle != null && !parsedTitle.FullSeason) + { + return title; + } + } + + var fileName = Path.GetFileNameWithoutExtension(localMovie.Path.CleanFilePath()); + + if (SceneChecker.IsSceneTitle(fileName)) + { + return fileName; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs index 5e4e2ede2..8bb5e78ea 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport public class ImportDecision { public LocalEpisode LocalEpisode { get; private set; } + public LocalMovie LocalMovie { get; private set; } public IEnumerable Rejections { get; private set; } public bool Approved => Rejections.Empty(); @@ -18,5 +19,20 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport LocalEpisode = localEpisode; Rejections = rejections.ToList(); } + + public ImportDecision(LocalMovie localMovie, params Rejection[] rejections) + { + LocalMovie = localMovie; + Rejections = rejections.ToList(); + LocalEpisode = new LocalEpisode + { + Quality = localMovie.Quality, + ExistingFile = localMovie.ExistingFile, + MediaInfo = localMovie.MediaInfo, + ParsedEpisodeInfo = localMovie.ParsedEpisodeInfo, + Path = localMovie.Path, + Size = localMovie.Size + }; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 5594ebe97..52ffdfa07 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -98,36 +98,32 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport { ImportDecision decision = null; - /*try + try { - var localEpisode = _parsingService.GetLocalEpisode(file, movie, shouldUseFolderName ? folderInfo : null, sceneSource); + var localMovie = _parsingService.GetLocalMovie(file, movie, shouldUseFolderName ? folderInfo : null, sceneSource); - if (localEpisode != null) + if (localMovie != null) { - localEpisode.Quality = GetQuality(folderInfo, localEpisode.Quality, movie); - localEpisode.Size = _diskProvider.GetFileSize(file); + localMovie.Quality = GetQuality(folderInfo, localMovie.Quality, movie); + localMovie.Size = _diskProvider.GetFileSize(file); - _logger.Debug("Size: {0}", localEpisode.Size); + _logger.Debug("Size: {0}", localMovie.Size); //TODO: make it so media info doesn't ruin the import process of a new series if (sceneSource) { - localEpisode.MediaInfo = _videoFileInfoReader.GetMediaInfo(file); - } - - if (localEpisode.Episodes.Empty()) - { - decision = new ImportDecision(localEpisode, new Rejection("Invalid season or episode")); + localMovie.MediaInfo = _videoFileInfoReader.GetMediaInfo(file); + decision = GetDecision(localMovie); } else { - decision = GetDecision(localEpisode); + decision = GetDecision(localMovie); } } else { - localEpisode = new LocalEpisode(); + var localEpisode = new LocalEpisode(); localEpisode.Path = file; decision = new ImportDecision(localEpisode, new Rejection("Unable to parse file")); @@ -139,13 +135,23 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport var localEpisode = new LocalEpisode { Path = file }; decision = new ImportDecision(localEpisode, new Rejection("Unexpected error processing file")); - }*/ + } + + //LocalMovie nullMovie = null; - decision = new ImportDecision(null, new Rejection("IMPLEMENTATION MISSING!!!")); + //decision = new ImportDecision(nullMovie, new Rejection("IMPLEMENTATION MISSING!!!")); return decision; } + private ImportDecision GetDecision(LocalMovie localMovie) + { + var reasons = _specifications.Select(c => EvaluateSpec(c, localMovie)) + .Where(c => c != null); + + return new ImportDecision(localMovie, reasons.ToArray()); + } + private ImportDecision GetDecision(string file, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource, bool shouldUseFolderName) { ImportDecision decision = null; @@ -204,6 +210,33 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return new ImportDecision(localEpisode, reasons.ToArray()); } + private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalMovie localMovie) + { + try + { + var result = spec.IsSatisfiedBy(localMovie); + + if (!result.Accepted) + { + return new Rejection(result.Reason); + } + } + catch (NotImplementedException e) + { + _logger.Warn(e, "Spec " + spec.ToString() + " currently does not implement evaluation for movies."); + return null; + } + catch (Exception e) + { + //e.Data.Add("report", remoteEpisode.Report.ToJson()); + //e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson()); + _logger.Error(e, "Couldn't evaluate decision on " + localMovie.Path); + return new Rejection(string.Format("{0}: {1}", spec.GetType().Name, e.Message)); + } + + return null; + } + private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalEpisode localEpisode) { try @@ -292,6 +325,17 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport }) == 1; } + private QualityModel GetQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Movie movie) + { + if (UseFolderQuality(folderInfo, fileQuality, movie)) + { + _logger.Debug("Using quality from folder: {0}", folderInfo.Quality); + return folderInfo.Quality; + } + + return fileQuality; + } + private QualityModel GetQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) { if (UseFolderQuality(folderInfo, fileQuality, series)) @@ -303,6 +347,31 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return fileQuality; } + private bool UseFolderQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Movie movie) + { + if (folderInfo == null) + { + return false; + } + + if (folderInfo.Quality.Quality == Quality.Unknown) + { + return false; + } + + if (fileQuality.QualitySource == QualitySource.Extension) + { + return true; + } + + if (new QualityModelComparer(movie.Profile).Compare(folderInfo.Quality, fileQuality) > 0) + { + return true; + } + + return false; + } + private bool UseFolderQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) { if (folderInfo == null) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs index 158059e29..1e8432e84 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs @@ -63,5 +63,48 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + if (_configService.SkipFreeSpaceCheckWhenImporting) + { + _logger.Debug("Skipping free space check when importing"); + return Decision.Accept(); + } + + try + { + if (localMovie.ExistingFile) + { + _logger.Debug("Skipping free space check for existing episode"); + return Decision.Accept(); + } + + var path = Directory.GetParent(localMovie.Movie.Path); + var freeSpace = _diskProvider.GetAvailableSpace(path.FullName); + + if (!freeSpace.HasValue) + { + _logger.Debug("Free space check returned an invalid result for: {0}", path); + return Decision.Accept(); + } + + if (freeSpace < localMovie.Size + 100.Megabytes()) + { + _logger.Warn("Not enough free space ({0}) to import: {1} ({2})", freeSpace, localMovie, localMovie.Size); + return Decision.Reject("Not enough free space"); + } + } + catch (DirectoryNotFoundException ex) + { + _logger.Error("Unable to check free disk space while importing. " + ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to check free disk space while importing: " + localMovie.Path); + } + + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs index 853f6b1b7..2daeda6cb 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs @@ -17,11 +17,16 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications { if (localEpisode.ParsedEpisodeInfo.FullSeason) { - //_logger.Debug("Single episode file detected as containing all episodes in the season"); //Not needed for Movies mwhahahahah - //return Decision.Reject("Single episode file contains all episodes in seasons"); + _logger.Debug("Single episode file detected as containing all episodes in the season"); //Not needed for Movies mwhahahahah + return Decision.Reject("Single episode file contains all episodes in seasons"); } return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs index 79ef96f88..55a8da073 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; @@ -14,6 +15,37 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications { _logger = logger; } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + if (localMovie.ExistingFile) + { + return Decision.Accept(); + } + + var dirInfo = new FileInfo(localMovie.Path).Directory; + + if (dirInfo == null) + { + return Decision.Accept(); + } + + var folderInfo = Parser.Parser.ParseTitle(dirInfo.Name); + + if (folderInfo == null) + { + return Decision.Accept(); + } + + if (folderInfo.FullSeason) + { + return Decision.Accept(); + } + + return Decision.Accept(); + } + + public Decision IsSatisfiedBy(LocalEpisode localEpisode) { if (localEpisode.ExistingFile) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs index c7b61d802..536ea093a 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs @@ -37,5 +37,27 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localEpisode) + { + if (localEpisode.ExistingFile) + { + _logger.Debug("Existing file, skipping sample check"); + return Decision.Accept(); + } + + var sample = _detectSample.IsSample(localEpisode.Movie, + localEpisode.Quality, + localEpisode.Path, + localEpisode.Size, + false); + + if (sample) + { + return Decision.Reject("Sample"); + } + + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs index 2260ed71a..a19359457 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs @@ -56,5 +56,40 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localEpisode) + { + if (localEpisode.ExistingFile) + { + _logger.Debug("{0} is in series folder, skipping unpacking check", localEpisode.Path); + return Decision.Accept(); + } + + foreach (var workingFolder in _configService.DownloadClientWorkingFolders.Split('|')) + { + DirectoryInfo parent = Directory.GetParent(localEpisode.Path); + while (parent != null) + { + if (parent.Name.StartsWith(workingFolder)) + { + if (OsInfo.IsNotWindows) + { + _logger.Debug("{0} is still being unpacked", localEpisode.Path); + return Decision.Reject("File is still being unpacked"); + } + + if (_diskProvider.FileGetLastWrite(localEpisode.Path) > DateTime.UtcNow.AddMinutes(-5)) + { + _logger.Debug("{0} appears to be unpacking still", localEpisode.Path); + return Decision.Reject("File is still being unpacked"); + } + } + + parent = parent.Parent; + } + } + + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs index ee6c02c53..c24d62aaa 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs @@ -1,3 +1,4 @@ +using System; using NLog; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Parser.Model; @@ -27,5 +28,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger.Debug("Episode file on disk contains more episodes than this file contains"); return Decision.Reject("Episode file on disk contains more episodes than this file contains"); } + + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs index ce65eb304..85becc0ba 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using NLog; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Parser.Model; @@ -13,6 +14,11 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } + public Decision IsSatisfiedBy(LocalMovie localMovie) + { + return Decision.Accept(); + } + public Decision IsSatisfiedBy(LocalEpisode localEpisode) { if (localEpisode.ExistingFile) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs index 3d07306af..b2d2c2c33 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs @@ -26,5 +26,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } + + public Decision IsSatisfiedBy(LocalMovie localEpisode) + { + return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs new file mode 100644 index 000000000..427996088 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieDownloadedEvent.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieDownloadedEvent : IEvent + { + public LocalMovie Movie { get; private set; } + public MovieFile MovieFile { get; private set; } + public List OldFiles { get; private set; } + + public MovieDownloadedEvent(LocalMovie episode, MovieFile episodeFile, List oldFiles) + { + Movie = episode; + MovieFile = episodeFile; + OldFiles = oldFiles; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs new file mode 100644 index 000000000..17f93dc0b --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFileAddedEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieFileAddedEvent : IEvent + { + public MovieFile MovieFile { get; private set; } + + public MovieFileAddedEvent(MovieFile episodeFile) + { + MovieFile = episodeFile; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs new file mode 100644 index 000000000..232f1686f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFileDeletedEvent.cs @@ -0,0 +1,16 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieFileDeletedEvent : IEvent + { + public MovieFile MovieFile { get; private set; } + public DeleteMediaFileReason Reason { get; private set; } + + public MovieFileDeletedEvent(MovieFile episodeFile, DeleteMediaFileReason reason) + { + MovieFile = episodeFile; + Reason = reason; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs new file mode 100644 index 000000000..a26031413 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFolderCreatedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieFolderCreatedEvent : IEvent + { + public Movie Movie { get; private set; } + public MovieFile MovieFile { get; private set; } + public string SeriesFolder { get; set; } + public string SeasonFolder { get; set; } + public string MovieFolder { get; set; } + + public MovieFolderCreatedEvent(Movie movie, MovieFile episodeFile) + { + Movie = movie; + MovieFile = episodeFile; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs new file mode 100644 index 000000000..91df27e3c --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieImportedEvent.cs @@ -0,0 +1,32 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class MovieImportedEvent : IEvent + { + public LocalMovie MovieInfo { get; private set; } + public MovieFile ImportedMovie { get; private set; } + public bool NewDownload { get; private set; } + public string DownloadClient { get; private set; } + public string DownloadId { get; private set; } + public bool IsReadOnly { get; set; } + + public MovieImportedEvent(LocalMovie episodeInfo, MovieFile importedMovie, bool newDownload) + { + MovieInfo = episodeInfo; + ImportedMovie = importedMovie; + NewDownload = newDownload; + } + + public MovieImportedEvent(LocalMovie episodeInfo, MovieFile importedMovie, bool newDownload, string downloadClient, string downloadId, bool isReadOnly) + { + MovieInfo = episodeInfo; + ImportedMovie = importedMovie; + NewDownload = newDownload; + DownloadClient = downloadClient; + DownloadId = downloadId; + IsReadOnly = isReadOnly; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index 051e85863..e3e82daa5 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -7,16 +7,20 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tv; using NzbDrone.Core.Tv.Events; using NzbDrone.Common; +using System; namespace NzbDrone.Core.MediaFiles { public interface IMediaFileService { + MovieFile Add(MovieFile episodeFile); + void Update(MovieFile episodeFile); + void Delete(MovieFile episodeFile, DeleteMediaFileReason reason); EpisodeFile Add(EpisodeFile episodeFile); void Update(EpisodeFile episodeFile); void Delete(EpisodeFile episodeFile, DeleteMediaFileReason reason); List GetFilesBySeries(int seriesId); - List GetFilesByMovie(int movieId); + List GetFilesByMovie(int movieId); List GetFilesBySeason(int seriesId, int seasonNumber); List GetFilesWithoutMediaInfo(); List FilterExistingFiles(List files, Series series); @@ -30,12 +34,14 @@ namespace NzbDrone.Core.MediaFiles { private readonly IEventAggregator _eventAggregator; private readonly IMediaFileRepository _mediaFileRepository; + private readonly IMovieFileRepository _movieFileRepository; private readonly Logger _logger; - public MediaFileService(IMediaFileRepository mediaFileRepository, IEventAggregator eventAggregator, Logger logger) + public MediaFileService(IMediaFileRepository mediaFileRepository, IMovieFileRepository movieFileRepository, IEventAggregator eventAggregator, Logger logger) { _mediaFileRepository = mediaFileRepository; _eventAggregator = eventAggregator; + _movieFileRepository = movieFileRepository; _logger = logger; } @@ -61,14 +67,24 @@ namespace NzbDrone.Core.MediaFiles _eventAggregator.PublishEvent(new EpisodeFileDeletedEvent(episodeFile, reason)); } + public void Delete(MovieFile episodeFile, DeleteMediaFileReason reason) + { + //Little hack so we have the episodes and series attached for the event consumers + episodeFile.Movie.LazyLoad(); + episodeFile.Path = Path.Combine(episodeFile.Movie.Value.Path, episodeFile.RelativePath); + + _movieFileRepository.Delete(episodeFile); + _eventAggregator.PublishEvent(new MovieFileDeletedEvent(episodeFile, reason)); + } + public List GetFilesBySeries(int seriesId) { return _mediaFileRepository.GetFilesBySeries(seriesId); } - public List GetFilesByMovie(int movieId) + public List GetFilesByMovie(int movieId) { - return _mediaFileRepository.GetFilesBySeries(movieId); //TODO: Update implementation for movie files. + return _movieFileRepository.GetFilesByMovie(movieId); //TODO: Update implementation for movie files. } public List GetFilesBySeason(int seriesId, int seasonNumber) @@ -114,5 +130,18 @@ namespace NzbDrone.Core.MediaFiles var files = GetFilesBySeries(message.Series.Id); _mediaFileRepository.DeleteMany(files); } + + public MovieFile Add(MovieFile episodeFile) + { + var addedFile = _movieFileRepository.Insert(episodeFile); + _eventAggregator.PublishEvent(new MovieFileAddedEvent(addedFile)); + return addedFile; + } + + public void Update(MovieFile episodeFile) + { + _movieFileRepository.Update(episodeFile); + } + } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MovieFile.cs b/src/NzbDrone.Core/MediaFiles/MovieFile.cs new file mode 100644 index 000000000..dfb753ab6 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFile.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using Marr.Data; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles.MediaInfo; + +namespace NzbDrone.Core.MediaFiles +{ + public class MovieFile : ModelBase + { + public int MovieId { get; set; } + public string RelativePath { get; set; } + public string Path { get; set; } + public long Size { get; set; } + public DateTime DateAdded { get; set; } + public string SceneName { get; set; } + public string ReleaseGroup { get; set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + public LazyLoaded Movie { get; set; } + + public override string ToString() + { + return string.Format("[{0}] {1}", Id, RelativePath); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs b/src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs new file mode 100644 index 000000000..a52faed61 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFileMoveResult.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles +{ + public class MovieFileMoveResult + { + public MovieFileMoveResult() + { + OldFiles = new List(); + } + + public MovieFile MovieFile { get; set; } + public List OldFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs new file mode 100644 index 000000000..cf8acd6f9 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFileMovingService.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IMoveMovieFiles + { + MovieFile MoveMovieFile(MovieFile movieFile, Movie movie); + MovieFile MoveMovieFile(MovieFile movieFile, LocalMovie localMovie); + MovieFile CopyMovieFile(MovieFile movieFile, LocalMovie localMovie); + } + + public class MovieFileMovingService : IMoveMovieFiles + { + private readonly IMovieService _movieService; + private readonly IUpdateMovieFileService _updateMovieFileService; + private readonly IBuildFileNames _buildFileNames; + private readonly IDiskTransferService _diskTransferService; + private readonly IDiskProvider _diskProvider; + private readonly IMediaFileAttributeService _mediaFileAttributeService; + private readonly IEventAggregator _eventAggregator; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public MovieFileMovingService(IMovieService movieService, + IUpdateMovieFileService updateMovieFileService, + IBuildFileNames buildFileNames, + IDiskTransferService diskTransferService, + IDiskProvider diskProvider, + IMediaFileAttributeService mediaFileAttributeService, + IEventAggregator eventAggregator, + IConfigService configService, + Logger logger) + { + _movieService = movieService; + _updateMovieFileService = updateMovieFileService; + _buildFileNames = buildFileNames; + _diskTransferService = diskTransferService; + _diskProvider = diskProvider; + _mediaFileAttributeService = mediaFileAttributeService; + _eventAggregator = eventAggregator; + _configService = configService; + _logger = logger; + } + + public MovieFile MoveMovieFile(MovieFile movieFile, Movie movie) + { + var newFileName = _buildFileNames.BuildFileName(movie, movieFile); + var filePath = _buildFileNames.BuildFilePath(movie, newFileName, Path.GetExtension(movieFile.RelativePath)); + + EnsureMovieFolder(movieFile, movie, filePath); + + _logger.Debug("Renaming movie file: {0} to {1}", movieFile, filePath); + + return TransferFile(movieFile, movie, filePath, TransferMode.Move); + } + + public MovieFile MoveMovieFile(MovieFile movieFile, LocalMovie localMovie) + { + var newFileName = _buildFileNames.BuildFileName(localMovie.Movie, movieFile); + var filePath = _buildFileNames.BuildFilePath(localMovie.Movie, newFileName, Path.GetExtension(localMovie.Path)); + + EnsureMovieFolder(movieFile, localMovie, filePath); + + _logger.Debug("Moving movie file: {0} to {1}", movieFile.Path, filePath); + + return TransferFile(movieFile, localMovie.Movie, filePath, TransferMode.Move); + } + + public MovieFile CopyMovieFile(MovieFile movieFile, LocalMovie localMovie) + { + var newFileName = _buildFileNames.BuildFileName(localMovie.Movie, movieFile); + var filePath = _buildFileNames.BuildFilePath(localMovie.Movie, newFileName, Path.GetExtension(localMovie.Path)); + + EnsureMovieFolder(movieFile, localMovie, filePath); + + if (_configService.CopyUsingHardlinks) + { + _logger.Debug("Hardlinking movie file: {0} to {1}", movieFile.Path, filePath); + return TransferFile(movieFile, localMovie.Movie, filePath, TransferMode.HardLinkOrCopy); + } + + _logger.Debug("Copying movie file: {0} to {1}", movieFile.Path, filePath); + return TransferFile(movieFile, localMovie.Movie, filePath, TransferMode.Copy); + } + + private MovieFile TransferFile(MovieFile movieFile, Movie movie, string destinationFilePath, TransferMode mode) + { + Ensure.That(movieFile, () => movieFile).IsNotNull(); + Ensure.That(movie,() => movie).IsNotNull(); + Ensure.That(destinationFilePath, () => destinationFilePath).IsValidPath(); + + var movieFilePath = movieFile.Path ?? Path.Combine(movie.Path, movieFile.RelativePath); + + if (!_diskProvider.FileExists(movieFilePath)) + { + throw new FileNotFoundException("Movie file path does not exist", movieFilePath); + } + + if (movieFilePath == destinationFilePath) + { + throw new SameFilenameException("File not moved, source and destination are the same", movieFilePath); + } + + _diskTransferService.TransferFile(movieFilePath, destinationFilePath, mode); + + movieFile.RelativePath = movie.Path.GetRelativePath(destinationFilePath); + + _updateMovieFileService.ChangeFileDateForFile(movieFile, movie); + + try + { + _mediaFileAttributeService.SetFolderLastWriteTime(movie.Path, movieFile.DateAdded); + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set last write time"); + } + + _mediaFileAttributeService.SetFilePermissions(destinationFilePath); + + return movieFile; + } + + private void EnsureMovieFolder(MovieFile movieFile, LocalMovie localMovie, string filePath) + { + EnsureMovieFolder(movieFile, localMovie.Movie, filePath); + } + + private void EnsureMovieFolder(MovieFile movieFile, Movie movie, string filePath) + { + var movieFolder = Path.GetDirectoryName(filePath); + var rootFolder = new OsPath(movieFolder).Directory.FullPath; + + if (!_diskProvider.FolderExists(rootFolder)) + { + throw new DirectoryNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder)); + } + + var changed = false; + var newEvent = new MovieFolderCreatedEvent(movie, movieFile); + + if (!_diskProvider.FolderExists(movieFolder)) + { + CreateFolder(movieFolder); + newEvent.SeriesFolder = movieFolder; + changed = true; + } + + if (changed) + { + _eventAggregator.PublishEvent(newEvent); + } + } + + private void CreateFolder(string directoryName) + { + Ensure.That(directoryName, () => directoryName).IsNotNullOrWhiteSpace(); + + var parentFolder = new OsPath(directoryName).Directory.FullPath; + if (!_diskProvider.FolderExists(parentFolder)) + { + CreateFolder(parentFolder); + } + + try + { + _diskProvider.CreateFolder(directoryName); + } + catch (IOException ex) + { + _logger.Error(ex, "Unable to create directory: " + directoryName); + } + + _mediaFileAttributeService.SetFolderPermissions(directoryName); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs new file mode 100644 index 000000000..9ed89b85f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieFileRepository.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + + +namespace NzbDrone.Core.MediaFiles +{ + public interface IMovieFileRepository : IBasicRepository + { + List GetFilesByMovie(int movieId); + List GetFilesWithoutMediaInfo(); + } + + + public class MovieFileRepository : BasicRepository, IMovieFileRepository + { + public MovieFileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public List GetFilesByMovie(int movieId) + { + return Query.Where(c => c.MovieId == movieId).ToList(); + } + + public List GetFilesWithoutMediaInfo() + { + return Query.Where(c => c.MediaInfo == null).ToList(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs b/src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs new file mode 100644 index 000000000..af45d8831 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/UpdateMovieFileService.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Exceptron; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IUpdateMovieFileService + { + void ChangeFileDateForFile(MovieFile movieFile, Movie movie); + } + + public class UpdateMovieFileService : IUpdateMovieFileService, + IHandle + { + private readonly IDiskProvider _diskProvider; + private readonly IConfigService _configService; + private readonly IMovieService _movieService; + private readonly Logger _logger; + + public UpdateMovieFileService(IDiskProvider diskProvider, + IConfigService configService, + IMovieService movieService, + Logger logger) + { + _diskProvider = diskProvider; + _configService = configService; + _movieService = movieService; + _logger = logger; + } + + public void ChangeFileDateForFile(MovieFile movieFile, Movie movie) + { + ChangeFileDate(movieFile, movie); + } + + private bool ChangeFileDate(MovieFile movieFile, Movie movie) + { + var movieFilePath = Path.Combine(movie.Path, movieFile.RelativePath); + + return false; + } + + public void Handle(SeriesScannedEvent message) + { + if (_configService.FileDate == FileDateType.None) + { + return; + } + + /* var movies = _movieService.MoviesWithFiles(message.Series.Id); + + var movieFiles = new List(); + var updated = new List(); + + foreach (var group in movies.GroupBy(e => e.MovieFileId)) + { + var moviesInFile = group.Select(e => e).ToList(); + var movieFile = moviesInFile.First().MovieFile; + + movieFiles.Add(movieFile); + + if (ChangeFileDate(movieFile, message.Series, moviesInFile)) + { + updated.Add(movieFile); + } + } + + if (updated.Any()) + { + _logger.ProgressDebug("Changed file date for {0} files of {1} in {2}", updated.Count, movieFiles.Count, message.Series.Title); + } + + else + { + _logger.ProgressDebug("No file dates changed for {0}", message.Series.Title); + }*/ + } + + private bool ChangeFileDateToLocalAirDate(string filePath, string fileDate, string fileTime) + { + DateTime airDate; + + if (DateTime.TryParse(fileDate + ' ' + fileTime, out airDate)) + { + // avoiding false +ve checks and set date skewing by not using UTC (Windows) + DateTime oldDateTime = _diskProvider.FileGetLastWrite(filePath); + + if (!DateTime.Equals(airDate, oldDateTime)) + { + try + { + _diskProvider.FileSetLastWriteTime(filePath, airDate); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldDateTime, airDate); + + return true; + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); + } + } + } + + else + { + _logger.Debug("Could not create valid date to change file [{0}]", filePath); + } + + return false; + } + + private bool ChangeFileDateToUtcAirDate(string filePath, DateTime airDateUtc) + { + DateTime oldLastWrite = _diskProvider.FileGetLastWrite(filePath); + + if (!DateTime.Equals(airDateUtc, oldLastWrite)) + { + try + { + _diskProvider.FileSetLastWriteTime(filePath, airDateUtc); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, airDateUtc); + + return true; + } + + catch (Exception ex) + { + ex.ExceptronIgnoreOnMono(); + _logger.Warn(ex, "Unable to set date of file [" + filePath + "]"); + } + } + + return false; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index 95f245e3e..b8cd9f36d 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.MediaFiles public interface IUpgradeMediaFiles { EpisodeFileMoveResult UpgradeEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode, bool copyOnly = false); + MovieFileMoveResult UpgradeMovieFile(MovieFile movieFile, LocalMovie localMovie, bool copyOnly = false); } public class UpgradeMediaFileService : IUpgradeMediaFiles @@ -16,22 +17,59 @@ namespace NzbDrone.Core.MediaFiles private readonly IRecycleBinProvider _recycleBinProvider; private readonly IMediaFileService _mediaFileService; private readonly IMoveEpisodeFiles _episodeFileMover; + private readonly IMoveMovieFiles _movieFileMover; private readonly IDiskProvider _diskProvider; private readonly Logger _logger; public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, IMoveEpisodeFiles episodeFileMover, + IMoveMovieFiles movieFileMover, IDiskProvider diskProvider, Logger logger) { _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; _episodeFileMover = episodeFileMover; + _movieFileMover = movieFileMover; _diskProvider = diskProvider; _logger = logger; } + public MovieFileMoveResult UpgradeMovieFile(MovieFile episodeFile, LocalMovie localEpisode, bool copyOnly = false) + { + var moveFileResult = new MovieFileMoveResult(); + var existingFile = localEpisode.Movie.MovieFile; + + if (existingFile.IsLoaded) + { + var file = existingFile.Value; + var episodeFilePath = Path.Combine(localEpisode.Movie.Path, file.RelativePath); + + if (_diskProvider.FileExists(episodeFilePath)) + { + _logger.Debug("Removing existing episode file: {0}", file); + _recycleBinProvider.DeleteFile(episodeFilePath); + } + + moveFileResult.OldFiles.Add(file); + _mediaFileService.Delete(file, DeleteMediaFileReason.Upgrade); + } + + + + if (copyOnly) + { + moveFileResult.MovieFile = _movieFileMover.CopyMovieFile(episodeFile, localEpisode); + } + else + { + moveFileResult.MovieFile= _movieFileMover.MoveMovieFile(episodeFile, localEpisode); + } + + return moveFileResult; + } + public EpisodeFileMoveResult UpgradeEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode, bool copyOnly = false) { var moveFileResult = new EpisodeFileMoveResult(); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 18f0b1ae7..cb3dfcba2 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -249,6 +249,7 @@ + @@ -700,6 +701,17 @@ + + + + + + + + + + + @@ -762,6 +774,7 @@ + @@ -875,6 +888,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 4d7773ad7..bac486ec1 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -17,6 +17,8 @@ namespace NzbDrone.Core.Organizer public interface IBuildFileNames { string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null); + string BuildFileName(Movie movie, MovieFile movieFile, NamingConfig namingConfig = null); + string BuildFilePath(Movie movie, string fileName, string extension); string BuildFilePath(Series series, int seasonNumber, string fileName, string extension); string BuildSeasonPath(Series series, int seasonNumber); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); @@ -137,6 +139,66 @@ namespace NzbDrone.Core.Organizer return fileName; } + public string BuildFileName(Movie movie, MovieFile movieFile, NamingConfig namingConfig = null) + { + if (namingConfig == null) + { + namingConfig = _namingConfigService.GetConfig(); + } + + if (!namingConfig.RenameEpisodes) + { + return GetOriginalTitle(movieFile); + } + + /*if (namingConfig.StandardEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Standard) + { + throw new NamingFormatException("Standard episode format cannot be empty"); + } + + if (namingConfig.DailyEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Daily) + { + throw new NamingFormatException("Daily episode format cannot be empty"); + } + + if (namingConfig.AnimeEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Anime) + { + throw new NamingFormatException("Anime episode format cannot be empty"); + }*/ + + /*var pattern = namingConfig.StandardEpisodeFormat; + var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); + + episodes = episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber).ToList(); + + if (series.SeriesType == SeriesTypes.Daily) + { + pattern = namingConfig.DailyEpisodeFormat; + } + + if (series.SeriesType == SeriesTypes.Anime && episodes.All(e => e.AbsoluteEpisodeNumber.HasValue)) + { + pattern = namingConfig.AnimeEpisodeFormat; + } + + pattern = AddSeasonEpisodeNumberingTokens(pattern, tokenHandlers, episodes, namingConfig); + pattern = AddAbsoluteNumberingTokens(pattern, tokenHandlers, series, episodes, namingConfig); + + AddSeriesTokens(tokenHandlers, series); + AddEpisodeTokens(tokenHandlers, episodes); + AddEpisodeFileTokens(tokenHandlers, episodeFile); + AddQualityTokens(tokenHandlers, series, episodeFile); + AddMediaInfoTokens(tokenHandlers, episodeFile); + + var fileName = ReplaceTokens(pattern, tokenHandlers, namingConfig).Trim(); + fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); + fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty);*/ + + //TODO: Update namingConfig for Movies! + + return GetOriginalTitle(movieFile); + } + public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); @@ -146,6 +208,15 @@ namespace NzbDrone.Core.Organizer return Path.Combine(path, fileName + extension); } + public string BuildFilePath(Movie movie, string fileName, string extension) + { + Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); + + var path = movie.Path; + + return Path.Combine(path, fileName + extension); + } + public string BuildSeasonPath(Series series, int seasonNumber) { var path = series.Path; @@ -246,7 +317,7 @@ namespace NzbDrone.Core.Organizer public string GetMovieFolder(Movie movie) { - return CleanFolderName(Parser.Parser.CleanSeriesTitle(movie.Title)); + return CleanFolderName(movie.Title); } public static string CleanTitle(string title) @@ -774,6 +845,26 @@ namespace NzbDrone.Core.Organizer return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); } + + private string GetOriginalTitle(MovieFile episodeFile) + { + if (episodeFile.SceneName.IsNullOrWhiteSpace()) + { + return GetOriginalFileName(episodeFile); + } + + return episodeFile.SceneName; + } + + private string GetOriginalFileName(MovieFile episodeFile) + { + if (episodeFile.RelativePath.IsNullOrWhiteSpace()) + { + return Path.GetFileNameWithoutExtension(episodeFile.Path); + } + + return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); + } } internal sealed class TokenMatch diff --git a/src/NzbDrone.Core/Parser/Model/LocalMovie.cs b/src/NzbDrone.Core/Parser/Model/LocalMovie.cs new file mode 100644 index 000000000..3f5c2344c --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/LocalMovie.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.MediaFiles.MediaInfo; + +namespace NzbDrone.Core.Parser.Model +{ + public class LocalMovie + { + public LocalMovie() + { + } + + public string Path { get; set; } + public long Size { get; set; } + public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } + public Movie Movie { get; set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + public bool ExistingFile { get; set; } + + + public override string ToString() + { + return Path; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index cc9ca8447..5466bcb5a 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -15,6 +15,8 @@ namespace NzbDrone.Core.Parser { LocalEpisode GetLocalEpisode(string filename, Series series); LocalEpisode GetLocalEpisode(string filename, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource); + LocalMovie GetLocalMovie(string filename, Movie movie); + LocalMovie GetLocalMovie(string filename, Movie movie, ParsedEpisodeInfo folderInfo, bool sceneSource); Series GetSeries(string title); Movie GetMovie(string title); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null); @@ -99,6 +101,46 @@ namespace NzbDrone.Core.Parser }; } + public LocalMovie GetLocalMovie(string filename, Movie movie) + { + return GetLocalMovie(filename, movie, null, false); + } + + public LocalMovie GetLocalMovie(string filename, Movie movie, ParsedEpisodeInfo folderInfo, bool sceneSource) + { + ParsedEpisodeInfo parsedEpisodeInfo; + + if (folderInfo != null) + { + parsedEpisodeInfo = folderInfo.JsonClone(); + parsedEpisodeInfo.Quality = QualityParser.ParseQuality(Path.GetFileName(filename)); + } + + else + { + parsedEpisodeInfo = Parser.ParsePath(filename); + } + + if (parsedEpisodeInfo == null) + { + if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(filename))) + { + _logger.Warn("Unable to parse episode info from path {0}", filename); + } + + return null; + } + + return new LocalMovie + { + Movie = movie, + Quality = parsedEpisodeInfo.Quality, + Path = filename, + ParsedEpisodeInfo = parsedEpisodeInfo, + ExistingFile = movie.Path.IsParentPath(filename) + }; + } + public Series GetSeries(string title) { var parsedEpisodeInfo = Parser.ParseTitle(title); diff --git a/src/NzbDrone.Core/Tv/Movie.cs b/src/NzbDrone.Core/Tv/Movie.cs index 9409ae23e..d5b49cda0 100644 --- a/src/NzbDrone.Core/Tv/Movie.cs +++ b/src/NzbDrone.Core/Tv/Movie.cs @@ -4,6 +4,7 @@ using Marr.Data; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.Profiles; +using NzbDrone.Core.MediaFiles; namespace NzbDrone.Core.Tv { @@ -41,6 +42,8 @@ namespace NzbDrone.Core.Tv public LazyLoaded Profile { get; set; } public HashSet Tags { get; set; } public AddMovieOptions AddOptions { get; set; } + public LazyLoaded MovieFile { get; set; } + public int MovieFileId { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/Tv/MovieRepository.cs b/src/NzbDrone.Core/Tv/MovieRepository.cs index 281152b05..f6c4b0ceb 100644 --- a/src/NzbDrone.Core/Tv/MovieRepository.cs +++ b/src/NzbDrone.Core/Tv/MovieRepository.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Linq; +using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; @@ -11,6 +13,8 @@ namespace NzbDrone.Core.Tv Movie FindByTitle(string cleanTitle); Movie FindByTitle(string cleanTitle, int year); Movie FindByImdbId(string imdbid); + List GetMoviesByFileId(int fileId); + void SetFileId(int fileId, int movieId); } public class MovieRepository : BasicRepository, IMovieRepository @@ -46,5 +50,16 @@ namespace NzbDrone.Core.Tv { return Query.Where(s => s.ImdbId == imdbid).SingleOrDefault(); } + + public List GetMoviesByFileId(int fileId) + { + return Query.Where(m => m.MovieFileId == fileId).ToList(); + } + + public void SetFileId(int episodeId, int fileId) + { + SetFields(new Movie { Id = episodeId, MovieFileId = fileId }, movie => movie.MovieFileId); + } + } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/MovieService.cs b/src/NzbDrone.Core/Tv/MovieService.cs index d3ec007cb..e255cb215 100644 --- a/src/NzbDrone.Core/Tv/MovieService.cs +++ b/src/NzbDrone.Core/Tv/MovieService.cs @@ -10,6 +10,8 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser; using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; namespace NzbDrone.Core.Tv { @@ -22,6 +24,7 @@ namespace NzbDrone.Core.Tv Movie FindByTitle(string title); Movie FindByTitle(string title, int year); Movie FindByTitleInexact(string title); + Movie GetMovieByFileId(int fileId); void DeleteMovie(int movieId, bool deleteFiles); List GetAllMovies(); Movie UpdateMovie(Movie movie); @@ -30,7 +33,8 @@ namespace NzbDrone.Core.Tv void RemoveAddOptions(Movie movie); } - public class MovieService : IMovieService + public class MovieService : IMovieService, IHandle, + IHandle { private readonly IMovieRepository _movieRepository; private readonly IEventAggregator _eventAggregator; @@ -195,5 +199,24 @@ namespace NzbDrone.Core.Tv { _movieRepository.SetFields(movie, s => s.AddOptions); } + + public void Handle(MovieFileAddedEvent message) + { + _movieRepository.SetFileId(message.MovieFile.Id, message.MovieFile.Movie.Value.Id); + _logger.Debug("Linking [{0}] > [{1}]", message.MovieFile.RelativePath, message.MovieFile.Movie.Value); + } + + public void Handle(MovieFileDeletedEvent message) + { + var movie = _movieRepository.GetMoviesByFileId(message.MovieFile.Id).First(); + movie.MovieFileId = 0; + + UpdateMovie(movie); + } + + public Movie GetMovieByFileId(int fileId) + { + return _movieRepository.GetMoviesByFileId(fileId).First(); + } } }