Merge Phase 18.3 (Opus delivery transport) into streaming-overhaul
This commit is contained in:
@@ -5,6 +5,7 @@ using DeepDrftContent.Constants;
|
|||||||
using DeepDrftContent.FileDatabase.Services;
|
using DeepDrftContent.FileDatabase.Services;
|
||||||
using DeepDrftContent.FileDatabase.Models;
|
using DeepDrftContent.FileDatabase.Models;
|
||||||
using DeepDrftContent.Processors;
|
using DeepDrftContent.Processors;
|
||||||
|
using DeepDrftContent.Processors.Opus;
|
||||||
using DeepDrftData;
|
using DeepDrftData;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
using DeepDrftModels.Enums;
|
using DeepDrftModels.Enums;
|
||||||
@@ -20,6 +21,7 @@ public class TrackController : ControllerBase
|
|||||||
private readonly UnifiedTrackService _unifiedService;
|
private readonly UnifiedTrackService _unifiedService;
|
||||||
private readonly ITrackService _sqlTrackService;
|
private readonly ITrackService _sqlTrackService;
|
||||||
private readonly WaveformProfileService _waveformProfileService;
|
private readonly WaveformProfileService _waveformProfileService;
|
||||||
|
private readonly TrackFormatResolver _formatResolver;
|
||||||
private readonly UploadStagingDirectory _stagingDirectory;
|
private readonly UploadStagingDirectory _stagingDirectory;
|
||||||
private readonly ILogger<TrackController> _logger;
|
private readonly ILogger<TrackController> _logger;
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ public class TrackController : ControllerBase
|
|||||||
UnifiedTrackService unifiedService,
|
UnifiedTrackService unifiedService,
|
||||||
ITrackService sqlTrackService,
|
ITrackService sqlTrackService,
|
||||||
WaveformProfileService waveformProfileService,
|
WaveformProfileService waveformProfileService,
|
||||||
|
TrackFormatResolver formatResolver,
|
||||||
UploadStagingDirectory stagingDirectory,
|
UploadStagingDirectory stagingDirectory,
|
||||||
ILogger<TrackController> logger)
|
ILogger<TrackController> logger)
|
||||||
{
|
{
|
||||||
@@ -43,6 +46,7 @@ public class TrackController : ControllerBase
|
|||||||
_unifiedService = unifiedService;
|
_unifiedService = unifiedService;
|
||||||
_sqlTrackService = sqlTrackService;
|
_sqlTrackService = sqlTrackService;
|
||||||
_waveformProfileService = waveformProfileService;
|
_waveformProfileService = waveformProfileService;
|
||||||
|
_formatResolver = formatResolver;
|
||||||
_stagingDirectory = stagingDirectory;
|
_stagingDirectory = stagingDirectory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -642,10 +646,27 @@ public class TrackController : ControllerBase
|
|||||||
|
|
||||||
// --- Parameterized routes ---
|
// --- Parameterized routes ---
|
||||||
|
|
||||||
|
// GET api/track/{trackId}?format=opus|lossless (unauthenticated)
|
||||||
|
// Streams the track's audio bytes with HTTP Range support. The optional `format` selector (Phase 18.3)
|
||||||
|
// picks the delivery rendering: absent or unrecognized ⇒ Lossless (byte-identical to pre-Phase-18 —
|
||||||
|
// the existing zero-copy disk-stream path, untouched); `opus` ⇒ the derived Ogg Opus 320 artifact
|
||||||
|
// when present, falling back to lossless when it is not (C2 — never 404/silence). The Opus path serves
|
||||||
|
// the resolved in-memory bytes via File(..., enableRangeProcessing: true) so Range: bytes=X- still
|
||||||
|
// yields 206 (load-bearing for streaming + seek), matching the lossless disk-stream's range behavior.
|
||||||
[HttpGet("{trackId}")]
|
[HttpGet("{trackId}")]
|
||||||
public async Task<ActionResult> GetTrack(string trackId)
|
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] string? format = null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
|
_logger.LogInformation("GetTrack called with trackId: {TrackId}, format: {Format}", trackId, format);
|
||||||
|
|
||||||
|
// Only `opus` diverges from today's behavior; everything else (null, "lossless", garbage) takes the
|
||||||
|
// unchanged lossless disk-stream path below, preserving the large-file zero-copy streaming. Routing
|
||||||
|
// lossless through the resolver would force the whole source (up to ~1 GB) into memory per request —
|
||||||
|
// a regression the resolver's in-memory byte[] result is fine for Opus (small) but not for lossless.
|
||||||
|
if (Enum.TryParse<AudioFormat>(format, ignoreCase: true, out var requestedFormat)
|
||||||
|
&& requestedFormat == AudioFormat.Opus)
|
||||||
|
{
|
||||||
|
return await GetTrackOpusAsync(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -700,6 +721,58 @@ public class TrackController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ?format=opus arm of GetTrack. Resolves the Opus artifact (or the lossless fallback when none
|
||||||
|
// exists, C2) via TrackFormatResolver and serves the resolved bytes with explicit range processing.
|
||||||
|
// enableRangeProcessing:true is the load-bearing detail the 18.2 reviewer flagged: File(byte[], ...)
|
||||||
|
// does NOT get ASP.NET's automatic range handling unless asked, so without this flag a Range: bytes=X-
|
||||||
|
// would silently return the whole body as 200 instead of a 206 slice — breaking seek for the Opus path
|
||||||
|
// (and Phase 21 windowing). The resolver reports the *actually-served* format via ResolvedAudio, so the
|
||||||
|
// content-type matches the bytes (audio/ogg on a hit, the source MIME on a fallback) and the eventual
|
||||||
|
// client decoder dispatches correctly.
|
||||||
|
private async Task<ActionResult> GetTrackOpusAsync(string trackId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolved = await _formatResolver.ResolveAsync(trackId, AudioFormat.Opus);
|
||||||
|
if (resolved is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Track not found for Opus request: {TrackId}", trackId);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Streaming track {TrackId} as {Format} ({Size} bytes, {ContentType})",
|
||||||
|
trackId, resolved.ResolvedFormat, resolved.Audio.Buffer.Length, resolved.ContentType);
|
||||||
|
|
||||||
|
return File(resolved.Audio.Buffer, resolved.ContentType, enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error retrieving track as Opus: {TrackId}", trackId);
|
||||||
|
return StatusCode(500, "Internal server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET api/track/{trackId}/opus/seekdata (unauthenticated)
|
||||||
|
// Returns the Opus setup-header + granule→byte seek-index sidecar bytes (Phase 18.3). The client
|
||||||
|
// fetches this once on track load and parses it into OpusSeekData (18.4) before issuing any Opus seek.
|
||||||
|
// Raw octet-stream — the bytes are the OpusSidecar blob exactly as 18.1 stored them. 404 when no sidecar
|
||||||
|
// is stored (no Opus artifact yet, or an older derive predating the sidecar); the client then degrades
|
||||||
|
// to lossless, mirroring the C2 posture of the audio path. Same public auth posture as the audio stream.
|
||||||
|
// The "opus/seekdata" literal suffix keeps this distinct from the audio and waveform routes.
|
||||||
|
[HttpGet("{trackId}/opus/seekdata")]
|
||||||
|
public async Task<ActionResult> GetOpusSeekData(string trackId)
|
||||||
|
{
|
||||||
|
var sidecar = await _formatResolver.GetOpusSidecarAsync(trackId);
|
||||||
|
if (sidecar is null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No Opus sidecar for track: {TrackId}", trackId);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(sidecar, "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
// GET api/track/{trackId}/waveform (unauthenticated)
|
// GET api/track/{trackId}/waveform (unauthenticated)
|
||||||
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
|
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
|
||||||
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
|
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NetBlocks.Models;
|
using NetBlocks.Models;
|
||||||
|
|
||||||
@@ -45,23 +46,37 @@ public class TrackMediaClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches the WAV stream for a track via an HTTP Range request starting at a
|
/// Fetches the audio stream for a track via an HTTP Range request starting at a
|
||||||
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
|
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
|
||||||
/// the start of the file on disk (including the WAV header) — callers seeking into
|
/// the start of the file on disk (including any container/header bytes) — callers
|
||||||
/// audio data must add the header size themselves. The cancellation token aborts
|
/// seeking into audio data must add the header size themselves. The cancellation
|
||||||
/// the in-flight server connection rather than leaving the server draining bytes
|
/// token aborts the in-flight server connection rather than leaving the server
|
||||||
/// into a dead socket.
|
/// draining bytes into a dead socket.
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
|
||||||
|
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
|
||||||
|
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
|
||||||
|
/// requests the low-data Ogg Opus artifact, which the server resolves and falls back to
|
||||||
|
/// lossless when absent (C2). The response <see cref="TrackMediaResponse.ContentType"/>
|
||||||
|
/// reports the format actually served, so the JS decoder dispatches on the real bytes.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||||
string trackId,
|
string trackId,
|
||||||
long byteOffset = 0,
|
long byteOffset = 0,
|
||||||
|
AudioFormat format = AudioFormat.Lossless,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Same URL for every seek — only the Range header differs. byteOffset 0 is
|
// Same URL for every seek — only the Range header differs. byteOffset 0 is
|
||||||
// not special-cased: "bytes=0-" requests the whole file from the start.
|
// not special-cased: "bytes=0-" requests the whole file from the start.
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
|
// Lossless omits the format param entirely so the request is byte-identical to
|
||||||
|
// the pre-Phase-18 endpoint; only Opus appends ?format=opus.
|
||||||
|
var uri = format == AudioFormat.Lossless
|
||||||
|
? $"api/track/{trackId}"
|
||||||
|
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
|
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
|
||||||
|
|
||||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||||
@@ -115,4 +130,33 @@ public class TrackMediaClient
|
|||||||
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
|
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the
|
||||||
|
/// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load
|
||||||
|
/// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus
|
||||||
|
/// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers
|
||||||
|
/// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it
|
||||||
|
/// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ApiResult<byte[]>> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return ApiResult<byte[]>.CreateFailResult("No Opus sidecar available");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
return ApiResult<byte[]>.CreatePassResult(bytes);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return ApiResult<byte[]>.CreateFailResult(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,21 +205,26 @@ public class TrackProxyController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
|
/// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
|
||||||
/// Forwards the incoming Range header upstream and relays the upstream status
|
/// Forwards the incoming Range header upstream and the optional <c>format</c> selector
|
||||||
/// (200 full, 206 partial, 416 unsatisfiable) and range-related response headers
|
/// (Phase 18.3 — <c>opus|lossless</c>, threaded the same way the listing params are),
|
||||||
/// back to the browser verbatim. The proxy does not slice — the upstream already did.
|
/// and relays the upstream status (200 full, 206 partial, 416 unsatisfiable) and
|
||||||
|
/// range-related response headers back to the browser verbatim. The proxy does not
|
||||||
|
/// slice — the upstream already did.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("{trackId}")]
|
[HttpGet("{trackId}")]
|
||||||
public async Task<ActionResult> GetTrack(
|
public async Task<ActionResult> GetTrack(
|
||||||
string trackId,
|
string trackId,
|
||||||
|
[FromQuery] string? format = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var rangeHeader = Request.Headers.Range.ToString();
|
var rangeHeader = Request.Headers.Range.ToString();
|
||||||
_logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader);
|
_logger.LogInformation("Proxying track {TrackId} range '{Range}' format '{Format}'", trackId, rangeHeader, format);
|
||||||
|
|
||||||
var request = new HttpRequestMessage(
|
var path = $"api/track/{Uri.EscapeDataString(trackId)}";
|
||||||
HttpMethod.Get,
|
if (!string.IsNullOrWhiteSpace(format))
|
||||||
$"api/track/{Uri.EscapeDataString(trackId)}");
|
path += $"?format={Uri.EscapeDataString(format)}";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||||
|
|
||||||
// Forward the browser's Range header upstream so DeepDrftAPI slices the file.
|
// Forward the browser's Range header upstream so DeepDrftAPI slices the file.
|
||||||
// TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw
|
// TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw
|
||||||
@@ -355,4 +360,40 @@ public class TrackProxyController : ControllerBase
|
|||||||
return Content(json, "application/json");
|
return Content(json, "application/json");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxies a track's Opus seek/setup sidecar (raw bytes) from DeepDrftAPI (Phase 18.3). Unauthenticated,
|
||||||
|
/// same posture as the audio stream forward. The sidecar is a small one-time fetch (≤ ~115 KB), so it is
|
||||||
|
/// buffered and relayed; a 404 (no Opus artifact / no sidecar stored) passes through so the client
|
||||||
|
/// degrades to lossless rather than treating it as an error. The "opus/seekdata" 3-segment route makes a
|
||||||
|
/// collision with the parameterized "{trackId}" audio route impossible.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{trackId}/opus/seekdata")]
|
||||||
|
public async Task<ActionResult> GetOpusSeekData(string trackId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var path = $"api/track/{Uri.EscapeDataString(trackId)}/opus/seekdata";
|
||||||
|
|
||||||
|
HttpResponseMessage upstream;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/opus/seekdata failed", trackId);
|
||||||
|
return StatusCode(502, "Upstream unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (upstream)
|
||||||
|
{
|
||||||
|
if (!upstream.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("DeepDrftAPI track/{TrackId}/opus/seekdata returned {Status}", trackId, (int)upstream.StatusCode);
|
||||||
|
return StatusCode((int)upstream.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await upstream.Content.ReadAsByteArrayAsync(ct);
|
||||||
|
return File(bytes, "application/octet-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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