Wave 3 T2: WAV upload flow — POST api/track/upload, POST api/cms/track, /cms/tracks/new

This commit is contained in:
Daniel Harvey
2026-05-18 15:18:28 -04:00
parent f46c2557c8
commit 266086906e
5 changed files with 469 additions and 2 deletions
+19 -2
View File
@@ -24,7 +24,7 @@ The binary content API host. ApiKey middleware, CORS, forwarded headers. Returns
- `WavOffsetService` — in `DeepDrftContent.Services`.
- Don't add new domain code to this project.
## The endpoint surface (exactly two endpoints)
## The endpoint surface (three endpoints)
### GET api/track/{trackId}?offset=0 (unauthenticated)
@@ -45,7 +45,24 @@ Returns the WAV bytes from the `tracks` vault.
- Actually: the endpoint is rarely used in production (the CLI calls `FileDatabase.RegisterResourceAsync` directly). But the endpoint exists for potential web-side uploads in future.
- Returns 200 on success, 401 if ApiKey invalid, 400 if body invalid.
**Do not add a third endpoint without product approval.** The surface is intentionally minimal.
### POST api/track/upload ([ApiKeyAuthorize])
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV via `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync`, and returns an unpersisted `TrackEntity` (no `Id` assigned). The caller (the CMS controller on `DeepDrftWeb`) is responsible for then saving that entity to SQL.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Form fields**:
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
- `trackName` (string, required)
- `artist` (string, required)
- `album` (string, optional)
- `genre` (string, optional)
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
- Returns 200 with the unpersisted `TrackEntity` JSON on success. Returns 400 for missing/invalid form fields (no WAV, missing required strings, wrong extension, bad date). Returns 500 if `AddTrackFromWavAsync` returns null or throws.
- **Wave 3 product approval covers this addition** (CMS-PLAN §5, Option B): `DeepDrftWeb` proxies CMS uploads here so it never touches the vault disk path. Do not extend the endpoint surface further without a fresh approval.
**The surface is intentionally minimal.** Do not add a fourth endpoint without product approval.
## ApiKey middleware behaviour
@@ -120,6 +120,111 @@ public class TrackController : ControllerBase
}
}
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → unpersisted TrackEntity out.
// Used by the CMS upload flow on DeepDrftWeb; that host proxies the upload here so it never
// touches the vault disk path directly (Option B in CMS-PLAN §5).
//
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
// of MB and 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.
[ApiKeyAuthorize]
[HttpPost("upload")]
[RequestSizeLimit(1_073_741_824)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
public async Task<ActionResult<DeepDrftModels.Entities.TrackEntity>> UploadTrack(
[FromForm] IFormFile? wav,
[FromForm] string? trackName,
[FromForm] string? artist,
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? releaseDate)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
trackName, artist, wav?.Length);
if (wav is null || wav.Length == 0)
{
return BadRequest("WAV file is required");
}
if (string.IsNullOrWhiteSpace(trackName))
{
return BadRequest("trackName is required");
}
if (string.IsNullOrWhiteSpace(artist))
{
return BadRequest("artist is required");
}
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Uploaded file must have a .wav 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;
}
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = wav.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream);
}
var entity = await _trackService.AddTrackFromWavAsync(
tempPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
string.IsNullOrWhiteSpace(genre) ? null : genre,
parsedReleaseDate);
if (entity is null)
{
_logger.LogWarning("UploadTrack: TrackService returned null for {TrackName}", trackName);
return StatusCode(500, "Failed to process and store WAV");
}
_logger.LogInformation("UploadTrack succeeded: entryKey={EntryKey}", entity.EntryKey);
return Ok(entity);
}
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);
}
}
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)