diff --git a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs index 09b524d..546608a 100644 --- a/octo-fiesta.Tests/SubsonicProxyServiceTests.cs +++ b/octo-fiesta.Tests/SubsonicProxyServiceTests.cs @@ -22,17 +22,17 @@ public class SubsonicProxyServiceTests _mockHttpClientFactory = new Mock(); _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); - - var settings = Options.Create(new SubsonicSettings + + var settings = Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }); - var httpContext = new DefaultHttpContext(); - var httpContextAccessor = new HttpContextAccessor - { - HttpContext = httpContext - }; + var httpContext = new DefaultHttpContext(); + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = httpContext + }; _service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor); } @@ -329,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 7c08988..9a6a783 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -41,7 +41,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 if (musicService == MusicService.Qobuz) diff --git a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs index 6de4595..da4eecc 100644 --- a/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs +++ b/octo-fiesta/Services/Subsonic/SubsonicProxyService.cs @@ -10,16 +10,16 @@ public class SubsonicProxyService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _subsonicSettings; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _httpContextAccessor; public SubsonicProxyService( IHttpClientFactory httpClientFactory, Microsoft.Extensions.Options.IOptions subsonicSettings, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor httpContextAccessor) { _httpClient = httpClientFactory.CreateClient(); _subsonicSettings = subsonicSettings.Value; - _httpContextAccessor = httpContextAccessor; + _httpContextAccessor = httpContextAccessor; } /// @@ -69,33 +69,36 @@ public class SubsonicProxyService { try { - // Get HTTP context for request/response forwarding - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext == null) - { - return new StatusCodeResult(500); - } - - var incomingRequest = httpContext.Request; - var outgoingResponse = httpContext.Response; + // 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 (fix for iOS client) - if (incomingRequest.Headers.TryGetValue("Range", out var range)) - { - request.Headers.TryAddWithoutValidation("Range", range.ToString()); - } - - if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) - { - request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToString()); - } - + // 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, @@ -106,22 +109,25 @@ public class SubsonicProxyService return new StatusCodeResult((int)response.StatusCode); } - // Iterate over and forward streaming-required headers - foreach (var header in new[] - { - "Accept-Ranges", - "Content-Range", - "Content-Length", - "ETag", - "Last-Modified" - }) - { - if (response.Headers.TryGetValues(header, out var values) || - response.Content.Headers.TryGetValues(header, out values)) - { - outgoingResponse.Headers[header] = values.ToArray(); - } - } + // 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 new[] + { + "Accept-Ranges", + "Content-Range", + "Content-Length", + "ETag", + "Last-Modified" + }) + { + 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";