fix: add HTTP Range header forwarding for iOS clients

This commit is contained in:
V1ck3s
2026-01-12 19:35:29 +01:00
parent 428b7f06c4
commit c8c4fd8322
3 changed files with 145 additions and 48 deletions

View File

@@ -329,4 +329,95 @@ public class SubsonicProxyServiceTests
var fileResult = Assert.IsType<FileStreamResult>(result); var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType); 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<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<string, string> { { "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<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<string, string> { { "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<string, string> { { "id", "song123" } };
// Act
var result = await service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
} }

View File

@@ -41,7 +41,7 @@ builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<SubsonicRequestParser>(); builder.Services.AddSingleton<SubsonicRequestParser>();
builder.Services.AddSingleton<SubsonicResponseBuilder>(); builder.Services.AddSingleton<SubsonicResponseBuilder>();
builder.Services.AddSingleton<SubsonicModelMapper>(); builder.Services.AddSingleton<SubsonicModelMapper>();
builder.Services.AddSingleton<SubsonicProxyService>(); builder.Services.AddScoped<SubsonicProxyService>();
// Register music service based on configuration // Register music service based on configuration
if (musicService == MusicService.Qobuz) if (musicService == MusicService.Qobuz)

View File

@@ -73,7 +73,10 @@ public class SubsonicProxyService
var httpContext = _httpContextAccessor.HttpContext; var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) if (httpContext == null)
{ {
return new StatusCodeResult(500); return new ObjectResult(new { error = "HTTP context not available" })
{
StatusCode = 500
};
} }
var incomingRequest = httpContext.Request; var incomingRequest = httpContext.Request;
@@ -85,15 +88,15 @@ public class SubsonicProxyService
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward Range headers (fix for iOS client) // Forward Range headers for progressive streaming support (iOS clients)
if (incomingRequest.Headers.TryGetValue("Range", out var range)) if (incomingRequest.Headers.TryGetValue("Range", out var range))
{ {
request.Headers.TryAddWithoutValidation("Range", range.ToString()); request.Headers.TryAddWithoutValidation("Range", range.ToArray());
} }
if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange))
{ {
request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToString()); request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToArray());
} }
var response = await _httpClient.SendAsync( var response = await _httpClient.SendAsync(
@@ -106,7 +109,10 @@ public class SubsonicProxyService
return new StatusCodeResult((int)response.StatusCode); return new StatusCodeResult((int)response.StatusCode);
} }
// Iterate over and forward streaming-required headers // 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[] foreach (var header in new[]
{ {
"Accept-Ranges", "Accept-Ranges",