using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Clients;
public class TrackMediaResponse : IDisposable
{
public Stream Stream { get; }
public long ContentLength { get; }
///
/// The response media type (e.g. "audio/wav", "audio/mpeg"). Drives format-decoder
/// selection on the JS side. Falls back to "audio/wav" when the server omits the header.
///
public string ContentType { get; }
///
/// The total file length in bytes, parsed from the 206 response's Content-Range:
/// bytes start-end/TOTAL header (Phase 21 Direction B). Null when the server returned
/// 200 (no Content-Range) — callers fall back to as the total.
/// This is the EOF boundary the segment loop advances its cursor toward, and the full
/// logical length the JS decoder must see (so a bounded segment's small Content-Length
/// never trips the decoder's byte-count completion early).
///
public long? TotalLength { get; }
private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, string contentType, long? totalLength, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
ContentType = contentType;
TotalLength = totalLength;
_response = response;
}
public void Dispose()
{
Stream?.Dispose();
_response?.Dispose();
}
}
public class TrackMediaClient
{
private readonly HttpClient _http;
public TrackMediaClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.Content");
}
///
/// Fetches the audio stream for a track via an HTTP Range request starting at a
/// file-absolute byte offset. is the position from
/// the start of the file on disk (including any container/header bytes) — 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.
///
/// (Phase 21 Direction B) bounds the request to a single
/// segment: when set, the Range header is bytes={byteOffset}-{byteEnd} (inclusive),
/// so the browser holds at most ~one segment of raw bytes regardless of file size — the
/// network-memory bound this phase exists for. When null the request is open-ended
/// (bytes={byteOffset}-), the pre-Direction-B behaviour. Either way the response's
/// Content-Range total is surfaced via
/// so the caller knows the EOF boundary and the full logical length the decoder must see.
///
///
/// selects the delivery rendering (Phase 18): the default
/// sends no format query param, so existing
/// callers hit the byte-identical pre-Phase-18 endpoint;
/// requests the low-data Ogg Opus artifact, which the server resolves and falls back to
/// lossless when absent (C2). The response
/// reports the format actually served, so the JS decoder dispatches on the real bytes.
///
///
public async Task> GetTrackMedia(
string trackId,
long byteOffset = 0,
long? byteEnd = null,
AudioFormat format = AudioFormat.Lossless,
CancellationToken cancellationToken = default)
{
try
{
// Same URL for every fetch — only the Range header differs. byteOffset 0 is
// not special-cased: "bytes=0-" requests the whole file from the start.
// Lossless omits the format param entirely so the request is byte-identical to
// the pre-Phase-18 endpoint; only Opus appends ?format=opus.
var uri = format == AudioFormat.Lossless
? $"api/track/{trackId}"
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
// Bounded (byteEnd set) → "bytes=start-end" so the server returns a finite 206
// slice and the browser buffers only that segment; open-ended (byteEnd null) →
// "bytes=start-". The server honours both via File(..., enableRangeProcessing: true),
// which parses the full RFC 7233 range grammar and slices accordingly.
request.Headers.Range = new RangeHeaderValue(byteOffset, byteEnd);
// Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix).
// In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the
// browser buffers the ENTIRE body before the response stream yields a byte. With Direction B
// each request is already bounded to one segment, so the body is small regardless — but
// streaming still lets us read it incrementally and is harmless on the SSR server-to-server
// path (SocketsHttpHandler ignores the unknown option). Kept for both the initial and the
// seek/refill paths since both share this method.
request.SetBrowserResponseStreamingEnabled(true);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength ?? 0;
// Default to WAV when the server omits the header — the only format shipping
// today — so the JS factory always receives a usable media type.
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
// Content-Range "bytes start-end/TOTAL" carries the full file length on a 206; on a 200
// there is no Content-Range, so TotalLength is null and callers use ContentLength.
var totalLength = response.Content.Headers.ContentRange?.Length;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// TrackMediaResponse takes ownership of both stream and response;
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
return ApiResult.CreatePassResult(
new TrackMediaResponse(stream, contentLength, contentType, totalLength, response));
}
catch (Exception e)
{
return ApiResult.CreateFailResult(e.Message);
}
}
///
/// Fetches a track's stored waveform loudness profile. A 404 means no profile is stored
/// (existing tracks predate profiling, or computation failed at upload); callers treat that
/// as "render a flat seekbar" rather than an error, so it surfaces as a fail result with a
/// stable message rather than throwing.
///
public async Task> GetWaveformProfileAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/waveform", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult.CreateFailResult("No waveform profile available");
}
response.EnsureSuccessStatusCode();
var profile = await response.Content.ReadFromJsonAsync();
if (profile is null)
{
return ApiResult.CreateFailResult("Waveform profile response was empty");
}
return ApiResult.CreatePassResult(profile);
}
catch (Exception e)
{
return ApiResult.CreateFailResult(e.Message);
}
}
///
/// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the
/// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load
/// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus
/// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers
/// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it
/// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync).
///
public async Task> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult.CreateFailResult("No Opus sidecar available");
}
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return ApiResult.CreatePassResult(bytes);
}
catch (Exception e)
{
return ApiResult.CreateFailResult(e.Message);
}
}
}