168 lines
8.2 KiB
C#
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;
|
|
}
|