feat: add HTTP Range header support for iOS streaming clients (#48)

This commit is contained in:
Vickes
2026-01-15 23:47:03 +01:00
committed by GitHub
3 changed files with 153 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Http;
using Moq; using Moq;
using Moq.Protected; using Moq.Protected;
using octo_fiesta.Models.Settings; using octo_fiesta.Models.Settings;
@@ -18,7 +19,7 @@ public class SubsonicProxyServiceTests
{ {
_mockHttpMessageHandler = new Mock<HttpMessageHandler>(); _mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_mockHttpMessageHandler.Object); var httpClient = new HttpClient(_mockHttpMessageHandler.Object);
_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);
@@ -27,7 +28,13 @@ public class SubsonicProxyServiceTests
Url = "http://localhost:4533" 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] [Fact]
@@ -322,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

@@ -16,6 +16,7 @@ builder.Services.AddControllers();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
// Exception handling // Exception handling
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
@@ -41,7 +42,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
// IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI // IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI

View File

@@ -10,13 +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;
public SubsonicProxyService( public SubsonicProxyService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
Microsoft.Extensions.Options.IOptions<SubsonicSettings> subsonicSettings) Microsoft.Extensions.Options.IOptions<SubsonicSettings> subsonicSettings,
IHttpContextAccessor httpContextAccessor)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value; _subsonicSettings = subsonicSettings.Value;
_httpContextAccessor = httpContextAccessor;
} }
/// <summary> /// <summary>
@@ -57,6 +60,15 @@ public class SubsonicProxyService
} }
} }
private static readonly string[] StreamingRequiredHeaders =
{
"Accept-Ranges",
"Content-Range",
"Content-Length",
"ETag",
"Last-Modified"
};
/// <summary> /// <summary>
/// Relays a stream request to the Subsonic server with range processing support. /// Relays a stream request to the Subsonic server with range processing support.
/// </summary> /// </summary>
@@ -66,11 +78,36 @@ public class SubsonicProxyService
{ {
try 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 => 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 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( var response = await _httpClient.SendAsync(
request, request,
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
@@ -81,6 +118,19 @@ public class SubsonicProxyService
return new StatusCodeResult((int)response.StatusCode); 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 stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";