Files
deepdrft/DeepDrftPublic.Client/Clients/TrackMediaClient.cs
T
daniel-c-harvey 0b0bcb3dee refactor(audio): extract IFormatDecoder/WavFormatDecoder and wire Content-Type to JS format selection
StreamDecoder is now format-agnostic; WavFormatDecoder delegates to WavUtils; contentType flows C# to JS.
2026-06-11 06:08:09 -04:00

119 lines
4.7 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Clients;
public class TrackMediaResponse : IDisposable
{
public Stream Stream { get; }
public long ContentLength { get; }
/// <summary>
/// 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.
/// </summary>
public string ContentType { get; }
private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
ContentType = contentType;
_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");
}
/// <summary>
/// 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,
long byteOffset = 0,
CancellationToken cancellationToken = default)
{
try
{
// 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.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";
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<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
}
catch (Exception e)
{
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<ApiResult<WaveformProfileDto>> GetWaveformProfileAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/waveform", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult<WaveformProfileDto>.CreateFailResult("No waveform profile available");
}
response.EnsureSuccessStatusCode();
var profile = await response.Content.ReadFromJsonAsync<WaveformProfileDto>();
if (profile is null)
{
return ApiResult<WaveformProfileDto>.CreateFailResult("Waveform profile response was empty");
}
return ApiResult<WaveformProfileDto>.CreatePassResult(profile);
}
catch (Exception e)
{
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
}
}
}