feat: image vault + cover-art API (upload/serve endpoints, ImagePath metadata link)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.Processors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ImageController : ControllerBase
|
||||
{
|
||||
// 50 MB ceiling — cover art is small, but this is generous headroom for high-res masters.
|
||||
private const int MaxImageBytes = 50_000_000;
|
||||
|
||||
// FileDatabase is injected directly because image operations are vault-only: there is no
|
||||
// SQL row for an image. The link to a track is TrackEntity.ImagePath (the entry key),
|
||||
// written separately via PUT api/track/meta/{id}.
|
||||
private readonly FileDatabase _fileDatabase;
|
||||
private readonly ImageProcessor _imageProcessor;
|
||||
private readonly ILogger<ImageController> _logger;
|
||||
|
||||
public ImageController(
|
||||
FileDatabase fileDatabase,
|
||||
ImageProcessor imageProcessor,
|
||||
ILogger<ImageController> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_imageProcessor = imageProcessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/image/upload ([ApiKeyAuthorize])
|
||||
// Stores a cover-art image in the images vault and returns its generated entry key. Images
|
||||
// are small enough to buffer whole in memory — no temp-file dance like the WAV upload path.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(MaxImageBytes)]
|
||||
public async Task<ActionResult> UploadImage([FromForm] IFormFile? image, CancellationToken cancellationToken)
|
||||
{
|
||||
if (image is null || image.Length == 0)
|
||||
{
|
||||
return BadRequest("Image file is required");
|
||||
}
|
||||
|
||||
if (image.Length > MaxImageBytes)
|
||||
{
|
||||
return BadRequest($"Image exceeds the {MaxImageBytes} byte limit");
|
||||
}
|
||||
|
||||
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning("UploadImage rejected: unsupported content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
byte[] buffer;
|
||||
await using (var stream = image.OpenReadStream())
|
||||
using (var memory = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
buffer = memory.ToArray();
|
||||
}
|
||||
|
||||
var imageBinary = _imageProcessor.Process(buffer, image.ContentType);
|
||||
if (imageBinary is null)
|
||||
{
|
||||
// Process only returns null for an unsupported content type, already screened above —
|
||||
// belt-and-suspenders in case ImageProcessor's validation diverges later.
|
||||
_logger.LogWarning("UploadImage: ImageProcessor rejected content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
var entryKey = Guid.NewGuid().ToString("N");
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("UploadImage: vault write failed for entryKey={EntryKey}, contentType={ContentType}, size={Size}",
|
||||
entryKey, image.ContentType, buffer.Length);
|
||||
return StatusCode(500, "Failed to store image");
|
||||
}
|
||||
|
||||
_logger.LogInformation("UploadImage succeeded: entryKey={EntryKey}, contentType={ContentType}, size={Size}",
|
||||
entryKey, image.ContentType, buffer.Length);
|
||||
return Ok(new { entryKey });
|
||||
}
|
||||
|
||||
// GET api/image/{entryKey} (unauthenticated)
|
||||
// Streams the image whole from disk. Same disk-streaming pattern as GET api/track/{trackId}
|
||||
// offset-0 path: File() takes ownership of the inner stream on the success path; the wrapper
|
||||
// is disposed only on the catch path.
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetImage(string entryKey)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Images);
|
||||
if (vault is null)
|
||||
{
|
||||
_logger.LogWarning("Images vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(entryKey);
|
||||
if (mediaStream is null)
|
||||
{
|
||||
_logger.LogWarning("Image not found: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
string mimeType;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
mimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return File(innerStream, mimeType, enableRangeProcessing: false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user