Extract ItemsProxy request builder

This commit is contained in:
ajp_anton
2026-07-15 19:40:38 +00:00
parent 09a1b6f589
commit e0c07c10e7
2 changed files with 169 additions and 170 deletions
@@ -267,7 +267,7 @@ public sealed class MultilangController : ControllerBase
return Unauthorized(new { Error = "NotAuthenticated" }); return Unauthorized(new { Error = "NotAuthenticated" });
var rules = GetRulesOrDefault(userId); var rules = GetRulesOrDefault(userId);
var proxyRequest = BuildProxyRequest(url, token, userId, rules.Enabled); var proxyRequest = ItemsProxyRequestBuilder.Build(Request, url, token, userId, rules.Enabled);
if (proxyRequest is null) if (proxyRequest is null)
return BadRequest(new { Error = "InvalidUrl" }); return BadRequest(new { Error = "InvalidUrl" });
@@ -413,7 +413,7 @@ public sealed class MultilangController : ControllerBase
var sortCulture = GetSortCulture(sortLocale); var sortCulture = GetSortCulture(sortLocale);
var sortArticles = GetSortArticles(sortLocale); var sortArticles = GetSortArticles(sortLocale);
var localGenreIds = rules.Enabled ? ParseLocalGenreIds(controls.GenreIds) : []; var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : [];
if (rules.Enabled || localGenreIds.Length > 0) if (rules.Enabled || localGenreIds.Length > 0)
_refreshService.EnqueueOnTheFlyMissing(ids); _refreshService.EnqueueOnTheFlyMissing(ids);
@@ -871,15 +871,6 @@ public sealed class MultilangController : ControllerBase
} }
} }
private static int[] ParseLocalGenreIds(string raw)
=> (raw ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(v => v.StartsWith("tmdb-", StringComparison.OrdinalIgnoreCase) ? v[5..] : string.Empty)
.Where(v => int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
.Select(v => int.Parse(v, CultureInfo.InvariantCulture))
.Distinct()
.ToArray();
private static int[] ParseJsonIntArray(string json) private static int[] ParseJsonIntArray(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -1779,165 +1770,6 @@ public sealed class MultilangController : ControllerBase
return [JellyfinAction]; return [JellyfinAction];
} }
private sealed record ProxyRequest(
Uri Upstream,
ItemsProxyControls Controls,
string CacheKey,
string NormalizedUrlForCache,
bool IsGenresRequest,
string GenreMedia);
private ProxyRequest? BuildProxyRequest(string rawUrl, string token, string userId, bool multilangEnabled)
{
var baseUri = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
var baseAddress = new Uri(baseUri);
var uri = new Uri(baseAddress, rawUrl.StartsWith('/') ? rawUrl : "/" + rawUrl);
if ((rawUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) &&
Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute))
{
if (!absolute.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!absolute.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return null;
if (!absolute.Host.Equals(baseAddress.Host, StringComparison.OrdinalIgnoreCase) ||
absolute.Port != baseAddress.Port)
return null;
uri = absolute;
}
var path = NormalizeUpstreamPath(uri.AbsolutePath, userId);
var builder = new UriBuilder(uri);
builder.Path = path;
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(builder.Query);
var rawControls = new ItemsProxyControls(
GetQueryString(query, "SortBy"),
GetQueryString(query, "SortOrder"),
GetQueryString(query, "NameStartsWith"),
GetQueryInt(query, "StartIndex"),
GetQueryInt(query, "Limit"),
Request.Query.TryGetValue("mlLocale", out var clientLocale) ? clientLocale.ToString() : string.Empty,
GetQueryString(query, "GenreIds"));
var hasLocalGenreFilter = multilangEnabled && ParseLocalGenreIds(rawControls.GenreIds).Length > 0;
var useLocalControls = multilangEnabled && ShouldUseLocalControls(path, query, hasLocalGenreFilter);
var controls = useLocalControls
? rawControls
: new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds);
var localCacheKey = string.Join('|',
controls.SortBy,
controls.SortOrder,
controls.NameStartsWith,
controls.StartIndex.ToString(CultureInfo.InvariantCulture),
controls.Limit.ToString(CultureInfo.InvariantCulture),
controls.ClientLocale,
controls.GenreIds);
var pairs = query
.Where(kv => !IsLocalProxyQuery(kv.Key, hasLocalGenreFilter, useLocalControls))
.SelectMany(kv => kv.Value.Select(v => KeyValuePair.Create<string, string?>(kv.Key, v ?? string.Empty)))
.ToList();
pairs.Add(KeyValuePair.Create<string, string?>("userId", userId));
if (hasLocalGenreFilter)
{
pairs.Add(KeyValuePair.Create<string, string?>("EnableImageTypes", "Primary,Backdrop,Banner,Thumb"));
pairs.Add(KeyValuePair.Create<string, string?>("Fields", "Genres,GenreItems"));
}
var cacheQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?');
pairs.Add(KeyValuePair.Create<string, string?>("api_key", token));
builder.Query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?');
var upstream = builder.Uri;
var normalizedForCache = builder.Path + (cacheQuery.Length > 0 ? "?" + cacheQuery : string.Empty);
return new ProxyRequest(
upstream,
controls,
$"{userId}|{normalizedForCache}|{localCacheKey}",
normalizedForCache,
IsGenresPath(path),
GenreMediaFromQuery(query));
}
private static bool ShouldUseLocalControls(
string normalizedPath,
IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query,
bool hasGenreFilter)
{
if (hasGenreFilter || !string.IsNullOrWhiteSpace(GetQueryString(query, "NameStartsWith")))
return true;
var segments = normalizedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.ToArray();
var itemsIndex = Array.LastIndexOf(segments, "items");
if (itemsIndex < 0)
return false;
return itemsIndex == segments.Length - 1;
}
private static bool IsGenresPath(string path)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return segments.Length > 0 && segments[^1].Equals("Genres", StringComparison.OrdinalIgnoreCase);
}
private static string GenreMediaFromQuery(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query)
{
var types = GetQueryString(query, "IncludeItemTypes")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (types.Any(t => t.Equals("Series", StringComparison.OrdinalIgnoreCase) ||
t.Equals("Season", StringComparison.OrdinalIgnoreCase) ||
t.Equals("Episode", StringComparison.OrdinalIgnoreCase)))
{
return "tv";
}
return "movie";
}
private static bool IsLocalProxyQuery(string key, bool hasGenreFilter, bool useLocalControls)
=> key.Equals("token", StringComparison.OrdinalIgnoreCase) ||
key.Equals("api_key", StringComparison.OrdinalIgnoreCase) ||
key.Equals("mlLocale", StringComparison.OrdinalIgnoreCase) ||
key.Equals("userId", StringComparison.OrdinalIgnoreCase) ||
(useLocalControls && (key.Equals("SortBy", StringComparison.OrdinalIgnoreCase) ||
key.Equals("SortOrder", StringComparison.OrdinalIgnoreCase) ||
key.Equals("StartIndex", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Limit", StringComparison.OrdinalIgnoreCase) ||
key.Equals("NameStartsWith", StringComparison.OrdinalIgnoreCase))) ||
(hasGenreFilter && (key.Equals("GenreIds", StringComparison.OrdinalIgnoreCase) ||
key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase) ||
key.Equals("EnableImageTypes", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Fields", StringComparison.OrdinalIgnoreCase)));
private static string NormalizeUpstreamPath(string path, string userId)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
if (segments.Count >= 2 &&
segments[0].Equals("Users", StringComparison.OrdinalIgnoreCase) &&
segments[1].Length == 32 &&
segments[1].All(Uri.IsHexDigit))
{
segments[1] = userId;
return "/" + string.Join('/', segments);
}
if (segments.Count > 0 && segments[0].Equals("Items", StringComparison.OrdinalIgnoreCase))
return "/Users/" + userId + "/" + string.Join('/', segments);
return path;
}
private static string GetQueryString(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
=> query.TryGetValue(key, out var value) ? value.ToString() : string.Empty;
private static int GetQueryInt(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
=> int.TryParse(GetQueryString(query, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
private static string ResolveSortLocale(string? configuredLocale, string? clientLocale) private static string ResolveSortLocale(string? configuredLocale, string? clientLocale)
{ {
var configured = (configuredLocale ?? string.Empty).Trim(); var configured = (configuredLocale ?? string.Empty).Trim();
@@ -0,0 +1,167 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace Jellyfin.Plugin.Multilang.Services;
public sealed record ItemsProxyRequest(
Uri Upstream,
ItemsProxyControls Controls,
string CacheKey,
string NormalizedUrlForCache,
bool IsGenresRequest,
string GenreMedia);
public static class ItemsProxyRequestBuilder
{
public static ItemsProxyRequest? Build(HttpRequest request, string rawUrl, string token, string userId, bool multilangEnabled)
{
var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}";
var baseAddress = new Uri(baseUri);
var uri = new Uri(baseAddress, rawUrl.StartsWith('/') ? rawUrl : "/" + rawUrl);
if ((rawUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) &&
Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute))
{
if (!absolute.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!absolute.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return null;
if (!absolute.Host.Equals(baseAddress.Host, StringComparison.OrdinalIgnoreCase) ||
absolute.Port != baseAddress.Port)
return null;
uri = absolute;
}
var path = NormalizeUpstreamPath(uri.AbsolutePath, userId);
var builder = new UriBuilder(uri) { Path = path };
var query = QueryHelpers.ParseQuery(builder.Query);
var rawControls = new ItemsProxyControls(
GetQueryString(query, "SortBy"),
GetQueryString(query, "SortOrder"),
GetQueryString(query, "NameStartsWith"),
GetQueryInt(query, "StartIndex"),
GetQueryInt(query, "Limit"),
request.Query.TryGetValue("mlLocale", out var clientLocale) ? clientLocale.ToString() : string.Empty,
GetQueryString(query, "GenreIds"));
var hasLocalGenreFilter = multilangEnabled && ParseLocalGenreIds(rawControls.GenreIds).Length > 0;
var useLocalControls = multilangEnabled && ShouldUseLocalControls(path, query, hasLocalGenreFilter);
var controls = useLocalControls
? rawControls
: new ItemsProxyControls(string.Empty, string.Empty, string.Empty, 0, 0, rawControls.ClientLocale, rawControls.GenreIds);
var localCacheKey = string.Join('|',
controls.SortBy,
controls.SortOrder,
controls.NameStartsWith,
controls.StartIndex.ToString(CultureInfo.InvariantCulture),
controls.Limit.ToString(CultureInfo.InvariantCulture),
controls.ClientLocale,
controls.GenreIds);
var pairs = query
.Where(kv => !IsLocalProxyQuery(kv.Key, hasLocalGenreFilter, useLocalControls))
.SelectMany(kv => kv.Value.Select(v => KeyValuePair.Create<string, string?>(kv.Key, v ?? string.Empty)))
.ToList();
pairs.Add(KeyValuePair.Create<string, string?>("userId", userId));
if (hasLocalGenreFilter)
{
pairs.Add(KeyValuePair.Create<string, string?>("EnableImageTypes", "Primary,Backdrop,Banner,Thumb"));
pairs.Add(KeyValuePair.Create<string, string?>("Fields", "Genres,GenreItems"));
}
var cacheQuery = QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?');
pairs.Add(KeyValuePair.Create<string, string?>("api_key", token));
builder.Query = QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?');
var normalizedForCache = builder.Path + (cacheQuery.Length > 0 ? "?" + cacheQuery : string.Empty);
return new ItemsProxyRequest(
builder.Uri,
controls,
$"{userId}|{normalizedForCache}|{localCacheKey}",
normalizedForCache,
IsGenresPath(path),
GenreMediaFromQuery(query));
}
public static int[] ParseLocalGenreIds(string raw)
=> (raw ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(v => v.StartsWith("tmdb-", StringComparison.OrdinalIgnoreCase) ? v[5..] : string.Empty)
.Where(v => int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
.Select(v => int.Parse(v, CultureInfo.InvariantCulture))
.Distinct()
.ToArray();
private static bool ShouldUseLocalControls(
string normalizedPath,
IReadOnlyDictionary<string, StringValues> query,
bool hasGenreFilter)
{
if (hasGenreFilter || !string.IsNullOrWhiteSpace(GetQueryString(query, "NameStartsWith")))
return true;
var segments = normalizedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.ToArray();
var itemsIndex = Array.LastIndexOf(segments, "items");
return itemsIndex == segments.Length - 1;
}
private static bool IsGenresPath(string path)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return segments.Length > 0 && segments[^1].Equals("Genres", StringComparison.OrdinalIgnoreCase);
}
private static string GenreMediaFromQuery(IReadOnlyDictionary<string, StringValues> query)
{
var types = GetQueryString(query, "IncludeItemTypes")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return types.Any(t => t.Equals("Series", StringComparison.OrdinalIgnoreCase) ||
t.Equals("Season", StringComparison.OrdinalIgnoreCase) ||
t.Equals("Episode", StringComparison.OrdinalIgnoreCase))
? "tv"
: "movie";
}
private static bool IsLocalProxyQuery(string key, bool hasGenreFilter, bool useLocalControls)
=> key.Equals("token", StringComparison.OrdinalIgnoreCase) ||
key.Equals("api_key", StringComparison.OrdinalIgnoreCase) ||
key.Equals("mlLocale", StringComparison.OrdinalIgnoreCase) ||
key.Equals("userId", StringComparison.OrdinalIgnoreCase) ||
(useLocalControls && (key.Equals("SortBy", StringComparison.OrdinalIgnoreCase) ||
key.Equals("SortOrder", StringComparison.OrdinalIgnoreCase) ||
key.Equals("StartIndex", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Limit", StringComparison.OrdinalIgnoreCase) ||
key.Equals("NameStartsWith", StringComparison.OrdinalIgnoreCase))) ||
(hasGenreFilter && (key.Equals("GenreIds", StringComparison.OrdinalIgnoreCase) ||
key.Equals("EnableTotalRecordCount", StringComparison.OrdinalIgnoreCase) ||
key.Equals("EnableImageTypes", StringComparison.OrdinalIgnoreCase) ||
key.Equals("Fields", StringComparison.OrdinalIgnoreCase)));
private static string NormalizeUpstreamPath(string path, string userId)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
if (segments.Count >= 2 &&
segments[0].Equals("Users", StringComparison.OrdinalIgnoreCase) &&
segments[1].Length == 32 &&
segments[1].All(Uri.IsHexDigit))
{
segments[1] = userId;
return "/" + string.Join('/', segments);
}
return segments.Count > 0 && segments[0].Equals("Items", StringComparison.OrdinalIgnoreCase)
? "/Users/" + userId + "/" + string.Join('/', segments)
: path;
}
private static string GetQueryString(IReadOnlyDictionary<string, StringValues> query, string key)
=> query.TryGetValue(key, out var value) ? value.ToString() : string.Empty;
private static int GetQueryInt(IReadOnlyDictionary<string, StringValues> query, string key)
=> int.TryParse(GetQueryString(query, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
}