diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index 6eda1708d..38782c108 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -57,11 +57,12 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path) => throw new NotSupportedException(); - public Task DeleteEmptyShows() => throw new NotSupportedException(); + public Task> FindEpisodePaths(LibraryPath libraryPath) => throw new NotSupportedException(); - public Task> GetShowByPath(int mediaSourceId, string path) => throw new NotSupportedException(); + public Task DeleteByPath(LibraryPath libraryPath, string path) => throw new NotSupportedException(); - public Task DeleteMissingSources(int localMediaSourceId, List allFolders) => - throw new NotSupportedException(); + public Task DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException(); + + public Task DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); } } diff --git a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs index a27e6e84d..945baf759 100644 --- a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using ErsatzTV.Core.Domain; @@ -44,6 +45,8 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Setup(x => x.GetOrAdd(It.IsAny(), It.IsAny())) .Returns( (LibraryPath _, string path) => Right(new FakeMovieWithPath(path)).AsTask()); + _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) + .Returns(new List().AsEnumerable().AsTask()); _localStatisticsProvider = new Mock(); _localMetadataProvider = new Mock(); @@ -359,6 +362,55 @@ namespace ErsatzTV.Core.Tests.Metadata Times.Once); } + [Test] + public async Task RenamedMovie_Should_Delete_Old_Movie() + { + string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); + string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); + + _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) + .Returns(new List { oldMoviePath }.AsEnumerable().AsTask()); + + string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; + + Either result = await service.ScanFolder(libraryPath, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.DeleteByPath(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); + } + + [Test] + public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie() + { + string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); + string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); + + _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) + .Returns(new List { oldMoviePath }.AsEnumerable().AsTask()); + + string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; + + Either result = await service.ScanFolder(libraryPath, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.DeleteByPath(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); + } + + private MovieFolderScanner GetService(params FakeFileEntry[] files) => new( new FakeLocalFileSystem(new List(files)), diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index e7bd50a7f..7b28ea6ac 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -13,5 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task Update(Movie movie); Task GetMovieCount(); Task> GetPagedMovies(int pageNumber, int pageSize); + Task> FindMoviePaths(LibraryPath libraryPath); + Task DeleteByPath(LibraryPath libraryPath, string path); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 9ac405b84..dd3287cc1 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -27,6 +27,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata); Task> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber); Task> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path); - Task DeleteEmptyShows(); + Task> FindEpisodePaths(LibraryPath libraryPath); + Task DeleteByPath(LibraryPath libraryPath, string path); + Task DeleteEmptySeasons(LibraryPath libraryPath); + Task DeleteEmptyShows(LibraryPath libraryPath); } } diff --git a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs index 7a40a2ddc..c1754f6c5 100644 --- a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -71,6 +71,7 @@ namespace ErsatzTV.Core.Metadata continue; } + foreach (string file in allFiles.OrderBy(identity)) { // TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper? @@ -86,6 +87,15 @@ namespace ErsatzTV.Core.Metadata } } + foreach (string path in await _movieRepository.FindMoviePaths(libraryPath)) + { + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Removing missing movie at {Path}", path); + await _movieRepository.DeleteByPath(libraryPath, path); + } + } + return Unit.Default; } diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs index 1e7227a9b..050589ca6 100644 --- a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -63,7 +63,17 @@ namespace ErsatzTV.Core.Metadata _ => Task.FromResult(Unit.Default)); } - await _televisionRepository.DeleteEmptyShows(); + foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath)) + { + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Removing missing episode at {Path}", path); + await _televisionRepository.DeleteByPath(libraryPath, path); + } + } + + await _televisionRepository.DeleteEmptySeasons(libraryPath); + await _televisionRepository.DeleteEmptyShows(libraryPath); return Unit.Default; } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index ad20a7293..9a4469eb5 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -93,6 +93,38 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .OrderBy(mm => mm.SortTitle) .ToListAsync(); + public Task> FindMoviePaths(LibraryPath libraryPath) => + _dbConnection.QueryAsync( + @"SELECT MF.Path + FROM MediaFile MF + INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id + INNER JOIN Movie M on MV.MovieId = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + WHERE MI.LibraryPathId = @LibraryPathId", + new { LibraryPathId = libraryPath.Id }); + + public async Task DeleteByPath(LibraryPath libraryPath, string path) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT M.Id + FROM Movie M + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN MediaVersion MV on M.Id = MV.MovieId + INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId + WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", + new { LibraryPathId = libraryPath.Id, Path = path }); + + foreach (int movieId in ids) + { + Movie movie = await _dbContext.Movies.FindAsync(movieId); + _dbContext.Movies.Remove(movie); + } + + await _dbContext.SaveChangesAsync(); + + return Unit.Default; + } + private async Task> AddMovie(int libraryPathId, string path) { try diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 9dd94ed82..c46781116 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -230,9 +230,55 @@ namespace ErsatzTV.Infrastructure.Data.Repositories () => AddEpisode(season, libraryPath.Id, path)); } - public Task DeleteEmptyShows() => + public Task> FindEpisodePaths(LibraryPath libraryPath) => + _dbConnection.QueryAsync( + @"SELECT MF.Path + FROM MediaFile MF + INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id + INNER JOIN Episode E on MV.EpisodeId = E.Id + INNER JOIN MediaItem MI on E.Id = MI.Id + WHERE MI.LibraryPathId = @LibraryPathId", + new { LibraryPathId = libraryPath.Id }); + + public async Task DeleteByPath(LibraryPath libraryPath, string path) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT E.Id + FROM Episode E + INNER JOIN MediaItem MI on E.Id = MI.Id + INNER JOIN MediaVersion MV on E.Id = MV.EpisodeId + INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId + WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", + new { LibraryPathId = libraryPath.Id, Path = path }); + + foreach (int episodeId in ids) + { + Episode episode = await _dbContext.Episodes.FindAsync(episodeId); + _dbContext.Episodes.Remove(episode); + } + + await _dbContext.SaveChangesAsync(); + + return Unit.Default; + } + + public Task DeleteEmptySeasons(LibraryPath libraryPath) => + _dbContext.Seasons + .Filter(s => s.LibraryPathId == libraryPath.Id) + .Filter(s => s.Episodes.Count == 0) + .ToListAsync() + .Bind( + list => + { + _dbContext.Seasons.RemoveRange(list); + return _dbContext.SaveChangesAsync(); + }) + .ToUnit(); + + public Task DeleteEmptyShows(LibraryPath libraryPath) => _dbContext.Shows - .Where(s => s.Seasons.Count == 0) + .Filter(s => s.LibraryPathId == libraryPath.Id) + .Filter(s => s.Seasons.Count == 0) .ToListAsync() .Bind( list =>