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

324 lines
12 KiB
C#

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<JsonObject, string[]> titleArticlesForItem)
{
if (!TryGetItemsArray(root, out var itemsArray))
return;
var items = itemsArray.OfType<JsonObject>().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<SortArticleEntry> 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<JsonObject> Sort(List<JsonObject> items, SortSpec[] specs, string sortOrder, CultureInfo culture, Func<JsonObject, string[]> 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<JsonObject> FilterByNameStartsWith(List<JsonObject> items, string prefix, CultureInfo culture, Func<JsonObject, string[]> 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<JsonObject> Slice(List<JsonObject> 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<string>() : 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<double>(out value))
return true;
if (jsonValue.TryGetValue<bool>(out var b))
{
value = b ? 1 : 0;
return true;
}
if (jsonValue.TryGetValue<string>(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;
}
}