mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
feat: performance improvement for uninjected playlists
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
using System.Reflection;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinPlaylistRouteMatchingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("playlists/abc123/items", "abc123")]
|
||||
[InlineData("Playlists/abc123/Items", "abc123")]
|
||||
[InlineData("/playlists/abc123/items/", "abc123")]
|
||||
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
|
||||
{
|
||||
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||
|
||||
Assert.Equal(expectedPlaylistId, playlistId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("playlists/abc123/items/extra")]
|
||||
[InlineData("users/user-1/playlists/abc123/items")]
|
||||
[InlineData("items/abc123")]
|
||||
[InlineData("playlists")]
|
||||
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
|
||||
{
|
||||
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||
|
||||
Assert.Null(playlistId);
|
||||
}
|
||||
|
||||
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
methodName,
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var result = method!.Invoke(null, args);
|
||||
return (T)result!;
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,67 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Contains("UserId=user-abc", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
var query = captured!.RequestUri!.Query;
|
||||
Assert.Contains("Fields=Genres", query);
|
||||
Assert.Contains("Fields=DateCreated", query);
|
||||
Assert.Contains("Fields=MediaSources", query);
|
||||
Assert.Contains("UserId=user-abc", query);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres",
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
|
||||
Assert.Contains("MediaBrowser Token=\"abc\"", values);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
|
||||
{
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.4.3";
|
||||
public const string Version = "1.4.5";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Net.Http;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
@@ -11,6 +12,20 @@ public partial class JellyfinController
|
||||
{
|
||||
#region Helpers
|
||||
|
||||
private static readonly HashSet<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
"Content-Type",
|
||||
"Content-Length"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Helper to handle proxy responses with proper status code handling.
|
||||
/// </summary>
|
||||
@@ -48,6 +63,56 @@ public partial class JellyfinController
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
|
||||
endpoint,
|
||||
Request.Headers,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
HttpContext.Response.RegisterForDispose(upstreamResponse);
|
||||
Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
|
||||
CopyPassthroughResponseHeaders(upstreamResponse);
|
||||
|
||||
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
|
||||
var stream = await upstreamResponse.Content.ReadAsStreamAsync(HttpContext.RequestAborted);
|
||||
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
|
||||
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
|
||||
{
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||
{
|
||||
Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||
{
|
||||
Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
|
||||
/// </summary>
|
||||
@@ -407,6 +472,24 @@ public partial class JellyfinController
|
||||
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string? GetExactPlaylistItemsRequestId(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3 ||
|
||||
!parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase) ||
|
||||
!parts[2].Equals("items", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether Spotify playlist count enrichment should run for a response.
|
||||
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
|
||||
|
||||
@@ -1292,33 +1292,37 @@ public partial class JellyfinController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// Intercept Spotify playlist requests by ID
|
||||
if (_spotifySettings.Enabled &&
|
||||
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
|
||||
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
|
||||
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
|
||||
if (!string.IsNullOrEmpty(playlistItemsRequestId))
|
||||
{
|
||||
// Extract playlist ID from path: playlists/{id}/items
|
||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
|
||||
if (_spotifySettings.Enabled)
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
|
||||
|
||||
// Check if this playlist ID is configured for Spotify injection
|
||||
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
|
||||
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
||||
_logger.LogInformation("========================================");
|
||||
return await GetPlaylistTracks(playlistId);
|
||||
return await GetPlaylistTracks(playlistItemsRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
var playlistItemsPath = path;
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
|
||||
playlistItemsRequestId);
|
||||
return await ProxyJsonPassthroughAsync(playlistItemsPath);
|
||||
}
|
||||
|
||||
// Handle non-JSON responses (images, robots.txt, etc.)
|
||||
|
||||
@@ -153,62 +153,34 @@ public class JellyfinProxyService
|
||||
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
|
||||
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
|
||||
/// </summary>
|
||||
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
|
||||
string endpoint,
|
||||
IHeaderDictionary? clientHeaders = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = BuildUrl(endpoint);
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
|
||||
var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Check if this is a browser request for static assets (favicon, etc.)
|
||||
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
|
||||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
|
||||
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
|
||||
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
|
||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||
|
||||
// Check if this is a public endpoint that doesn't require authentication
|
||||
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Forward authentication headers from client if provided
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
if (authHeaderAdded)
|
||||
{
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
// Check for api_key query parameter (some clients use this)
|
||||
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authHeaderAdded = true; // It's in the URL, no need to add header
|
||||
_logger.LogTrace("Using api_key from query string");
|
||||
}
|
||||
}
|
||||
|
||||
// Only log warnings for non-public, non-browser requests without auth
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
@@ -245,6 +217,69 @@ public class JellyfinProxyService
|
||||
return (JsonDocument.Parse(content), statusCode);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateClientGetRequest(
|
||||
string url,
|
||||
IHeaderDictionary? clientHeaders,
|
||||
out bool isBrowserStaticRequest,
|
||||
out bool isPublicEndpoint)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a browser request for static assets (favicon, etc.)
|
||||
isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
|
||||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
|
||||
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
|
||||
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
|
||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||
|
||||
// Check if this is a public endpoint that doesn't require authentication
|
||||
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var authHeaderAdded = false;
|
||||
|
||||
// Forward authentication headers from client if provided
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
if (authHeaderAdded)
|
||||
{
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
// Check for api_key query parameter (some clients use this)
|
||||
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authHeaderAdded = true; // It's in the URL, no need to add header
|
||||
_logger.LogTrace("Using api_key from query string");
|
||||
}
|
||||
}
|
||||
|
||||
// Only log warnings for non-public, non-browser requests without auth
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request to the Jellyfin server with JSON body.
|
||||
/// Forwards client headers for authentication passthrough.
|
||||
|
||||
Reference in New Issue
Block a user