From 5b4e2b9afccb24ebb1659d1e4c7bc750dcae8987 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Thu, 20 Aug 2026 23:58:48 +0200 Subject: [PATCH] feat(media): repurpose GIFs as video and serve converted mp4 when available - Classify .gif as EAssetType.Video via MimeTypes (moved image/gif to the Video registry) - Add MediaRepository.GetConvertedData, served by the existing media/{id} endpoint when a converted file exists on disk (with a fallback + warning log if missing) - Expose the converted MIME type on AssetPreviewDto/AlbumAssetPreviewDto so the frontend can detect video assets from a single MimeType field --- Butter/Dtos/Album/AlbumAssetPreviewDto.cs | 5 ++++ Butter/MimeTypes.cs | 3 +- Lactose/Controllers/MediaController.cs | 36 ++++++++++++++++++++++- Lactose/Jobs/FileSystemCrawlJob.cs | 4 +-- Lactose/Mapper/AlbumMapper.cs | 1 + Lactose/Mapper/AssetsMapper.cs | 2 +- Lactose/Repositories/AlbumRepository.cs | 1 + Lactose/Repositories/AssetRepository.cs | 3 +- Lactose/Repositories/IMediaRepository.cs | 9 ++++++ Lactose/Repositories/MediaRepository.cs | 14 +++++++++ 10 files changed, 71 insertions(+), 7 deletions(-) diff --git a/Butter/Dtos/Album/AlbumAssetPreviewDto.cs b/Butter/Dtos/Album/AlbumAssetPreviewDto.cs index 1a089ad..05f4a89 100644 --- a/Butter/Dtos/Album/AlbumAssetPreviewDto.cs +++ b/Butter/Dtos/Album/AlbumAssetPreviewDto.cs @@ -27,6 +27,11 @@ public class AlbumAssetPreviewDto { /// public bool HasPreview { get; set; } /// + /// Gets or sets the MIME type of the asset's display media. For animated assets this is the + /// converted video MIME type (e.g. "video/mp4"); otherwise the original MIME type. + /// + public string MimeType { get; set; } = string.Empty; + /// /// Gets or sets the original filename of the asset. /// public string? FileName { get; set; } diff --git a/Butter/MimeTypes.cs b/Butter/MimeTypes.cs index 8aafca0..abb5343 100644 --- a/Butter/MimeTypes.cs +++ b/Butter/MimeTypes.cs @@ -26,7 +26,6 @@ public static class MimeTypes{ new("image/bmp}", [".bmp"]), new("image/cgm", [".cgm"]), new("image/g3fax", [".g3"]), - new("image/gif", [".gif"]), new("image/heic", [".heif", ".heic"]), new("image/ief", [".ief"]), new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]), @@ -89,6 +88,8 @@ public static class MimeTypes{ public static readonly MimeTypeMap[] Video = [ new("video/3gpp", [".3gp"]), new("video/3gpp2", [".3g2"]), + // Animated GIFs are repurposed as video so they flow through the video rendering path. + new("image/gif", [".gif"]), new("video/h261", [".h261"]), new("video/h263", [".h263"]), new("video/h264", [".h264"]), diff --git a/Lactose/Controllers/MediaController.cs b/Lactose/Controllers/MediaController.cs index 38c8f4f..c87c59b 100644 --- a/Lactose/Controllers/MediaController.cs +++ b/Lactose/Controllers/MediaController.cs @@ -18,6 +18,7 @@ namespace Lactose.Controllers; /// Media repository. /// Asset repository. /// JWT signing key configuration. +/// Logger instance. [ApiController] [Authorize] [AllowAnonymous] @@ -26,7 +27,8 @@ public class MediaController( LactoseAuthService authService, IMediaRepository mediaRepository, IAssetRepository assetRepository, - IOptions signKeyOptions + IOptions signKeyOptions, + ILogger logger ) : ControllerBase { /// @@ -43,15 +45,47 @@ public class MediaController( if (asset == null) return NotFound(); + // Animated assets with a converted video on disk serve the converted file (and its MIME type), + // never the original animation. A stale ConvertedPath (file missing) falls back to the original. + bool hasConverted = HasConvertedFile(asset); + if (CanAccessAssetDirectly(user, asset.DeletedAt)) { + if (hasConverted) + return PhysicalFile(asset.ConvertedPath, asset.ConvertedMimeType); return PhysicalFile(asset.OriginalPath, asset.MimeType); } + + var converted = hasConverted + ? mediaRepository.GetConvertedData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User) + : null; + if (converted != null) + return PhysicalFile(converted.Path, converted.MimeType); + var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User); if (data == null) return NotFound(); return PhysicalFile(data.Path, data.MimeType); } + /// + /// Determines whether an asset has a converted video file available on disk. + /// Logs a warning when the converted path is set but the file is missing. + /// + /// The asset to inspect. + /// when the converted path and MIME type are set and the file exists. + private bool HasConvertedFile(Models.Asset asset) { + if (string.IsNullOrEmpty(asset.ConvertedPath) || string.IsNullOrEmpty(asset.ConvertedMimeType)) + return false; + + if (!System.IO.File.Exists(asset.ConvertedPath)) { + logger.LogWarning("Converted file for asset {AssetId} is missing on disk at {Path}; falling back to the original.", + asset.Id, asset.ConvertedPath); + return false; + } + + return true; + } + /// /// Gets the thumbnail image for an asset. /// diff --git a/Lactose/Jobs/FileSystemCrawlJob.cs b/Lactose/Jobs/FileSystemCrawlJob.cs index 9a735e1..683b825 100644 --- a/Lactose/Jobs/FileSystemCrawlJob.cs +++ b/Lactose/Jobs/FileSystemCrawlJob.cs @@ -296,8 +296,8 @@ public sealed class FileSystemCrawlJob : Job { var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, [])); var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, [])); - if (mimeImg.MimeType != Empty) type = EAssetType.Image; - else if (mimeVid.MimeType != Empty) type = EAssetType.Video; + if (mimeVid.MimeType != Empty) type = EAssetType.Video; + else if (mimeImg.MimeType != Empty) type = EAssetType.Image; else return null; return new Asset { diff --git a/Lactose/Mapper/AlbumMapper.cs b/Lactose/Mapper/AlbumMapper.cs index 79f8990..d62375c 100644 --- a/Lactose/Mapper/AlbumMapper.cs +++ b/Lactose/Mapper/AlbumMapper.cs @@ -44,6 +44,7 @@ public static class AlbumMapper { ResolutionHeight = asset.ResolutionHeight, HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath), HasPreview = !string.IsNullOrEmpty(asset.PreviewPath), + MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType, FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null, Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null, DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null diff --git a/Lactose/Mapper/AssetsMapper.cs b/Lactose/Mapper/AssetsMapper.cs index 605d6e8..0264f5e 100644 --- a/Lactose/Mapper/AssetsMapper.cs +++ b/Lactose/Mapper/AssetsMapper.cs @@ -43,7 +43,7 @@ public static class AssetsMapper { /// A AssetPreviewDto object. public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto { Id = asset.Id, - MimeType = asset.MimeType, + MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType, ResolutionWidth = asset.ResolutionWidth, ResolutionHeight = asset.ResolutionHeight, HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath), diff --git a/Lactose/Repositories/AlbumRepository.cs b/Lactose/Repositories/AlbumRepository.cs index bfecbb0..b5aea31 100644 --- a/Lactose/Repositories/AlbumRepository.cs +++ b/Lactose/Repositories/AlbumRepository.cs @@ -177,6 +177,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository { ResolutionHeight = a.ResolutionHeight, HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "", HasPreview = a.PreviewPath != null && a.PreviewPath != "", + MimeType = a.ConvertedMimeType != null && a.ConvertedMimeType != "" ? a.ConvertedMimeType : a.MimeType, FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null, Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null, DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null diff --git a/Lactose/Repositories/AssetRepository.cs b/Lactose/Repositories/AssetRepository.cs index 852c739..90221c7 100644 --- a/Lactose/Repositories/AssetRepository.cs +++ b/Lactose/Repositories/AssetRepository.cs @@ -317,7 +317,7 @@ public class AssetRepository(LactoseDbContext context, ILogger .Where(a => pageIds.Contains(a.Id)) .Select(a => new AssetPreviewDto { Id = a.Id, - MimeType = a.MimeType, + MimeType = a.ConvertedMimeType != null && a.ConvertedMimeType != "" ? a.ConvertedMimeType : a.MimeType, ResolutionWidth = a.ResolutionWidth, ResolutionHeight = a.ResolutionHeight, HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "", @@ -459,7 +459,6 @@ public class AssetRepository(LactoseDbContext context, ILogger return new Butter.Dtos.Asset.AssetBrowseResultDto { Directories = dirs, Assets = assetDtos, - CurrentPath = currentPath, TotalAssetCount = totalAtLevel }; } diff --git a/Lactose/Repositories/IMediaRepository.cs b/Lactose/Repositories/IMediaRepository.cs index 16b2387..6cd6a50 100644 --- a/Lactose/Repositories/IMediaRepository.cs +++ b/Lactose/Repositories/IMediaRepository.cs @@ -33,4 +33,13 @@ public interface IMediaRepository : IDisposable { /// The requesting user's access level. /// A containing the media data, or null if not found or unauthorized. public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User); + + /// + /// Retrieves the converted media data (e.g. the mp4 for an animated GIF). + /// + /// The ID of the media. + /// The ID of the user requesting the media. + /// The requesting user's access level. + /// A containing the converted media data, or null if the asset has no converted file or is not visible. + public MediaDto? GetConvertedData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User); } \ No newline at end of file diff --git a/Lactose/Repositories/MediaRepository.cs b/Lactose/Repositories/MediaRepository.cs index 3d3298c..7a73c8c 100644 --- a/Lactose/Repositories/MediaRepository.cs +++ b/Lactose/Repositories/MediaRepository.cs @@ -93,6 +93,20 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR }; } + /// + public MediaDto? GetConvertedData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) { + var media = GetVisibleAsset(id, userId, accessLevel); + + if (media == null) return null; + + if (string.IsNullOrEmpty(media.ConvertedPath) || string.IsNullOrEmpty(media.ConvertedMimeType)) return null; + + return new MediaDto { + MimeType = media.ConvertedMimeType, + Path = media.ConvertedPath + }; + } + /// public void Dispose() { context.Dispose();