From 16784b37f273dc28ee5c45fbb334f14b1ae38475 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 18 Jun 2026 12:59:56 -0400 Subject: [PATCH] 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. --- DeepDrftAPI/Controllers/TrackController.cs | 80 ++++++ DeepDrftAPI/Services/UnifiedTrackService.cs | 49 ++++ DeepDrftContent/TrackContentService.cs | 48 ++++ .../Components/Pages/Tracks/BatchEdit.razor | 83 +++++- .../Pages/Tracks/BatchTrackDetail.razor | 2 +- .../Pages/Tracks/BatchTrackList.razor | 54 +++- DeepDrftManager/Services/CmsTrackService.cs | 247 +++++++++++----- DeepDrftManager/Services/ICmsTrackService.cs | 18 ++ DeepDrftTests/TrackReplaceAudioTests.cs | 263 ++++++++++++++++++ 9 files changed, 761 insertions(+), 83 deletions(-) create mode 100644 DeepDrftTests/TrackReplaceAudioTests.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index e70907e..ca8c619 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 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 diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 5ed663c..94d429f 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -166,6 +166,55 @@ public class UnifiedTrackService return saveResult; } + /// + /// 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. + /// + public async Task 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 diff --git a/DeepDrftContent/TrackContentService.cs b/DeepDrftContent/TrackContentService.cs index bf6d13c..92d3ea9 100644 --- a/DeepDrftContent/TrackContentService.cs +++ b/DeepDrftContent/TrackContentService.cs @@ -100,6 +100,54 @@ public class TrackContentService string? originalFileName = null) => AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName); + /// + /// Swaps the audio bytes for an existing track in place: processes a new audio file and + /// re-registers it under the SAME in the tracks vault. The track's + /// vault key — and therefore its SQL link, release membership, position, and metadata — is + /// untouched; only the binary changes. The prior entry is removed first so a replacement whose + /// extension differs from the original (e.g. .wav → .flac) does not strand the old file on disk + /// under its former filename. Returns the freshly stored on success + /// (so the caller can regenerate waveform data from the same bytes), or null on processing or + /// vault failure — matching the FileDatabase swallow-and-return-null contract. + /// + public async Task ReplaceTrackAudioAsync(string entryKey, string audioFilePath) + { + try + { + var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath); + if (audioBinary == null) + { + Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}"); + return null; + } + + if (!_fileDatabase.HasVault(VaultConstants.Tracks)) + { + await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio); + } + + // Drop the old entry first. The backing file is keyed by entryKey + its *stored* + // extension, so a register alone would leave a stale file when the new format differs. + // A null/false removal is non-fatal (the entry may already be absent); the register + // below is the authoritative write. + await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey); + + var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audioBinary); + if (!success) + { + Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}"); + return null; + } + + return audioBinary; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync failed: {ex.Message}"); + return null; + } + } + /// /// Retrieves audio binary from FileDatabase /// diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index d51e9c9..40802e8 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -63,10 +63,12 @@ @bind-SelectedIndex="_selectedIndex" Disabled="_saving" AllowNewTracks="@(_medium == ReleaseMedium.Cut)" + ExistingTrackCount="@_tracks.Count(t => t.Id.HasValue)" OnWavFilesSelected="HandleWavFilesSelected" OnMoveUp="MoveUp" OnMoveDown="MoveDown" - OnRemove="RemoveRow" /> + OnRemove="RemoveRow" + OnReplaceFileSelected="HandleReplaceFileSelected" /> @@ -322,6 +324,85 @@ if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1; } + private async Task HandleReplaceFileSelected((int Index, IBrowserFile File) picked) + { + var (index, file) = picked; + if (index < 0 || index >= _tracks.Count) return; + + var row = _tracks[index]; + if (!row.Id.HasValue) + { + // Defensive: replace is only offered on persisted rows. A new row would have no track to + // swap against — it takes the upload path on save instead. + return; + } + + if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning); + return; + } + + var confirmed = await DialogService.ShowMessageBox( + "Replace audio", + $"Replace the audio for '{row.TrackName}' with '{file.Name}'? " + + "Metadata stays the same; the waveform is regenerated for the new audio.", + yesText: "Replace", cancelText: "Cancel"); + if (confirmed != true) return; + + row.Status = BatchRowStatus.Uploading; + row.UploadedBytes = 0; + row.TotalBytes = file.Size; + row.ErrorMessage = null; + StateHasChanged(); + + try + { + await using var wavStream = file.OpenReadStream(MaxUploadBytes); + + var lastPercent = -1; + var progress = new Progress(written => + { + row.UploadedBytes = written; + if (row.UploadPercent != lastPercent) + { + lastPercent = row.UploadPercent; + StateHasChanged(); + } + }); + + var result = await CmsTrackService.ReplaceTrackAudioAsync( + row.Id.Value, wavStream, file.Size, file.Name, file.ContentType, progress); + + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + row.Status = BatchRowStatus.Failed; + row.ErrorMessage = error; + Snackbar.Add($"Replace failed: {error}", Severity.Error); + } + else + { + // Reset to Queued (not Done): a Done row is skipped by SaveAsync, but the admin may + // still want to save pending metadata edits. The audio swap is already persisted. + row.Status = BatchRowStatus.Queued; + row.OriginalFileName = file.Name; + Snackbar.Add($"Replaced audio for '{row.TrackName}'.", Severity.Success); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Replace audio failed for track id {Id}", row.Id); + row.Status = BatchRowStatus.Failed; + row.ErrorMessage = "Replace failed — please try again."; + Snackbar.Add("Replace failed — please try again.", Severity.Error); + } + finally + { + StateHasChanged(); + } + } + private void RemoveCover() { // Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchTrackDetail.razor b/DeepDrftManager/Components/Pages/Tracks/BatchTrackDetail.razor index 0933dda..0583cdc 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchTrackDetail.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchTrackDetail.razor @@ -21,7 +21,7 @@ else { @(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName) - Existing track — audio is not editable. + Use the Replace audio action in the list to swap this track's audio. } else diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor b/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor index 9e8c7f3..48c1121 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchTrackList.razor @@ -34,12 +34,37 @@ Disabled="@(index == Tracks.Count - 1 || Disabled)" OnClick="@(() => OnMoveDown.InvokeAsync(index))" aria-label="Move track down" /> - + @* Replace audio: existing (persisted) rows only. New rows still pick their WAV + via the file input above, so a replace control there would be redundant. A + native