Merge branch 'p2-w1-cover-art-api' into dev

This commit is contained in:
daniel-c-harvey
2026-06-07 16:27:42 -04:00
6 changed files with 283 additions and 1 deletions
+125
View File
@@ -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);
}
}
@@ -285,6 +285,10 @@ public class TrackController : ControllerBase
track.Genre = request.Genre;
track.ReleaseDate = request.ReleaseDate;
// Only update ImagePath when the request explicitly provides a value (null = no change, "" = clear).
if (request.ImagePath is not null)
track.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
var update = await _sqlTrackService.Update(track);
if (!update.Success)
{
@@ -4,9 +4,15 @@ namespace DeepDrftAPI.Models;
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
/// travels over this surface.
/// </summary>
/// <remarks>
/// <paramref name="ImagePath"/> follows tri-state semantics distinct from the other optional
/// fields: <c>null</c> leaves the existing value unchanged, an empty string clears it, and a
/// non-empty value is the images-vault entry key to link.
/// </remarks>
public record UpdateTrackMetadataRequest(
string TrackName,
string Artist,
string? Album,
string? Genre,
DateOnly? ReleaseDate);
DateOnly? ReleaseDate,
string? ImagePath = null);
+12
View File
@@ -19,6 +19,9 @@ namespace DeepDrftAPI
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<TrackContentService>();
// Image services
builder.Services.AddSingleton<ImageProcessor>();
// Waveform loudness profiling (upload-time, off the playback path)
builder.Services.Configure<WaveformProfileOptions>(
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
@@ -38,6 +41,7 @@ namespace DeepDrftAPI
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
return db;
});
@@ -51,5 +55,13 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
}
private static async Task InitializeImageVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Images))
{
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
}
}
}
}