using DeepDrftAPI.Middleware; using DeepDrftAPI.Models; using DeepDrftAPI.Services; using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Services; 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 TrackController : ControllerBase { private readonly DeepDrftContent.TrackContentService _trackContentService; private readonly UnifiedTrackService _unifiedService; private readonly ITrackService _sqlTrackService; private readonly WaveformProfileService _waveformProfileService; private readonly ILogger _logger; // FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed // AudioBinaryDto over the wire, not a WAV file path. TrackContentService.AddTrackFromWavAsync is // file-path-oriented and not applicable here. If a file-upload flow is added in future, // route it through TrackContentService instead. private readonly DeepDrftContent.FileDatabase.Services.FileDatabase _fileDatabase; public TrackController( DeepDrftContent.TrackContentService trackContentService, DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase, UnifiedTrackService unifiedService, ITrackService sqlTrackService, WaveformProfileService waveformProfileService, ILogger logger) { _trackContentService = trackContentService; _fileDatabase = fileDatabase; _unifiedService = unifiedService; _sqlTrackService = sqlTrackService; _waveformProfileService = waveformProfileService; _logger = logger; } // --- Literal-segment routes first --- // These are declared before the parameterized "{trackId}" / "{id:long}" actions so route // resolution never treats "page", "upload", or "meta" as a trackId. // GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId= // Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}. // q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering). // releaseId is the authoritative release→tracks join (exact match), preferred over album title. [HttpGet("page")] public async Task GetPage( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortColumn = null, [FromQuery] bool sortDescending = false, [FromQuery] string? q = null, [FromQuery] string? album = null, [FromQuery] string? genre = null, [FromQuery] long? releaseId = null, CancellationToken cancellationToken = default) { var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId }; var effectiveFilter = filter.IsEmpty ? null : filter; var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetPage failed: {Error}", error); return StatusCode(500, "Failed to load tracks"); } return Ok(result.Value); } // GET api/track/albums (unauthenticated) // All releases with per-release track counts. Public browse data, same posture as GET // api/track/page. Literal segment, declared before the parameterized "{trackId}" route. // Route name kept as "albums" for client/proxy compatibility; the payload is List. [HttpGet("albums")] public async Task GetAlbums(CancellationToken ct = default) { var result = await _sqlTrackService.GetReleases(ct); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetAlbums failed: {Error}", error); return StatusCode(500, "Failed to load albums"); } return Ok(result.Value); } // GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize]) // Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the // matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses // the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the // pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, // soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the // parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape). [ApiKeyAuthorize] [HttpGet("release/exists")] public async Task ReleaseExists( [FromQuery] string? title, [FromQuery] string? artist, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist)) return BadRequest("title and artist are both required"); var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error); return StatusCode(500, "Failed to check release"); } if (result.Value is null) return NotFound(); return Ok(result.Value); } // GET api/track/genres (unauthenticated) // Distinct non-null genres with track counts. Public browse data, same posture as GET // api/track/page. Literal segment, declared before the parameterized "{trackId}" route. [HttpGet("genres")] public async Task GetGenres(CancellationToken ct = default) { var result = await _sqlTrackService.GetDistinctGenres(ct); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetGenres failed: {Error}", error); return StatusCode(500, "Failed to load genres"); } return Ok(result.Value); } // GET api/track/random (unauthenticated) // Picks one track at random from the full library and returns its metadata. Public, same auth // posture as GET api/track/page. Selection math lives in the SQL service/repository, not here. // 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 + // TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there. [HttpGet("random")] public async Task GetRandom(CancellationToken cancellationToken = default) { var result = await _sqlTrackService.GetRandom(cancellationToken); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetRandom failed: {Error}", error); return StatusCode(500, "Failed to load track"); } if (result.Value is null) { return NotFound(); } return Ok(result.Value); } // GET api/track/waveform-status ([ApiKeyAuthorize]) // Admin backfill view: returns every track with flags for whether each waveform datum is stored — // the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer // datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads // the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal // segment is never treated as a trackId. [ApiKeyAuthorize] [HttpGet("waveform-status")] public async Task GetWaveformStatus() { var tracks = await _sqlTrackService.GetAll(); if (!tracks.Success || tracks.Value is null) { var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error); return StatusCode(500, "Failed to load tracks"); } var status = new List(tracks.Value.Count); foreach (var track in tracks.Value) { var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey); var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms); status.Add(new WaveformStatusDto { TrackId = track.Id, EntryKey = track.EntryKey, TrackName = track.TrackName, HasProfile = profile is not null, HasHighRes = highRes is not null, }); } return Ok(status); } // POST api/track/duration/backfill ([ApiKeyAuthorize], no body) // One-time admin backfill: for every track whose SQL duration is still null, read the duration from // the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run // only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block // (before "{trackId}") so the segment is never treated as a trackId. [ApiKeyAuthorize] [HttpPost("duration/backfill")] public async Task BackfillDurations(CancellationToken cancellationToken) { var result = await _unifiedService.BackfillDurationsAsync(cancellationToken); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("BackfillDurations failed: {Error}", error); return StatusCode(500, error); } return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped }); } // POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out. // Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host // proxies the upload here so it never touches the vault disk path or SQL directly. // UnifiedTrackService owns the two-database write. // // RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to // hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them // outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the // limit is the per-request ceiling, not a buffered allocation. 2_000_000_000 stays below // int.MaxValue (2,147,483,647) so it is safe where limits are int-typed. [ApiKeyAuthorize] [HttpPost("upload")] [RequestSizeLimit(2_000_000_000)] [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] public async Task> UploadTrack( [FromForm] IFormFile? audioFile, [FromForm] string? trackName, [FromForm] string? artist, [FromForm] string? album, [FromForm] string? genre, [FromForm] string? description, [FromForm] string? releaseDate, [FromForm] string? originalFileName, [FromForm] long createdByUserId, [FromForm] string? releaseType, [FromForm] string? medium, [FromForm] int? trackNumber, [FromForm] long? releaseId, CancellationToken cancellationToken) { _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}", trackName, artist, originalFileName, audioFile?.Length); if (audioFile is null || audioFile.Length == 0) { return BadRequest("Audio file is required"); } if (string.IsNullOrWhiteSpace(trackName)) { return BadRequest("trackName is required"); } if (string.IsNullOrWhiteSpace(artist)) { return BadRequest("artist is required"); } var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant(); if (uploadExtension is not (".wav" or ".mp3" or ".flac")) { return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension"); } DateOnly? parsedReleaseDate = null; if (!string.IsNullOrWhiteSpace(releaseDate)) { if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed)) { return BadRequest("releaseDate must be in YYYY-MM-DD format"); } parsedReleaseDate = parsed; } // Default to Single for null/unparseable release type; default track number to a valid 1-based value. ReleaseType parsedReleaseType; if (!string.IsNullOrWhiteSpace(releaseType) && Enum.TryParse(releaseType, ignoreCase: true, out var rt) && Enum.IsDefined(rt)) { parsedReleaseType = rt; } else { parsedReleaseType = ReleaseType.Single; if (!string.IsNullOrWhiteSpace(releaseType)) _logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType); } // Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above. ReleaseMedium parsedMedium; if (!string.IsNullOrWhiteSpace(medium) && Enum.TryParse(medium, ignoreCase: true, out var rm) && Enum.IsDefined(rm)) { parsedMedium = rm; } else { parsedMedium = ReleaseMedium.Cut; if (!string.IsNullOrWhiteSpace(medium)) _logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium); } var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1; // The processor router selects by extension and reads from disk, so the temp file must carry // the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects — // generate our own path preserving the validated .wav/.mp3/.flac extension. var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension); try { await using (var tempStream = new FileStream( tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true)) await using (var uploadStream = audioFile.OpenReadStream()) { await uploadStream.CopyToAsync(tempStream, cancellationToken); } var result = await _unifiedService.UploadAsync( tempPath, trackName, artist, string.IsNullOrWhiteSpace(album) ? null : album, string.IsNullOrWhiteSpace(genre) ? null : genre, string.IsNullOrWhiteSpace(description) ? null : description, parsedReleaseDate, createdByUserId, string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName, parsedReleaseType, parsedMedium, resolvedTrackNumber, releaseId, cancellationToken); if (!result.Success || result.Value is null) { var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio"; _logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error); // A cardinality or duplicate-release rejection is a well-formed request that violates a // domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure. // The marker is stripped so the client sees only the human-readable detail. if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal)) { return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]); } if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal)) { return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]); } return StatusCode(500, error); } _logger.LogInformation("UploadTrack succeeded: id={Id}, entryKey={EntryKey}", result.Value.Id, result.Value.EntryKey); return Ok(result.Value); } catch (Exception ex) { _logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName); return StatusCode(500, "Internal server error"); } finally { try { if (System.IO.File.Exists(tempPath)) { System.IO.File.Delete(tempPath); } } catch (Exception ex) { _logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath); } } } // GET api/track/meta/{id}: single track metadata from SQL. [ApiKeyAuthorize] [HttpGet("meta/{id:long}")] public async Task GetMeta(long id) { var result = await _sqlTrackService.GetById(id); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetMeta failed for {TrackId}: {Error}", id, error); return StatusCode(500, "Failed to load track"); } if (result.Value is null) { return NotFound(); } return Ok(result.Value); } // GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key. // Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the // public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}. [HttpGet("meta/by-key/{entryKey}")] public async Task GetMetaByKey(string entryKey) { var result = await _sqlTrackService.GetByEntryKey(entryKey); if (!result.Success) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error); return StatusCode(500, "Failed to load track"); } if (result.Value is null) { return NotFound(); } return Ok(result.Value); } // PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body. [ApiKeyAuthorize] [HttpPut("meta/{id:long}")] public async Task UpdateMeta(long id, [FromBody] UpdateTrackMetadataRequest request) { var lookup = await _sqlTrackService.GetById(id); if (!lookup.Success) { var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("UpdateMeta lookup failed for {TrackId}: {Error}", id, error); return StatusCode(500, "Failed to load track"); } if (lookup.Value is null) { return NotFound(); } if (request.TrackNumber is <= 0) return BadRequest("trackNumber must be a positive integer when provided."); var track = lookup.Value; // Track-cardinal fields update the track row directly. track.TrackName = request.TrackName; if (request.TrackNumber is > 0) track.TrackNumber = request.TrackNumber.Value; // Release-cardinal fields update the linked release (handled in TrackManager.Update, which // persists track.Release when the track carries a resolved ReleaseId). The loaded track has // its Release populated via the Include; mutate it in place so the edited values flow through. // A loose track (no release) cannot take release-cardinal edits — there is no release row to // write to — so these fields are simply not persisted in that case. if (track.Release is { } release) { release.Artist = request.Artist; release.Title = request.Album ?? string.Empty; release.Genre = request.Genre; release.Description = request.Description; release.ReleaseDate = request.ReleaseDate; // ImagePath is tri-state: null = no change, "" = clear, value = set. if (request.ImagePath is not null) release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath; // ReleaseType is non-null on the release; null in the request means "no change". if (request.ReleaseType is not null) release.ReleaseType = request.ReleaseType.Value; // Medium is non-null on the release; null in the request means "no change". if (request.Medium is not null) { release.Medium = request.Medium.Value; // ReleaseType is meaningful only for Cut. When the medium is anything else, reset // ReleaseType to the DB-level default rather than leaving a stale studio-format value — // mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This // runs after the ReleaseType apply above, so it correctly overrides a contradictory // ReleaseType sent in the same request alongside a non-Cut medium. if (request.Medium.Value != ReleaseMedium.Cut) release.ReleaseType = ReleaseType.Single; } } var update = await _sqlTrackService.Update(track); if (!update.Success) { var error = update.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("UpdateMeta failed for {TrackId}: {Error}", id, error); return StatusCode(500, "Failed to update track"); } return Ok(); } // DELETE api/track/{id}: removes the SQL row then the vault entry. UnifiedTrackService owns // the ordering and orphan handling. Declared (with the long route constraint) before the // string "{trackId}" GET so a numeric id routes here. [ApiKeyAuthorize] [HttpDelete("{id:long}")] public async Task DeleteTrack(long id, CancellationToken cancellationToken) { _logger.LogInformation("DeleteTrack called with id: {Id}", id); var result = await _unifiedService.DeleteAsync(id, cancellationToken); if (result.Success) { return Ok(); } var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal)) { return NotFound(); } _logger.LogError("DeleteTrack failed for id {Id}: {Error}", id, error); return StatusCode(500, error); } // POST api/track/{id}/replace-audio ([ApiKeyAuthorize]) // Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey, // release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the // vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file // streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The // literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never // resolves to the parameterized "{trackId}" GET. [ApiKeyAuthorize] [HttpPost("{id:long}/replace-audio")] [RequestSizeLimit(2_000_000_000)] [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] public async Task ReplaceAudio( long id, [FromForm] IFormFile? audioFile, CancellationToken cancellationToken) { _logger.LogInformation("ReplaceAudio called: id={Id}, size={Size}", id, audioFile?.Length); if (audioFile is null || audioFile.Length == 0) { return BadRequest("Audio file is required"); } var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant(); if (uploadExtension is not (".wav" or ".mp3" or ".flac")) { return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension"); } // The processor router selects by extension and reads from disk, so the temp file must carry // the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp. var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension); try { await using (var tempStream = new FileStream( tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true)) await using (var uploadStream = audioFile.OpenReadStream()) { await uploadStream.CopyToAsync(tempStream, cancellationToken); } var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken); if (result.Success) { _logger.LogInformation("ReplaceAudio succeeded: id={Id}", id); return Ok(); } var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to replace audio"; if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal)) { return NotFound(); } _logger.LogError("ReplaceAudio failed for id {Id}: {Error}", id, error); return StatusCode(500, error); } catch (Exception ex) { _logger.LogError(ex, "ReplaceAudio failed for id {Id}", id); return StatusCode(500, "Internal server error"); } finally { try { if (System.IO.File.Exists(tempPath)) { System.IO.File.Delete(tempPath); } } catch (Exception ex) { _logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath); } } } // DELETE api/track/release/{id} ([ApiKeyAuthorize]) // Soft-delete a release row directly. Used by the albums browser to remove an orphaned release // (one with no live tracks). "release" is a literal segment, declared here in the literal-route // block so it never resolves to the parameterized "{trackId}" GET. [ApiKeyAuthorize] [HttpDelete("release/{id:long}")] public async Task DeleteRelease(long id, CancellationToken cancellationToken) { var result = await _sqlTrackService.DeleteRelease(id, cancellationToken); if (result.Success) return Ok(); var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error"; _logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error); return StatusCode(500, error); } // --- Parameterized routes --- [HttpGet("{trackId}")] public async Task GetTrack(string trackId) { _logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId); try { var vault = _fileDatabase.GetVault(VaultConstants.Tracks); if (vault == null) { _logger.LogWarning("Tracks vault not found"); return NotFound(); } var mediaStream = await vault.GetEntryStreamAsync(trackId); if (mediaStream == null) { _logger.LogWarning("Track not found: {TrackId}", trackId); return NotFound(); } // Resolve MIME and log before handing the stream to File(). // If anything here throws, the finally block disposes the wrapper // (and its inner FileStream) so neither leaks. On the success path // File() takes ownership of the inner stream; ASP.NET Core disposes // it after the response body is sent. The wrapper is a thin struct // with no extra resources, so disposing it after extracting the // inner stream is a no-op — we only call Dispose() in the catch path. string streamMimeType; long streamLength; Stream innerStream; try { streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension); streamLength = mediaStream.Stream.Length; innerStream = mediaStream.Stream; } catch { await mediaStream.DisposeAsync(); throw; } _logger.LogInformation( "Streaming track from disk: {TrackId}, Size: {Size} bytes", trackId, streamLength); // enableRangeProcessing: true — seek is served by HTTP Range requests. // The FileStream is seekable, so ASP.NET Core honours an incoming // Range header by slicing the file and responding 206 Partial Content. return File(innerStream, streamMimeType, enableRangeProcessing: true); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving track: {TrackId}", trackId); return StatusCode(500, "Internal server error"); } } // GET api/track/{trackId}/waveform (unauthenticated) // Returns the stored waveform loudness profile for a track, base64-encoded. Public listener // data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored // (existing tracks predate profiling, or computation failed at upload — the frontend falls back // to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route. [HttpGet("{trackId}/waveform")] public async Task GetWaveform(string trackId) { var bytes = await _waveformProfileService.GetProfileAsync(trackId); if (bytes is null) { _logger.LogInformation("No waveform profile for track: {TrackId}", trackId); return NotFound(); } return Ok(new WaveformProfileDto { BucketCount = bytes.Length, Data = Convert.ToBase64String(bytes), }); } // GET api/track/{trackId}/waveform/high-res (unauthenticated) // Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum // from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer // fetches for whatever track is currently playing/selected — the release is only addressing context. // Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the // "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is // stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the // parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins. [HttpGet("{trackId}/waveform/high-res")] public async Task GetHighResWaveform(string trackId) { var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms); if (bytes is null) { _logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId); return NotFound(); } return Ok(new WaveformProfileDto { BucketCount = bytes.Length, Data = Convert.ToBase64String(bytes), }); } // POST api/track/{trackId}/waveform ([ApiKeyAuthorize]) // Admin backfill: compute and store a waveform profile for an existing track from its vault // audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the // WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for // tracks that predate the WaveformSeeker feature. [ApiKeyAuthorize] [HttpPost("{trackId}/waveform")] public async Task GenerateWaveform(string trackId) { var audio = await _trackContentService.GetAudioBinaryAsync(trackId); if (audio is null) { _logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId); return NotFound(); } var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId); if (!stored) { _logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId); return StatusCode(500, "Failed to generate waveform profile."); } return Ok(); } // POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize]) // Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store // the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the // track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill. // Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio // is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared // before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins. [ApiKeyAuthorize] [HttpPost("{trackId}/waveform/high-res")] public async Task GenerateHighResWaveform(string trackId) { var audio = await _trackContentService.GetAudioBinaryAsync(trackId); if (audio is null) { _logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId); return NotFound(); } var stored = await _waveformProfileService.ComputeAndStoreHighResAsync( audio.Buffer, trackId, audio.Duration); if (!stored) { _logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId); return StatusCode(500, "Failed to generate high-res waveform datum."); } return Ok(); } [ApiKeyAuthorize] [HttpPut("{trackId}")] public async Task PutTrack(string trackId, [FromBody] AudioBinaryDto track) { _logger.LogInformation("PutTrack called with trackId: {TrackId}", trackId); // Reject unknown MIME types up front rather than silently storing the binary // with a ".bin" extension. GetExtension returns ".bin" for any unrecognised // MIME, so treat that as the sentinel for an unsupported type. if (MimeTypeExtensions.GetExtension(track.Mime) == ".bin") { _logger.LogWarning("PutTrack rejected: unsupported MIME type '{Mime}' for track {TrackId}", track.Mime, trackId); return BadRequest($"Unsupported MIME type: {track.Mime}"); } var audioBinary = AudioBinary.From(track); // Direct FileDatabase write: this endpoint receives an already-processed AudioBinaryDto, // not a WAV file, so TrackContentService.AddTrackFromWavAsync does not apply. See constructor comment. var success = await _fileDatabase.RegisterResourceAsync( DeepDrftContent.Constants.VaultConstants.Tracks, trackId, audioBinary); return success ? Ok() : BadRequest("Failed to store audio track"); } }