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; } /// /// 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; } 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"); } /// /// 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. /// /// 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, 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.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, 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); } } }