diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 4b69aef..d6daaea 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -403,8 +403,10 @@ public class TrackController : ControllerBase _logger.LogInformation( "Streaming track from disk: {TrackId}, Size: {Size} bytes", trackId, streamLength); - // enableRangeProcessing: false — seek is served by WavOffsetService, not Range. - return File(innerStream, streamMimeType, enableRangeProcessing: false); + // enableRangeProcessing: true — seek is served by HTTP Range requests. + // The FileStream is seekable, so ASP.NET Core honours an incoming + // Range header by slicing the file and responding 206 Partial Content. + return File(innerStream, streamMimeType, enableRangeProcessing: true); } // Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's diff --git a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs index e2c981d..d3bef1b 100644 --- a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using DeepDrftModels.DTOs; using Microsoft.Extensions.DependencyInjection; @@ -10,16 +11,19 @@ public class TrackMediaResponse : IDisposable { public Stream Stream { get; } public long ContentLength { get; } + private readonly HttpResponseMessage _response; - public TrackMediaResponse(Stream stream, long contentLength) + public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response) { Stream = stream; ContentLength = contentLength; + _response = response; } public void Dispose() { Stream?.Dispose(); + _response?.Dispose(); } } @@ -33,10 +37,12 @@ public class TrackMediaClient } /// - /// Fetches the WAV stream for a track, optionally starting from a byte offset. - /// The cancellation token is forwarded to so a - /// navigation or seek-replacement aborts the in-flight server connection rather - /// than leaving the server draining bytes into a dead socket. + /// Fetches the WAV 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 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. /// public async Task> GetTrackMedia( string trackId, @@ -45,19 +51,21 @@ public class TrackMediaClient { try { - // Build URL with optional offset parameter - var url = byteOffset > 0 - ? $"api/track/{trackId}?offset={byteOffset}" - : $"api/track/{trackId}"; + // 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.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); var contentLength = response.Content.Headers.ContentLength ?? 0; var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength)); + // 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, response)); } catch (Exception e) { diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 0e5daff..ed89af2 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -377,7 +377,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS if (result.Success) { - if (result.SeekBeyondBuffer && result.ByteOffset > 0) + if (result.SeekBeyondBuffer && result.ByteOffset >= 0) { // Need to load new stream from offset _logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}", diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index 87438b6..3128a17 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -128,25 +128,33 @@ public class TrackProxyController : ControllerBase } /// - /// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset - /// so seek-beyond-buffer works through the proxy without buffering. + /// 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. /// [HttpGet("{trackId}")] public async Task GetTrack( string trackId, - [FromQuery] long offset = 0, CancellationToken ct = default) { - _logger.LogInformation("Proxying track {TrackId} offset {Offset}", trackId, offset); + var rangeHeader = Request.Headers.Range.ToString(); + _logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader); - var path = offset == 0 - ? $"api/track/{Uri.EscapeDataString(trackId)}" - : $"api/track/{Uri.EscapeDataString(trackId)}?offset={offset}"; + 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.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct); + upstream = await _upstream.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); } catch (Exception ex) { @@ -154,6 +162,16 @@ public class TrackProxyController : ControllerBase 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(); @@ -161,18 +179,35 @@ public class TrackProxyController : ControllerBase return StatusCode((int)upstream.StatusCode); } - // Do NOT dispose upstream here — File() takes ownership of the response stream - // and disposes it after the body is sent. - var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav"; - var contentLength = upstream.Content.Headers.ContentLength; + // 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"; - // Forward Content-Length so the WASM player has duration info from the WAV header length. - if (contentLength.HasValue) - Response.ContentLength = contentLength.Value; + // 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); - HttpContext.Response.RegisterForDispose(upstream); - return File(stream, contentType, enableRangeProcessing: false); + 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(); + } } /// diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index ee0c50a..17f2617 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -321,18 +321,28 @@ export class AudioPlayer { */ private seekBeyondBuffer(position: number): AudioResult { try { - const byteOffset = this.streamDecoder.calculateByteOffset(position); + const audioOffset = this.streamDecoder.calculateByteOffset(position); // 0 is a valid offset (seek to start of audio data). Only a negative result // indicates calculation failure — typically a missing/unparsed WAV header. - if (byteOffset < 0) { + if (audioOffset < 0) { return { success: false, error: 'Cannot calculate byte offset' }; } - // Signal that C# needs to request new stream from offset + // The Range request is file-absolute: byte position from the start of the + // file on disk, header included. calculateByteOffset returns an audio-data- + // relative offset, so add headerSize to land on the right byte. (The old + // ?offset= contract was audio-relative; the server added the header itself.) + const header = this.streamDecoder.getWavHeader(); + if (!header) { + return { success: false, error: 'Cannot calculate byte offset' }; + } + const fileOffset = header.headerSize + audioOffset; + + // Signal that C# needs to request a new stream from this file-absolute offset return { success: true, seekBeyondBuffer: true, - byteOffset: byteOffset + byteOffset: fileOffset }; } catch (error) { return { success: false, error: (error as Error).message }; @@ -368,8 +378,10 @@ export class AudioPlayer { this.scheduler.clearForSeek(); this.scheduler.setPlaybackOffset(seekPosition); - // Reinitialize decoder for new stream - this.streamDecoder.reinitializeForOffset(totalStreamLength); + // Reinitialize decoder for the Range-continuation stream. totalStreamLength + // here is the 206 Content-Length (range start → EOF), not the full file size — + // the decoder uses it to detect stream-complete against raw audio bytes. + this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength); // Update state this.pausePosition = seekPosition; diff --git a/DeepDrftPublic/Interop/audio/StreamDecoder.ts b/DeepDrftPublic/Interop/audio/StreamDecoder.ts index 52ba777..73a6c3a 100644 --- a/DeepDrftPublic/Interop/audio/StreamDecoder.ts +++ b/DeepDrftPublic/Interop/audio/StreamDecoder.ts @@ -60,6 +60,14 @@ export class StreamDecoder { private streamComplete: boolean = false; private headerError: string | null = null; + // Range-continuation state. After a seek-beyond-buffer the server responds 206 + // with raw PCM from a file-absolute offset (no WAV header). We retain the header + // parsed from the initial stream and treat the whole body as audio data. The + // stream-complete check then counts raw bytes against the 206 Content-Length + // (remainingByteLength) rather than the full-file totalStreamLength + headerSize. + private isContinuation: boolean = false; + private remainingByteLength: number = 0; + // Pre-header accumulator. WAV headers can span multiple network chunks // (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.), // so we buffer raw bytes here until parseHeader succeeds rather than assuming @@ -84,6 +92,8 @@ export class StreamDecoder { this.headerBytesReceived = 0; this.headerSearchChunks = []; this.headerError = null; + this.isContinuation = false; + this.remainingByteLength = 0; } /** @@ -183,6 +193,16 @@ export class StreamDecoder { * otherwise pre-header bytes count toward the total. */ private updateStreamCompleteFlag(): void { + // Range-continuation: the 206 body is pure audio (no header), so compare raw + // audio bytes directly against the 206 Content-Length. Do NOT add headerSize — + // there is no header in this response. + if (this.isContinuation) { + if (this.remainingByteLength > 0 && this.totalRawBytes >= this.remainingByteLength) { + this.streamComplete = true; + } + return; + } + if (this.totalStreamLength <= 0) return; const totalReceived = this.wavHeader ? this.totalRawBytes + this.wavHeader.headerSize @@ -445,23 +465,34 @@ export class StreamDecoder { this.headerBytesReceived = 0; this.headerSearchChunks = []; this.headerError = null; + this.isContinuation = false; + this.remainingByteLength = 0; } /** - * Reinitialize for offset streaming - preserves header format knowledge - * Called when seeking beyond buffer to prepare for new stream from server + * Reinitialize for a Range-continuation stream after seek-beyond-buffer. + * + * The server responds to a Range request with 206 Partial Content carrying raw + * PCM from a file-absolute offset — there is NO WAV header in this body. We retain + * the header parsed from the initial stream (its format describes every segment we + * synthesise via createWavFile) and feed the entire 206 body straight into the + * decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes + * directly to addRawData and tryParseHeader is never re-entered. + * + * @param remainingByteLength the Content-Length of the 206 response — the number of + * bytes from the range start to EOF, NOT the full file size. Stream-complete is + * reached when totalRawBytes >= this value. */ - reinitializeForOffset(totalStreamLength: number): void { - // Reset data state but we'll get a fresh header from the offset stream + reinitializeForRangeContinuation(remainingByteLength: number): void { + // Retain this.wavHeader — the 206 body carries no header to reparse. this.rawChunks = []; this.totalRawBytes = 0; this.processedBytes = 0; - this.totalStreamLength = totalStreamLength; this.streamComplete = false; this.headerBytesReceived = 0; this.headerSearchChunks = []; this.headerError = null; - // wavHeader will be reparsed from the new stream (server sends fresh header) - this.wavHeader = null; + this.isContinuation = true; + this.remainingByteLength = remainingByteLength; } }