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 _logger; public ImageController( FileDatabase fileDatabase, ImageProcessor imageProcessor, ILogger 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 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 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); } }