diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 146f07b..9b4a372 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -499,6 +499,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 71d7568..679bce1 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 c978653..208ba36 100644 --- a/DeepDrftContent/TrackContentService.cs +++ b/DeepDrftContent/TrackContentService.cs @@ -103,6 +103,84 @@ 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 new audio is written first; only on confirmed success + /// is a stale old backing file cleaned up. A cross-format replacement (e.g. .wav → .flac) leaves + /// the old file on disk under its former filename once the index is updated; the post-success + /// cleanup removes it. For a same-extension overwrite the register alone suffices — the file is + /// written in place. If the register fails the original audio is left intact and null is returned, + /// so the track remains playable. Returns the freshly stored on success + /// (so the caller can regenerate waveform data from the same bytes) — matching the FileDatabase + /// swallow-and-return-null contract. + /// + public async Task ReplaceTrackAudioAsync(string entryKey, string audioFilePath) + { + try + { + // Capture the old extension before touching the vault. After register the index + // will point to the new extension, so we need the old value now to detect a + // cross-format swap and clean up the stale file post-success. + var existing = await _fileDatabase.LoadResourceAsync(VaultConstants.Tracks, entryKey); + var oldExtension = existing?.Extension; + + 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); + } + + // Register the new audio. This upserts the index entry (new extension recorded) and + // writes the new file to disk. If this fails the original entry and file are untouched. + var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audioBinary); + if (!success) + { + Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}; original audio preserved"); + return null; + } + + // Post-success stale-file cleanup for cross-format swaps. The register wrote the new + // file (e.g. .flac) and updated the index to the new extension, but the old backing + // file (e.g. .wav) is now unreferenced on disk. Delete it directly by constructing the + // old path — RemoveResourceAsync would now resolve to the new extension and delete the + // wrong file. Non-fatal: an orphaned old file is a disk-hygiene concern, not a + // playback issue (the index no longer references it). + if (oldExtension != null && oldExtension != audioBinary.Extension) + { + var vault = _fileDatabase.GetVault(VaultConstants.Tracks); + if (vault != null) + { + var sanitizedKey = System.Text.RegularExpressions.Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-"); + var staleFilePath = Path.Combine(vault.RootPath, $"{sanitizedKey}{oldExtension}"); + try + { + if (File.Exists(staleFilePath)) + File.Delete(staleFilePath); + } + catch (Exception ex) + { + Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: stale backing-file removal failed for {entryKey} ({staleFilePath}): {ex.Message} — new audio is live; orphaned file may remain on disk"); + } + } + } + + 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..f6d4442 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -59,14 +59,19 @@ single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@ + @* ExistingTrackCount counts edit-session persisted rows (Id.HasValue), not authoritative + live release count — acceptable because this gate only hides a UI control; the + TrySoftDeleteEmptyReleaseAsync backstop remains the authoritative guard. *@ + OnRemove="RemoveRow" + OnReplaceFileSelected="HandleReplaceFileSelected" /> @@ -322,6 +327,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