diff --git a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs index f5b40e1..546608a 100644 --- a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs +++ b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Http; using Moq; using Moq.Protected; using octo_fiesta.Models.Settings; @@ -18,7 +19,7 @@ public class SubsonicProxyServiceTests { _mockHttpMessageHandler = new Mock(); var httpClient = new HttpClient(_mockHttpMessageHandler.Object); - + _mockHttpClientFactory = new Mock(); _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); @@ -27,7 +28,13 @@ public class SubsonicProxyServiceTests Url = "http://localhost:4533" }); - _service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings); + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = httpContext + }; + + _service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor); } [Fact] @@ -322,4 +329,95 @@ public class SubsonicProxyServiceTests var fileResult = Assert.IsType(result); Assert.Equal("audio/mpeg", fileResult.ContentType); } + + [Fact] + public async Task RelayStreamAsync_WithRangeHeader_ForwardsRangeToUpstream() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var streamContent = new byte[] { 1, 2, 3, 4, 5 }; + var responseMessage = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(streamContent) + }; + responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/mpeg"); + + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(responseMessage); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Range"] = "bytes=0-1023"; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var service = new SubsonicProxyService(_mockHttpClientFactory.Object, + Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }), + httpContextAccessor); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + await service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest!.Headers.Contains("Range")); + Assert.Equal("bytes=0-1023", capturedRequest.Headers.GetValues("Range").First()); + } + + [Fact] + public async Task RelayStreamAsync_WithIfRangeHeader_ForwardsIfRangeToUpstream() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var streamContent = new byte[] { 1, 2, 3 }; + var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(streamContent) + }; + + _mockHttpMessageHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => capturedRequest = req) + .ReturnsAsync(responseMessage); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["If-Range"] = "\"etag123\""; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var service = new SubsonicProxyService(_mockHttpClientFactory.Object, + Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }), + httpContextAccessor); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + await service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest!.Headers.Contains("If-Range")); + } + + [Fact] + public async Task RelayStreamAsync_NullHttpContext_ReturnsError() + { + // Arrange + var httpContextAccessor = new HttpContextAccessor { HttpContext = null }; + var service = new SubsonicProxyService(_mockHttpClientFactory.Object, + Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }), + httpContextAccessor); + + var parameters = new Dictionary { { "id", "song123" } }; + + // Act + var result = await service.RelayStreamAsync(parameters, CancellationToken.None); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(500, objectResult.StatusCode); + } } diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 7f540aa..a6753a8 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -16,6 +16,7 @@ builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddHttpContextAccessor(); // Exception handling builder.Services.AddExceptionHandler(); @@ -41,7 +42,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Register music service based on configuration // IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs index ff531f2..265575f 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -10,13 +10,16 @@ public class SubsonicProxyService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _subsonicSettings; + private readonly IHttpContextAccessor _httpContextAccessor; public SubsonicProxyService( IHttpClientFactory httpClientFactory, - Microsoft.Extensions.Options.IOptions subsonicSettings) + Microsoft.Extensions.Options.IOptions subsonicSettings, + IHttpContextAccessor httpContextAccessor) { _httpClient = httpClientFactory.CreateClient(); _subsonicSettings = subsonicSettings.Value; + _httpContextAccessor = httpContextAccessor; } /// @@ -57,6 +60,15 @@ public class SubsonicProxyService } } + private static readonly string[] StreamingRequiredHeaders = + { + "Accept-Ranges", + "Content-Range", + "Content-Length", + "ETag", + "Last-Modified" + }; + /// /// Relays a stream request to the Subsonic server with range processing support. /// @@ -66,11 +78,36 @@ public class SubsonicProxyService { try { + // Get HTTP context for request/response forwarding + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + return new ObjectResult(new { error = "HTTP context not available" }) + { + StatusCode = 500 + }; + } + + var incomingRequest = httpContext.Request; + var outgoingResponse = httpContext.Response; + var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{_subsonicSettings.Url}/rest/stream?{query}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Forward Range headers for progressive streaming support (iOS clients) + if (incomingRequest.Headers.TryGetValue("Range", out var range)) + { + request.Headers.TryAddWithoutValidation("Range", range.ToArray()); + } + + if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) + { + request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToArray()); + } + var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, @@ -81,6 +118,19 @@ public class SubsonicProxyService return new StatusCodeResult((int)response.StatusCode); } + // Forward HTTP status code (e.g., 206 Partial Content for range requests) + outgoingResponse.StatusCode = (int)response.StatusCode; + + // Forward streaming-required headers from upstream response + foreach (var header in StreamingRequiredHeaders) + { + if (response.Headers.TryGetValues(header, out var values) || + response.Content.Headers.TryGetValues(header, out values)) + { + outgoingResponse.Headers[header] = values.ToArray(); + } + } + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";