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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user