Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestBuilder.cs
T

168 lines
8.2 KiB
C#

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;
}