Add language-aware article sorting

This commit is contained in:
ajp_anton
2026-07-19 01:35:06 +00:00
parent 8c79cfd3e6
commit 3c82485a0a
8 changed files with 237 additions and 37 deletions
@@ -29,6 +29,9 @@ public static class ItemsProxySorting
}
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;
@@ -36,9 +39,9 @@ public static class ItemsProxySorting
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 = Sort(items, specs, controls.SortOrder, culture, titleArticlesForItem);
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticles);
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticlesForItem);
var filteredCount = items.Count;
items = Slice(items, controls.StartIndex, controls.Limit);
@@ -77,24 +80,45 @@ public static class ItemsProxySorting
}
}
public static string[] GetSortArticles(SortArticleCatalog articleCatalog, IEnumerable<SortArticleEntry> configuredArticles, string? sortLocale)
public static string[] GetSortArticles(
SortArticleCatalog articleCatalog,
IEnumerable<SortArticleEntry> configuredArticles,
string? titleLanguage,
bool ignoreArticles = true)
{
var locale = (sortLocale ?? string.Empty).Trim();
if (locale.Length == 0)
locale = "en";
if (!ignoreArticles)
return [];
var configured = configuredArticles.ToArray();
var custom = configured.FirstOrDefault(e => e.Language.Equals(locale, StringComparison.OrdinalIgnoreCase))
?? configured.FirstOrDefault(e => locale.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase));
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(locale, out var exact))
if (builtIns.TryGetValue(language, out var exact))
return exact;
var dash = locale.IndexOf('-', StringComparison.Ordinal);
return dash > 0 && builtIns.TryGetValue(locale[..dash], out var languageOnly)
var dash = language.IndexOf('-', StringComparison.Ordinal);
return dash > 0 && builtIns.TryGetValue(language[..dash], out var languageOnly)
? languageOnly
: [];
}
@@ -149,12 +173,12 @@ public static class ItemsProxySorting
_ => SortKind.Text
};
private static List<JsonObject> Sort(List<JsonObject> items, SortSpec[] specs, string sortOrder, CultureInfo culture, string[] titleArticles)
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, titleArticles)).ToArray() }).ToList();
var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticlesForItem(item))).ToArray() }).ToList();
values.Sort((left, right) =>
{
@@ -197,14 +221,14 @@ public static class ItemsProxySorting
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)
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"), titleArticles), prefix, CompareOptions.IgnoreCase))
.Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticlesForItem(i)), prefix, CompareOptions.IgnoreCase))
.ToList();
}
@@ -227,8 +251,9 @@ public static class ItemsProxySorting
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();
var attachedArticle = word.EndsWith("'", StringComparison.Ordinal);
if (text.StartsWith(word + (attachedArticle ? string.Empty : " "), StringComparison.OrdinalIgnoreCase))
return text[word.Length..].TrimStart();
}
return text;
@@ -13,7 +13,7 @@ namespace Jellyfin.Plugin.Multilang.Services;
public readonly record struct ItemsProxyTransformResult(string Body, string[] ItemIds);
public readonly record struct ItemsProxyResolvedText(bool Change, string? Value);
public readonly record struct ItemsProxyResolvedText(bool Change, string? Value, string? Language = null);
public readonly record struct ItemsProxyResolvedAsset(bool Change, string? Value);
@@ -61,9 +61,9 @@ public sealed class ItemsProxyTransformer
if (ids.Length == 0)
return new ItemsProxyTransformResult(body, ids);
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale);
var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale);
var sortArticles = ItemsProxySorting.GetSortArticles(_articleCatalog, Plugin.Instance?.Configuration?.ArticleEntries ?? [], sortLocale);
var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : [];
if (rules.Enabled || localGenreIds.Length > 0)
@@ -80,10 +80,19 @@ public sealed class ItemsProxyTransformer
ids = GetItemIds(items);
}
if (rules.Enabled)
ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem);
var titleLanguages = rules.Enabled
? ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem)
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var jellyfinTitleLanguages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
ItemsProxySorting.Apply(root, controls, sortCulture, sortArticles);
ItemsProxySorting.Apply(root, controls, sortCulture, item =>
{
var itemId = TryGetItemId(item);
var language = itemId is not null && titleLanguages.TryGetValue(itemId, out var translatedLanguage)
? translatedLanguage
: ResolveJellyfinTitleLanguage(itemId, config, jellyfinTitleLanguages);
return ItemsProxySorting.GetSortArticles(_articleCatalog, config.ArticleEntries, language, config.IgnoreArticlesWhenSorting);
});
return new ItemsProxyTransformResult(root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), ids);
}
@@ -230,12 +239,12 @@ public sealed class ItemsProxyTransformer
if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) &&
action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(facts.OriginalTitle))
return new ItemsProxyResolvedText(true, facts.OriginalTitle);
return new ItemsProxyResolvedText(true, facts.OriginalTitle, facts.OriginalLanguage);
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
{
var originalValue = GetTranslatedField(byLang, OriginalAction, field);
if (!string.IsNullOrWhiteSpace(originalValue))
return new ItemsProxyResolvedText(true, originalValue);
return new ItemsProxyResolvedText(true, originalValue, facts.OriginalLanguage);
continue;
}
@@ -244,7 +253,7 @@ public sealed class ItemsProxyTransformer
var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field);
if (!string.IsNullOrWhiteSpace(value))
return new ItemsProxyResolvedText(true, value);
return new ItemsProxyResolvedText(true, value, action[LanguagePrefix.Length..]);
}
return field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
@@ -347,7 +356,7 @@ public sealed class ItemsProxyTransformer
return string.Empty;
}
private void ApplyRules(
private Dictionary<string, string> ApplyRules(
IEnumerable<JsonObject> items,
string[] ids,
UserRulesDocument rules,
@@ -357,6 +366,7 @@ public sealed class ItemsProxyTransformer
var langs = GetNeededLanguages(rules);
var translations = _store.GetTranslations(ids, langs);
var assets = _store.GetAssets(ids, langs);
var titleLanguages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var movieGenreNames = _store.GetGenreNames("movie", clientLocale);
var tvGenreNames = _store.GetGenreNames("tv", clientLocale);
@@ -373,6 +383,8 @@ public sealed class ItemsProxyTransformer
var title = ResolveField(TitleField, GetActionList(rules, category, TitleField), facts, byLang);
if (title.Change)
item["Name"] = title.Value ?? string.Empty;
if (!string.IsNullOrWhiteSpace(title.Language))
titleLanguages[itemId] = title.Language;
var overview = ResolveField(OverviewField, GetActionList(rules, category, OverviewField), facts, byLang);
if (overview.Change)
@@ -395,6 +407,27 @@ public sealed class ItemsProxyTransformer
ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind));
ApplyGenres(item, facts, movieGenreNames, tvGenreNames);
}
return titleLanguages;
}
private string ResolveJellyfinTitleLanguage(
string? itemId,
PluginConfiguration config,
Dictionary<string, string> resolvedLanguages)
{
if (string.IsNullOrWhiteSpace(itemId))
return config.JellyfinTitleLanguageFallback;
if (resolvedLanguages.TryGetValue(itemId, out var existing))
return existing;
var language = Guid.TryParse(itemId, out var itemGuid)
? _libraryManager.GetItemById(itemGuid)?.GetPreferredMetadataLanguage()
: null;
language = string.IsNullOrWhiteSpace(language) ? config.JellyfinTitleLanguageFallback : language;
resolvedLanguages[itemId] = language ?? string.Empty;
return resolvedLanguages[itemId];
}
private static void ApplyImageAsset(JsonObject item, string imageType, ItemsProxyResolvedAsset asset)