diff --git a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs index d957a3d..e97838b 100644 --- a/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs +++ b/src/Jellyfin.Plugin.Multilang/Api/MultilangController.cs @@ -267,7 +267,7 @@ public sealed class MultilangController : ControllerBase return Unauthorized(new { Error = "NotAuthenticated" }); 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) return BadRequest(new { Error = "InvalidUrl" }); @@ -413,7 +413,7 @@ public sealed class MultilangController : ControllerBase var sortCulture = GetSortCulture(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) _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) { if (string.IsNullOrWhiteSpace(json)) @@ -1779,165 +1770,6 @@ public sealed class MultilangController : ControllerBase 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(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 = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, pairs).TrimStart('?'); - pairs.Add(KeyValuePair.Create("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 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 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 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; - private static string ResolveSortLocale(string? configuredLocale, string? clientLocale) { var configured = (configuredLocale ?? string.Empty).Trim(); diff --git a/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestBuilder.cs b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestBuilder.cs new file mode 100644 index 0000000..021bad5 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Services/ItemsProxyRequestBuilder.cs @@ -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(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; +}