feature: Phase 18.3 — Opus delivery transport (?format= stream + seek sidecar endpoint)
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
using System.Text;
|
||||
using DeepDrftAPI.Controllers;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ContentTrackService = DeepDrftContent.TrackContentService;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Delivery-layer tests for the Phase 18.3 <c>?format=</c> stream selector and the Opus seek/setup sidecar
|
||||
/// endpoint on <see cref="TrackController"/>. These exercise the real <see cref="FileDb"/>, the real
|
||||
/// <see cref="TrackContentService"/>, and the real <see cref="TrackFormatResolver"/> over temp-directory
|
||||
/// vaults — the same integration posture as <see cref="TrackReplaceAudioTests"/>.
|
||||
///
|
||||
/// The SQL-only collaborators (<c>UnifiedTrackService</c>, <c>ITrackService</c>) are passed as null: the
|
||||
/// actions under test (<see cref="TrackController.GetTrack"/>, <see cref="TrackController.GetOpusSeekData"/>)
|
||||
/// only touch the FileDatabase + resolver path, never the SQL services, so standing up a database is not
|
||||
/// required to assert the delivery contract.
|
||||
///
|
||||
/// The Range→206 contract is asserted at the load-bearing seam: ASP.NET performs the actual byte-slicing
|
||||
/// for any <see cref="FileResult"/> whose <see cref="FileResult"/>.EnableRangeProcessing is true. The lossless
|
||||
/// path proves this via the disk-stream <see cref="FileStreamResult"/>; the resolved Opus path via the
|
||||
/// in-memory <see cref="FileContentResult"/> — both must report range processing enabled, the explicit fix
|
||||
/// the 18.2 reviewer flagged for the byte[] path.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class TrackFormatDeliveryTests
|
||||
{
|
||||
private string _testDir = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "TrackFormatDeliveryTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
// --- Format resolution at the endpoint ---
|
||||
|
||||
[Test]
|
||||
public async Task GetTrack_FormatOpus_WhenOpusArtifactPresent_ServesOpusBytesAndOggContentType()
|
||||
{
|
||||
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
|
||||
|
||||
var result = await controller.GetTrack(entryKey, format: "opus");
|
||||
|
||||
var file = result as FileContentResult;
|
||||
Assert.That(file, Is.Not.Null, "Opus delivery serves an in-memory byte[] (FileContentResult)");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(file!.ContentType, Is.EqualTo("audio/ogg"), "Opus bytes must carry the audio/ogg content-type");
|
||||
Assert.That(file.FileContents, Is.EqualTo(OpusBytes), "The served bytes must be the Opus artifact, not the source");
|
||||
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing must be enabled on the resolved Opus byte[] path");
|
||||
});
|
||||
}
|
||||
|
||||
// --- The C2 fallback ---
|
||||
|
||||
[Test]
|
||||
public async Task GetTrack_FormatOpus_WhenNoOpusArtifact_FallsBackToLosslessBytesAndContentType()
|
||||
{
|
||||
// No Opus artifact stored — the resolver degrades to lossless (C2): the listener still gets audio,
|
||||
// never a 404 or silence, and the content-type reports the lossless format actually served.
|
||||
var (controller, entryKey) = await SeedAsync(withOpus: false, withSidecar: false);
|
||||
|
||||
var result = await controller.GetTrack(entryKey, format: "opus");
|
||||
|
||||
var file = result as FileContentResult;
|
||||
Assert.That(file, Is.Not.Null, "The fallback still serves resolved bytes via the byte[] path");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(file!.ContentType, Is.EqualTo("audio/wav"), "Fallback content-type must be the lossless source's MIME");
|
||||
Assert.That(file.FileContents, Is.EqualTo(_sourceWav), "Fallback must serve the lossless source bytes");
|
||||
Assert.That(file.EnableRangeProcessing, Is.True, "Range processing stays enabled on the fallback path too");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetTrack_NoFormatParam_ServesLosslessDiskStream_ByteIdenticalToPrePhase18()
|
||||
{
|
||||
// The no-format path must be byte-identical to today: the zero-copy disk-stream FileStreamResult,
|
||||
// NOT the resolver's in-memory byte[] path (which would force the whole source into memory).
|
||||
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
|
||||
|
||||
var result = await controller.GetTrack(entryKey, format: null);
|
||||
|
||||
var file = result as FileStreamResult;
|
||||
Assert.That(file, Is.Not.Null, "Lossless delivery streams from disk (FileStreamResult), not a byte[]");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(file!.ContentType, Is.EqualTo("audio/wav"));
|
||||
Assert.That(file.EnableRangeProcessing, Is.True, "Range→206 must work on the lossless disk-stream path");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetTrack_FormatLossless_TakesTheLosslessDiskStreamPath()
|
||||
{
|
||||
// An explicit format=lossless must behave exactly like no param — the disk-stream path, never Opus.
|
||||
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
|
||||
|
||||
var result = await controller.GetTrack(entryKey, format: "lossless");
|
||||
|
||||
Assert.That(result, Is.InstanceOf<FileStreamResult>(),
|
||||
"format=lossless must take the disk-stream path even when an Opus artifact exists");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetTrack_FormatOpus_WhenTrackDoesNotExist_Returns404()
|
||||
{
|
||||
var controller = BuildController(await FreshDbAsync());
|
||||
|
||||
var result = await controller.GetTrack("no-such-track", format: "opus");
|
||||
|
||||
Assert.That(result, Is.InstanceOf<NotFoundResult>(),
|
||||
"When even the lossless source is missing, the Opus request 404s (no audio at all)");
|
||||
}
|
||||
|
||||
// --- Sidecar 200 / 404 ---
|
||||
|
||||
[Test]
|
||||
public async Task GetOpusSeekData_WhenSidecarPresent_Returns200WithRawBytes()
|
||||
{
|
||||
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: true);
|
||||
|
||||
var result = await controller.GetOpusSeekData(entryKey);
|
||||
|
||||
var file = result as FileContentResult;
|
||||
Assert.That(file, Is.Not.Null, "A stored sidecar is served as raw bytes");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(file!.ContentType, Is.EqualTo("application/octet-stream"));
|
||||
Assert.That(file.FileContents, Is.EqualTo(SidecarBytes), "The served bytes must be the stored sidecar blob");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetOpusSeekData_WhenNoSidecar_Returns404()
|
||||
{
|
||||
var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false);
|
||||
|
||||
var result = await controller.GetOpusSeekData(entryKey);
|
||||
|
||||
Assert.That(result, Is.InstanceOf<NotFoundResult>(),
|
||||
"No sidecar → 404, so the client degrades to lossless rather than treating it as an error");
|
||||
}
|
||||
|
||||
// --- Fixtures + helpers ---
|
||||
|
||||
private static readonly byte[] OpusBytes = Encoding.ASCII.GetBytes("OggS-fake-opus-payload-for-delivery-test");
|
||||
private static readonly byte[] SidecarBytes = Encoding.ASCII.GetBytes("setup-header+seek-index-sidecar-blob");
|
||||
|
||||
private byte[] _sourceWav = [];
|
||||
|
||||
private async Task<FileDb> FreshDbAsync()
|
||||
{
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
Assert.That(db, Is.Not.Null);
|
||||
return db!;
|
||||
}
|
||||
|
||||
// Seeds a track's lossless source in the tracks vault and, optionally, its Opus artifact and sidecar in
|
||||
// the track-opus vault, then returns a controller wired over those real vaults plus the entry key.
|
||||
private async Task<(TrackController Controller, string EntryKey)> SeedAsync(bool withOpus, bool withSidecar)
|
||||
{
|
||||
var db = await FreshDbAsync();
|
||||
var content = new ContentTrackService(db, new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
||||
|
||||
var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
||||
_sourceWav = BuildMinimalPcmWav(2.0);
|
||||
await File.WriteAllBytesAsync(wavPath, _sourceWav);
|
||||
|
||||
var seeded = await content.AddTrackAsync(wavPath, "Track", "Artist");
|
||||
Assert.That(seeded, Is.Not.Null);
|
||||
var entryKey = seeded!.EntryKey;
|
||||
|
||||
// GetAudioBinaryAsync re-reads what AddTrackAsync stored, so the bytes we assert the fallback against
|
||||
// are the exact stored source bytes (the processor may normalize the input WAV before storing).
|
||||
var storedSource = await content.GetAudioBinaryAsync(entryKey);
|
||||
Assert.That(storedSource, Is.Not.Null);
|
||||
_sourceWav = storedSource!.Buffer;
|
||||
|
||||
await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
|
||||
if (withOpus)
|
||||
{
|
||||
var opus = new AudioBinary(new AudioBinaryParams(OpusBytes, OpusBytes.Length, ".opus", 2.0, 320));
|
||||
Assert.That(
|
||||
await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
if (withSidecar)
|
||||
{
|
||||
var sidecar = new MediaBinary(new MediaBinaryParams(SidecarBytes, SidecarBytes.Length, ".opusidx"));
|
||||
Assert.That(
|
||||
await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
return (BuildController(db, content), entryKey);
|
||||
}
|
||||
|
||||
private static TrackController BuildController(FileDb db, ContentTrackService? content = null)
|
||||
{
|
||||
content ??= new ContentTrackService(db, new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
||||
|
||||
var waveforms = new WaveformProfileService(
|
||||
db, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
||||
|
||||
var resolver = new TrackFormatResolver(db, content, NullLogger<TrackFormatResolver>.Instance);
|
||||
|
||||
// SQL-only collaborators are null: the delivery actions under test never touch them.
|
||||
var controller = new TrackController(
|
||||
content,
|
||||
db,
|
||||
unifiedService: null!,
|
||||
sqlTrackService: null!,
|
||||
waveforms,
|
||||
resolver,
|
||||
stagingDirectory: null!,
|
||||
NullLogger<TrackController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user