diff --git a/DeepDrftAPI/Controllers/ImageController.cs b/DeepDrftAPI/Controllers/ImageController.cs new file mode 100644 index 0000000..758725c --- /dev/null +++ b/DeepDrftAPI/Controllers/ImageController.cs @@ -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 _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); + } +} diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 83ad7ec..82a7e01 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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) { diff --git a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs index fae58de..36eb46b 100644 --- a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs +++ b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs @@ -4,9 +4,15 @@ namespace DeepDrftAPI.Models; /// Body of PUT api/track/meta/{id}. Metadata-only — EntryKey is immutable and never /// travels over this surface. /// +/// +/// follows tri-state semantics distinct from the other optional +/// fields: null leaves the existing value unchanged, an empty string clears it, and a +/// non-empty value is the images-vault entry key to link. +/// public record UpdateTrackMetadataRequest( string TrackName, string Artist, string? Album, string? Genre, - DateOnly? ReleaseDate); + DateOnly? ReleaseDate, + string? ImagePath = null); diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index e05e64b..a55fb49 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -19,6 +19,9 @@ namespace DeepDrftAPI builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Image services + builder.Services.AddSingleton(); + // Waveform loudness profiling (upload-time, off the playback path) builder.Services.Configure( 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); + } + } } } \ No newline at end of file diff --git a/DeepDrftContent/Constants/VaultConstants.cs b/DeepDrftContent/Constants/VaultConstants.cs index cec5f88..f58a332 100644 --- a/DeepDrftContent/Constants/VaultConstants.cs +++ b/DeepDrftContent/Constants/VaultConstants.cs @@ -14,4 +14,10 @@ public static class VaultConstants /// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey. /// public const string WaveformProfiles = "waveform-profiles"; + + /// + /// Vault name for storing cover-art images, keyed by a generated entry key referenced + /// from TrackEntity.ImagePath. + /// + public const string Images = "images"; } \ No newline at end of file diff --git a/DeepDrftContent/Processors/ImageProcessor.cs b/DeepDrftContent/Processors/ImageProcessor.cs new file mode 100644 index 0000000..e558d2a --- /dev/null +++ b/DeepDrftContent/Processors/ImageProcessor.cs @@ -0,0 +1,129 @@ +using System.Buffers.Binary; +using DeepDrftContent.FileDatabase.Models; + +namespace DeepDrftContent.Processors; + +/// +/// Processes raw image bytes into an , mirroring the shape of +/// . 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. +/// +/// +/// 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. +/// +public class ImageProcessor +{ + /// + /// Builds an from raw image bytes and a MIME content type. + /// Returns null when the content type does not resolve to a recognised image extension + /// (the .bin sentinel from ). + /// + 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); + } + + /// + /// 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. + /// + 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; + } + } + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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; +}