From 7265754c27bebe94a98eb127fb621790b8cd01b8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 18 Jun 2026 15:03:38 -0400 Subject: [PATCH 1/2] fix: write DurationSeconds to SQL after replace-audio vault swap --- DeepDrftAPI/Services/UnifiedTrackService.cs | 27 ++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 679bce1..6fcbca8 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -168,12 +168,13 @@ public class UnifiedTrackService /// /// 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. + /// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the + /// new duration to SQL. Track id, EntryKey, release membership, track number, and all other + /// metadata are preserved. 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. The duration write is not best-effort — a failure is surfaced so derived + /// aggregates (e.g. MixRuntimeSeconds) do not silently go stale. 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) { @@ -212,6 +213,20 @@ public class UnifiedTrackService _logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey); } + // Write the new duration to SQL. The vault bytes are already swapped, so this is the + // authoritative metadata update for the replace. A failure here is surfaced (unlike the + // best-effort waveform regen above) because a stale DurationSeconds silently corrupts + // derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint). + var durationWrite = await _sqlTrackService.UpdateDuration(trackId, newAudio.Duration, ct); + if (!durationWrite.Success) + { + var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError( + "ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}", + trackId, entryKey, error); + return Result.CreateFailResult("Audio replaced but duration metadata could not be updated."); + } + return Result.CreatePassResult(); } From e8359d547379c6485f927c6ca6c4ad2faad2e687 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 04:19:39 -0400 Subject: [PATCH 2/2] fix: replace-audio duration write now unconditional via SetDuration UpdateDuration's null guard matched zero rows for tracks that already had a duration (all normally-uploaded tracks). Add SetDurationAsync/SetDuration/ITrackService.SetDuration with no null guard; fail on zero rows. ReplaceAudioAsync now calls SetDuration. --- DeepDrftAPI/Services/UnifiedTrackService.cs | 2 +- DeepDrftData/ITrackService.cs | 8 ++++++++ DeepDrftData/Repositories/TrackRepository.cs | 12 ++++++++++++ DeepDrftData/TrackManager.cs | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 6fcbca8..208c7dd 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -217,7 +217,7 @@ public class UnifiedTrackService // authoritative metadata update for the replace. A failure here is surfaced (unlike the // best-effort waveform regen above) because a stale DurationSeconds silently corrupts // derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint). - var durationWrite = await _sqlTrackService.UpdateDuration(trackId, newAudio.Duration, ct); + var durationWrite = await _sqlTrackService.SetDuration(trackId, newAudio.Duration, ct); if (!durationWrite.Success) { var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error"; diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index f6c4a23..2845bba 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -47,6 +47,14 @@ public interface ITrackService /// Task> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default); + /// + /// Unconditionally overwrite the SQL duration for one track. Unlike , + /// this carries no null guard — it is for the replace-audio path where the track already has a + /// non-null duration that must be overwritten with the new audio's value. Returns a fail Result + /// when zero rows are affected (track removed between lookup and write). + /// + Task> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default); + /// /// Resolve the release matching + , creating /// one from when none exists. Backs the upload flow's FK diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index 3b638ff..6c741fc 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -210,6 +210,18 @@ public class TrackRepository : Repository .SetProperty(t => t.DurationSeconds, durationSeconds) .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct); + // Unconditional duration overwrite for one track (no load round-trip), used by the replace-audio + // path. Unlike UpdateDurationAsync, there is no null guard — replace always overwrites the + // existing value because a normally-uploaded track already has a non-null DurationSeconds and the + // null-guarded backfill query would match zero rows and silently leave it stale. Returns the count + // of rows affected; zero means the track was removed between the GetById lookup and this write. + public async Task SetDurationAsync(long id, double durationSeconds, CancellationToken ct = default) + => await Query + .Where(t => t.Id == id) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.DurationSeconds, durationSeconds) + .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct); + // Resolve an existing release by its natural key (title + artist). Returns null when no match, // signalling the manager to create one. Soft-deleted releases never match. public async Task GetReleaseByTitleAndArtistAsync( diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 240ec98..a04673c 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -276,6 +276,21 @@ public class TrackManager } } + public async Task> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default) + { + try + { + var affected = await Repository.SetDurationAsync(id, durationSeconds, cancellationToken); + if (affected == 0) + return ResultContainer.CreateFailResult($"Duration write matched no rows for track {id}."); + return ResultContainer.CreatePassResult(affected); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } + public async Task> Create(TrackDto newTrack) { try