feature: Phase 18.3 — Opus delivery transport (?format= stream + seek sidecar endpoint)

This commit is contained in:
daniel-c-harvey
2026-06-23 08:34:37 -04:00
parent e807ddb91b
commit 740d01a67f
4 changed files with 462 additions and 15 deletions
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
@@ -45,23 +46,37 @@ public class TrackMediaClient
}
/// <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
/// the start of the file on disk (including the WAV header) — callers seeking into
/// audio data must add the header size themselves. The cancellation token aborts
/// the in-flight server connection rather than leaving the server draining bytes
/// into a dead socket.
/// the start of the file on disk (including any container/header bytes) — callers
/// seeking into audio data must add the header size themselves. The cancellation
/// token aborts the in-flight server connection rather than leaving the server
/// 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>
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId,
long byteOffset = 0,
AudioFormat format = AudioFormat.Lossless,
CancellationToken cancellationToken = default)
{
try
{
// 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.
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);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
@@ -115,4 +130,33 @@ public class TrackMediaClient
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);
}
}
}