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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user