Wire Opus end-to-end playback + Backfill-Opus action (Phase 18.5)

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.
This commit is contained in:
daniel-c-harvey
2026-06-23 12:39:13 -04:00
parent dce5530890
commit 2bde4908d7
12 changed files with 961 additions and 1 deletions
+251
View File
@@ -0,0 +1,251 @@
using System.Text;
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Tests for the Phase 18.5 Backfill-Opus scheduling contract
/// (<see cref="UnifiedTrackService.BackfillOpusAsync"/> and <see cref="UnifiedTrackService.EnqueueOpusAsync"/>).
/// These assert the enqueue decision — which tracks get a background derive scheduled — over a real
/// <see cref="FileDb"/>, a real <see cref="TrackFormatResolver"/>, and an in-memory SQL store, with a
/// recording <see cref="NoOpOpusTranscodeQueue"/> standing in for the background worker (the actual transcode
/// is not exercised here — it needs ffmpeg and is out of scope for the scheduling contract).
///
/// The decision under test: a track is enqueued iff it lacks a COMPLETE Opus artifact (both the Opus audio
/// bytes and the seek/setup sidecar). A track with both is skipped; a half-derived track (audio without
/// sidecar) is treated as incomplete and re-enqueued so a backfill heals it.
/// </summary>
[TestFixture]
public class OpusBackfillTests
{
private string _testDir = string.Empty;
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "OpusBackfillTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private TrackManager CreateManager()
{
var repository = new TrackRepository(
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
return new TrackManager(
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
}
private sealed record Harness(
UnifiedTrackService Service,
NoOpOpusTranscodeQueue Queue,
TrackContentService Content,
FileDb FileDatabase,
ITrackService Sql);
private async Task<Harness> BuildAsync()
{
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();
var sql = CreateManager();
var service = new UnifiedTrackService(
content, sql, fileDatabase!, waveforms, queue, resolver,
NullLogger<UnifiedTrackService>.Instance);
await fileDatabase!.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
return new Harness(service, queue, content, fileDatabase!, sql);
}
// Seeds a track: stores a real source WAV in the tracks vault and a SQL row pointing at the same EntryKey.
// Returns the EntryKey so the test can selectively add Opus artifacts to a subset.
private async Task<string> SeedTrackAsync(Harness h, string title)
{
var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(wavPath, BuildMinimalPcmWav(2.0));
var unpersisted = await h.Content.AddTrackAsync(wavPath, title, "Artist");
Assert.That(unpersisted, Is.Not.Null);
var dto = new TrackDto { EntryKey = unpersisted!.EntryKey, TrackName = title };
var created = await h.Sql.Create(dto);
Assert.That(created.Success, Is.True, created.Messages.FirstOrDefault()?.Message);
return unpersisted.EntryKey;
}
private async Task StoreOpusAudioAsync(Harness h, string entryKey)
{
var opus = new AudioBinary(new AudioBinaryParams("opus"u8.ToArray(), 4, ".opus", 2.0, 320));
Assert.That(
await h.FileDatabase.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus),
Is.True);
}
private async Task StoreSidecarAsync(Harness h, string entryKey)
{
var sidecar = new MediaBinary(new MediaBinaryParams("idx"u8.ToArray(), 3, ".opusidx"));
Assert.That(
await h.FileDatabase.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar),
Is.True);
}
[Test]
public async Task BackfillOpus_EnqueuesOnlyTracksWithoutCompleteOpus()
{
var h = await BuildAsync();
// Three tracks: one fully derived (audio + sidecar), one bare (no Opus), one half-derived (audio only).
var complete = await SeedTrackAsync(h, "Complete");
await StoreOpusAudioAsync(h, complete);
await StoreSidecarAsync(h, complete);
var bare = await SeedTrackAsync(h, "Bare");
var halfDerived = await SeedTrackAsync(h, "HalfDerived");
await StoreOpusAudioAsync(h, halfDerived); // audio but no sidecar → unseekable → treated as incomplete
var result = await h.Service.BackfillOpusAsync(CancellationToken.None);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.Multiple(() =>
{
Assert.That(result.Value.Enqueued, Is.EqualTo(2), "the bare and half-derived tracks must be enqueued");
Assert.That(result.Value.Skipped, Is.EqualTo(1), "the fully-derived track must be skipped");
Assert.That(h.Queue.Enqueued, Does.Contain(bare));
Assert.That(h.Queue.Enqueued, Does.Contain(halfDerived));
Assert.That(h.Queue.Enqueued, Does.Not.Contain(complete), "a complete Opus artifact is not re-enqueued");
});
}
[Test]
public async Task BackfillOpus_WhenAllTracksHaveOpus_EnqueuesNothing()
{
var h = await BuildAsync();
var a = await SeedTrackAsync(h, "A");
await StoreOpusAudioAsync(h, a);
await StoreSidecarAsync(h, a);
var result = await h.Service.BackfillOpusAsync(CancellationToken.None);
Assert.That(result.Success, Is.True);
Assert.Multiple(() =>
{
Assert.That(result.Value.Enqueued, Is.Zero);
Assert.That(result.Value.Skipped, Is.EqualTo(1));
Assert.That(h.Queue.Enqueued, Is.Empty, "an all-derived catalogue schedules no transcodes");
});
}
[Test]
public async Task EnqueueOpus_KnownTrack_Enqueues()
{
var h = await BuildAsync();
var entryKey = await SeedTrackAsync(h, "Solo");
var result = await h.Service.EnqueueOpusAsync(entryKey, CancellationToken.None);
Assert.That(result.Success, Is.True);
Assert.That(h.Queue.Enqueued, Does.Contain(entryKey));
}
[Test]
public async Task EnqueueOpus_UnknownTrack_FailsWithNotFound_AndEnqueuesNothing()
{
var h = await BuildAsync();
var result = await h.Service.EnqueueOpusAsync("no-such-track", CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Success, Is.False);
Assert.That(result.Messages.FirstOrDefault()?.Message, Is.EqualTo(UnifiedTrackService.TrackNotFoundMessage));
Assert.That(h.Queue.Enqueued, Is.Empty, "an unknown track must not schedule a transcode");
});
}
// 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();
}
}
+180
View File
@@ -0,0 +1,180 @@
using System.Net;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Clients;
using DeepDrftPublic.Client.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 18.5 player-side format-selection seam
/// (<see cref="StreamingAudioPlayerService.ResolveStreamFormatAsync"/>): the default policy (OQ2) of "Opus
/// when the browser can decode Ogg Opus AND a sidecar exists, else lossless", the capability gate (AC7), and
/// the sidecar-absent → lossless fallback (C2). The seam is the single, overridable hook 18.6 will use to
/// inject the listener's quality preference; these tests pin the capability-gated default it falls through to.
///
/// The seam touches two collaborators: <see cref="AudioInteropService"/> (over a fake <see cref="IJSRuntime"/>
/// — <c>canDecodeOggOpus</c> + <c>setOpusSidecar</c>) and <see cref="TrackMediaClient"/> (over a stub HTTP
/// handler — the one-time sidecar fetch). Both are real instances wired over the fakes; only the network/JS
/// boundary is faked, so the selection logic under test is exercised exactly as it runs in the browser.
/// </summary>
[TestFixture]
public class OpusFormatSelectionTests
{
// A scriptable JS runtime: canDecodeOggOpus returns a configured bool; setOpusSidecar returns a
// configured success/failure; every other invocation returns default. Records the calls so a test can
// assert the set-before-init contract was honoured (the sidecar was actually handed to the player).
private sealed class FakeJsRuntime : IJSRuntime
{
private readonly bool _canDecode;
private readonly bool _sidecarParseSucceeds;
public FakeJsRuntime(bool canDecode, bool sidecarParseSucceeds)
{
_canDecode = canDecode;
_sidecarParseSucceeds = sidecarParseSucceeds;
}
public int SetSidecarCallCount { get; private set; }
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
if (identifier == "DeepDrftAudio.canDecodeOggOpus")
return ValueTask.FromResult((TValue)(object)_canDecode);
if (identifier == "DeepDrftAudio.setOpusSidecar")
{
SetSidecarCallCount++;
var result = new AudioOperationResult
{
Success = _sidecarParseSucceeds,
Error = _sidecarParseSucceeds ? null : "Invalid Opus sidecar blob",
};
return ValueTask.FromResult((TValue)(object)result);
}
return ValueTask.FromResult<TValue>(default!);
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> InvokeAsync<TValue>(identifier, args);
}
// Returns a configured status (with a body) for GET api/track/{id}/opus/seekdata; any other request 404s.
private sealed class StubSidecarHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly byte[] _body;
public StubSidecarHandler(HttpStatusCode status, byte[]? body = null)
{
_status = status;
_body = body ?? [];
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(_status);
if (_status == HttpStatusCode.OK)
response.Content = new ByteArrayContent(_body);
return Task.FromResult(response);
}
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public SingleClientFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) =>
new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") };
}
// Exposes the protected seam for direct assertion. The 18.6 override will replace this same method.
private sealed class TestablePlayer : StreamingAudioPlayerService
{
public TestablePlayer(AudioInteropService interop, TrackMediaClient media)
: base(interop, media, NullLogger<StreamingAudioPlayerService>.Instance) { }
public Task<AudioFormat> ResolveFormatForTest(string entryKey) =>
ResolveStreamFormatAsync(entryKey, CancellationToken.None);
}
private static TestablePlayer BuildPlayer(
bool canDecode, bool sidecarParseSucceeds, HttpStatusCode sidecarStatus, byte[]? sidecarBody)
{
var js = new FakeJsRuntime(canDecode, sidecarParseSucceeds);
var interop = new AudioInteropService(js);
var media = new TrackMediaClient(new SingleClientFactory(new StubSidecarHandler(sidecarStatus, sidecarBody)));
return new TestablePlayer(interop, media);
}
private static readonly byte[] SidecarBytes = "setup-header+seek-index"u8.ToArray();
// Capable browser + present sidecar → Opus. The happy path: the default policy picks the low-data format.
[Test]
public async Task ResolveStreamFormat_CapableBrowser_SidecarPresent_ChoosesOpus()
{
var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true,
HttpStatusCode.OK, SidecarBytes);
var format = await player.ResolveFormatForTest("track-1");
Assert.That(format, Is.EqualTo(AudioFormat.Opus));
}
// Capability gate (AC7): a browser that cannot decode Ogg Opus always gets lossless, and the sidecar is
// never even fetched/injected — Opus is off the table before any sidecar work.
[Test]
public async Task ResolveStreamFormat_IncapableBrowser_ChoosesLossless_AndDoesNotInjectSidecar()
{
var js = new FakeJsRuntime(canDecode: false, sidecarParseSucceeds: true);
var interop = new AudioInteropService(js);
var media = new TrackMediaClient(new SingleClientFactory(
new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes)));
var player = new TestablePlayer(interop, media);
var format = await player.ResolveFormatForTest("track-1");
Assert.Multiple(() =>
{
Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "incapable browser must fall back to lossless");
Assert.That(js.SetSidecarCallCount, Is.Zero, "no sidecar should be injected when Opus is gated out");
});
}
// C2 fallback: capable browser but no sidecar (legacy / not-yet-transcoded track, 404) → lossless.
[Test]
public async Task ResolveStreamFormat_CapableBrowser_NoSidecar_FallsBackToLossless()
{
var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true,
HttpStatusCode.NotFound, sidecarBody: null);
var format = await player.ResolveFormatForTest("track-1");
Assert.That(format, Is.EqualTo(AudioFormat.Lossless),
"a capable browser with no Opus sidecar must request lossless, not Opus");
}
// A present-but-unparseable sidecar (the JS decoder rejects the bytes) → lossless, so a malformed sidecar
// never breaks playback. The injection was attempted (set-before-init), but its failure degrades safely.
[Test]
public async Task ResolveStreamFormat_SidecarPresentButUnparseable_FallsBackToLossless()
{
var js = new FakeJsRuntime(canDecode: true, sidecarParseSucceeds: false);
var interop = new AudioInteropService(js);
var media = new TrackMediaClient(new SingleClientFactory(
new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes)));
var player = new TestablePlayer(interop, media);
var format = await player.ResolveFormatForTest("track-1");
Assert.Multiple(() =>
{
Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "an unparseable sidecar must degrade to lossless");
Assert.That(js.SetSidecarCallCount, Is.EqualTo(1), "the player attempted the set-before-init injection");
});
}
}
+171
View File
@@ -0,0 +1,171 @@
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();
}
}
@@ -4,6 +4,7 @@ using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
@@ -74,10 +75,13 @@ public class UploadDuplicateDetectionTests
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);
return new UnifiedTrackService(
content, sqlTrackService, fileDatabase!, waveforms,
new NoOpOpusTranscodeQueue(),
resolver,
NullLogger<UnifiedTrackService>.Instance);
}