diff --git a/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs b/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs index a22ea7d09..13243443d 100644 --- a/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs +++ b/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs @@ -1,9 +1,7 @@ -using System; -using System.IO; -using System.Security.Cryptography; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Images; using LanguageExt; using MediatR; @@ -11,33 +9,12 @@ namespace ErsatzTV.Application.Images.Commands { public class SaveImageToDiskHandler : IRequestHandler> { - private static readonly SHA1CryptoServiceProvider Crypto; + private readonly IImageCache _imageCache; - static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider(); + public SaveImageToDiskHandler(IImageCache imageCache) => _imageCache = imageCache; - public async Task> Handle( + public Task> Handle( SaveImageToDisk request, - CancellationToken cancellationToken) - { - try - { - byte[] hash = Crypto.ComputeHash(request.Buffer); - string hex = BitConverter.ToString(hash).Replace("-", string.Empty); - - string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex); - - if (!Directory.Exists(FileSystemLayout.ImageCacheFolder)) - { - Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder); - } - - await File.WriteAllBytesAsync(fileName, request.Buffer, cancellationToken); - return hex; - } - catch (Exception ex) - { - return BaseError.New(ex.Message); - } - } + CancellationToken cancellationToken) => _imageCache.SaveImage(request.Buffer); } } diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs index a6005b44f..31d5a0ca3 100644 --- a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs +++ b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs @@ -1,9 +1,8 @@ namespace ErsatzTV.Application.MediaItems { public record AggregateMediaItemViewModel( - int MediaItemId, string Title, string Subtitle, string SortTitle, - bool HasPoster); + string Poster); } diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs index 5b2edda53..93ee700af 100644 --- a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs +++ b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs @@ -30,11 +30,10 @@ namespace ErsatzTV.Application.MediaItems.Queries var results = allItems .Map( s => new AggregateMediaItemViewModel( - s.MediaItemId, s.Title, s.Subtitle, s.SortTitle, - !string.IsNullOrWhiteSpace(s.PosterPath))) + s.Poster)) .ToList(); return new AggregateMediaItemResults(count, results); diff --git a/ErsatzTV.Application/MediaItems/Queries/GetPosterContentsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetPosterContentsHandler.cs index d39797c45..735f00a26 100644 --- a/ErsatzTV.Application/MediaItems/Queries/GetPosterContentsHandler.cs +++ b/ErsatzTV.Application/MediaItems/Queries/GetPosterContentsHandler.cs @@ -39,12 +39,12 @@ namespace ErsatzTV.Application.MediaItems.Queries try { return await _memoryCache.GetOrCreateAsync( - mediaItem.PosterPath, + mediaItem.Poster, async entry => { entry.SlidingExpiration = TimeSpan.FromHours(1); - byte[] contents = await File.ReadAllBytesAsync(mediaItem.PosterPath); + byte[] contents = await File.ReadAllBytesAsync(mediaItem.Poster); MimeType mimeType = MimeTypes.GetMimeType(contents); return new ImageViewModel(contents, mimeType.Name); }); @@ -63,7 +63,7 @@ namespace ErsatzTV.Application.MediaItems.Queries .ToValidation($"MediaItem {request.MediaItemId} does not exist."); private static Validation PosterPathMustExist(MediaItem mediaItem) => - Optional(mediaItem.PosterPath) + Optional(mediaItem.Poster) .Filter(p => !string.IsNullOrWhiteSpace(p) && File.Exists(p)) .Map(_ => mediaItem) .ToValidation($"MediaItem {mediaItem.Id} does not have a poster"); diff --git a/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs b/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs index 0ae3f6920..90461143b 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 PosterPath, int MediaItemId); + public record MediaItemSummary(string Title, string SortTitle, string Subtitle, string Poster); } diff --git a/ErsatzTV.Core/Domain/MediaItem.cs b/ErsatzTV.Core/Domain/MediaItem.cs index 87a6f7d48..d5e8fd7b7 100644 --- a/ErsatzTV.Core/Domain/MediaItem.cs +++ b/ErsatzTV.Core/Domain/MediaItem.cs @@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Domain public int MediaSourceId { get; set; } public MediaSource Source { get; set; } public string Path { get; set; } - public string PosterPath { get; set; } + public string Poster { get; set; } public MediaMetadata Metadata { get; set; } public DateTime? LastWriteTime { get; set; } public IList SimpleMediaCollections { get; set; } diff --git a/ErsatzTV.Core/Interfaces/Images/IImageCache.cs b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs new file mode 100644 index 000000000..5d031a050 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Images +{ + public interface IImageCache + { + Task> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width); + Task> SaveImage(byte[] imageBuffer); + } +} diff --git a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs index ae483bc5a..eb352942b 100644 --- a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs @@ -2,35 +2,42 @@ using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; +using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; namespace ErsatzTV.Core.Metadata { public class LocalPosterProvider : ILocalPosterProvider { + private readonly IImageCache _imageCache; + private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; - public LocalPosterProvider(IMediaItemRepository mediaItemRepository) => + public LocalPosterProvider( + IMediaItemRepository mediaItemRepository, + IImageCache imageCache, + ILogger logger) + { _mediaItemRepository = mediaItemRepository; + _imageCache = imageCache; + _logger = logger; + } - public Task RefreshPoster(MediaItem mediaItem) + public async Task RefreshPoster(MediaItem mediaItem) { - Option maybePoster = mediaItem.Metadata.MediaType switch + Option maybePosterPath = mediaItem.Metadata.MediaType switch { MediaType.Movie => RefreshMoviePoster(mediaItem), MediaType.TvShow => RefreshTelevisionPoster(mediaItem), _ => None }; - return maybePoster.Match( - path => - { - mediaItem.PosterPath = path; - return _mediaItemRepository.Update(mediaItem); - }, + await maybePosterPath.Match( + path => SavePosterToDisk(mediaItem, path), Task.CompletedTask); } @@ -50,5 +57,22 @@ namespace ErsatzTV.Core.Metadata } private Option RefreshTelevisionPoster(MediaItem mediaItem) => None; + + private async Task SavePosterToDisk(MediaItem mediaItem, string posterPath) + { + byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); + Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); + await maybeHash.Match( + hash => + { + mediaItem.Poster = hash; + return _mediaItemRepository.Update(mediaItem); + }, + error => + { + _logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value); + return Task.CompletedTask; + }); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index 638cdecf2..004d9cc10 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -49,8 +49,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories Metadata_Title AS Title, Metadata_SortTitle AS SortTitle, substr(Metadata_Aired, 1, 4) AS Subtitle, - PosterPath, - Id as MediaItemId + Poster FROM MediaItems WHERE Metadata_MediaType=2 ORDER BY Metadata_SortTitle LIMIT {0} OFFSET {1}", @@ -63,8 +62,7 @@ LIMIT {0} OFFSET {1}", Metadata_Title AS Title, Metadata_SortTitle AS SortTitle, count(*) || ' Episodes' AS Subtitle, - PosterPath, - min(Id) as MediaItemId + Poster FROM MediaItems WHERE Metadata_MediaType=1 GROUP BY Metadata_Title, Metadata_SortTitle ORDER BY Metadata_SortTitle diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 6f4df44d0..db9613c4a 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -13,6 +13,7 @@ + diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs new file mode 100644 index 000000000..0c467781e --- /dev/null +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Images; +using LanguageExt; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; + +namespace ErsatzTV.Infrastructure.Images +{ + public class ImageCache : IImageCache + { + private static readonly SHA1CryptoServiceProvider Crypto; + + static ImageCache() => Crypto = new SHA1CryptoServiceProvider(); + + public async Task> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width) + { + await using var inStream = new MemoryStream(imageBuffer); + using var image = await Image.LoadAsync(inStream); + + Size size = height.HasValue ? new Size { Height = height.Value } : new Size { Width = width.Value }; + + image.Mutate( + i => i.Resize( + new ResizeOptions + { + Mode = ResizeMode.Max, + Size = size + })); + + await using var outStream = new MemoryStream(); + await image.SaveAsync(outStream, new JpegEncoder { Quality = 90 }); + + return await SaveImage(outStream.ToArray()); + } + + public async Task> SaveImage(byte[] imageBuffer) + { + try + { + byte[] hash = Crypto.ComputeHash(imageBuffer); + string hex = BitConverter.ToString(hash).Replace("-", string.Empty); + + string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex); + + if (!Directory.Exists(FileSystemLayout.ImageCacheFolder)) + { + Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder); + } + + await File.WriteAllBytesAsync(fileName, imageBuffer); + return hex; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs similarity index 95% rename from ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.Designer.cs rename to ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs index 12294fe3c..281a65d95 100644 --- a/ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.Designer.cs +++ b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs @@ -9,8 +9,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ErsatzTV.Infrastructure.Migrations { [DbContext(typeof(TvContext))] - [Migration("20210213191309_MediaItemPosterPath")] - partial class MediaItemPosterPath + [Migration("20210213221040_MediaItemPoster")] + partial class MediaItemPoster { protected override void BuildTargetModel(ModelBuilder modelBuilder) { @@ -18,6 +18,42 @@ namespace ErsatzTV.Infrastructure.Migrations modelBuilder .HasAnnotation("ProductVersion", "5.0.3"); + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + }); + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => { b.Property("Id") @@ -168,7 +204,7 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Path") .HasColumnType("TEXT"); - b.Property("PosterPath") + b.Property("Poster") .HasColumnType("TEXT"); b.HasKey("Id"); diff --git a/ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.cs b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs similarity index 92% rename from ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.cs rename to ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs index 665df2064..b0f47f9c1 100644 --- a/ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.cs +++ b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs @@ -2,7 +2,7 @@ namespace ErsatzTV.Infrastructure.Migrations { - public partial class MediaItemPosterPath : Migration + public partial class MediaItemPoster : Migration { protected override void Up(MigrationBuilder migrationBuilder) { @@ -13,7 +13,7 @@ namespace ErsatzTV.Infrastructure.Migrations "MediaCollectionSummaries"); migrationBuilder.AddColumn( - "PosterPath", + "Poster", "MediaItems", "TEXT", nullable: true); @@ -22,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Migrations protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( - "PosterPath", + "Poster", "MediaItems"); migrationBuilder.CreateTable( diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index a22b6828b..80651b368 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -16,6 +16,48 @@ namespace ErsatzTV.Infrastructure.Migrations modelBuilder .HasAnnotation("ProductVersion", "5.0.3"); + modelBuilder.Entity( + "ErsatzTV.Core.AggregateModels.GenericIntegerId", + b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.AggregateModels.MediaCollectionSummary", + b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.AggregateModels.MediaItemSummary", + b => + { + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.Channel", b => @@ -176,7 +218,7 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Path") .HasColumnType("TEXT"); - b.Property("PosterPath") + b.Property("Poster") .HasColumnType("TEXT"); b.HasKey("Id"); diff --git a/ErsatzTV/Controllers/PostersController.cs b/ErsatzTV/Controllers/PostersController.cs index 518503e7b..9395903ac 100644 --- a/ErsatzTV/Controllers/PostersController.cs +++ b/ErsatzTV/Controllers/PostersController.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; using ErsatzTV.Application.Images; -using ErsatzTV.Application.MediaItems.Queries; +using ErsatzTV.Application.Images.Queries; using ErsatzTV.Core; using LanguageExt; using MediatR; @@ -16,10 +16,10 @@ namespace ErsatzTV.Controllers public PostersController(IMediator mediator) => _mediator = mediator; - [HttpGet("/posters/{mediaItemId}")] - public async Task ForMediaItem(int mediaItemId) + [HttpGet("/posters/{fileName}")] + public async Task GetImage(string fileName) { - Either imageContents = await _mediator.Send(new GetPosterContents(mediaItemId)); + Either imageContents = await _mediator.Send(new GetImageContents(fileName)); return imageContents.Match( Left: _ => new NotFoundResult(), Right: r => new FileContentResult(r.Contents, r.MimeType)); diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index e219cce2f..93706e081 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -1,9 +1,9 @@ @using ErsatzTV.Application.MediaItems
- @if (Data.HasPoster) + @if (!string.IsNullOrWhiteSpace(Data.Poster)) { - + } else { diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 911715366..212a89df7 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application.Channels.Queries; using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; @@ -15,6 +16,7 @@ using ErsatzTV.Core.Scheduling; using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Serialization; using ErsatzTV.Services; @@ -167,6 +169,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService();