324 lines
12 KiB
C#
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;
|
|
}
|
|
}
|