Initial jellyfin-multilang rewrite
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
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)
|
||||
{
|
||||
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, titleArticles);
|
||||
|
||||
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticles);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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, string[] titleArticles)
|
||||
{
|
||||
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, titleArticles)).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, string[] titleArticles)
|
||||
{
|
||||
prefix = (prefix ?? string.Empty).Trim();
|
||||
if (prefix.Length == 0)
|
||||
return items;
|
||||
|
||||
return items
|
||||
.Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticles), 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;
|
||||
if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase))
|
||||
return text[(word.Length + 1)..].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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user