diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs index 31d5a0ca3..8fed60516 100644 --- a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs +++ b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs @@ -1,6 +1,7 @@ namespace ErsatzTV.Application.MediaItems { public record AggregateMediaItemViewModel( + int MediaItemId, string Title, string Subtitle, string SortTitle, diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs new file mode 100644 index 000000000..c0a6f3ace --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaItems.Commands +{ + public class + RefreshMediaItemPosterHandler : MediatR.IRequestHandler> + { + private readonly ILocalPosterProvider _localPosterProvider; + private readonly IMediaItemRepository _mediaItemRepository; + + public RefreshMediaItemPosterHandler( + IMediaItemRepository mediaItemRepository, + ILocalPosterProvider localPosterProvider) + { + _mediaItemRepository = mediaItemRepository; + _localPosterProvider = localPosterProvider; + } + + public Task> Handle( + RefreshMediaItemPoster request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(RefreshPoster) + .Bind(v => v.ToEitherAsync()); + + private Task> Validate(RefreshMediaItemPoster request) => + MediaItemMustExist(request); + + private Task> MediaItemMustExist(RefreshMediaItemPoster request) => + _mediaItemRepository.Get(request.MediaItemId) + .Map( + maybeItem => maybeItem.ToValidation( + $"[MediaItem] {request.MediaItemId} does not exist.")); + + private Task RefreshPoster(MediaItem mediaItem) => + _localPosterProvider.RefreshPoster(mediaItem).ToUnit(); + } +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs index 93ee700af..67dcfd792 100644 --- a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs +++ b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs @@ -30,6 +30,7 @@ namespace ErsatzTV.Application.MediaItems.Queries var results = allItems .Map( s => new AggregateMediaItemViewModel( + s.MediaItemId, s.Title, s.Subtitle, s.SortTitle, diff --git a/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs b/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs index 90461143b..be009ab73 100644 --- a/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs +++ b/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs @@ -1,4 +1,4 @@ namespace ErsatzTV.Core.AggregateModels { - public record MediaItemSummary(string Title, string SortTitle, string Subtitle, string Poster); + public record MediaItemSummary(int MediaItemId, string Title, string SortTitle, string Subtitle, string Poster); } diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs index 82def5ae3..3841ade71 100644 --- a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs @@ -66,8 +66,19 @@ namespace ErsatzTV.Core.Metadata ".avi", ".wmv", ".mov", ".mkv", ".ts" }; - var allFiles = Directory.GetFiles(localMediaSource.Folder, "*", SearchOption.AllDirectories) + Seq allDirectories = Directory + .GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories) + .ToSeq().Add(localMediaSource.Folder); + + // remove any directories with an .etvignore file locally, or in any parent directory + Seq excluded = allDirectories.Filter(ShouldExcludeDirectory); + Seq relevantDirectories = allDirectories + .Filter(d => !excluded.Any(d.StartsWith)); + + var allFiles = relevantDirectories + .Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly)) .Filter(file => knownExtensions.Contains(Path.GetExtension(file))) + .OrderBy(identity) .ToSeq(); // check if the media item exists @@ -85,13 +96,14 @@ namespace ErsatzTV.Core.Metadata } // if exists, check if the file was modified - // also, try to re-categorize "other" by refreshing metadata + // also, try to re-categorize incorrect media types by refreshing metadata Seq modifiedMediaItems = existingMediaItems.Filter( mediaItem => { DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path); bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue); - return modified || mediaItem.Metadata == null || mediaItem.Metadata.MediaType == MediaType.Other; + return modified || mediaItem.Metadata == null || + mediaItem.Metadata.MediaType != localMediaSource.MediaType; }); modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems)); foreach (MediaItem mediaItem in modifiedMediaItems) @@ -141,5 +153,7 @@ namespace ErsatzTV.Core.Metadata await _localPosterProvider.RefreshPoster(mediaItem); await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); } + + private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore")); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index 089659449..83c9c7a6f 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -24,7 +24,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } public Task> Get(int id) => - _dbContext.MediaItems.SingleOrDefaultAsync(i => i.Id == id).Map(Optional); + _dbContext.MediaItems + .Include(i => i.Source) + .SingleOrDefaultAsync(i => i.Id == id) + .Map(Optional); public Task> GetAll() => _dbContext.MediaItems.ToListAsync(); @@ -46,6 +49,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw( @"SELECT + Id AS MediaItemId, Metadata_Title AS Title, Metadata_SortTitle AS SortTitle, substr(Metadata_Aired, 1, 4) AS Subtitle, @@ -59,10 +63,11 @@ LIMIT {0} OFFSET {1}", .ToListAsync(), MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw( @"SELECT + min(Id) AS MediaItemId, Metadata_Title AS Title, Metadata_SortTitle AS SortTitle, count(*) || ' Episodes' AS Subtitle, - min(Poster) AS Poster + max(Poster) AS Poster FROM MediaItems WHERE Metadata_MediaType=1 GROUP BY Metadata_Title, Metadata_SortTitle ORDER BY Metadata_SortTitle diff --git a/ErsatzTV.sln.DotSettings b/ErsatzTV.sln.DotSettings index 71b2aa00a..b999bc941 100644 --- a/ErsatzTV.sln.DotSettings +++ b/ErsatzTV.sln.DotSettings @@ -11,6 +11,7 @@ True True True + True True True True diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 1b97f7cad..4ebab80ad 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -20,7 +20,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index 93706e081..44fea3a41 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -1,16 +1,17 @@ @using ErsatzTV.Application.MediaItems +@using ErsatzTV.Application.MediaItems.Commands +@using Unit = LanguageExt.Unit +@inject IMediator Mediator +
- - @if (!string.IsNullOrWhiteSpace(Data.Poster)) - { - - } - else + + @if (string.IsNullOrWhiteSpace(Data.Poster)) { @Placeholder(Data.SortTitle) } + @Data.Title @@ -25,10 +26,26 @@ [Parameter] public AggregateMediaItemViewModel Data { get; set; } + [Parameter] + public EventCallback DataRefreshed { get; set; } + private string Placeholder(string sortTitle) { string first = sortTitle.Substring(0, 1).ToUpperInvariant(); return int.TryParse(first, out _) ? "#" : first; } + private string PosterForItem() => string.IsNullOrWhiteSpace(Data.Poster) + ? "position: relative" + : $"position: relative; background-image: url(/posters/{Data.Poster}); background-size: cover"; + + private async Task RefreshMetadata() + { + // TODO: how should we refresh an entire television show? + await Mediator.Send(new RefreshMediaItemMetadata(Data.MediaItemId)); + await Mediator.Send(new RefreshMediaItemCollections(Data.MediaItemId)); + await Mediator.Send(new RefreshMediaItemPoster(Data.MediaItemId)); + await DataRefreshed.InvokeAsync(); + } + } \ No newline at end of file diff --git a/ErsatzTV/Shared/MediaItemsGrid.razor b/ErsatzTV/Shared/MediaItemsGrid.razor index fb9882c03..84a4ca52f 100644 --- a/ErsatzTV/Shared/MediaItemsGrid.razor +++ b/ErsatzTV/Shared/MediaItemsGrid.razor @@ -21,7 +21,7 @@ @foreach (AggregateMediaItemViewModel item in _data.DataPage) { - + } diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index b612390cd..d3f31315d 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -8,12 +8,16 @@ .media-card { display: flex; + filter: brightness(100%); flex-direction: column; height: 220px; justify-content: center; + transition: all 0.2s ease; width: 152px; } +.media-card:hover { filter: brightness(80%); } + .media-card-title { overflow: hidden; text-overflow: ellipsis; @@ -21,4 +25,13 @@ width: 100%; } -.media-card-poster-placeholder { font-weight: bold; } \ No newline at end of file +.media-card-poster-placeholder { font-weight: bold; } + +.media-card-menu { + bottom: 0; + display: none; + position: absolute; + right: 0; +} + +.media-card:hover .media-card-menu { display: block; } \ No newline at end of file