diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs
index 679bce1..208c7dd 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.SetDuration(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();
}
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