Merge branch 'p4-w1-range-streaming' into dev
This commit is contained in:
@@ -403,8 +403,10 @@ public class TrackController : ControllerBase
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||||
trackId, streamLength);
|
trackId, streamLength);
|
||||||
// enableRangeProcessing: false — seek is served by WavOffsetService, not Range.
|
// enableRangeProcessing: true — seek is served by HTTP Range requests.
|
||||||
return File(innerStream, streamMimeType, enableRangeProcessing: false);
|
// The FileStream is seekable, so ASP.NET Core honours an incoming
|
||||||
|
// Range header by slicing the file and responding 206 Partial Content.
|
||||||
|
return File(innerStream, streamMimeType, enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
|
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -10,16 +11,19 @@ public class TrackMediaResponse : IDisposable
|
|||||||
{
|
{
|
||||||
public Stream Stream { get; }
|
public Stream Stream { get; }
|
||||||
public long ContentLength { get; }
|
public long ContentLength { get; }
|
||||||
|
private readonly HttpResponseMessage _response;
|
||||||
|
|
||||||
public TrackMediaResponse(Stream stream, long contentLength)
|
public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
Stream = stream;
|
Stream = stream;
|
||||||
ContentLength = contentLength;
|
ContentLength = contentLength;
|
||||||
|
_response = response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Stream?.Dispose();
|
Stream?.Dispose();
|
||||||
|
_response?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +37,12 @@ public class TrackMediaClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
|
/// Fetches the WAV stream for a track via an HTTP Range request starting at a
|
||||||
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
|
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
|
||||||
/// navigation or seek-replacement aborts the in-flight server connection rather
|
/// the start of the file on disk (including the WAV header) — callers seeking into
|
||||||
/// than leaving the server draining bytes into a dead socket.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||||
string trackId,
|
string trackId,
|
||||||
@@ -45,19 +51,21 @@ public class TrackMediaClient
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Build URL with optional offset parameter
|
// Same URL for every seek — only the Range header differs. byteOffset 0 is
|
||||||
var url = byteOffset > 0
|
// not special-cased: "bytes=0-" requests the whole file from the start.
|
||||||
? $"api/track/{trackId}?offset={byteOffset}"
|
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
|
||||||
: $"api/track/{trackId}";
|
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
|
||||||
|
|
||||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||||
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
|
||||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
// TrackMediaResponse takes ownership of both stream and response;
|
||||||
|
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
|
||||||
|
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, response));
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
|
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
if (result.SeekBeyondBuffer && result.ByteOffset > 0)
|
if (result.SeekBeyondBuffer && result.ByteOffset >= 0)
|
||||||
{
|
{
|
||||||
// Need to load new stream from offset
|
// Need to load new stream from offset
|
||||||
_logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
|
_logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
|
||||||
|
|||||||
@@ -128,25 +128,33 @@ public class TrackProxyController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset
|
/// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
|
||||||
/// so seek-beyond-buffer works through the proxy without buffering.
|
/// 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>
|
/// </summary>
|
||||||
[HttpGet("{trackId}")]
|
[HttpGet("{trackId}")]
|
||||||
public async Task<ActionResult> GetTrack(
|
public async Task<ActionResult> GetTrack(
|
||||||
string trackId,
|
string trackId,
|
||||||
[FromQuery] long offset = 0,
|
|
||||||
CancellationToken ct = default)
|
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
|
var request = new HttpRequestMessage(
|
||||||
? $"api/track/{Uri.EscapeDataString(trackId)}"
|
HttpMethod.Get,
|
||||||
: $"api/track/{Uri.EscapeDataString(trackId)}?offset={offset}";
|
$"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;
|
HttpResponseMessage upstream;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
|
upstream = await _upstream.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -154,6 +162,16 @@ public class TrackProxyController : ControllerBase
|
|||||||
return StatusCode(502, "Upstream unavailable");
|
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)
|
if (!upstream.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
upstream.Dispose();
|
upstream.Dispose();
|
||||||
@@ -161,18 +179,35 @@ public class TrackProxyController : ControllerBase
|
|||||||
return StatusCode((int)upstream.StatusCode);
|
return StatusCode((int)upstream.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do NOT dispose upstream here — File() takes ownership of the response stream
|
// Stream the body manually rather than via File(): FileStreamResult forces the
|
||||||
// and disposes it after the body is sent.
|
// status to 200 (with enableRangeProcessing:false) and would clobber a relayed
|
||||||
var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
|
// 206. Writing status + headers + body directly keeps the partial-content
|
||||||
var contentLength = upstream.Content.Headers.ContentLength;
|
// 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.
|
// Relay range-related headers so the browser sees the same partial-content
|
||||||
if (contentLength.HasValue)
|
// contract the upstream emitted.
|
||||||
Response.ContentLength = contentLength.Value;
|
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);
|
var stream = await upstream.Content.ReadAsStreamAsync(ct);
|
||||||
HttpContext.Response.RegisterForDispose(upstream);
|
try
|
||||||
return File(stream, contentType, enableRangeProcessing: false);
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -321,18 +321,28 @@ export class AudioPlayer {
|
|||||||
*/
|
*/
|
||||||
private seekBeyondBuffer(position: number): AudioResult {
|
private seekBeyondBuffer(position: number): AudioResult {
|
||||||
try {
|
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
|
// 0 is a valid offset (seek to start of audio data). Only a negative result
|
||||||
// indicates calculation failure — typically a missing/unparsed WAV header.
|
// indicates calculation failure — typically a missing/unparsed WAV header.
|
||||||
if (byteOffset < 0) {
|
if (audioOffset < 0) {
|
||||||
return { success: false, error: 'Cannot calculate byte offset' };
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
seekBeyondBuffer: true,
|
seekBeyondBuffer: true,
|
||||||
byteOffset: byteOffset
|
byteOffset: fileOffset
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
@@ -368,8 +378,10 @@ export class AudioPlayer {
|
|||||||
this.scheduler.clearForSeek();
|
this.scheduler.clearForSeek();
|
||||||
this.scheduler.setPlaybackOffset(seekPosition);
|
this.scheduler.setPlaybackOffset(seekPosition);
|
||||||
|
|
||||||
// Reinitialize decoder for new stream
|
// Reinitialize decoder for the Range-continuation stream. totalStreamLength
|
||||||
this.streamDecoder.reinitializeForOffset(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
|
// Update state
|
||||||
this.pausePosition = seekPosition;
|
this.pausePosition = seekPosition;
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ export class StreamDecoder {
|
|||||||
private streamComplete: boolean = false;
|
private streamComplete: boolean = false;
|
||||||
private headerError: string | null = null;
|
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
|
// Pre-header accumulator. WAV headers can span multiple network chunks
|
||||||
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
|
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
|
||||||
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
|
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
|
||||||
@@ -84,6 +92,8 @@ export class StreamDecoder {
|
|||||||
this.headerBytesReceived = 0;
|
this.headerBytesReceived = 0;
|
||||||
this.headerSearchChunks = [];
|
this.headerSearchChunks = [];
|
||||||
this.headerError = null;
|
this.headerError = null;
|
||||||
|
this.isContinuation = false;
|
||||||
|
this.remainingByteLength = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,6 +193,16 @@ export class StreamDecoder {
|
|||||||
* otherwise pre-header bytes count toward the total.
|
* otherwise pre-header bytes count toward the total.
|
||||||
*/
|
*/
|
||||||
private updateStreamCompleteFlag(): void {
|
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;
|
if (this.totalStreamLength <= 0) return;
|
||||||
const totalReceived = this.wavHeader
|
const totalReceived = this.wavHeader
|
||||||
? this.totalRawBytes + this.wavHeader.headerSize
|
? this.totalRawBytes + this.wavHeader.headerSize
|
||||||
@@ -445,23 +465,34 @@ export class StreamDecoder {
|
|||||||
this.headerBytesReceived = 0;
|
this.headerBytesReceived = 0;
|
||||||
this.headerSearchChunks = [];
|
this.headerSearchChunks = [];
|
||||||
this.headerError = null;
|
this.headerError = null;
|
||||||
|
this.isContinuation = false;
|
||||||
|
this.remainingByteLength = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reinitialize for offset streaming - preserves header format knowledge
|
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
|
||||||
* Called when seeking beyond buffer to prepare for new stream from server
|
*
|
||||||
|
* 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 {
|
reinitializeForRangeContinuation(remainingByteLength: number): void {
|
||||||
// Reset data state but we'll get a fresh header from the offset stream
|
// Retain this.wavHeader — the 206 body carries no header to reparse.
|
||||||
this.rawChunks = [];
|
this.rawChunks = [];
|
||||||
this.totalRawBytes = 0;
|
this.totalRawBytes = 0;
|
||||||
this.processedBytes = 0;
|
this.processedBytes = 0;
|
||||||
this.totalStreamLength = totalStreamLength;
|
|
||||||
this.streamComplete = false;
|
this.streamComplete = false;
|
||||||
this.headerBytesReceived = 0;
|
this.headerBytesReceived = 0;
|
||||||
this.headerSearchChunks = [];
|
this.headerSearchChunks = [];
|
||||||
this.headerError = null;
|
this.headerError = null;
|
||||||
// wavHeader will be reparsed from the new stream (server sends fresh header)
|
this.isContinuation = true;
|
||||||
this.wavHeader = null;
|
this.remainingByteLength = remainingByteLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user