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(kv.Key, v ?? string.Empty))) .ToList(); pairs.Add(KeyValuePair.Create("userId", userId)); if (hasLocalGenreFilter) { pairs.Add(KeyValuePair.Create("EnableImageTypes", "Primary,Backdrop,Banner,Thumb")); pairs.Add(KeyValuePair.Create("Fields", "Genres,GenreItems")); } var cacheQuery = QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?'); pairs.Add(KeyValuePair.Create("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 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 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 query, string key) => query.TryGetValue(key, out var value) ? value.ToString() : string.Empty; private static int GetQueryInt(IReadOnlyDictionary query, string key) => int.TryParse(GetQueryString(query, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : 0; }