163 lines
7.1 KiB
C#
163 lines
7.1 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using DeepDrftModels.DTOs;
|
|
using DeepDrftModels.Enums;
|
|
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 audio 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 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.
|
|
/// <para>
|
|
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
|
|
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
|
|
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
|
|
/// requests the low-data Ogg Opus artifact, which the server resolves and falls back to
|
|
/// lossless when absent (C2). The response <see cref="TrackMediaResponse.ContentType"/>
|
|
/// reports the format actually served, so the JS decoder dispatches on the real bytes.
|
|
/// </para>
|
|
/// </summary>
|
|
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
|
string trackId,
|
|
long byteOffset = 0,
|
|
AudioFormat format = AudioFormat.Lossless,
|
|
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.
|
|
// 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);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public async Task<ApiResult<byte[]>> 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<byte[]>.CreateFailResult("No Opus sidecar available");
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
return ApiResult<byte[]>.CreatePassResult(bytes);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return ApiResult<byte[]>.CreateFailResult(e.Message);
|
|
}
|
|
}
|
|
}
|