From 6504ca10a8c3f6fbd5bb232a011fa24786931a3c Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Wed, 26 May 2021 19:49:08 -0500 Subject: [PATCH] cache local artwork on disk (#217) --- .../Images/CachedImagePathViewModel.cs | 4 ++ ErsatzTV.Application/Images/ImageViewModel.cs | 5 -- ...ImageContents.cs => GetCachedImagePath.cs} | 5 +- .../Queries/GetCachedImagePathHandler.cs | 72 +++++++++++++++++++ .../Images/Queries/GetImageContentsHandler.cs | 69 ------------------ .../Interfaces/Images/IImageCache.cs | 1 + ErsatzTV.Infrastructure/Images/ImageCache.cs | 22 +++++- ErsatzTV/Controllers/ArtworkController.cs | 24 +++---- ErsatzTV/Controllers/IptvController.cs | 8 +-- 9 files changed, 116 insertions(+), 94 deletions(-) create mode 100644 ErsatzTV.Application/Images/CachedImagePathViewModel.cs delete mode 100644 ErsatzTV.Application/Images/ImageViewModel.cs rename ErsatzTV.Application/Images/Queries/{GetImageContents.cs => GetCachedImagePath.cs} (63%) create mode 100644 ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs delete mode 100644 ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs diff --git a/ErsatzTV.Application/Images/CachedImagePathViewModel.cs b/ErsatzTV.Application/Images/CachedImagePathViewModel.cs new file mode 100644 index 000000000..bc276043a --- /dev/null +++ b/ErsatzTV.Application/Images/CachedImagePathViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Images +{ + public record CachedImagePathViewModel(string FileName, string MimeType); +} diff --git a/ErsatzTV.Application/Images/ImageViewModel.cs b/ErsatzTV.Application/Images/ImageViewModel.cs deleted file mode 100644 index d9c0c0acc..000000000 --- a/ErsatzTV.Application/Images/ImageViewModel.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ErsatzTV.Application.Images -{ - // ReSharper disable once SuggestBaseTypeForParameter - public record ImageViewModel(byte[] Contents, string MimeType); -} diff --git a/ErsatzTV.Application/Images/Queries/GetImageContents.cs b/ErsatzTV.Application/Images/Queries/GetCachedImagePath.cs similarity index 63% rename from ErsatzTV.Application/Images/Queries/GetImageContents.cs rename to ErsatzTV.Application/Images/Queries/GetCachedImagePath.cs index b1ef0ed46..f8996757f 100644 --- a/ErsatzTV.Application/Images/Queries/GetImageContents.cs +++ b/ErsatzTV.Application/Images/Queries/GetCachedImagePath.cs @@ -5,6 +5,7 @@ using MediatR; namespace ErsatzTV.Application.Images.Queries { - public record GetImageContents - (string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest>; + public record GetCachedImagePath + (string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest< + Either>; } diff --git a/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs b/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs new file mode 100644 index 000000000..a8c6585da --- /dev/null +++ b/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Images; +using LanguageExt; +using MediatR; +using Winista.Mime; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Images.Queries +{ + public class + GetCachedImagePathHandler : IRequestHandler> + { + private static readonly MimeTypes MimeTypes = new(); + private readonly IImageCache _imageCache; + + public GetCachedImagePathHandler(IImageCache imageCache) => _imageCache = imageCache; + + public async Task> Handle( + GetCachedImagePath request, + CancellationToken cancellationToken) + { + try + { + MimeType mimeType; + + string cachePath = _imageCache.GetPathForImage( + request.FileName, + request.ArtworkKind, + Optional(request.MaxHeight)); + if (!File.Exists(cachePath)) + { + if (request.MaxHeight.HasValue) + { + string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None); + byte[] contents = await File.ReadAllBytesAsync(originalPath, cancellationToken); + Either resizeResult = + await _imageCache.ResizeImage(contents, request.MaxHeight.Value); + resizeResult.IfRight(result => contents = result); + + string baseFolder = Path.GetDirectoryName(cachePath); + if (baseFolder != null && !Directory.Exists(baseFolder)) + { + Directory.CreateDirectory(baseFolder); + } + + await File.WriteAllBytesAsync(cachePath, contents, cancellationToken); + + mimeType = new MimeType("image/jpeg"); + } + else + { + return BaseError.New($"Artwork does not exist on disk at {cachePath}"); + } + } + else + { + mimeType = MimeTypes.GetMimeTypeFromFile(cachePath); + } + + return new CachedImagePathViewModel(cachePath, mimeType.Name); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs b/ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs deleted file mode 100644 index e17c9963e..000000000 --- a/ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Images; -using LanguageExt; -using MediatR; -using Microsoft.Extensions.Caching.Memory; -using Winista.Mime; - -namespace ErsatzTV.Application.Images.Queries -{ - public class GetImageContentsHandler : IRequestHandler> - { - private static readonly MimeTypes MimeTypes = new(); - private readonly IImageCache _imageCache; - private readonly IMemoryCache _memoryCache; - - public GetImageContentsHandler(IImageCache imageCache, IMemoryCache memoryCache) - { - _imageCache = imageCache; - _memoryCache = memoryCache; - } - - public async Task> Handle( - GetImageContents request, - CancellationToken cancellationToken) - { - try - { - return await _memoryCache.GetOrCreateAsync( - request.FileName, - async entry => - { - entry.SlidingExpiration = TimeSpan.FromHours(1); - - string subfolder = request.FileName.Substring(0, 2); - string baseFolder = request.ArtworkKind switch - { - ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder), - ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder), - ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder), - ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder), - _ => FileSystemLayout.LegacyImageCacheFolder - }; - - string fileName = Path.Combine(baseFolder, request.FileName); - byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken); - - if (request.MaxHeight.HasValue) - { - Either resizeResult = await _imageCache - .ResizeImage(contents, request.MaxHeight.Value); - resizeResult.IfRight(result => contents = result); - } - - MimeType mimeType = MimeTypes.GetMimeType(contents); - return new ImageViewModel(contents, mimeType.Name); - }); - } - catch (Exception ex) - { - return BaseError.New(ex.Message); - } - } - } -} diff --git a/ErsatzTV.Core/Interfaces/Images/IImageCache.cs b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs index 84d359e76..b2ebb1bf4 100644 --- a/ErsatzTV.Core/Interfaces/Images/IImageCache.cs +++ b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs @@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Interfaces.Images Task> ResizeImage(byte[] imageBuffer, int height); Task> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind); Task> CopyArtworkToCache(string path, ArtworkKind artworkKind); + string GetPathForImage(string fileName, ArtworkKind artworkKind, Option maybeMaxHeight); } } diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs index a5385717b..cebdc093e 100644 --- a/ErsatzTV.Infrastructure/Images/ImageCache.cs +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -50,7 +50,7 @@ namespace ErsatzTV.Infrastructure.Images { byte[] hash = Crypto.ComputeHash(imageBuffer); string hex = BitConverter.ToString(hash).Replace("-", string.Empty); - string subfolder = hex.Substring(0, 2); + string subfolder = hex[..2]; string baseFolder = artworkKind switch { ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder), @@ -82,7 +82,7 @@ namespace ErsatzTV.Infrastructure.Images var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}"; byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey)); string hex = BitConverter.ToString(hash).Replace("-", string.Empty); - string subfolder = hex.Substring(0, 2); + string subfolder = hex[..2]; string baseFolder = artworkKind switch { ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder), @@ -102,5 +102,23 @@ namespace ErsatzTV.Infrastructure.Images return BaseError.New(ex.ToString()); } } + + public string GetPathForImage(string fileName, ArtworkKind artworkKind, Option maybeMaxHeight) + { + string subfolder = maybeMaxHeight.Match( + maxHeight => Path.Combine(maxHeight.ToString(), fileName[..2]), + () => fileName[..2]); + + string baseFolder = artworkKind switch + { + ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder), + ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder), + ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder), + ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder), + _ => FileSystemLayout.LegacyImageCacheFolder + }; + + return Path.Combine(baseFolder, fileName); + } } } diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index 04e454121..aae9991c4 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -39,21 +39,21 @@ namespace ErsatzTV.Controllers [HttpGet("/artwork/posters/{fileName}")] public async Task GetPoster(string fileName) { - Either imageContents = - await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Poster, 440)); - return imageContents.Match( + Either cachedImagePath = + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Poster, 440)); + return cachedImagePath.Match( Left: _ => new NotFoundResult(), - Right: r => new FileContentResult(r.Contents, r.MimeType)); + Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } [HttpGet("/artwork/fanart/{fileName}")] public async Task GetFanArt(string fileName) { - Either imageContents = - await _mediator.Send(new GetImageContents(fileName, ArtworkKind.FanArt)); - return imageContents.Match( + Either cachedImagePath = + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt)); + return cachedImagePath.Match( Left: _ => new NotFoundResult(), - Right: r => new FileContentResult(r.Contents, r.MimeType)); + Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } @@ -108,11 +108,11 @@ namespace ErsatzTV.Controllers [HttpGet("/artwork/thumbnails/{fileName}")] public async Task GetThumbnail(string fileName) { - Either imageContents = - await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Thumbnail, 220)); - return imageContents.Match( + Either cachedImagePath = + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, 220)); + return cachedImagePath.Match( Left: _ => new NotFoundResult(), - Right: r => new FileContentResult(r.Contents, r.MimeType)); + Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } private async Task GetPlexArtwork(int plexMediaSourceId, string transcodePath) diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 1e00a9932..604027fbe 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -67,11 +67,11 @@ namespace ErsatzTV.Controllers [HttpGet("iptv/logos/{fileName}")] public async Task GetImage(string fileName) { - Either imageContents = - await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Logo)); - return imageContents.Match( + Either cachedImagePath = + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo)); + return cachedImagePath.Match( Left: _ => new NotFoundResult(), - Right: r => new FileContentResult(r.Contents, r.MimeType)); + Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } } }