mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
fix: add HTTP Range header forwarding for iOS clients
This commit is contained in:
@@ -22,17 +22,17 @@ public class SubsonicProxyServiceTests
|
|||||||
|
|
||||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||||
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
var settings = Options.Create(new SubsonicSettings
|
var settings = Options.Create(new SubsonicSettings
|
||||||
{
|
{
|
||||||
Url = "http://localhost:4533"
|
Url = "http://localhost:4533"
|
||||||
});
|
});
|
||||||
|
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
var httpContextAccessor = new HttpContextAccessor
|
var httpContextAccessor = new HttpContextAccessor
|
||||||
{
|
{
|
||||||
HttpContext = httpContext
|
HttpContext = httpContext
|
||||||
};
|
};
|
||||||
|
|
||||||
_service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor);
|
_service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings, httpContextAccessor);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ public class SubsonicProxyService
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
public SubsonicProxyService(
|
public SubsonicProxyService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
Microsoft.Extensions.Options.IOptions<SubsonicSettings> subsonicSettings,
|
Microsoft.Extensions.Options.IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IHttpContextAccessor httpContextAccessor)
|
IHttpContextAccessor httpContextAccessor)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -69,33 +69,36 @@ public class SubsonicProxyService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get HTTP context for request/response forwarding
|
// Get HTTP context for request/response forwarding
|
||||||
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 outgoingResponse = httpContext.Response;
|
}
|
||||||
|
|
||||||
|
var incomingRequest = httpContext.Request;
|
||||||
|
var outgoingResponse = httpContext.Response;
|
||||||
|
|
||||||
var query = string.Join("&", parameters.Select(kv =>
|
var query = string.Join("&", parameters.Select(kv =>
|
||||||
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||||
var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
|
var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
|
||||||
|
|
||||||
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(
|
||||||
request,
|
request,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
@@ -106,22 +109,25 @@ 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)
|
||||||
foreach (var header in new[]
|
outgoingResponse.StatusCode = (int)response.StatusCode;
|
||||||
{
|
|
||||||
"Accept-Ranges",
|
// Forward streaming-required headers from upstream response
|
||||||
"Content-Range",
|
foreach (var header in new[]
|
||||||
"Content-Length",
|
{
|
||||||
"ETag",
|
"Accept-Ranges",
|
||||||
"Last-Modified"
|
"Content-Range",
|
||||||
})
|
"Content-Length",
|
||||||
{
|
"ETag",
|
||||||
if (response.Headers.TryGetValues(header, out var values) ||
|
"Last-Modified"
|
||||||
response.Content.Headers.TryGetValues(header, out values))
|
})
|
||||||
{
|
{
|
||||||
outgoingResponse.Headers[header] = values.ToArray();
|
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 stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
|
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
|
||||||
|
|||||||
Reference in New Issue
Block a user