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:
@@ -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
@@ -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"]),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user