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
@@ -17,6 +17,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -42,6 +43,7 @@ public sealed class MultilangController : ControllerBase
private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IApplicationPaths _appPaths;
private readonly ILocalizationManager _localizationManager;
private readonly ProviderCatalog _providerCatalog;
private readonly SortArticleCatalog _articleCatalog;
private readonly RefreshService _refreshService;
@@ -59,6 +61,7 @@ public sealed class MultilangController : ControllerBase
IUserManager userManager,
IHttpClientFactory httpClientFactory,
IApplicationPaths appPaths,
ILocalizationManager localizationManager,
ProviderCatalog providerCatalog,
SortArticleCatalog articleCatalog,
RefreshService refreshService,
@@ -75,6 +78,7 @@ public sealed class MultilangController : ControllerBase
_userManager = userManager;
_httpClientFactory = httpClientFactory;
_appPaths = appPaths;
_localizationManager = localizationManager;
_providerCatalog = providerCatalog;
_articleCatalog = articleCatalog;
_refreshService = refreshService;
@@ -111,6 +115,13 @@ public sealed class MultilangController : ControllerBase
public IActionResult ArticleBuiltins()
=> Ok(_articleCatalog.GetBuiltIns().OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(k => k.Key, v => v.Value));
[HttpGet("MetadataLanguages")]
public IActionResult MetadataLanguages()
=> Ok(_localizationManager.GetCultures()
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
.Select(c => new { c.Name, c.DisplayName }));
[HttpGet("AdminConfig")]
public async Task<IActionResult> AdminConfig()
{
@@ -143,6 +154,8 @@ public sealed class MultilangController : ControllerBase
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
!string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.OrdinalIgnoreCase) ||
previous.IgnoreArticlesWhenSorting != next.IgnoreArticlesWhenSorting ||
!string.Equals(previous.JellyfinTitleLanguageFallback, next.JellyfinTitleLanguageFallback, StringComparison.OrdinalIgnoreCase) ||
!ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries);
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
@@ -1401,6 +1414,10 @@ public sealed class AdminConfigDto
public string AssetStorageMode { get; set; } = "url";
public bool IgnoreArticlesWhenSorting { get; set; } = true;
public string JellyfinTitleLanguageFallback { get; set; } = string.Empty;
public SortArticleEntry[] ArticleEntries { get; set; } = [];
public bool EnableLogging { get; set; }
@@ -1422,6 +1439,8 @@ public sealed class AdminConfigDto
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback,
ArticleEntries = cfg.ArticleEntries,
EnableLogging = cfg.EnableLogging,
VerboseLogging = cfg.VerboseLogging,
@@ -1444,6 +1463,8 @@ public sealed class AdminConfigDto
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty,
ArticleEntries = ArticleEntries
.Where(e => !string.IsNullOrWhiteSpace(e.Language))
.Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply })
@@ -29,6 +29,10 @@ public sealed class PluginConfiguration : BasePluginConfiguration
public string AssetStorageMode { get; set; } = "url";
public bool IgnoreArticlesWhenSorting { get; set; } = true;
public string JellyfinTitleLanguageFallback { get; set; } = string.Empty;
[XmlArray("SortArticles")]
[XmlArrayItem("SortArticleEntry")]
public SortArticleEntry[] ArticleEntries { get; set; } = [];
@@ -28,16 +28,25 @@
</fieldset>
<fieldset class="ml-section">
<legend>Languages</legend>
<legend>Languages to fetch</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-languages">Languages</label>
<label class="inputLabel inputLabelUnfocused" for="ml-languages">Languages to fetch</label>
<input id="ml-languages" type="text" class="emby-input ml-input" placeholder="en-US, fi-FI, sv-SE">
<div class="fieldDescription">Use BCP-47 language tags. These are the languages refresh jobs fetch and user rules can select.</div>
</div>
<div class="inputContainer">
<label><input id="ml-ignore-articles" type="checkbox"> Ignore articles when sorting</label>
</div>
<div id="ml-jellyfin-title-language-row" class="inputContainer ml-article-sorting-fallback">
<label class="inputLabel inputLabelUnfocused" for="ml-jellyfin-title-language">When in doubt, assume title language is</label>
<select id="ml-jellyfin-title-language" is="emby-select" class="emby-select-withcolor emby-select ml-input"></select>
</div>
<details id="ml-article-details" class="ml-articles">
<summary>Articles</summary>
<summary>List of articles</summary>
<div class="fieldDescription">Comma- or semicolon-separated leading articles to ignore per language.</div>
<div class="fieldDescription">To ignore a language's articles, even on titles in other languages, check "Always ignore".</div>
<div id="ml-article-list" class="ml-articles-list"></div>
<div class="ml-articles-actions">
<button id="ml-article-add" is="emby-button" type="button" class="raised"><span>Add language</span></button>
@@ -179,7 +188,7 @@
<script>
(function () {
var M = window.Multilang;
var state = { config: null, providers: [], articles: {} };
var state = { config: null, providers: [], articles: {}, metadataLanguages: [] };
var page = document.getElementById("ml-admin");
var form = document.getElementById("ml-admin-form");
var status = document.getElementById("ml-status");
@@ -226,6 +235,38 @@
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
}
function renderJellyfinTitleLanguages() {
var select = document.getElementById("ml-jellyfin-title-language");
var selected = state.config.JellyfinTitleLanguageFallback || state.config.jellyfinTitleLanguageFallback || "";
select.replaceChildren();
var defaultOption = make("option", "", "Jellyfin's default");
defaultOption.value = "";
select.appendChild(defaultOption);
var separator = make("option", "", "--------------------");
separator.disabled = true;
select.appendChild(separator);
state.metadataLanguages.forEach(function (language) {
var name = String(language.Name || language.name || "");
if (!name) return;
var option = make("option", "", String(language.DisplayName || language.displayName || name) + " (" + name + ")");
option.value = name;
select.appendChild(option);
});
if (selected && !Array.prototype.some.call(select.options, function (option) { return option.value === selected; })) {
var unknown = make("option", "", selected);
unknown.value = selected;
select.appendChild(unknown);
}
select.value = selected;
}
function syncArticleSortingControls() {
var enabled = document.getElementById("ml-ignore-articles").checked;
var row = document.getElementById("ml-jellyfin-title-language-row");
row.classList.toggle("ml-disabled", !enabled);
document.getElementById("ml-jellyfin-title-language").disabled = !enabled;
}
function syncProviderSourceWidth() {
var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source"));
if (!sources.length) return;
@@ -316,7 +357,7 @@
var alwaysInput = make("input", "ml-articles-apply-toggle");
alwaysInput.type = "checkbox";
alwaysInput.checked = !!alwaysApply;
always.append(alwaysInput, make("span", "", "Always"));
always.append(alwaysInput, make("span", "", "Always ignore"));
var remove = make("button", "raised ml-articles-remove");
remove.type = "button";
remove.setAttribute("is", "emby-button");
@@ -383,6 +424,8 @@
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1);
state.config.AssetStorageMode = nextStorageMode;
state.config.IgnoreArticlesWhenSorting = document.getElementById("ml-ignore-articles").checked;
state.config.JellyfinTitleLanguageFallback = document.getElementById("ml-jellyfin-title-language").value;
state.config.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked;
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
state.config.CleanupDataOnUninstall = document.getElementById("ml-cleanup-uninstall").checked;
@@ -405,11 +448,14 @@
document.getElementById("ml-cache-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50;
document.getElementById("ml-asset-storage").value = (state.config.AssetStorageMode || state.config.assetStorageMode || "url").toLowerCase() === "local" ? "local" : "url";
document.getElementById("ml-ignore-articles").checked = state.config.IgnoreArticlesWhenSorting !== false && state.config.ignoreArticlesWhenSorting !== false;
document.getElementById("ml-log-enabled").checked = !!state.config.EnableLogging;
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
renderProviderInputs();
renderProviderBuckets();
renderJellyfinTitleLanguages();
syncArticleSortingControls();
renderArticles();
loadCacheDiagnostics();
loadRefreshDiagnostics();
@@ -716,11 +762,13 @@
var all = await Promise.all([
M.get("/Multilang/Providers"),
M.get("/Multilang/ArticleBuiltins"),
M.get("/Multilang/MetadataLanguages"),
M.get("/Multilang/AdminConfig")
]);
state.providers = all[0] || [];
state.articles = all[1] || {};
state.config = all[2] || {};
state.metadataLanguages = all[2] || [];
state.config = all[3] || {};
loadIntoForm();
M.setStatus(status, "");
} catch (err) {
@@ -884,6 +932,7 @@
document.getElementById("ml-log-verbose").addEventListener("change", function () {
if (this.checked) document.getElementById("ml-log-enabled").checked = true;
});
document.getElementById("ml-ignore-articles").addEventListener("change", syncArticleSortingControls);
document.getElementById("ml-article-add").addEventListener("click", function () { addArticleRow("", "", false, false); });
document.getElementById("ml-article-repair").addEventListener("click", renderArticles);
document.getElementById("ml-article-reset").addEventListener("click", function () {
@@ -297,6 +297,10 @@ select.emby-select option:checked {
opacity: .7;
}
.ml-disabled {
opacity: .5;
}
.ml-error {
color: #ff9b9b;
}
@@ -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)
@@ -8,7 +8,7 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxySortingTests
{
[Fact]
public void GetSortArticlesUsesBuiltInLocaleFallback()
public void GetSortArticlesUsesBuiltInLanguageFallback()
{
var articles = ItemsProxySorting.GetSortArticles(new SortArticleCatalog(), [], "sv-FI");
@@ -27,7 +27,7 @@ public sealed class ItemsProxySortingTests
}
[Fact]
public void GetSortArticlesPrefersConfiguredExactLocale()
public void GetSortArticlesPrefersConfiguredExactLanguage()
{
var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(),
@@ -40,6 +40,32 @@ public sealed class ItemsProxySortingTests
Assert.Equal(["exact"], articles);
}
[Fact]
public void GetSortArticlesIncludesAlwaysIgnoredArticlesFromOtherLanguages()
{
var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(),
[
new SortArticleEntry { Language = "en", Articles = "a, an, the", AlwaysApply = true },
new SortArticleEntry { Language = "it", Articles = "il, lo, la" }
],
"it");
Assert.Equal(["a", "an", "the", "il", "lo", "la"], articles);
}
[Fact]
public void GetSortArticlesReturnsNoneWhenArticleIgnoringIsDisabled()
{
var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(),
[new SortArticleEntry { Language = "en", Articles = "a, an, the", AlwaysApply = true }],
"en",
ignoreArticles: false);
Assert.Empty(articles);
}
[Fact]
public void ApplySortsByTitleAfterLeadingArticles()
{
@@ -54,6 +80,41 @@ public sealed class ItemsProxySortingTests
Assert.Equal(["Avatar", "A Beautiful Mind", "The Matrix"], ItemNames(root));
}
[Fact]
public void ApplyUsesEachTitlesLanguageForArticleRemoval()
{
var root = ItemsRoot("La La Land", "La vita e bella", "Terminator", "The Matrix");
var articles = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["La La Land"] = [],
["La vita e bella"] = ["il", "lo", "la"],
["Terminator"] = [],
["The Matrix"] = ["a", "an", "the"]
};
ItemsProxySorting.Apply(
root,
new ItemsProxyControls("SortName", "Ascending", "", 0, 0, "en-US", ""),
CultureInfo.GetCultureInfo("en-US"),
item => articles[item["Name"]!.GetValue<string>()]);
Assert.Equal(["La La Land", "The Matrix", "Terminator", "La vita e bella"], ItemNames(root));
}
[Fact]
public void ApplyStripsApostropheArticles()
{
var root = ItemsRoot("L'avventura", "The Matrix");
ItemsProxySorting.Apply(
root,
new ItemsProxyControls("SortName", "Ascending", "", 0, 0, "en-US", ""),
CultureInfo.GetCultureInfo("en-US"),
item => item["Name"]!.GetValue<string>() == "L'avventura" ? ["l'"] : ["the"]);
Assert.Equal(["L'avventura", "The Matrix"], ItemNames(root));
}
[Fact]
public void ApplyPadsNumbersDuringTitleSort()
{
@@ -29,6 +29,7 @@ public sealed class ItemsProxyTransformerResolutionTests
Assert.True(result.Change);
Assert.Equal("Finnish overview", result.Value);
Assert.Equal("fi", result.Language);
}
[Fact]
@@ -37,11 +38,12 @@ public sealed class ItemsProxyTransformerResolutionTests
var result = ItemsProxyTransformer.ResolveField(
TitleField,
[OriginalAction, LanguagePrefix + "en"],
Facts(originalTitle: "Original title"),
Facts(originalTitle: "Original title", originalLanguage: "en"),
TextTranslations(("en", TitleField, "English title")));
Assert.True(result.Change);
Assert.Equal("Original title", result.Value);
Assert.Equal("en", result.Language);
}
[Fact]
@@ -55,6 +57,7 @@ public sealed class ItemsProxyTransformerResolutionTests
Assert.False(result.Change);
Assert.Null(result.Value);
Assert.Null(result.Language);
}
[Fact]
@@ -145,13 +148,13 @@ public sealed class ItemsProxyTransformerResolutionTests
return result;
}
private static FactsData Facts(string originalTitle = "")
private static FactsData Facts(string originalTitle = "", string originalLanguage = "")
=> new(
"item",
"tmdb",
"movie",
originalTitle,
"",
originalLanguage,
"",
"[]",
"[]",