From 052a95e09bf2f357e4d66c403b7a11a825a75c41 Mon Sep 17 00:00:00 2001 From: Amatsugu Date: Fri, 27 Jun 2025 22:58:59 -0400 Subject: [PATCH] cache thumbnails --- AobaCore/Models/MediaThumbnail.cs | 19 ++++ AobaCore/Models/MeidaThumbnail.cs | 5 - AobaCore/Services/AobaIndexCreationService.cs | 4 + AobaCore/Services/ThumbnailService.cs | 104 ++++++++++++++---- AobaServer/Controllers/MediaController.cs | 21 +++- 5 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 AobaCore/Models/MediaThumbnail.cs delete mode 100644 AobaCore/Models/MeidaThumbnail.cs diff --git a/AobaCore/Models/MediaThumbnail.cs b/AobaCore/Models/MediaThumbnail.cs new file mode 100644 index 0000000..3216736 --- /dev/null +++ b/AobaCore/Models/MediaThumbnail.cs @@ -0,0 +1,19 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace AobaCore.Models; + +public record MediaThumbnail +{ + [BsonId] + public required ObjectId Id { get; init; } + public Dictionary Sizes { get; set; } = []; +} + +public enum ThumbnailSize +{ + Small = 128, + Medium = 256, + Large = 512, + ExtraLarge = 1024 +} \ No newline at end of file diff --git a/AobaCore/Models/MeidaThumbnail.cs b/AobaCore/Models/MeidaThumbnail.cs deleted file mode 100644 index b7ea5ca..0000000 --- a/AobaCore/Models/MeidaThumbnail.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace AobaCore.Models; - -internal class MeidaThumbnail -{ -} \ No newline at end of file diff --git a/AobaCore/Services/AobaIndexCreationService.cs b/AobaCore/Services/AobaIndexCreationService.cs index 0e8360e..f818744 100644 --- a/AobaCore/Services/AobaIndexCreationService.cs +++ b/AobaCore/Services/AobaIndexCreationService.cs @@ -2,6 +2,9 @@ using Microsoft.Extensions.Hosting; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; using MongoDB.Driver; namespace AobaCore.Services; @@ -12,6 +15,7 @@ public class AobaIndexCreationService(IMongoDatabase db): BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); var textKeys = Builders.IndexKeys .Text(m => m.Filename); diff --git a/AobaCore/Services/ThumbnailService.cs b/AobaCore/Services/ThumbnailService.cs index 5be7ae9..2dcfa21 100644 --- a/AobaCore/Services/ThumbnailService.cs +++ b/AobaCore/Services/ThumbnailService.cs @@ -1,5 +1,7 @@ using AobaCore.Models; +using MaybeError.Errors; + using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Driver.GridFS; @@ -10,6 +12,7 @@ using SixLabors.ImageSharp.Processing; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; @@ -17,30 +20,83 @@ namespace AobaCore.Services; public class ThumbnailService(IMongoDatabase db, AobaService aobaService) { private readonly GridFSBucket _gridfs = new GridFSBucket(db); - private readonly IMongoCollection _thumbnails = db.GetCollection("thumbs"); + private readonly IMongoCollection _thumbnails = db.GetCollection("thumbs"); - public async Task GetThumbnailAsync(ObjectId id, CancellationToken cancellationToken = default) + + /// + /// + /// + /// File id + /// + /// + /// + public async Task> GetOrCreateThumbnailAsync(ObjectId id, ThumbnailSize size, CancellationToken cancellationToken = default) { - var media = await aobaService.GetMediaAsync(id); + var existingThumb = await GetThumbnailAsync(id, size, cancellationToken); + if (existingThumb != null) + return existingThumb; + + var media = await aobaService.GetMediaFromFileAsync(id, cancellationToken); + if (media == null) - return null; - if (media.MediaType != MediaType.Image) - return null; - using var file = await _gridfs.OpenDownloadStreamAsync(media.MediaId, new GridFSDownloadOptions { Seekable = true }); - return await GenerateThumbnailAsync(file, cancellationToken); - } - public async Task GetThumbnailFromFileAsync(ObjectId id, CancellationToken cancellationToken = default) - { - var media = await aobaService.GetMediaFromFileAsync(id); - if (media == null) - return null; - if (media.MediaType != MediaType.Image) - return null; - using var file = await _gridfs.OpenDownloadStreamAsync(media.MediaId, new GridFSDownloadOptions { Seekable = true }); - return await GenerateThumbnailAsync(file, cancellationToken); + return new Error("Media does not exist"); + + try + { + + var mediaData = await _gridfs.OpenDownloadStreamAsync(media.MediaId, new GridFSDownloadOptions { Seekable = true }, cancellationToken); + var thumb = await GenerateThumbnailAsync(mediaData, size, media.MediaType, media.Ext, cancellationToken); + + if (thumb.HasError) + return thumb.Error; + cancellationToken.ThrowIfCancellationRequested(); + + var thumbId = await _gridfs.UploadFromStreamAsync($"{media.Filename}.webp", thumb, cancellationToken: CancellationToken.None); + var update = Builders.Update.Set(t => t.Sizes[size], thumbId); + await _thumbnails.UpdateOneAsync(t => t.Id == id, update, cancellationToken: CancellationToken.None); + thumb.Value.Position = 0; + return thumb; + } catch (Exception ex) { + return ex; + } + + + } - public async Task GenerateThumbnailAsync(Stream stream, CancellationToken cancellationToken = default) + /// + /// + /// + /// File Id + /// + /// + /// + public async Task GetThumbnailAsync(ObjectId id, ThumbnailSize size, CancellationToken cancellationToken = default) + { + var thumb = await _thumbnails.Find(t => t.Id == id).FirstOrDefaultAsync(cancellationToken); + if (thumb == null) + return null; + + if (!thumb.Sizes.TryGetValue(size, out var tid)) + return null; + + var thumbData = await _gridfs.OpenDownloadStreamAsync(tid, cancellationToken: cancellationToken); + return thumbData; + } + + + public async Task> GenerateThumbnailAsync(Stream stream, ThumbnailSize size, MediaType type, string ext, CancellationToken cancellationToken = default) + { + return type switch + { + MediaType.Image => await GenerateImageThumbnailAsync(stream, size, cancellationToken), + MediaType.Video => await GenerateVideoThumbnailAsync(stream, size, cancellationToken), + MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken), + _ => new Error($"No Thumbnail for {type}"), + }; + } + + public async Task GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, CancellationToken cancellationToken = default) { var img = Image.Load(stream); img.Mutate(o => @@ -57,4 +113,14 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService) result.Position = 0; return result; } + + public async Task> GenerateVideoThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default) + { + return new NotImplementedException(); + } + + public async Task> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default) + { + return new NotImplementedException(); + } } diff --git a/AobaServer/Controllers/MediaController.cs b/AobaServer/Controllers/MediaController.cs index acf2853..adbdaa7 100644 --- a/AobaServer/Controllers/MediaController.cs +++ b/AobaServer/Controllers/MediaController.cs @@ -1,4 +1,5 @@ -using AobaCore.Services; +using AobaCore.Models; +using AobaCore.Services; using HeyRed.Mime; @@ -44,11 +45,21 @@ public class MediaController(AobaService aobaService, ILogger l } [HttpGet("thumb/{id}")] - public async Task ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, CancellationToken cancellationToken) + [ResponseCache(Duration = int.MaxValue)] + public async Task ThumbAsync(ObjectId id, [FromServices] ThumbnailService thumbnailService, [FromQuery] ThumbnailSize size = ThumbnailSize.Medium, CancellationToken cancellationToken = default) { - var thumb = await thumbnailService.GetThumbnailFromFileAsync(id, cancellationToken); - if (thumb == null) - return NotFound(); + var thumb = await thumbnailService.GetOrCreateThumbnailAsync(id, size, cancellationToken); + if (thumb.HasError) + { + logger.LogError("Failed to generate thumbnail: {}", thumb.Error); + return DefaultThumbnailAsync(); + } return File(thumb, "image/webp", true); } + + [NonAction] + private IActionResult DefaultThumbnailAsync() + { + return NoContent(); + } }