Merge branch 'p2-w1-cover-art-api' into dev
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,6 +285,10 @@ public class TrackController : ControllerBase
|
|||||||
track.Genre = request.Genre;
|
track.Genre = request.Genre;
|
||||||
track.ReleaseDate = request.ReleaseDate;
|
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);
|
var update = await _sqlTrackService.Update(track);
|
||||||
if (!update.Success)
|
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
|
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
|
||||||
/// travels over this surface.
|
/// travels over this surface.
|
||||||
/// </summary>
|
/// </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(
|
public record UpdateTrackMetadataRequest(
|
||||||
string TrackName,
|
string TrackName,
|
||||||
string Artist,
|
string Artist,
|
||||||
string? Album,
|
string? Album,
|
||||||
string? Genre,
|
string? Genre,
|
||||||
DateOnly? ReleaseDate);
|
DateOnly? ReleaseDate,
|
||||||
|
string? ImagePath = null);
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ namespace DeepDrftAPI
|
|||||||
builder.Services.AddSingleton<AudioProcessor>();
|
builder.Services.AddSingleton<AudioProcessor>();
|
||||||
builder.Services.AddSingleton<TrackContentService>();
|
builder.Services.AddSingleton<TrackContentService>();
|
||||||
|
|
||||||
|
// Image services
|
||||||
|
builder.Services.AddSingleton<ImageProcessor>();
|
||||||
|
|
||||||
// Waveform loudness profiling (upload-time, off the playback path)
|
// Waveform loudness profiling (upload-time, off the playback path)
|
||||||
builder.Services.Configure<WaveformProfileOptions>(
|
builder.Services.Configure<WaveformProfileOptions>(
|
||||||
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
|
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
|
||||||
@@ -38,6 +41,7 @@ namespace DeepDrftAPI
|
|||||||
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
|
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
|
||||||
if (db is null) throw new Exception("Unable to initialize file database");
|
if (db is null) throw new Exception("Unable to initialize file database");
|
||||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||||
|
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||||
return db;
|
return db;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,5 +55,13 @@ namespace DeepDrftAPI
|
|||||||
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,4 +14,10 @@ public static class VaultConstants
|
|||||||
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
|
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string WaveformProfiles = "waveform-profiles";
|
public const string WaveformProfiles = "waveform-profiles";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vault name for storing cover-art images, keyed by a generated entry key referenced
|
||||||
|
/// from <c>TrackEntity.ImagePath</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const string Images = "images";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using DeepDrftContent.FileDatabase.Models;
|
||||||
|
|
||||||
|
namespace DeepDrftContent.Processors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes raw image bytes into an <see cref="ImageBinary"/>, mirroring the shape of
|
||||||
|
/// <see cref="AudioProcessor"/>. Validates the content type resolves to a known image
|
||||||
|
/// extension, derives the aspect ratio from the image dimensions where cheaply parseable
|
||||||
|
/// (PNG, JPEG), and defaults to 1.0 for formats whose headers we don't parse.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Operates entirely in memory — no disk I/O. Follows the FileDatabase error-handling
|
||||||
|
/// philosophy: dimension parsing logs a warning and falls back to a best-effort aspect
|
||||||
|
/// ratio of 1.0 rather than throwing. Content-type rejection is a caller-facing validation
|
||||||
|
/// failure (returns null), distinct from a parse hiccup.
|
||||||
|
/// </remarks>
|
||||||
|
public class ImageProcessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <see cref="ImageBinary"/> from raw image bytes and a MIME content type.
|
||||||
|
/// Returns null when the content type does not resolve to a recognised image extension
|
||||||
|
/// (the <c>.bin</c> sentinel from <see cref="MimeTypeExtensions.GetExtension"/>).
|
||||||
|
/// </summary>
|
||||||
|
public ImageBinary? Process(byte[] imageBytes, string contentType)
|
||||||
|
{
|
||||||
|
var extension = MimeTypeExtensions.GetExtension(contentType);
|
||||||
|
if (extension == ".bin")
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: ImageProcessor rejected unsupported content type '{contentType}'");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var aspectRatio = ComputeAspectRatio(imageBytes, extension);
|
||||||
|
|
||||||
|
var parameters = new ImageBinaryParams(
|
||||||
|
Buffer: imageBytes,
|
||||||
|
Size: imageBytes.Length,
|
||||||
|
Extension: extension,
|
||||||
|
AspectRatio: aspectRatio);
|
||||||
|
|
||||||
|
return new ImageBinary(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives width/height from the format header and returns width/height. Defaults to 1.0
|
||||||
|
/// for unparsed formats (gif, webp, bmp, svg) and on any parse failure.
|
||||||
|
/// </summary>
|
||||||
|
private static double ComputeAspectRatio(byte[] bytes, string extension)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return extension switch
|
||||||
|
{
|
||||||
|
".png" => ParsePngAspectRatio(bytes),
|
||||||
|
".jpg" or ".jpeg" => ParseJpegAspectRatio(bytes),
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Warning: image dimension parsing failed for '{extension}', defaulting aspect ratio to 1.0: {ex.Message}");
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PNG: the IHDR chunk places width at bytes 16–19 and height at 20–23, both big-endian
|
||||||
|
/// uint32. Guards on the "PNG" signature at bytes 1–3.
|
||||||
|
/// </summary>
|
||||||
|
private static double ParsePngAspectRatio(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length < 24 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G')
|
||||||
|
{
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var width = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(16, 4));
|
||||||
|
var height = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(20, 4));
|
||||||
|
return Ratio(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JPEG: walk the marker segments from byte 2 looking for SOF0 (0xFF 0xC0) or SOF2
|
||||||
|
/// (0xFF 0xC2). Height is a big-endian uint16 at marker+5, width at marker+7. Guards on
|
||||||
|
/// the SOI marker (0xFF 0xD8) at bytes 0–1.
|
||||||
|
/// </summary>
|
||||||
|
private static double ParseJpegAspectRatio(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
|
||||||
|
{
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pos = 2;
|
||||||
|
while (pos + 9 < bytes.Length)
|
||||||
|
{
|
||||||
|
// Marker segments begin with 0xFF; skip any fill bytes before the marker id.
|
||||||
|
if (bytes[pos] != 0xFF)
|
||||||
|
{
|
||||||
|
pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var marker = bytes[pos + 1];
|
||||||
|
if (marker == 0xC0 || marker == 0xC2)
|
||||||
|
{
|
||||||
|
var height = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 5, 2));
|
||||||
|
var width = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 7, 2));
|
||||||
|
return Ratio(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone markers (RSTn, SOI, EOI, TEM) carry no length payload; everything
|
||||||
|
// else has a 2-byte big-endian segment length immediately after the marker id.
|
||||||
|
if (marker is 0xD8 or 0xD9 or 0x01 || (marker >= 0xD0 && marker <= 0xD7))
|
||||||
|
{
|
||||||
|
pos += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 2, 2));
|
||||||
|
pos += 2 + segmentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Ratio(uint width, uint height) => height == 0 ? 1.0 : (double)width / height;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user