using System.Globalization; using System.Text; using System.Text.Json.Nodes; using Jellyfin.Plugin.Multilang.Configuration; namespace Jellyfin.Plugin.Multilang.Services; public sealed record ItemsProxyControls( string SortBy, string SortOrder, string NameStartsWith, int StartIndex, int Limit, string ClientLocale, string GenreIds); public static class ItemsProxySorting { private readonly record struct SortSpec(string Field, SortKind Kind); private enum SortKind { Text, Number, Date, SortName, SeriesSortName, Random } public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles) => Apply(root, controls, culture, _ => titleArticles); public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, Func titleArticlesForItem) { if (!TryGetItemsArray(root, out var itemsArray)) return; var items = itemsArray.OfType().ToList(); var specs = BuildSortSpecs(controls.SortBy); if (specs.Length > 0 && items.Count > 1) items = Sort(items, specs, controls.SortOrder, culture, titleArticlesForItem); items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticlesForItem); var filteredCount = items.Count; items = Slice(items, controls.StartIndex, controls.Limit); itemsArray.Clear(); foreach (var item in items) itemsArray.Add(item); if (root is JsonObject obj && obj.ContainsKey("TotalRecordCount")) { obj["TotalRecordCount"] = filteredCount; obj["StartIndex"] = Math.Clamp(controls.StartIndex, 0, filteredCount); } } public static string ResolveSortLocale(string? configuredLocale, string? clientLocale) { var configured = (configuredLocale ?? string.Empty).Trim(); return configured.Length > 0 && !configured.Equals("Auto", StringComparison.OrdinalIgnoreCase) ? configured : (clientLocale ?? string.Empty).Trim(); } public static CultureInfo GetSortCulture(string? sortLocale) { var locale = (sortLocale ?? string.Empty).Trim(); if (locale.Length == 0) return CultureInfo.InvariantCulture; try { return CultureInfo.GetCultureInfo(locale); } catch (CultureNotFoundException) { return CultureInfo.InvariantCulture; } } public static string[] GetSortArticles( SortArticleCatalog articleCatalog, IEnumerable configuredArticles, string? titleLanguage, bool ignoreArticles = true) { if (!ignoreArticles) return []; var configured = configuredArticles.ToArray(); var always = configured .Where(e => e.AlwaysApply == true) .SelectMany(e => SplitArticles(e.Articles)); return always .Concat(GetLanguageArticles(articleCatalog, configured, titleLanguage)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } private static string[] GetLanguageArticles( SortArticleCatalog articleCatalog, SortArticleEntry[] configured, string? titleLanguage) { var language = (titleLanguage ?? string.Empty).Trim(); if (language.Length == 0) return []; var custom = configured.FirstOrDefault(e => e.Language.Equals(language, StringComparison.OrdinalIgnoreCase)) ?? configured.FirstOrDefault(e => language.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase)); if (custom is not null) return SplitArticles(custom.Articles); var builtIns = articleCatalog.GetBuiltIns(); if (builtIns.TryGetValue(language, out var exact)) return exact; var dash = language.IndexOf('-', StringComparison.Ordinal); return dash > 0 && builtIns.TryGetValue(language[..dash], out var languageOnly) ? languageOnly : []; } private static string[] SplitArticles(string articles) => articles.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); private static bool TryGetItemsArray(JsonNode root, out JsonArray items) { if (root is JsonObject obj && obj["Items"] is JsonArray array) { items = array; return true; } if (root is JsonArray direct) { items = direct; return true; } items = []; return false; } private static SortSpec[] BuildSortSpecs(string sortBy) { var fields = (sortBy ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(f => f.Length > 0) .ToList(); if (fields.Count == 0) return []; if (!fields.Any(f => f.Equals("SortName", StringComparison.OrdinalIgnoreCase))) fields.Add("SortName"); if (!fields.Any(f => f.Equals("ProductionYear", StringComparison.OrdinalIgnoreCase))) fields.Add("ProductionYear"); return fields.Select(f => new SortSpec(f, KindFor(f))).ToArray(); } private static SortKind KindFor(string field) => field.ToLowerInvariant() switch { "sortname" => SortKind.SortName, "seriessortname" => SortKind.SeriesSortName, "random" => SortKind.Random, "isfolder" or "productionyear" or "playcount" or "communityrating" or "criticrating" or "runtimeticks" or "runtime" or "indexnumber" or "parentindexnumber" => SortKind.Number, "premieredate" or "datecreated" or "dateplayed" => SortKind.Date, _ => SortKind.Text }; private static List Sort(List items, SortSpec[] specs, string sortOrder, CultureInfo culture, Func titleArticlesForItem) { var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true; var comparer = StringComparer.Create(culture, true); var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticlesForItem(item))).ToArray() }).ToList(); values.Sort((left, right) => { for (var i = 0; i < specs.Length; i++) { var a = left.Values[i]; var b = right.Values[i]; if (!a.HasValue && !b.HasValue) continue; if (!a.HasValue) return descending ? 1 : -1; if (!b.HasValue) return descending ? -1 : 1; var cmp = specs[i].Kind is SortKind.Text or SortKind.SortName or SortKind.SeriesSortName ? comparer.Compare(a.Text, b.Text) : a.Number.CompareTo(b.Number); if (cmp != 0) return descending ? -cmp : cmp; } return 0; }); return values.Select(v => v.Item).ToList(); } private static SortValue ValueFor(JsonObject item, SortSpec spec, CultureInfo culture, string[] titleArticles) { return spec.Kind switch { SortKind.Random => new SortValue(true, string.Empty, Random.Shared.NextDouble()), SortKind.SortName => new SortValue(true, NormalizeSortText(GetString(item, "Name"), titleArticles, culture), 0), SortKind.SeriesSortName => new SortValue(true, NormalizeSortText(GetString(item, "SeriesName"), titleArticles, culture), 0), SortKind.Number => TryGetNumber(item, spec.Field, out var number) ? new SortValue(true, string.Empty, number) : default, SortKind.Date => TryGetDate(item, spec.Field, out var ticks) ? new SortValue(true, string.Empty, ticks) : default, _ => !string.IsNullOrWhiteSpace(GetString(item, spec.Field)) ? new SortValue(true, GetString(item, spec.Field), 0) : default }; } private readonly record struct SortValue(bool HasValue, string Text, double Number); private static List FilterByNameStartsWith(List items, string prefix, CultureInfo culture, Func titleArticlesForItem) { prefix = (prefix ?? string.Empty).Trim(); if (prefix.Length == 0) return items; return items .Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticlesForItem(i)), prefix, CompareOptions.IgnoreCase)) .ToList(); } private static List Slice(List items, int startIndex, int limit) { startIndex = Math.Clamp(startIndex, 0, items.Count); if (limit > 0) return items.Skip(startIndex).Take(limit).ToList(); return startIndex > 0 ? items.Skip(startIndex).ToList() : items; } private static string NormalizeSortText(string value, string[] titleArticles, CultureInfo culture) => PadNumberSequences(StripArticle(value, titleArticles)).ToLower(culture); private static string StripArticle(string value, string[] titleArticles) { var text = (value ?? string.Empty).Trim(); foreach (var article in titleArticles) { var word = article.Trim(); if (word.Length == 0 || text.Length <= word.Length) continue; var attachedArticle = word.EndsWith("'", StringComparison.Ordinal); if (text.StartsWith(word + (attachedArticle ? string.Empty : " "), StringComparison.OrdinalIgnoreCase)) return text[word.Length..].TrimStart(); } return text; } private static string PadNumberSequences(string input) { if (!input.Any(char.IsDigit)) return input; var sb = new StringBuilder(input.Length + 8); for (var i = 0; i < input.Length;) { if (!char.IsDigit(input[i])) { sb.Append(input[i++]); continue; } var start = i; while (i < input.Length && char.IsDigit(input[i])) i++; var len = i - start; if (len < 10) sb.Append('0', 10 - len); sb.Append(input, start, len); } return sb.ToString(); } private static string GetString(JsonObject item, string property) => item.TryGetPropertyValue(property, out var node) && node is not null ? node.GetValueKind() == System.Text.Json.JsonValueKind.String ? node.GetValue() : node.ToString() : string.Empty; private static bool TryGetNumber(JsonObject item, string property, out double value) { value = 0; if (!item.TryGetPropertyValue(property, out var node) || node is null) return false; if (node is JsonValue jsonValue) { if (jsonValue.TryGetValue(out value)) return true; if (jsonValue.TryGetValue(out var b)) { value = b ? 1 : 0; return true; } if (jsonValue.TryGetValue(out var s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) return true; } return double.TryParse(node.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out value); } private static bool TryGetDate(JsonObject item, string property, out double value) { value = 0; var raw = GetString(item, property); if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) return false; value = date.UtcTicks; return true; } }