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.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
using System.Text;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the content-side audio-replace seam
|
||||
/// (<see cref="TrackContentService.ReplaceTrackAudioAsync"/>) and the waveform regeneration that the
|
||||
/// API orchestrator runs after it. These exercise the real <see cref="FileDb"/>, the real
|
||||
/// <see cref="AudioProcessorRouter"/>, and the real <see cref="WaveformProfileService"/> over
|
||||
/// temp-directory-isolated vaults — the same pattern as <see cref="WaveformProfileServiceTests"/>.
|
||||
///
|
||||
/// The replace contract under test: the vault key (EntryKey) is preserved, only the bytes change,
|
||||
/// no stale backing file is left behind on a cross-format swap, and the waveform datums are
|
||||
/// re-computed against the new audio. SQL-side preservation (track id, release link, position,
|
||||
/// metadata) is guaranteed structurally — the orchestrator never writes SQL on replace — so it is
|
||||
/// documented here rather than asserted against a database this suite does not stand up.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class TrackReplaceAudioTests
|
||||
{
|
||||
private string _testDir = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "TrackReplaceAudioTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
private static TrackContentService CreateContentService(FileDb fileDatabase) =>
|
||||
new(fileDatabase, new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
||||
|
||||
private static WaveformProfileService CreateWaveformService(FileDb fileDatabase) =>
|
||||
new(fileDatabase, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTrackAudioAsync_SwapsBytes_PreservingEntryKey()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
Assert.That(fileDatabase, Is.Not.Null);
|
||||
var content = CreateContentService(fileDatabase!);
|
||||
|
||||
// Seed a 2-second track, then replace it with a 6-second one. The entry key is the stable
|
||||
// SQL→vault link; replace must reuse it so the track row keeps pointing at live audio.
|
||||
var original = await WriteWavAsync(BuildMinimalPcmWav(2.0), ".wav");
|
||||
var seeded = await content.AddTrackAsync(original, "Original", "Artist");
|
||||
Assert.That(seeded, Is.Not.Null);
|
||||
var entryKey = seeded!.EntryKey;
|
||||
|
||||
var before = await content.GetAudioBinaryAsync(entryKey);
|
||||
Assert.That(before, Is.Not.Null);
|
||||
var originalDuration = before!.Duration;
|
||||
|
||||
var replacement = await WriteWavAsync(BuildMinimalPcmWav(6.0), ".wav");
|
||||
var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
|
||||
Assert.That(newAudio, Is.Not.Null, "Replace should return the freshly stored audio");
|
||||
|
||||
var after = await content.GetAudioBinaryAsync(entryKey);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(after, Is.Not.Null, "The track must remain retrievable under the same EntryKey");
|
||||
Assert.That(after!.Duration, Is.GreaterThan(originalDuration),
|
||||
"The retrieved audio must reflect the longer replacement, not the original");
|
||||
Assert.That(newAudio!.Duration, Is.EqualTo(after.Duration),
|
||||
"The returned binary must match what is stored under the key");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTrackAudioAsync_CrossFormat_RemovesStaleBackingFile()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
var content = CreateContentService(fileDatabase!);
|
||||
|
||||
// A .wav original replaced by a .flac: the backing filename is keyed by extension, so a
|
||||
// register-only swap would strand the old .wav. The replace removes the old entry first.
|
||||
var original = await WriteWavAsync(BuildMinimalPcmWav(2.0), ".wav");
|
||||
var seeded = await content.AddTrackAsync(original, "Original", "Artist");
|
||||
var entryKey = seeded!.EntryKey;
|
||||
|
||||
var vaultDir = Path.Combine(_testDir, VaultConstants.Tracks);
|
||||
var wavFilesBefore = Directory.GetFiles(vaultDir, "*.wav");
|
||||
Assert.That(wavFilesBefore, Is.Not.Empty, "Sanity: the original .wav backing file exists");
|
||||
|
||||
var replacement = await WriteFlacAsync();
|
||||
var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
|
||||
Assert.That(newAudio, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Directory.GetFiles(vaultDir, "*.wav"), Is.Empty,
|
||||
"The stale .wav backing file must be removed on a cross-format replace");
|
||||
Assert.That(Directory.GetFiles(vaultDir, "*.flac"), Is.Not.Empty,
|
||||
"The new .flac backing file must be present");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceThenRegenerate_RewritesWaveformDatumsForNewAudio()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
var content = CreateContentService(fileDatabase!);
|
||||
var waveforms = CreateWaveformService(fileDatabase!);
|
||||
|
||||
// Seed a short track and its waveform datums, then replace with a longer track and regenerate
|
||||
// (the exact sequence UnifiedTrackService.ReplaceAudioAsync runs). The high-res datum is
|
||||
// duration-derived, so a longer replacement yields a denser datum — proving the regen ran
|
||||
// against the new audio rather than leaving the stale datum in place.
|
||||
var original = await WriteWavAsync(BuildMinimalPcmWav(3.0), ".wav");
|
||||
var seeded = await content.AddTrackAsync(original, "Original", "Artist");
|
||||
var entryKey = seeded!.EntryKey;
|
||||
|
||||
var seedAudio = await content.GetAudioBinaryAsync(entryKey);
|
||||
await waveforms.ComputeAndStoreAsync(seedAudio!.Buffer, entryKey);
|
||||
await waveforms.ComputeAndStoreHighResAsync(seedAudio.Buffer, entryKey, seedAudio.Duration);
|
||||
var staleHighRes = await waveforms.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
Assert.That(staleHighRes, Is.Not.Null);
|
||||
|
||||
var replacement = await WriteWavAsync(BuildMinimalPcmWav(20.0), ".wav");
|
||||
var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
Assert.That(newAudio, Is.Not.Null);
|
||||
|
||||
// Regen step (mirrors the orchestrator).
|
||||
Assert.That(await waveforms.ComputeAndStoreAsync(newAudio!.Buffer, entryKey), Is.True);
|
||||
Assert.That(await waveforms.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration), Is.True);
|
||||
|
||||
var freshHighRes = await waveforms.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
var freshProfile = await waveforms.GetProfileAsync(entryKey);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(freshHighRes, Is.Not.Null);
|
||||
Assert.That(freshProfile, Is.Not.Null, "The 512-bucket profile must also be present after regen");
|
||||
Assert.That(freshHighRes!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(20.0)),
|
||||
"The high-res datum must track the new (longer) duration");
|
||||
Assert.That(freshHighRes.Length, Is.Not.EqualTo(staleHighRes!.Length),
|
||||
"The regenerated datum must differ from the stale one keyed to the shorter original");
|
||||
});
|
||||
}
|
||||
|
||||
// --- WAV / FLAC test fixtures ---
|
||||
|
||||
private async Task<string> WriteWavAsync(byte[] bytes, string extension)
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + extension);
|
||||
await File.WriteAllBytesAsync(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
// Minimal valid FLAC: 'fLaC' magic + a STREAMINFO metadata block. The processor reads STREAMINFO
|
||||
// for sample rate / channels / bits / total samples; it does not decode frames, so an empty audio
|
||||
// payload is sufficient to produce a non-null AudioBinary with a .flac extension.
|
||||
private async Task<string> WriteFlacAsync()
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".flac");
|
||||
await File.WriteAllBytesAsync(path, BuildMinimalFlac());
|
||||
return path;
|
||||
}
|
||||
|
||||
// Builds a standard-PCM mono 16-bit 44.1 kHz WAV of the requested duration with a full-scale
|
||||
// square wave (non-silent so the loudness algorithm yields a real envelope). Same layout as
|
||||
// WaveformProfileServiceTests.BuildMinimalPcmWav.
|
||||
private static byte[] BuildMinimalPcmWav(double durationSeconds)
|
||||
{
|
||||
const int sampleRate = 44100;
|
||||
const ushort channels = 1;
|
||||
const ushort bitsPerSample = 16;
|
||||
const ushort blockAlign = channels * (bitsPerSample / 8);
|
||||
const uint byteRate = sampleRate * blockAlign;
|
||||
|
||||
var frames = (int)(sampleRate * durationSeconds);
|
||||
var data = new byte[frames * blockAlign];
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
|
||||
data[i * 2] = (byte)(sample & 0xFF);
|
||||
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + data.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(16u);
|
||||
w.Write((ushort)1); // PCM
|
||||
w.Write(channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write(bitsPerSample);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
// Builds the minimal FLAC the processor can parse: 'fLaC' + one STREAMINFO block (type 0, last).
|
||||
// STREAMINFO is 34 bytes; the processor reads the bit-packed 20-bit sample rate, 3-bit channels,
|
||||
// 5-bit bits-per-sample, and 36-bit total-samples fields. Values: 44.1 kHz, mono, 16-bit, some
|
||||
// total samples so duration > 0.
|
||||
private static byte[] BuildMinimalFlac()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("fLaC"));
|
||||
|
||||
// Metadata block header: last-block flag (0x80) | block type 0 (STREAMINFO), then 24-bit length = 34.
|
||||
ms.WriteByte(0x80);
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(34);
|
||||
|
||||
var streamInfo = new byte[34];
|
||||
// Bytes 0-1: min block size; 2-3: max block size — non-zero placeholders.
|
||||
streamInfo[0] = 0x10; streamInfo[2] = 0x10;
|
||||
// Bytes 10-17 hold the packed sampleRate(20) | channels(3) | bitsPerSample(5) | totalSamples(36).
|
||||
const int sampleRate = 44100;
|
||||
const int channels = 1;
|
||||
const int bitsPerSample = 16;
|
||||
const long totalSamples = 44100L * 3; // 3 seconds
|
||||
|
||||
// sampleRate occupies the top 20 bits of bytes 10-12.
|
||||
streamInfo[10] = (byte)((sampleRate >> 12) & 0xFF);
|
||||
streamInfo[11] = (byte)((sampleRate >> 4) & 0xFF);
|
||||
// Low 4 bits of sampleRate into the top nibble of byte 12; then (channels-1) in 3 bits and the
|
||||
// top bit of (bitsPerSample-1).
|
||||
var bps = bitsPerSample - 1; // 5-bit field stores bitsPerSample-1
|
||||
streamInfo[12] = (byte)(((sampleRate & 0x0F) << 4)
|
||||
| (((channels - 1) & 0x07) << 1)
|
||||
| ((bps >> 4) & 0x01));
|
||||
// Remaining 4 bits of bps into the top nibble of byte 13, then top 4 bits of the 36-bit total.
|
||||
streamInfo[13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));
|
||||
streamInfo[14] = (byte)((totalSamples >> 24) & 0xFF);
|
||||
streamInfo[15] = (byte)((totalSamples >> 16) & 0xFF);
|
||||
streamInfo[16] = (byte)((totalSamples >> 8) & 0xFF);
|
||||
streamInfo[17] = (byte)(totalSamples & 0xFF);
|
||||
|
||||
ms.Write(streamInfo);
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user