Browse Source

resize movie posters and store in cache with channel logos

pull/22/head
Jason Dove 6 years ago
parent
commit
d87ecb6440
  1. 35
      ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs
  2. 3
      ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs
  3. 3
      ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs
  4. 6
      ErsatzTV.Application/MediaItems/Queries/GetPosterContentsHandler.cs
  5. 2
      ErsatzTV.Core/AggregateModels/MediaItemSummary.cs
  6. 2
      ErsatzTV.Core/Domain/MediaItem.cs
  7. 11
      ErsatzTV.Core/Interfaces/Images/IImageCache.cs
  8. 42
      ErsatzTV.Core/Metadata/LocalPosterProvider.cs
  9. 6
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  10. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  11. 64
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  12. 42
      ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs
  13. 6
      ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs
  14. 44
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  15. 8
      ErsatzTV/Controllers/PostersController.cs
  16. 4
      ErsatzTV/Shared/MediaCard.razor
  17. 3
      ErsatzTV/Startup.cs

35
ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs

@ -1,9 +1,7 @@ @@ -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 @@ -11,33 +9,12 @@ namespace ErsatzTV.Application.Images.Commands
{
public class SaveImageToDiskHandler : IRequestHandler<SaveImageToDisk, Either<BaseError, string>>
{
private static readonly SHA1CryptoServiceProvider Crypto;
private readonly IImageCache _imageCache;
static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider();
public SaveImageToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, string>> Handle(
public Task<Either<BaseError, string>> 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);
}
}

3
ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs

@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
bool HasPoster);
string Poster);
}

3
ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs

@ -30,11 +30,10 @@ namespace ErsatzTV.Application.MediaItems.Queries @@ -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);

6
ErsatzTV.Application/MediaItems/Queries/GetPosterContentsHandler.cs

@ -39,12 +39,12 @@ namespace ErsatzTV.Application.MediaItems.Queries @@ -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 @@ -63,7 +63,7 @@ namespace ErsatzTV.Application.MediaItems.Queries
.ToValidation<BaseError>($"MediaItem {request.MediaItemId} does not exist.");
private static Validation<BaseError, MediaItem> PosterPathMustExist(MediaItem mediaItem) =>
Optional(mediaItem.PosterPath)
Optional(mediaItem.Poster)
.Filter(p => !string.IsNullOrWhiteSpace(p) && File.Exists(p))
.Map(_ => mediaItem)
.ToValidation<BaseError>($"MediaItem {mediaItem.Id} does not have a poster");

2
ErsatzTV.Core/AggregateModels/MediaItemSummary.cs

@ -1,4 +1,4 @@ @@ -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);
}

2
ErsatzTV.Core/Domain/MediaItem.cs

@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Domain @@ -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<SimpleMediaCollection> SimpleMediaCollections { get; set; }

11
ErsatzTV.Core/Interfaces/Images/IImageCache.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Threading.Tasks;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images
{
public interface IImageCache
{
Task<Either<BaseError, string>> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width);
Task<Either<BaseError, string>> SaveImage(byte[] imageBuffer);
}
}

42
ErsatzTV.Core/Metadata/LocalPosterProvider.cs

@ -2,35 +2,42 @@ @@ -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<LocalPosterProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalPosterProvider(IMediaItemRepository mediaItemRepository) =>
public LocalPosterProvider(
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
ILogger<LocalPosterProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_logger = logger;
}
public Task RefreshPoster(MediaItem mediaItem)
public async Task RefreshPoster(MediaItem mediaItem)
{
Option<string> maybePoster = mediaItem.Metadata.MediaType switch
Option<string> 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 @@ -50,5 +57,22 @@ namespace ErsatzTV.Core.Metadata
}
private Option<string> RefreshTelevisionPoster(MediaItem mediaItem) => None;
private async Task SavePosterToDisk(MediaItem mediaItem, string posterPath)
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> 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;
});
}
}
}

6
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -49,8 +49,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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}", @@ -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

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.3" />
<PackageReference Include="Refit" Version="6.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2" />
</ItemGroup>
<ItemGroup>

64
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -0,0 +1,64 @@ @@ -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<Either<BaseError, string>> 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<Either<BaseError, string>> 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);
}
}
}
}

42
ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.Designer.cs → ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs generated

@ -9,8 +9,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -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 @@ -18,6 +18,42 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b =>
{
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Property<int>("Id")
@ -168,7 +204,7 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -168,7 +204,7 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("PosterPath")
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.HasKey("Id");

6
ErsatzTV.Infrastructure/Migrations/20210213191309_MediaItemPosterPath.cs → ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs

@ -2,7 +2,7 @@ @@ -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 @@ -13,7 +13,7 @@ namespace ErsatzTV.Infrastructure.Migrations
"MediaCollectionSummaries");
migrationBuilder.AddColumn<string>(
"PosterPath",
"Poster",
"MediaItems",
"TEXT",
nullable: true);
@ -22,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -22,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"PosterPath",
"Poster",
"MediaItems");
migrationBuilder.CreateTable(

44
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -16,6 +16,48 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -16,6 +16,48 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.GenericIntegerId",
b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
});
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.MediaCollectionSummary",
b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
});
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.MediaItemSummary",
b =>
{
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
@ -176,7 +218,7 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -176,7 +218,7 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("PosterPath")
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.HasKey("Id");

8
ErsatzTV/Controllers/PostersController.cs

@ -1,6 +1,6 @@ @@ -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 @@ -16,10 +16,10 @@ namespace ErsatzTV.Controllers
public PostersController(IMediator mediator) => _mediator = mediator;
[HttpGet("/posters/{mediaItemId}")]
public async Task<IActionResult> ForMediaItem(int mediaItemId)
[HttpGet("/posters/{fileName}")]
public async Task<IActionResult> GetImage(string fileName)
{
Either<BaseError, ImageViewModel> imageContents = await _mediator.Send(new GetPosterContents(mediaItemId));
Either<BaseError, ImageViewModel> imageContents = await _mediator.Send(new GetImageContents(fileName));
return imageContents.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new FileContentResult(r.Contents, r.MimeType));

4
ErsatzTV/Shared/MediaCard.razor

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
@using ErsatzTV.Application.MediaItems
<div class="media-card-container mx-3 pb-3">
<MudPaper Class="media-card">
@if (Data.HasPoster)
@if (!string.IsNullOrWhiteSpace(Data.Poster))
{
<img src="@($"/posters/{Data.MediaItemId}")" style="max-height: 220px"/>
<img src="@($"/posters/{Data.Poster}")" style="max-height: 220px"/>
}
else
{

3
ErsatzTV/Startup.cs

@ -6,6 +6,7 @@ using ErsatzTV.Application.Channels.Queries; @@ -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; @@ -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 @@ -167,6 +169,7 @@ namespace ErsatzTV
services.AddScoped<ILocalPosterProvider, LocalPosterProvider>();
services.AddScoped<ILocalMediaScanner, LocalMediaScanner>();
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>();
services.AddScoped<IImageCache, ImageCache>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();

Loading…
Cancel
Save