feat(audio): add MP3 and FLAC upload support via format-routed processors

AudioProcessorRouter dispatches by extension; vault stores original bytes with correct MIME type.
This commit is contained in:
daniel-c-harvey
2026-06-11 05:49:17 -04:00
parent f8186fb7c7
commit 3bb8104967
8 changed files with 725 additions and 30 deletions
+18 -15
View File
@@ -166,11 +166,12 @@ public class TrackController : ControllerBase
return Ok(status);
}
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
// 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.
// 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 GB: WAV uploads can be tens to hundreds
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio 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.
@@ -179,7 +180,7 @@ public class TrackController : ControllerBase
[RequestSizeLimit(1_073_741_824)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
[FromForm] IFormFile? wav,
[FromForm] IFormFile? audioFile,
[FromForm] string? trackName,
[FromForm] string? artist,
[FromForm] string? album,
@@ -190,11 +191,11 @@ public class TrackController : ControllerBase
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
trackName, artist, originalFileName, wav?.Length);
trackName, artist, originalFileName, audioFile?.Length);
if (wav is null || wav.Length == 0)
if (audioFile is null || audioFile.Length == 0)
{
return BadRequest("WAV file is required");
return BadRequest("Audio file is required");
}
if (string.IsNullOrWhiteSpace(trackName))
@@ -207,9 +208,10 @@ public class TrackController : ControllerBase
return BadRequest("artist is required");
}
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
{
return BadRequest("Uploaded file must have a .wav extension");
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
DateOnly? parsedReleaseDate = null;
@@ -222,16 +224,17 @@ public class TrackController : ControllerBase
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");
// 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 = wav.OpenReadStream())
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
@@ -249,7 +252,7 @@ public class TrackController : ControllerBase
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
return StatusCode(500, error);
}
+5 -4
View File
@@ -37,9 +37,10 @@ public class UnifiedTrackService
}
/// <summary>
/// Process a WAV into the vault, then persist its metadata to SQL. On success the returned
/// DTO carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails,
/// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually.
/// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata
/// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds
/// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is
/// recoverable manually.
/// </summary>
public async Task<ResultContainer<TrackDto>> UploadAsync(
string tempFilePath,
@@ -52,7 +53,7 @@ public class UnifiedTrackService
string? originalFileName,
CancellationToken ct)
{
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
var unpersisted = await _contentTrackContentService.AddTrackAsync(
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
if (unpersisted is null)
+3
View File
@@ -15,6 +15,9 @@ namespace DeepDrftAPI
{
// Audio services
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<Mp3AudioProcessor>();
builder.Services.AddSingleton<FlacAudioProcessor>();
builder.Services.AddSingleton<AudioProcessorRouter>();
builder.Services.AddSingleton<TrackContentService>();
// Image services