feat(cms): replace track audio in edit form, gate last-track delete

Swap a track's audio by EntryKey (metadata/release/position preserved, waveform regenerated); hide per-track remove on a release's sole persisted track so it can only be replaced or release-deleted.
This commit is contained in:
daniel-c-harvey
2026-06-18 12:59:56 -04:00
parent 8ddecb4acc
commit 16784b37f2
9 changed files with 761 additions and 83 deletions
@@ -479,6 +479,86 @@ public class TrackController : ControllerBase
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 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(1_073_741_824)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
public async Task<ActionResult> 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
@@ -166,6 +166,55 @@ public class UnifiedTrackService
return saveResult;
}
/// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
/// keyed by its EntryKey, then regenerate both waveform datums from the new audio. Track id,
/// EntryKey, release membership, track number, and all metadata are preserved — nothing in SQL
/// is written. The waveform regen is best-effort (a missing datum renders as a flat seekbar /
/// blank visualizer downstream), so a datum failure is logged and swallowed rather than failing
/// the replace. No release-cardinality cascade applies: the track count is unchanged, so the
/// single-track-Mix case stays intact.
/// </summary>
public async Task<Result> ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct)
{
var lookup = await _sqlTrackService.GetById(trackId);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogError("ReplaceAudioAsync: GetById failed for track {TrackId}: {Error}", trackId, error);
return Result.CreateFailResult("Failed to load track.");
}
if (lookup.Value is null)
{
return Result.CreateFailResult(TrackNotFoundMessage);
}
var entryKey = lookup.Value.EntryKey;
var newAudio = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath);
if (newAudio is null)
{
_logger.LogWarning("ReplaceAudioAsync: content swap returned null for track {TrackId} ({EntryKey})", trackId, entryKey);
return Result.CreateFailResult("Failed to process and store the replacement audio.");
}
// The old waveform no longer matches the new bytes. Regenerate both datums in place; keyed
// by the same EntryKey, the re-run overwrites the stale data (proven re-runnable). The
// freshly stored buffer is the authoritative source — no re-read of the vault needed.
try
{
await _waveformProfileService.ComputeAndStoreAsync(newAudio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey);
}
return Result.CreatePassResult();
}
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same