diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollection.cs new file mode 100644 index 000000000..cacb5f4bd --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollection.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record AddMovieToSimpleMediaCollection + (int MediaCollectionId, int MovieId) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs new file mode 100644 index 000000000..a42304b0a --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs @@ -0,0 +1,62 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + AddMovieToSimpleMediaCollectionHandler : MediatR.IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly IMovieRepository _movieRepository; + + public AddMovieToSimpleMediaCollectionHandler( + IMediaCollectionRepository mediaCollectionRepository, + IMovieRepository movieRepository) + { + _mediaCollectionRepository = mediaCollectionRepository; + _movieRepository = movieRepository; + } + + public Task> Handle( + AddMovieToSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(ApplyAddMoviesRequest) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyAddMoviesRequest(RequestParameters parameters) + { + parameters.Collection.Movies.Add(parameters.MovieToAdd); + await _mediaCollectionRepository.Update(parameters.Collection); + + return Unit.Default; + } + + private async Task> + Validate(AddMovieToSimpleMediaCollection request) => + (await SimpleMediaCollectionMustExist(request), await ValidateMovies(request)) + .Apply( + (simpleMediaCollectionToUpdate, movieToAdd) => + new RequestParameters(simpleMediaCollectionToUpdate, movieToAdd)); + + private Task> SimpleMediaCollectionMustExist( + AddMovieToSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + + private Task> ValidateMovies( + AddMovieToSimpleMediaCollection request) => + LoadMovie(request) + .Map(v => v.ToValidation("MovieMediaItem does not exist")); + + private Task> LoadMovie(AddMovieToSimpleMediaCollection request) => + _movieRepository.GetMovie(request.MovieId); + + private record RequestParameters(SimpleMediaCollection Collection, MovieMediaItem MovieToAdd); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMoviesToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMoviesToSimpleMediaCollection.cs deleted file mode 100644 index 8ca7687a5..000000000 --- a/ErsatzTV.Application/MediaCollections/Commands/AddMoviesToSimpleMediaCollection.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; -using ErsatzTV.Core; -using LanguageExt; - -namespace ErsatzTV.Application.MediaCollections.Commands -{ - public record AddMoviesToSimpleMediaCollection - (int MediaCollectionId, List MovieIds) : MediatR.IRequest>; -} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMoviesToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMoviesToSimpleMediaCollectionHandler.cs deleted file mode 100644 index f56730eec..000000000 --- a/ErsatzTV.Application/MediaCollections/Commands/AddMoviesToSimpleMediaCollectionHandler.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Application.MediaCollections.Commands -{ - public class - AddMoviesToSimpleMediaCollectionHandler : MediatR.IRequestHandler> - { - private readonly IMediaCollectionRepository _mediaCollectionRepository; - private readonly IMediaItemRepository _mediaItemRepository; - - public AddMoviesToSimpleMediaCollectionHandler( - IMediaCollectionRepository mediaCollectionRepository, - IMediaItemRepository mediaItemRepository) - { - _mediaCollectionRepository = mediaCollectionRepository; - _mediaItemRepository = mediaItemRepository; - } - - public Task> Handle( - AddMoviesToSimpleMediaCollection request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(ApplyAddMoviesRequest) - .Bind(v => v.ToEitherAsync()); - - private async Task ApplyAddMoviesRequest(RequestParameters parameters) - { - foreach (MovieMediaItem item in parameters.MoviesToAdd.Where( - item => parameters.Collection.Movies.All(i => i.Id != item.Id))) - { - parameters.Collection.Movies.Add(item); - } - - await _mediaCollectionRepository.Update(parameters.Collection); - - return Unit.Default; - } - - private async Task> - Validate(AddMoviesToSimpleMediaCollection request) => - (await SimpleMediaCollectionMustExist(request), await ValidateMovies(request)) - .Apply( - (simpleMediaCollectionToUpdate, moviesToAdd) => - new RequestParameters(simpleMediaCollectionToUpdate, moviesToAdd)); - - private Task> SimpleMediaCollectionMustExist( - AddMoviesToSimpleMediaCollection updateSimpleMediaCollection) => - _mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId) - .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); - - private Task>> ValidateMovies( - AddMoviesToSimpleMediaCollection request) => - LoadAllMovies(request) - .Map(v => v.ToValidation("MovieMediaItem does not exist")); - - private async Task>> LoadAllMovies(AddMoviesToSimpleMediaCollection request) - { - var items = (await request.MovieIds.Map(async id => await _mediaItemRepository.Get(id)).Sequence()) - .ToList(); - if (items.Any(i => i.IsNone)) - { - return None; - } - - return items.Somes().OfType().ToList(); - } - - private record RequestParameters(SimpleMediaCollection Collection, List MoviesToAdd); - } -} diff --git a/ErsatzTV.Application/Movies/Mapper.cs b/ErsatzTV.Application/Movies/Mapper.cs new file mode 100644 index 000000000..4656151fa --- /dev/null +++ b/ErsatzTV.Application/Movies/Mapper.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Movies +{ + internal static class Mapper + { + internal static MovieViewModel ProjectToViewModel(MovieMediaItem movie) => + new(movie.Metadata.Title, movie.Metadata.Year?.ToString(), movie.Metadata.Plot, movie.Poster); + } +} diff --git a/ErsatzTV.Application/Movies/MovieViewModel.cs b/ErsatzTV.Application/Movies/MovieViewModel.cs new file mode 100644 index 000000000..a854506ea --- /dev/null +++ b/ErsatzTV.Application/Movies/MovieViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Movies +{ + public record MovieViewModel(string Title, string Year, string Plot, string Poster); +} diff --git a/ErsatzTV.Application/Movies/Queries/GetMovieById.cs b/ErsatzTV.Application/Movies/Queries/GetMovieById.cs new file mode 100644 index 000000000..7a264190d --- /dev/null +++ b/ErsatzTV.Application/Movies/Queries/GetMovieById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Movies.Queries +{ + public record GetMovieById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs new file mode 100644 index 000000000..ed5fb72e9 --- /dev/null +++ b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Movies.Mapper; + +namespace ErsatzTV.Application.Movies.Queries +{ + public class GetMovieByIdHandler : IRequestHandler> + { + private readonly IMovieRepository _movieRepository; + + public GetMovieByIdHandler(IMovieRepository movieRepository) => + _movieRepository = movieRepository; + + public Task> Handle( + GetMovieById request, + CancellationToken cancellationToken) => + _movieRepository.GetMovie(request.Id).MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index 98569126c..08371123f 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IMovieRepository { + Task> GetMovie(int movieId); Task> GetOrAdd(int mediaSourceId, string path); Task Update(MovieMediaItem movie); Task GetMovieCount(); diff --git a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs index 1d0ddcde9..83e2a7706 100644 --- a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs @@ -64,7 +64,8 @@ namespace ErsatzTV.Core.Metadata try { if (mediaItem.Statistics is null || - mediaItem.LastWriteTime < _localFileSystem.GetLastWriteTime(mediaItem.Path)) + (mediaItem.Statistics.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(mediaItem.Path)) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path); await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); diff --git a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs index 8da36d3a5..9be90b171 100644 --- a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -15,14 +15,17 @@ namespace ErsatzTV.Core.Metadata { public class LocalStatisticsProvider : ILocalStatisticsProvider { + private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; public LocalStatisticsProvider( IMediaItemRepository mediaItemRepository, + ILocalFileSystem localFileSystem, ILogger logger) { _mediaItemRepository = mediaItemRepository; + _localFileSystem = localFileSystem; _logger = logger; } @@ -52,6 +55,7 @@ namespace ErsatzTV.Core.Metadata bool durationChange = mediaItem.Statistics.Duration != statistics.Duration; + mediaItem.Statistics.LastWriteTime = _localFileSystem.GetLastWriteTime(mediaItem.Path); mediaItem.Statistics.Duration = statistics.Duration; mediaItem.Statistics.AudioCodec = statistics.AudioCodec; mediaItem.Statistics.SampleAspectRatio = statistics.SampleAspectRatio; diff --git a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs index fbd79e3ea..23006b56f 100644 --- a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -71,7 +71,8 @@ namespace ErsatzTV.Core.Metadata async nfoFile => { if (movie.Metadata == null || movie.Metadata.Source == MetadataSource.Fallback || - movie.Metadata.LastWriteTime < _localFileSystem.GetLastWriteTime(nfoFile)) + (movie.Metadata.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(nfoFile)) { _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile); @@ -102,10 +103,11 @@ namespace ErsatzTV.Core.Metadata async posterFile => { if (string.IsNullOrWhiteSpace(movie.Poster) || - movie.PosterLastWriteTime < _localFileSystem.GetLastWriteTime(posterFile)) + (movie.PosterLastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(posterFile)) { _logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile); - await SavePosterToDisk(movie, posterFile, _movieRepository.Update); + await SavePosterToDisk(movie, posterFile, _movieRepository.Update, 440); } }); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index ae8c79729..2185d6970 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -34,7 +34,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task> GetSimpleMediaCollectionWithItems(int id) => _dbContext.SimpleMediaCollections .Include(s => s.Movies) - .ThenInclude(i => i.Source) + .ThenInclude(m => m.Source) .Include(s => s.TelevisionShows) .ThenInclude(s => s.Metadata) .Include(s => s.TelevisionSeasons) @@ -48,6 +48,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .AsNoTracking() .Include(s => s.Movies) .ThenInclude(i => i.Source) + .Include(s => s.Movies) + .ThenInclude(m => m.Metadata) .Include(s => s.TelevisionShows) .ThenInclude(s => s.Metadata) .Include(s => s.TelevisionSeasons) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index 097ba7cdb..29b7a3a61 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -17,6 +17,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public MovieRepository(TvContext dbContext) => _dbContext = dbContext; + public Task> GetMovie(int movieId) => + _dbContext.MovieMediaItems + .Include(m => m.Metadata) + .SingleOrDefaultAsync(m => m.Id == movieId) + .Map(Optional); + public async Task> GetOrAdd(int mediaSourceId, string path) { Option maybeExisting = await _dbContext.MovieMediaItems diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index ec5078c66..0ca8c2935 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -198,6 +198,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { Option maybeExisting = await _dbContext.TelevisionEpisodeMediaItems .Include(i => i.Metadata) + .Include(i => i.Statistics) .SingleOrDefaultAsync(i => i.Path == path); return await maybeExisting.Match( diff --git a/ErsatzTV/Pages/Movie.razor b/ErsatzTV/Pages/Movie.razor new file mode 100644 index 000000000..3004229c3 --- /dev/null +++ b/ErsatzTV/Pages/Movie.razor @@ -0,0 +1,76 @@ +@page "/media/movies/{MovieId:int}" +@using ErsatzTV.Application.Movies +@using ErsatzTV.Application.Movies.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@inject IMediator Mediator +@inject IDialogService Dialog +@inject NavigationManager NavigationManager + + + + +
+ @if (!string.IsNullOrWhiteSpace(_movie.Poster)) + { + + + + } + +
+ @_movie.Title + @_movie.Year + @_movie.Plot +
+ + Add To Collection + +
+
+
+
+
+
+ +@code { + + [Parameter] + public int MovieId { get; set; } + + private MovieViewModel _movie; + + private List _breadcrumbs; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() + { + await Mediator.Send(new GetMovieById(MovieId)) + .IfSomeAsync(vm => _movie = vm); + + _breadcrumbs = new List + { + new("Movies", "/media/movies"), + new($"{_movie.Title} ({_movie.Year})", null, true) + }; + } + + private async Task AddToCollection() + { + var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + await Mediator.Send(new AddMovieToSimpleMediaCollection(collection.Id, MovieId)); + NavigationManager.NavigateTo($"/media/collections/{collection.Id}"); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Movies.razor b/ErsatzTV/Pages/MovieList.razor similarity index 91% rename from ErsatzTV/Pages/Movies.razor rename to ErsatzTV/Pages/MovieList.razor index 8bfcc7a26..20d0b2b30 100644 --- a/ErsatzTV/Pages/Movies.razor +++ b/ErsatzTV/Pages/MovieList.razor @@ -22,7 +22,9 @@ @foreach (MovieCardViewModel card in _data.Cards) { - + }