2bde4908d7
Player picks Opus when the browser can decode it and a sidecar exists (else lossless), injecting the sidecar before stream init; seek reuses the same format. Adds the Backfill-Opus bulk API endpoint + CMS action.
172 lines
8.8 KiB
C#
172 lines
8.8 KiB
C#
using System.Text;
|
|
using DeepDrftAPI.Services;
|
|
using DeepDrftContent;
|
|
using DeepDrftContent.Processors;
|
|
using DeepDrftContent.Processors.Opus;
|
|
using DeepDrftData;
|
|
using DeepDrftModels.DTOs;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Models.Common;
|
|
using NetBlocks.Models;
|
|
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Confirms the Phase 18.5 acceptance point that <see cref="UnifiedTrackService.ReplaceAudioAsync"/>
|
|
/// regenerates the Opus artifact: a replace must schedule a background Opus re-derive for the track, because
|
|
/// the stale Opus no longer matches the new source bytes (the same reason it regenerates the waveform datums
|
|
/// and re-derives duration). The enqueue was wired in 18.1; this test pins it so a future refactor cannot
|
|
/// silently drop it, leaving a track serving Opus that does not match its lossless source.
|
|
///
|
|
/// The vault + content + waveform collaborators are real (over a temp-dir <see cref="FileDb"/>); the SQL
|
|
/// service is a focused fake. The fake is used here rather than the in-memory EF store because the replace
|
|
/// path's duration write goes through <c>SetDuration</c> → <c>ExecuteUpdateAsync</c>, which the EF in-memory
|
|
/// provider does not support — the fake lets the orchestration run to the post-write enqueue under test.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ReplaceAudioOpusRegenTests
|
|
{
|
|
private string _testDir = string.Empty;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_testDir = Path.Combine(Path.GetTempPath(), "ReplaceAudioOpusRegenTests", Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(_testDir);
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
try { Directory.Delete(_testDir, recursive: true); }
|
|
catch { /* Best-effort cleanup — ignore failures */ }
|
|
}
|
|
|
|
// A focused ITrackService fake: only GetById (returns the seeded track) and SetDuration (records the
|
|
// write and reports success) are meaningful — the two members the replace path calls. Everything else
|
|
// throws, documenting that the replace orchestration touches nothing else on the SQL boundary.
|
|
private sealed class FakeTrackService : ITrackService
|
|
{
|
|
private readonly TrackDto _track;
|
|
public double? LastDurationWritten { get; private set; }
|
|
|
|
public FakeTrackService(TrackDto track) => _track = track;
|
|
|
|
public Task<ResultContainer<TrackDto?>> GetById(long id) =>
|
|
Task.FromResult(ResultContainer<TrackDto?>.CreatePassResult(id == _track.Id ? _track : null));
|
|
|
|
public Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken ct = default)
|
|
{
|
|
LastDurationWritten = durationSeconds;
|
|
return Task.FromResult(ResultContainer<int>.CreatePassResult(1));
|
|
}
|
|
|
|
// Unused by the replace path — fail loudly if the orchestration ever reaches them.
|
|
public Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey) => throw new NotSupportedException();
|
|
public Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<List<TrackDto>>> GetAll() => throw new NotSupportedException();
|
|
public Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int p, int s, string? c, bool d, TrackFilter? f = null, CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<int>> UpdateDuration(long id, double d, CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(string t, string a, ReleaseDto r, CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(string t, string a, CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<TrackDto>> Create(TrackDto t) => throw new NotSupportedException();
|
|
public Task<ResultContainer<TrackDto>> Update(TrackDto t) => throw new NotSupportedException();
|
|
public Task<Result> Delete(long id) => throw new NotSupportedException();
|
|
public Task<Result> DeleteRelease(long id, CancellationToken ct = default) => throw new NotSupportedException();
|
|
public Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken ct = default) => throw new NotSupportedException();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAudio_EnqueuesOpusRegen_ForTheReplacedTrack()
|
|
{
|
|
var fileDatabase = await FileDb.FromAsync(_testDir);
|
|
Assert.That(fileDatabase, Is.Not.Null);
|
|
|
|
var content = new TrackContentService(
|
|
fileDatabase!, new AudioProcessorRouter(
|
|
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
|
var waveforms = new WaveformProfileService(
|
|
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
|
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
|
var resolver = new TrackFormatResolver(
|
|
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
|
|
var queue = new NoOpOpusTranscodeQueue();
|
|
|
|
// Seed the source WAV in the vault and point a fake SQL row at the same EntryKey.
|
|
var originalPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
|
await File.WriteAllBytesAsync(originalPath, BuildMinimalPcmWav(2.0));
|
|
var unpersisted = await content.AddTrackAsync(originalPath, "Original", "Artist");
|
|
Assert.That(unpersisted, Is.Not.Null);
|
|
|
|
const long trackId = 42;
|
|
var sql = new FakeTrackService(new TrackDto { Id = trackId, EntryKey = unpersisted!.EntryKey, TrackName = "Original" });
|
|
|
|
var service = new UnifiedTrackService(
|
|
content, sql, fileDatabase!, waveforms, queue, resolver,
|
|
NullLogger<UnifiedTrackService>.Instance);
|
|
|
|
// Replace the audio with a longer take.
|
|
var replacementPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
|
await File.WriteAllBytesAsync(replacementPath, BuildMinimalPcmWav(6.0));
|
|
|
|
var result = await service.ReplaceAudioAsync(trackId, replacementPath, CancellationToken.None);
|
|
|
|
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(queue.Enqueued, Does.Contain(unpersisted.EntryKey),
|
|
"a replace must schedule an Opus re-derive so the artifact tracks the new source");
|
|
Assert.That(sql.LastDurationWritten, Is.GreaterThan(0),
|
|
"the replace must also write the new duration (the enqueue follows a successful duration write)");
|
|
});
|
|
}
|
|
|
|
// Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites.
|
|
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();
|
|
}
|
|
}
|