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
@@ -1,4 +1,5 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.DependencyInjection;
@@ -10,16 +11,19 @@ public class TrackMediaResponse : IDisposable
{
public Stream Stream { 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;
ContentLength = contentLength;
_response = response;
}
public void Dispose()
{
Stream?.Dispose();
_response?.Dispose();
}
}
@@ -33,10 +37,12 @@ public class TrackMediaClient
}
/// <summary>
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
/// navigation or seek-replacement aborts the in-flight server connection rather
/// than leaving the server draining bytes into a dead socket.
/// Fetches the WAV 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.
/// </summary>
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId,
@@ -45,19 +51,21 @@ public class TrackMediaClient
{
try
{
// Build URL with optional offset parameter
var url = byteOffset > 0
? $"api/track/{trackId}?offset={byteOffset}"
: $"api/track/{trackId}";
// 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}");
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
// 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();
var contentLength = response.Content.Headers.ContentLength ?? 0;
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)
{