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
This commit is contained in:
2026-08-20 23:58:48 +02:00
parent 7fd294043e
commit 5b4e2b9afc
10 changed files with 71 additions and 7 deletions
@@ -27,6 +27,11 @@ public class AlbumAssetPreviewDto {
/// </summary>
public bool HasPreview { get; set; }
/// <summary>
/// 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.
/// </summary>
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
+2 -1
View File
@@ -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"]),
+35 -1
View File
@@ -18,6 +18,7 @@ namespace Lactose.Controllers;
/// <param name="mediaRepository">Media repository.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="signKeyOptions">JWT signing key configuration.</param>
/// <param name="logger">Logger instance.</param>
[ApiController]
[Authorize]
[AllowAnonymous]
@@ -26,7 +27,8 @@ public class MediaController(
LactoseAuthService authService,
IMediaRepository mediaRepository,
IAssetRepository assetRepository,
IOptions<SignKeyConfiguration> signKeyOptions
IOptions<SignKeyConfiguration> signKeyOptions,
ILogger<MediaController> logger
) : ControllerBase {
/// <summary>
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="asset">The asset to inspect.</param>
/// <returns><see langword="true"/> when the converted path and MIME type are set and the file exists.</returns>
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;
}
/// <summary>
/// Gets the thumbnail image for an asset.
/// </summary>
+2 -2
View File
@@ -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 {
+1
View File
@@ -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
+1 -1
View File
@@ -43,7 +43,7 @@ public static class AssetsMapper {
/// <returns>A AssetPreviewDto object.</returns>
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),
+1
View File
@@ -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
+1 -2
View File
@@ -317,7 +317,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
.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<AssetRepository>
return new Butter.Dtos.Asset.AssetBrowseResultDto {
Directories = dirs,
Assets = assetDtos,
CurrentPath = currentPath,
TotalAssetCount = totalAtLevel
};
}
+9
View File
@@ -33,4 +33,13 @@ public interface IMediaRepository : IDisposable {
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
/// <summary>
/// Retrieves the converted media data (e.g. the mp4 for an animated GIF).
/// </summary>
/// <param name="id">The ID of the media.</param>
/// <param name="userId">The ID of the user requesting the media.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>A <see cref="MediaDto"/> containing the converted media data, or null if the asset has no converted file or is not visible.</returns>
public MediaDto? GetConvertedData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
}
+14
View File
@@ -93,6 +93,20 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
};
}
/// <inheritdoc />
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
};
}
/// <inheritdoc />
public void Dispose() {
context.Dispose();