Merge replace-audio-duration-sync into dev (sync DurationSeconds on audio replace via unconditional SetDuration)

This commit is contained in:
daniel-c-harvey
2026-06-19 10:13:19 -04:00
4 changed files with 56 additions and 6 deletions
+21 -6
View File
@@ -168,12 +168,13 @@ public class UnifiedTrackService
/// <summary> /// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes /// 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, /// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
/// EntryKey, release membership, track number, and all metadata are preserved — nothing in SQL /// new duration to SQL. Track id, EntryKey, release membership, track number, and all other
/// is written. The waveform regen is best-effort (a missing datum renders as a flat seekbar / /// metadata are preserved. The waveform regen is best-effort (a missing datum renders as a flat
/// blank visualizer downstream), so a datum failure is logged and swallowed rather than failing /// seekbar / blank visualizer downstream), so a datum failure is logged and swallowed rather than
/// the replace. No release-cardinality cascade applies: the track count is unchanged, so the /// failing the replace. The duration write is not best-effort — a failure is surfaced so derived
/// single-track-Mix case stays intact. /// 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.
/// </summary> /// </summary>
public async Task<Result> ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct) public async Task<Result> 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); _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(); return Result.CreatePassResult();
} }
+8
View File
@@ -47,6 +47,14 @@ public interface ITrackService
/// </summary> /// </summary>
Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default); Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// Unconditionally overwrite the SQL duration for one track. Unlike <see cref="UpdateDuration"/>,
/// 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).
/// </summary>
Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating /// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK /// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
@@ -210,6 +210,18 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
.SetProperty(t => t.DurationSeconds, durationSeconds) .SetProperty(t => t.DurationSeconds, durationSeconds)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct); .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<int> 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, // 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. // signalling the manager to create one. Soft-deleted releases never match.
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync( public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
+15
View File
@@ -276,6 +276,21 @@ public class TrackManager
} }
} }
public async Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var affected = await Repository.SetDurationAsync(id, durationSeconds, cancellationToken);
if (affected == 0)
return ResultContainer<int>.CreateFailResult($"Duration write matched no rows for track {id}.");
return ResultContainer<int>.CreatePassResult(affected);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack) public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{ {
try try