PlotDirector/PlotLine/Services/BookCoverService.cs

250 lines
9.5 KiB
C#

using SkiaSharp;
using System.Collections.Concurrent;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IBookCoverService
{
Task<BookCoverUploadResult> UploadCoverAsync(Book book, IFormFile upload);
Task DeleteCoverFilesAsync(Book book);
}
public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null);
public sealed class BookCoverService(
IUploadStorageService uploadStorage,
IProjectCollaborationRepository collaboration,
ISubscriptionService subscriptions,
IUserFileRepository userFiles,
IBookRepository books,
ILogger<BookCoverService> logger) : IBookCoverService
{
public const string PlaceholderPath = "/images/placeholders/book-cover.svg";
private const long MaxUploadBytes = 10L * 1024L * 1024L;
private const string ThumbnailFileName = "cover-thumb.webp";
private const string StandardFileName = "cover-standard.webp";
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
private static readonly ConcurrentDictionary<int, SemaphoreSlim> BookLocks = new();
public async Task<BookCoverUploadResult> UploadCoverAsync(Book book, IFormFile upload)
{
var uploadLock = BookLocks.GetOrAdd(book.BookID, _ => new SemaphoreSlim(1, 1));
await uploadLock.WaitAsync();
try
{
return await UploadCoverCoreAsync(book, upload);
}
finally
{
uploadLock.Release();
}
}
private async Task<BookCoverUploadResult> UploadCoverCoreAsync(Book book, IFormFile upload)
{
if (upload.Length <= 0)
{
return new(false, "Choose a cover image to upload.");
}
if (upload.Length > MaxUploadBytes)
{
return new(false, "Cover image must be 10 MB or smaller.");
}
if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName)))
{
return new(false, "Cover image must be a JPG, PNG or WebP file.");
}
var ownerUserId = await collaboration.GetOwnerUserIdAsync(book.ProjectID);
if (!ownerUserId.HasValue)
{
return new(false, "Book owner could not be found.");
}
var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length));
if (!storageLimit.Allowed)
{
return new(false, storageLimit.Message);
}
SKBitmap bitmap;
try
{
await using var uploadStream = upload.OpenReadStream();
using var memory = new MemoryStream();
await uploadStream.CopyToAsync(memory);
memory.Position = 0;
using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException();
if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
{
return new(false, "Cover image must be a valid JPG, PNG or WebP file.");
}
memory.Position = 0;
bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException();
}
catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
{
return new(false, "Cover image must be a valid JPG, PNG or WebP file.");
}
using (bitmap)
{
if (bitmap.Width <= 0 || bitmap.Height <= 0)
{
return new(false, "Cover image could not be read as a valid image.");
}
var uploadRoot = uploadStorage.GetDirectory("books", book.BookID.ToString());
var version = Guid.NewGuid().ToString("N");
var thumbnailTemp = Path.Combine(uploadRoot, $"cover-thumb-{version}.tmp");
var standardTemp = Path.Combine(uploadRoot, $"cover-standard-{version}.tmp");
var thumbnailPath = Path.Combine(uploadRoot, ThumbnailFileName);
var standardPath = Path.Combine(uploadRoot, StandardFileName);
var thumbnailStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), ThumbnailFileName);
var standardStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), StandardFileName);
try
{
SaveWebpVariant(bitmap, thumbnailTemp, 400, 600, 72);
SaveWebpVariant(bitmap, standardTemp, 600, 900, 78);
ReplaceFile(thumbnailTemp, thumbnailPath);
ReplaceFile(standardTemp, standardPath);
var oldThumbnailPath = book.CoverThumbnailPath;
var oldStandardPath = book.CoverStandardPath;
await books.UpdateCoverAsync(book.BookID, thumbnailStoragePath, standardStoragePath);
await MarkPreviousLedgerRowsAsync(oldThumbnailPath);
await MarkPreviousLedgerRowsAsync(oldStandardPath);
await TrackCoverAsync(ownerUserId.Value, book.ProjectID, book.BookID, thumbnailStoragePath, "Book cover thumbnail", new FileInfo(thumbnailPath).Length);
await TrackCoverAsync(ownerUserId.Value, book.ProjectID, book.BookID, standardStoragePath, "Book cover standard", new FileInfo(standardPath).Length);
DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath);
DeletePreviousPhysicalFile(oldStandardPath, standardStoragePath);
return new(true);
}
catch (Exception ex)
{
TryDeleteFile(thumbnailTemp);
TryDeleteFile(standardTemp);
logger.LogWarning(ex, "Book cover upload failed for book {BookID}.", book.BookID);
return new(false, "Cover image could not be processed. Try another image.");
}
}
}
public async Task DeleteCoverFilesAsync(Book book)
{
await MarkAndDeletePreviousAsync(book.CoverThumbnailPath, null);
await MarkAndDeletePreviousAsync(book.CoverStandardPath, null);
}
private static void SaveWebpVariant(SKBitmap source, string path, int width, int height, int quality)
{
using var surface = SKSurface.Create(new SKImageInfo(width, height));
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
var sourceRatio = source.Width / (float)source.Height;
var targetRatio = width / (float)height;
SKRect sourceRect;
if (sourceRatio > targetRatio)
{
var cropWidth = source.Height * targetRatio;
var left = (source.Width - cropWidth) / 2f;
sourceRect = new SKRect(left, 0, left + cropWidth, source.Height);
}
else
{
var cropHeight = source.Width / targetRatio;
var top = (source.Height - cropHeight) / 2f;
sourceRect = new SKRect(0, top, source.Width, top + cropHeight);
}
using var paint = new SKPaint { IsAntialias = true };
using var sourceImage = SKImage.FromBitmap(source);
canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint);
canvas.Flush();
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
using var output = File.Create(path);
data.SaveTo(output);
}
private async Task TrackCoverAsync(int ownerUserId, int projectId, int bookId, string storagePath, string originalFileName, long fileSizeBytes)
{
await userFiles.CreateAsync(new UserFile
{
UserID = ownerUserId,
ProjectID = projectId,
EntityType = "BookCover",
EntityID = bookId,
FileName = Path.GetFileName(storagePath),
OriginalFileName = originalFileName,
ContentType = "image/webp",
FileSizeBytes = fileSizeBytes,
StoragePath = storagePath
});
}
private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath)
{
if (string.IsNullOrWhiteSpace(oldStoragePath)
|| string.Equals(oldStoragePath, PlaceholderPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath);
}
private async Task MarkAndDeletePreviousAsync(string? oldStoragePath, string? currentStoragePath)
{
await MarkPreviousLedgerRowsAsync(oldStoragePath);
DeletePreviousPhysicalFile(oldStoragePath, currentStoragePath);
}
private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath)
{
if (string.IsNullOrWhiteSpace(oldStoragePath)
|| string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase)
|| string.Equals(oldStoragePath, PlaceholderPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
uploadStorage.TryDeleteFile(oldStoragePath, "uploads/books");
}
private static void ReplaceFile(string tempPath, string finalPath)
{
if (File.Exists(finalPath))
{
File.Delete(finalPath);
}
File.Move(tempPath, finalPath);
}
private static void TryDeleteFile(string path)
{
if (!File.Exists(path))
{
return;
}
File.Delete(path);
}
private static long EstimatedGeneratedSize(long uploadBytes)
=> Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes));
}