diff --git a/src/NzbDrone.Api/MovieFiles/MovieFileModule.cs b/src/NzbDrone.Api/MovieFiles/MovieFileModule.cs index 89ff86a9f..93a1ae136 100644 --- a/src/NzbDrone.Api/MovieFiles/MovieFileModule.cs +++ b/src/NzbDrone.Api/MovieFiles/MovieFileModule.cs @@ -1,37 +1,35 @@ -using System.IO; using NLog; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Movies; using NzbDrone.SignalR; using Radarr.Http; +using HttpStatusCode = System.Net.HttpStatusCode; namespace NzbDrone.Api.MovieFiles { public class MovieFileModule : RadarrRestModuleWithSignalR, IHandle { private readonly IMediaFileService _mediaFileService; - private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IDeleteMediaFiles _mediaFileDeletionService; private readonly IMovieService _movieService; private readonly IUpgradableSpecification _qualityUpgradableSpecification; - private readonly Logger _logger; public MovieFileModule(IBroadcastSignalRMessage signalRBroadcaster, IMediaFileService mediaFileService, - IRecycleBinProvider recycleBinProvider, + IDeleteMediaFiles mediaFileDeletionService, IMovieService movieService, - IUpgradableSpecification qualityUpgradableSpecification, - Logger logger) + IUpgradableSpecification qualityUpgradableSpecification) : base(signalRBroadcaster) { _mediaFileService = mediaFileService; - _recycleBinProvider = recycleBinProvider; + _mediaFileDeletionService = mediaFileDeletionService; _movieService = movieService; _qualityUpgradableSpecification = qualityUpgradableSpecification; - _logger = logger; GetResourceById = GetMovieFile; UpdateResource = SetQuality; DeleteResource = DeleteMovieFile; @@ -56,12 +54,15 @@ private void SetQuality(MovieFileResource movieFileResource) private void DeleteMovieFile(int id) { var movieFile = _mediaFileService.GetMovie(id); - var movie = _movieService.GetMovie(movieFile.MovieId); - var fullPath = Path.Combine(movie.Path, movieFile.RelativePath); - _logger.Info("Deleting movie file: {0}", fullPath); - _recycleBinProvider.DeleteFile(fullPath); - _mediaFileService.Delete(movieFile, DeleteMediaFileReason.Manual); + if (movieFile == null) + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Movie file not found"); + } + + var movie = _movieService.GetMovie(movieFile.MovieId); + + _mediaFileDeletionService.DeleteMovieFile(movie, movieFile); } public void Handle(MovieFileAddedEvent message) diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteMovieFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteMovieFileFixture.cs new file mode 100644 index 000000000..cdf5a9727 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteMovieFileFixture.cs @@ -0,0 +1,143 @@ +using System.IO; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.MediaFileDeletionService +{ + [TestFixture] + public class DeleteMovieFileFixture : CoreTest + { + private static readonly string RootFolder = @"C:\Test\Movies"; + private Movie _movie; + private MovieFile _movieFile; + + [SetUp] + public void Setup() + { + _movie = Builder.CreateNew() + .With(s => s.Path = Path.Combine(RootFolder, "Movie Title")) + .Build(); + + _movieFile = Builder.CreateNew() + .With(f => f.RelativePath = "Some SubFolder") + .With(f => f.Path = Path.Combine(_movie.Path, "Some SubFolder")) + .Build(); + + Mocker.GetMock() + .Setup(s => s.GetParentFolder(_movie.Path)) + .Returns(RootFolder); + + Mocker.GetMock() + .Setup(s => s.GetParentFolder(_movieFile.Path)) + .Returns(_movie.Path); + } + + private void GivenRootFolderExists() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(RootFolder)) + .Returns(true); + } + + private void GivenRootFolderHasFolders() + { + Mocker.GetMock() + .Setup(s => s.GetDirectories(RootFolder)) + .Returns(new[] { _movie.Path }); + } + + private void GivenMovieFolderExists() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(_movie.Path)) + .Returns(true); + } + + [Test] + public void should_throw_if_root_folder_does_not_exist() + { + Assert.Throws(() => Subject.DeleteMovieFile(_movie, _movieFile)); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_should_throw_if_root_folder_is_empty() + { + GivenRootFolderExists(); + + Assert.Throws(() => Subject.DeleteMovieFile(_movie, _movieFile)); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_delete_from_db_if_movie_folder_does_not_exist() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + + Subject.DeleteMovieFile(_movie, _movieFile); + + Mocker.GetMock().Verify(v => v.Delete(_movieFile, DeleteMediaFileReason.Manual), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(_movieFile.Path, It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_from_db_if_movie_file_does_not_exist() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenMovieFolderExists(); + + Subject.DeleteMovieFile(_movie, _movieFile); + + Mocker.GetMock().Verify(v => v.Delete(_movieFile, DeleteMediaFileReason.Manual), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(_movieFile.Path, It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_from_disk_and_db_if_movie_file_exists() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenMovieFolderExists(); + + Mocker.GetMock() + .Setup(s => s.FileExists(_movieFile.Path)) + .Returns(true); + + Subject.DeleteMovieFile(_movie, _movieFile); + + Mocker.GetMock().Verify(v => v.DeleteFile(_movieFile.Path, "Movie Title"), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_movieFile, DeleteMediaFileReason.Manual), Times.Once()); + } + + [Test] + public void should_handle_error_deleting_movie_file() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenMovieFolderExists(); + + Mocker.GetMock() + .Setup(s => s.FileExists(_movieFile.Path)) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.DeleteFile(_movieFile.Path, "Movie Title")) + .Throws(new IOException()); + + Assert.Throws(() => Subject.DeleteMovieFile(_movie, _movieFile)); + + ExceptionVerification.ExpectedErrors(1); + Mocker.GetMock().Verify(v => v.DeleteFile(_movieFile.Path, "Movie Title"), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_movieFile, DeleteMediaFileReason.Manual), Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs new file mode 100644 index 000000000..da2732ef1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Net; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Events; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IDeleteMediaFiles + { + void DeleteMovieFile(Movie movie, MovieFile movieFile); + } + + public class MediaFileDeletionService : IDeleteMediaFiles, + IHandleAsync, + IHandle + { + private readonly IDiskProvider _diskProvider; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IMediaFileService _mediaFileService; + private readonly IMovieService _movieService; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public MediaFileDeletionService(IDiskProvider diskProvider, + IRecycleBinProvider recycleBinProvider, + IMediaFileService mediaFileService, + IMovieService movieService, + IConfigService configService, + Logger logger) + { + _diskProvider = diskProvider; + _recycleBinProvider = recycleBinProvider; + _mediaFileService = mediaFileService; + _movieService = movieService; + _configService = configService; + _logger = logger; + } + + public void DeleteMovieFile(Movie movie, MovieFile movieFile) + { + var fullPath = Path.Combine(movie.Path, movieFile.RelativePath); + var rootFolder = _diskProvider.GetParentFolder(movie.Path); + + if (!_diskProvider.FolderExists(rootFolder)) + { + _logger.Warn("Movie's root folder ({0}) doesn't exist.", rootFolder); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Movie's root folder ({0}) doesn't exist.", rootFolder); + } + + if (_diskProvider.GetDirectories(rootFolder).Empty()) + { + _logger.Warn("Movie's root folder ({0}) is empty.", rootFolder); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Movie's root folder ({0}) is empty.", rootFolder); + } + + if (_diskProvider.FolderExists(movie.Path) && _diskProvider.FileExists(fullPath)) + { + _logger.Info("Deleting movie file: {0}", fullPath); + + var subfolder = _diskProvider.GetParentFolder(movie.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath)); + + try + { + _recycleBinProvider.DeleteFile(fullPath, subfolder); + } + catch (Exception e) + { + _logger.Error(e, "Unable to delete movie file"); + throw new NzbDroneClientException(HttpStatusCode.InternalServerError, "Unable to delete movie file"); + } + } + + // Delete the movie file from the database to clean it up even if the file was already deleted + _mediaFileService.Delete(movieFile, DeleteMediaFileReason.Manual); + } + + public void HandleAsync(MovieDeletedEvent message) + { + if (message.DeleteFiles) + { + var movie = message.Movie; + var allMovies = _movieService.GetAllMovies(); + + foreach (var s in allMovies) + { + if (s.Id == movie.Id) + { + continue; + } + + if (movie.Path.IsParentPath(s.Path)) + { + _logger.Error("Movie path: '{0}' is a parent of another movie, not deleting files.", movie.Path); + return; + } + + if (movie.Path.PathEquals(s.Path)) + { + _logger.Error("Movie path: '{0}' is the same as another movie, not deleting files.", movie.Path); + return; + } + } + + if (_diskProvider.FolderExists(message.Movie.Path)) + { + _recycleBinProvider.DeleteFolder(message.Movie.Path); + } + } + } + + [EventHandleOrder(EventHandleOrder.Last)] + public void Handle(MovieFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) + { + return; + } + + if (_configService.DeleteEmptyFolders) + { + var movie = message.MovieFile.Movie; + var movieFileFolder = message.MovieFile.Path.GetParentPath(); + + if (_diskProvider.GetFiles(movie.Path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(movie.Path, true); + } + else if (_diskProvider.GetFiles(movieFileFolder, SearchOption.AllDirectories).Empty()) + { + _diskProvider.RemoveEmptySubfolders(movieFileFolder); + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs index 7dd2ac926..a34933565 100644 --- a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs +++ b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs @@ -7,8 +7,6 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Movies.Events; namespace NzbDrone.Core.MediaFiles { @@ -20,7 +18,7 @@ public interface IRecycleBinProvider void Cleanup(); } - public class RecycleBinProvider : IExecute, IRecycleBinProvider, IHandleAsync + public class RecycleBinProvider : IExecute, IRecycleBinProvider { private readonly IDiskTransferService _diskTransferService; private readonly IDiskProvider _diskProvider; @@ -195,17 +193,6 @@ private void SetLastWriteTime(string file, DateTime dateTime) } } - public void HandleAsync(MovieDeletedEvent message) - { - if (message.DeleteFiles) - { - if (_diskProvider.FolderExists(message.Movie.Path)) - { - DeleteFolder(message.Movie.Path); - } - } - } - public void Execute(CleanUpRecycleBinCommand message) { Cleanup(); diff --git a/src/Radarr.Api.V3/MovieFiles/MovieFileModule.cs b/src/Radarr.Api.V3/MovieFiles/MovieFileModule.cs index 76633756c..e9c8463c6 100644 --- a/src/Radarr.Api.V3/MovieFiles/MovieFileModule.cs +++ b/src/Radarr.Api.V3/MovieFiles/MovieFileModule.cs @@ -1,12 +1,11 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Nancy; -using NLog; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; @@ -24,27 +23,24 @@ public class MovieFileModule : RadarrRestModuleWithSignalR { private readonly IMediaFileService _mediaFileService; - private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IDeleteMediaFiles _mediaFileDeletionService; private readonly IMovieService _movieService; private readonly ICustomFormatCalculationService _formatCalculator; private readonly IUpgradableSpecification _qualityUpgradableSpecification; - private readonly Logger _logger; public MovieFileModule(IBroadcastSignalRMessage signalRBroadcaster, IMediaFileService mediaFileService, - IRecycleBinProvider recycleBinProvider, + IDeleteMediaFiles mediaFileDeletionService, IMovieService movieService, ICustomFormatCalculationService formatCalculator, - IUpgradableSpecification qualityUpgradableSpecification, - Logger logger) + IUpgradableSpecification qualityUpgradableSpecification) : base(signalRBroadcaster) { _mediaFileService = mediaFileService; - _recycleBinProvider = recycleBinProvider; + _mediaFileDeletionService = mediaFileDeletionService; _movieService = movieService; _formatCalculator = formatCalculator; _qualityUpgradableSpecification = qualityUpgradableSpecification; - _logger = logger; GetResourceById = GetMovieFile; GetResourceAll = GetMovieFiles; @@ -148,15 +144,15 @@ private object SetMovieFile() private void DeleteMovieFile(int id) { var movieFile = _mediaFileService.GetMovie(id); + + if (movieFile == null) + { + throw new NzbDroneClientException(global::System.Net.HttpStatusCode.NotFound, "Movie file not found"); + } + var movie = _movieService.GetMovie(movieFile.MovieId); - var fullPath = Path.Combine(movie.Path, movieFile.RelativePath); - _logger.Info("Deleting movie file: {0}", fullPath); - _recycleBinProvider.DeleteFile(fullPath); - _mediaFileService.Delete(movieFile, DeleteMediaFileReason.Manual); - - // TODO: Pull MediaFileDeletionService from Sonarr - //_mediaFileDeletionService.Delete(series, episodeFile); + _mediaFileDeletionService.DeleteMovieFile(movie, movieFile); } private object DeleteMovieFiles() @@ -167,13 +163,7 @@ private object DeleteMovieFiles() foreach (var movieFile in movieFiles) { - var fullPath = Path.Combine(movie.Path, movieFile.RelativePath); - _logger.Info("Deleting movie file: {0}", fullPath); - _recycleBinProvider.DeleteFile(fullPath); - _mediaFileService.Delete(movieFile, DeleteMediaFileReason.Manual); - - // TODO: Pull MediaFileDeletionService from Sonarr - //_mediaFileDeletionService.DeleteEpisodeFile(movie, movieFile); + _mediaFileDeletionService.DeleteMovieFile(movie, movieFile); } return new object();