using DeepDrftAPI.Middleware; using DeepDrftAPI.Services; using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.Processors; using DeepDrftData; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using Microsoft.AspNetCore.Mvc; namespace DeepDrftAPI.Controllers; [ApiController] [Route("api/[controller]")] public class ReleaseController : ControllerBase { private readonly IReleaseService _releaseService; private readonly UnifiedReleaseService _unifiedReleaseService; private readonly WaveformProfileService _waveformProfileService; private readonly ILogger _logger; public ReleaseController( IReleaseService releaseService, UnifiedReleaseService unifiedReleaseService, WaveformProfileService waveformProfileService, ILogger logger) { _releaseService = releaseService; _unifiedReleaseService = unifiedReleaseService; _waveformProfileService = waveformProfileService; _logger = logger; } // GET api/release?medium=session&q=text&genre=House&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth) // Paged release list, optionally narrowed by medium, free-text search (q), and genre. The matching // medium's metadata satellite is populated; the others are null. Backs the public /archive browser. // Public browse data, same auth posture as GET api/track/page. [HttpGet] public async Task GetReleases( [FromQuery] string? medium = null, [FromQuery] string? q = null, [FromQuery] string? genre = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortColumn = null, [FromQuery] bool sortDescending = false, CancellationToken ct = default) { ReleaseMedium? parsedMedium = null; if (!string.IsNullOrWhiteSpace(medium)) { if (!Enum.TryParse(medium, ignoreCase: true, out var m) || !Enum.IsDefined(m)) return BadRequest($"Unrecognised medium: {medium}"); parsedMedium = m; } var filter = new ReleaseFilter { SearchText = q, Genre = genre }; var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, filter, ct); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetReleases failed: {Error}", error); return StatusCode(500, "Failed to load releases"); } return Ok(result.Value); } // GET api/release/{entryKey}/mix/waveform (unauthenticated) // Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from // the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum // is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey} // string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb + // constraint). Declared before the shorter "{entryKey}" route for clarity. // // LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it // resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/ // high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum, // since a Mix is single-track) and has no client caller today; remove it once nothing depends on the // release-addressed shape. [HttpGet("{entryKey}/mix/waveform")] public async Task GetMixWaveform(string entryKey, CancellationToken ct = default) { var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct); if (!lookup.Success) { var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error); return StatusCode(500, "Failed to load release"); } var release = lookup.Value; var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey; if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey)) { _logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey); return NotFound(); } var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms); if (bytes is null) { _logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey); return NotFound(); } return Ok(new WaveformProfileDto { BucketCount = bytes.Length, Data = Convert.ToBase64String(bytes), }); } // POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body) // Server-side trigger: stream the Mix's track audio from the vault, compute a duration-derived // high-res waveform, store it in the track-waveforms vault, and set MixMetadata.WaveformEntryKey. // 404 when the release is missing or has no stored audio; 500 on compute/storage failure. Declared // before "{id:long}". [ApiKeyAuthorize] [HttpPost("{id:long}/mix/waveform")] public async Task GenerateMixWaveform(long id, CancellationToken ct = default) { var result = await _unifiedReleaseService.TriggerMixWaveformAsync(id, ct); if (result.Success) return Ok(); var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal) || string.Equals(error, UnifiedReleaseService.MixTrackNoAudioMessage, StringComparison.Ordinal) || string.Equals(error, UnifiedReleaseService.MixHasNoTrackMessage, StringComparison.Ordinal)) { return NotFound(); } _logger.LogError("GenerateMixWaveform failed for {ReleaseId}: {Error}", id, error); return StatusCode(500, error); } // POST api/release/{id}/session/hero-image ([ApiKeyAuthorize], multipart) // Stores a hero image in the images vault and sets SessionMetadata.HeroImageEntryKey. The release // must be a Session medium (enforced in the service). Declared before "{id:long}". [ApiKeyAuthorize] [HttpPost("{id:long}/session/hero-image")] [RequestSizeLimit(50_000_000)] public async Task UploadSessionHeroImage( long id, [FromForm] IFormFile? image, CancellationToken ct = default) { if (image is null || image.Length == 0) return BadRequest("Image file is required"); if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin") { _logger.LogWarning("UploadSessionHeroImage rejected: unsupported content type '{ContentType}'", image.ContentType); return BadRequest($"Unsupported image content type: {image.ContentType}"); } var result = await _unifiedReleaseService.SetHeroImageAsync(id, image, ct); if (result.Success) return Ok(); var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal)) return NotFound(); _logger.LogError("UploadSessionHeroImage failed for {ReleaseId}: {Error}", id, error); return StatusCode(500, error); } // GET api/release/{entryKey} (unauthenticated) // Single release with both metadata navs (nulls for non-matching media). Public read — addresses by // the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform" // route so the segmented route resolves first. [HttpGet("{entryKey}")] public async Task GetReleaseByEntryKey(string entryKey, CancellationToken ct = default) { var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error); return StatusCode(500, "Failed to load release"); } if (result.Value is null) return NotFound(); return Ok(result.Value); } }