Files
deepdrft/DeepDrftPublic/Controllers/TrackProxyController.cs
T
daniel-c-harvey aaa9f732ae 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)
2026-06-09 07:00:35 -04:00

247 lines
10 KiB
C#

using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies public track API calls to DeepDrftAPI so the browser never makes
/// cross-origin requests. The WASM client points both named HttpClients at
/// this host; this controller forwards unauthenticated public routes upstream.
/// SSR prerender calls DeepDrftAPI directly (server-to-server) via the same
/// named clients — no proxy hop needed on the server side.
/// </summary>
[ApiController]
[Route("api/track")]
public class TrackProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<TrackProxyController> _logger;
public TrackProxyController(IHttpClientFactory httpClientFactory, ILogger<TrackProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies paged track metadata from DeepDrftAPI.</summary>
[HttpGet("page")]
public async Task<ActionResult> GetPage(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
CancellationToken ct = default)
{
var query = $"api/track/page?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (!string.IsNullOrWhiteSpace(sortColumn))
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(query, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/page failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/page returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies the random-track metadata lookup from DeepDrftAPI. Unauthenticated, same posture as
/// the paged listing. Small JSON, buffered and relayed; a 404 from upstream (empty library)
/// passes through so the client renders it as a valid empty state. Declared before the
/// parameterized "{trackId}" route so the literal segment is never treated as a trackId.
/// </summary>
[HttpGet("random")]
public async Task<ActionResult> GetRandom(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/track/random", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/random failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/random returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated,
/// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from
/// upstream (no track with that entry key) passes through. Declared before the parameterized
/// "{trackId}" route, though the 3-segment template makes a collision impossible regardless.
/// </summary>
[HttpGet("meta/by-key/{entryKey}")]
public async Task<ActionResult> GetMetaByKey(string entryKey, CancellationToken ct = default)
{
var path = $"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/meta/by-key/{EntryKey} failed", entryKey);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/meta/by-key/{EntryKey} returned {Status}", entryKey, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary>
/// 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,
CancellationToken ct = default)
{
var rangeHeader = Request.Headers.Range.ToString();
_logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader);
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.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId} failed", trackId);
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();
_logger.LogWarning("DeepDrftAPI track/{TrackId} returned {Status}", trackId, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
// 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";
// 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);
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>
/// Proxies a track's stored waveform profile (JSON) from DeepDrftAPI. Unauthenticated,
/// same posture as the audio stream forward. The profile is small JSON, so it is buffered
/// and relayed rather than streamed; a 404 from upstream (no profile stored) passes through.
/// </summary>
[HttpGet("{trackId}/waveform")]
public async Task<ActionResult> GetWaveform(string trackId, CancellationToken ct = default)
{
var path = $"api/track/{Uri.EscapeDataString(trackId)}/waveform";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/waveform failed", trackId);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/{TrackId}/waveform returned {Status}", trackId, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
}