feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client

- API: enableRangeProcessing true on no-offset FileStream path
- Proxy: transparent Range relay, forwards 206/416/Content-Range verbatim
- TrackMediaClient: Range: bytes=X- replaces ?offset=X; response disposed via TrackMediaResponse
- StreamDecoder: reinitializeForRangeContinuation retains wavHeader, counts raw PCM against 206 Content-Length
- AudioPlayer: seekBeyondBuffer adds headerSize for file-absolute offset; duration guard prevents continuation overwriting full-track duration
- StreamingAudioPlayerService: seek guard corrected to >= 0 (file-absolute offset contract)
This commit is contained in:
daniel-c-harvey
2026-06-09 07:00:35 -04:00
parent 5c3c3c3d0c
commit aaa9f732ae
6 changed files with 132 additions and 44 deletions
@@ -128,25 +128,33 @@ public class TrackProxyController : ControllerBase
}
/// <summary>
/// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset
/// so seek-beyond-buffer works through the proxy without buffering.
/// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
/// Forwards the incoming Range header upstream 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>
[HttpGet("{trackId}")]
public async Task<ActionResult> GetTrack(
string trackId,
[FromQuery] long offset = 0,
CancellationToken ct = default)
{
_logger.LogInformation("Proxying track {TrackId} offset {Offset}", trackId, offset);
var rangeHeader = Request.Headers.Range.ToString();
_logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader);
var path = offset == 0
? $"api/track/{Uri.EscapeDataString(trackId)}"
: $"api/track/{Uri.EscapeDataString(trackId)}?offset={offset}";
var request = new HttpRequestMessage(
HttpMethod.Get,
$"api/track/{Uri.EscapeDataString(trackId)}");
// Forward the browser's Range header upstream so DeepDrftAPI slices the file.
// TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw
// header verbatim, keeping the proxy transparent.
if (!string.IsNullOrEmpty(rangeHeader))
request.Headers.TryAddWithoutValidation("Range", rangeHeader);
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
upstream = await _upstream.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
@@ -154,6 +162,16 @@ public class TrackProxyController : ControllerBase
return StatusCode(502, "Upstream unavailable");
}
// 416 Range Not Satisfiable is a legitimate upstream answer (seek past EOF);
// relay it as-is rather than collapsing it into a 502.
if ((int)upstream.StatusCode == StatusCodes.Status416RangeNotSatisfiable)
{
upstream.Dispose();
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}
// 206 Partial Content reports IsSuccessStatusCode == true, so this guard only
// catches genuine upstream failures (404, 5xx).
if (!upstream.IsSuccessStatusCode)
{
upstream.Dispose();
@@ -161,18 +179,35 @@ public class TrackProxyController : ControllerBase
return StatusCode((int)upstream.StatusCode);
}
// Do NOT dispose upstream here — File() takes ownership of the response stream
// and disposes it after the body is sent.
var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
var contentLength = upstream.Content.Headers.ContentLength;
// Stream the body manually rather than via File(): FileStreamResult forces the
// status to 200 (with enableRangeProcessing:false) and would clobber a relayed
// 206. Writing status + headers + body directly keeps the partial-content
// contract intact, with the proxy doing zero slicing of its own.
HttpContext.Response.RegisterForDispose(upstream);
Response.StatusCode = (int)upstream.StatusCode;
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
// Forward Content-Length so the WASM player has duration info from the WAV header length.
if (contentLength.HasValue)
Response.ContentLength = contentLength.Value;
// Relay range-related headers so the browser sees the same partial-content
// contract the upstream emitted.
if (upstream.Headers.AcceptRanges.Count > 0)
Response.Headers.AcceptRanges = string.Join(", ", upstream.Headers.AcceptRanges);
if (upstream.Content.Headers.ContentRange is { } contentRange)
Response.Headers.ContentRange = contentRange.ToString();
if (upstream.Content.Headers.ContentLength is { } contentLength)
Response.ContentLength = contentLength;
var stream = await upstream.Content.ReadAsStreamAsync(ct);
HttpContext.Response.RegisterForDispose(upstream);
return File(stream, contentType, enableRangeProcessing: false);
try
{
await stream.CopyToAsync(Response.Body, ct);
return new EmptyResult();
}
catch (OperationCanceledException)
{
// Client navigated away or issued a new seek — nothing to do.
// The upstream connection is cleaned up via RegisterForDispose.
return new EmptyResult();
}
}
/// <summary>
+18 -6
View File
@@ -321,18 +321,28 @@ export class AudioPlayer {
*/
private seekBeyondBuffer(position: number): AudioResult {
try {
const byteOffset = this.streamDecoder.calculateByteOffset(position);
const audioOffset = this.streamDecoder.calculateByteOffset(position);
// 0 is a valid offset (seek to start of audio data). Only a negative result
// indicates calculation failure — typically a missing/unparsed WAV header.
if (byteOffset < 0) {
if (audioOffset < 0) {
return { success: false, error: 'Cannot calculate byte offset' };
}
// Signal that C# needs to request new stream from offset
// The Range request is file-absolute: byte position from the start of the
// file on disk, header included. calculateByteOffset returns an audio-data-
// relative offset, so add headerSize to land on the right byte. (The old
// ?offset= contract was audio-relative; the server added the header itself.)
const header = this.streamDecoder.getWavHeader();
if (!header) {
return { success: false, error: 'Cannot calculate byte offset' };
}
const fileOffset = header.headerSize + audioOffset;
// Signal that C# needs to request a new stream from this file-absolute offset
return {
success: true,
seekBeyondBuffer: true,
byteOffset: byteOffset
byteOffset: fileOffset
};
} catch (error) {
return { success: false, error: (error as Error).message };
@@ -368,8 +378,10 @@ export class AudioPlayer {
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Reinitialize decoder for new stream
this.streamDecoder.reinitializeForOffset(totalStreamLength);
// Reinitialize decoder for the Range-continuation stream. totalStreamLength
// here is the 206 Content-Length (range start → EOF), not the full file size —
// the decoder uses it to detect stream-complete against raw audio bytes.
this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
// Update state
this.pausePosition = seekPosition;
+38 -7
View File
@@ -60,6 +60,14 @@ export class StreamDecoder {
private streamComplete: boolean = false;
private headerError: string | null = null;
// Range-continuation state. After a seek-beyond-buffer the server responds 206
// with raw PCM from a file-absolute offset (no WAV header). We retain the header
// parsed from the initial stream and treat the whole body as audio data. The
// stream-complete check then counts raw bytes against the 206 Content-Length
// (remainingByteLength) rather than the full-file totalStreamLength + headerSize.
private isContinuation: boolean = false;
private remainingByteLength: number = 0;
// Pre-header accumulator. WAV headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
@@ -84,6 +92,8 @@ export class StreamDecoder {
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
this.isContinuation = false;
this.remainingByteLength = 0;
}
/**
@@ -183,6 +193,16 @@ export class StreamDecoder {
* otherwise pre-header bytes count toward the total.
*/
private updateStreamCompleteFlag(): void {
// Range-continuation: the 206 body is pure audio (no header), so compare raw
// audio bytes directly against the 206 Content-Length. Do NOT add headerSize —
// there is no header in this response.
if (this.isContinuation) {
if (this.remainingByteLength > 0 && this.totalRawBytes >= this.remainingByteLength) {
this.streamComplete = true;
}
return;
}
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
@@ -445,23 +465,34 @@ export class StreamDecoder {
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
this.isContinuation = false;
this.remainingByteLength = 0;
}
/**
* Reinitialize for offset streaming - preserves header format knowledge
* Called when seeking beyond buffer to prepare for new stream from server
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
*
* The server responds to a Range request with 206 Partial Content carrying raw
* PCM from a file-absolute offset — there is NO WAV header in this body. We retain
* the header parsed from the initial stream (its format describes every segment we
* synthesise via createWavFile) and feed the entire 206 body straight into the
* decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes
* directly to addRawData and tryParseHeader is never re-entered.
*
* @param remainingByteLength the Content-Length of the 206 response — the number of
* bytes from the range start to EOF, NOT the full file size. Stream-complete is
* reached when totalRawBytes >= this value.
*/
reinitializeForOffset(totalStreamLength: number): void {
// Reset data state but we'll get a fresh header from the offset stream
reinitializeForRangeContinuation(remainingByteLength: number): void {
// Retain this.wavHeader — the 206 body carries no header to reparse.
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
// wavHeader will be reparsed from the new stream (server sends fresh header)
this.wavHeader = null;
this.isContinuation = true;
this.remainingByteLength = remainingByteLength;
}
}