Add language-aware article sorting

This commit is contained in:
ajp_anton
2026-07-19 01:35:02 +00:00
parent 6c33dc1d69
commit 6a81debc8c
8 changed files with 237 additions and 37 deletions
@@ -17,6 +17,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Globalization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -42,6 +43,7 @@ public sealed class MultilangController : ControllerBase
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
private readonly ILocalizationManager _localizationManager;
private readonly ProviderCatalog _providerCatalog; private readonly ProviderCatalog _providerCatalog;
private readonly SortArticleCatalog _articleCatalog; private readonly SortArticleCatalog _articleCatalog;
private readonly RefreshService _refreshService; private readonly RefreshService _refreshService;
@@ -59,6 +61,7 @@ public sealed class MultilangController : ControllerBase
IUserManager userManager, IUserManager userManager,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IApplicationPaths appPaths, IApplicationPaths appPaths,
ILocalizationManager localizationManager,
ProviderCatalog providerCatalog, ProviderCatalog providerCatalog,
SortArticleCatalog articleCatalog, SortArticleCatalog articleCatalog,
RefreshService refreshService, RefreshService refreshService,
@@ -75,6 +78,7 @@ public sealed class MultilangController : ControllerBase
_userManager = userManager; _userManager = userManager;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_appPaths = appPaths; _appPaths = appPaths;
_localizationManager = localizationManager;
_providerCatalog = providerCatalog; _providerCatalog = providerCatalog;
_articleCatalog = articleCatalog; _articleCatalog = articleCatalog;
_refreshService = refreshService; _refreshService = refreshService;
@@ -111,6 +115,13 @@ public sealed class MultilangController : ControllerBase
public IActionResult ArticleBuiltins() public IActionResult ArticleBuiltins()
=> Ok(_articleCatalog.GetBuiltIns().OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(k => k.Key, v => v.Value)); => 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")] [HttpGet("AdminConfig")]
public async Task<IActionResult> AdminConfig() public async Task<IActionResult> AdminConfig()
{ {
@@ -143,6 +154,8 @@ public sealed class MultilangController : ControllerBase
private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next) private static bool AdminConfigAffectsCachedItems(PluginConfiguration previous, PluginConfiguration next)
=> !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) || => !StringArrayEquals(previous.Languages, next.Languages, StringComparer.OrdinalIgnoreCase) ||
!string.Equals(previous.AssetStorageMode, next.AssetStorageMode, StringComparison.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); !ArticleEntriesEqual(previous.ArticleEntries, next.ArticleEntries);
private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer) private static bool StringArrayEquals(string[] previous, string[] next, StringComparer comparer)
@@ -1432,6 +1445,10 @@ public sealed class AdminConfigDto
public string AssetStorageMode { get; set; } = "url"; 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 SortArticleEntry[] ArticleEntries { get; set; } = [];
public bool EnableLogging { get; set; } public bool EnableLogging { get; set; }
@@ -1453,6 +1470,8 @@ public sealed class AdminConfigDto
ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes, ItemsProxyCacheTtlMinutes = cfg.ItemsProxyCacheTtlMinutes,
ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB, ItemsProxyCacheMaxMiB = cfg.ItemsProxyCacheMaxMiB,
AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode), AssetStorageMode = NormalizeAssetStorageMode(cfg.AssetStorageMode),
IgnoreArticlesWhenSorting = cfg.IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = cfg.JellyfinTitleLanguageFallback,
ArticleEntries = cfg.ArticleEntries, ArticleEntries = cfg.ArticleEntries,
EnableLogging = cfg.EnableLogging, EnableLogging = cfg.EnableLogging,
VerboseLogging = cfg.VerboseLogging, VerboseLogging = cfg.VerboseLogging,
@@ -1475,6 +1494,8 @@ public sealed class AdminConfigDto
ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes), ItemsProxyCacheTtlMinutes = Math.Max(1, ItemsProxyCacheTtlMinutes),
ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB), ItemsProxyCacheMaxMiB = Math.Max(1, ItemsProxyCacheMaxMiB),
AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode), AssetStorageMode = NormalizeAssetStorageMode(AssetStorageMode),
IgnoreArticlesWhenSorting = IgnoreArticlesWhenSorting,
JellyfinTitleLanguageFallback = JellyfinTitleLanguageFallback?.Trim() ?? string.Empty,
ArticleEntries = ArticleEntries ArticleEntries = ArticleEntries
.Where(e => !string.IsNullOrWhiteSpace(e.Language)) .Where(e => !string.IsNullOrWhiteSpace(e.Language))
.Select(e => new SortArticleEntry { Language = e.Language.Trim(), Articles = e.Articles ?? string.Empty, AlwaysApply = e.AlwaysApply }) .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 string AssetStorageMode { get; set; } = "url";
public bool IgnoreArticlesWhenSorting { get; set; } = true;
public string JellyfinTitleLanguageFallback { get; set; } = string.Empty;
[XmlArray("SortArticles")] [XmlArray("SortArticles")]
[XmlArrayItem("SortArticleEntry")] [XmlArrayItem("SortArticleEntry")]
public SortArticleEntry[] ArticleEntries { get; set; } = []; public SortArticleEntry[] ArticleEntries { get; set; } = [];
@@ -28,16 +28,25 @@
</fieldset> </fieldset>
<fieldset class="ml-section"> <fieldset class="ml-section">
<legend>Languages</legend> <legend>Languages to fetch</legend>
<div class="inputContainer"> <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"> <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 class="fieldDescription">Use BCP-47 language tags. These are the languages refresh jobs fetch and user rules can select.</div>
</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"> <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">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 id="ml-article-list" class="ml-articles-list"></div>
<div class="ml-articles-actions"> <div class="ml-articles-actions">
<button id="ml-article-add" is="emby-button" type="button" class="raised"><span>Add language</span></button> <button id="ml-article-add" is="emby-button" type="button" class="raised"><span>Add language</span></button>
@@ -179,7 +188,7 @@
<script> <script>
(function () { (function () {
var M = window.Multilang; var M = window.Multilang;
var state = { config: null, providers: [], articles: {} }; var state = { config: null, providers: [], articles: {}, metadataLanguages: [] };
var page = document.getElementById("ml-admin"); var page = document.getElementById("ml-admin");
var form = document.getElementById("ml-admin-form"); var form = document.getElementById("ml-admin-form");
var status = document.getElementById("ml-status"); var status = document.getElementById("ml-status");
@@ -226,6 +235,38 @@
return String(value || "").trim().toLowerCase().replace(/_/g, "-"); 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() { function syncProviderSourceWidth() {
var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source")); var sources = Array.prototype.slice.call(page.querySelectorAll(".ml-provider-source"));
if (!sources.length) return; if (!sources.length) return;
@@ -316,7 +357,7 @@
var alwaysInput = make("input", "ml-articles-apply-toggle"); var alwaysInput = make("input", "ml-articles-apply-toggle");
alwaysInput.type = "checkbox"; alwaysInput.type = "checkbox";
alwaysInput.checked = !!alwaysApply; alwaysInput.checked = !!alwaysApply;
always.append(alwaysInput, make("span", "", "Always")); always.append(alwaysInput, make("span", "", "Always ignore"));
var remove = make("button", "raised ml-articles-remove"); var remove = make("button", "raised ml-articles-remove");
remove.type = "button"; remove.type = "button";
remove.setAttribute("is", "emby-button"); remove.setAttribute("is", "emby-button");
@@ -383,6 +424,8 @@
state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1); state.config.ItemsProxyCacheTtlMinutes = Number(document.getElementById("ml-cache-ttl").value || 1);
state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1); state.config.ItemsProxyCacheMaxMiB = Number(document.getElementById("ml-cache-max").value || 1);
state.config.AssetStorageMode = nextStorageMode; 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.EnableLogging = document.getElementById("ml-log-enabled").checked || document.getElementById("ml-log-verbose").checked;
state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked; state.config.VerboseLogging = document.getElementById("ml-log-verbose").checked;
state.config.CleanupDataOnUninstall = document.getElementById("ml-cleanup-uninstall").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-ttl").value = state.config.ItemsProxyCacheTtlMinutes ?? 120;
document.getElementById("ml-cache-max").value = state.config.ItemsProxyCacheMaxMiB ?? 50; 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-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-enabled").checked = !!state.config.EnableLogging;
document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging; document.getElementById("ml-log-verbose").checked = !!state.config.VerboseLogging;
document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall; document.getElementById("ml-cleanup-uninstall").checked = !!state.config.CleanupDataOnUninstall;
renderProviderInputs(); renderProviderInputs();
renderProviderBuckets(); renderProviderBuckets();
renderJellyfinTitleLanguages();
syncArticleSortingControls();
renderArticles(); renderArticles();
loadCacheDiagnostics(); loadCacheDiagnostics();
loadRefreshDiagnostics(); loadRefreshDiagnostics();
@@ -716,11 +762,13 @@
var all = await Promise.all([ var all = await Promise.all([
M.get("/Multilang/Providers"), M.get("/Multilang/Providers"),
M.get("/Multilang/ArticleBuiltins"), M.get("/Multilang/ArticleBuiltins"),
M.get("/Multilang/MetadataLanguages"),
M.get("/Multilang/AdminConfig") M.get("/Multilang/AdminConfig")
]); ]);
state.providers = all[0] || []; state.providers = all[0] || [];
state.articles = all[1] || {}; state.articles = all[1] || {};
state.config = all[2] || {}; state.metadataLanguages = all[2] || [];
state.config = all[3] || {};
loadIntoForm(); loadIntoForm();
M.setStatus(status, ""); M.setStatus(status, "");
} catch (err) { } catch (err) {
@@ -884,6 +932,7 @@
document.getElementById("ml-log-verbose").addEventListener("change", function () { document.getElementById("ml-log-verbose").addEventListener("change", function () {
if (this.checked) document.getElementById("ml-log-enabled").checked = true; 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-add").addEventListener("click", function () { addArticleRow("", "", false, false); });
document.getElementById("ml-article-repair").addEventListener("click", renderArticles); document.getElementById("ml-article-repair").addEventListener("click", renderArticles);
document.getElementById("ml-article-reset").addEventListener("click", function () { document.getElementById("ml-article-reset").addEventListener("click", function () {
@@ -297,6 +297,10 @@ select.emby-select option:checked {
opacity: .7; opacity: .7;
} }
.ml-disabled {
opacity: .5;
}
.ml-error { .ml-error {
color: #ff9b9b; color: #ff9b9b;
} }
@@ -29,6 +29,9 @@ public static class ItemsProxySorting
} }
public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles) 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)) if (!TryGetItemsArray(root, out var itemsArray))
return; return;
@@ -36,9 +39,9 @@ public static class ItemsProxySorting
var items = itemsArray.OfType<JsonObject>().ToList(); var items = itemsArray.OfType<JsonObject>().ToList();
var specs = BuildSortSpecs(controls.SortBy); var specs = BuildSortSpecs(controls.SortBy);
if (specs.Length > 0 && items.Count > 1) 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; var filteredCount = items.Count;
items = Slice(items, controls.StartIndex, controls.Limit); 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 (!ignoreArticles)
if (locale.Length == 0) return [];
locale = "en";
var configured = configuredArticles.ToArray(); var configured = configuredArticles.ToArray();
var custom = configured.FirstOrDefault(e => e.Language.Equals(locale, StringComparison.OrdinalIgnoreCase)) var always = configured
?? configured.FirstOrDefault(e => locale.StartsWith(e.Language + "-", StringComparison.OrdinalIgnoreCase)); .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) if (custom is not null)
return SplitArticles(custom.Articles); return SplitArticles(custom.Articles);
var builtIns = articleCatalog.GetBuiltIns(); var builtIns = articleCatalog.GetBuiltIns();
if (builtIns.TryGetValue(locale, out var exact)) if (builtIns.TryGetValue(language, out var exact))
return exact; return exact;
var dash = locale.IndexOf('-', StringComparison.Ordinal); var dash = language.IndexOf('-', StringComparison.Ordinal);
return dash > 0 && builtIns.TryGetValue(locale[..dash], out var languageOnly) return dash > 0 && builtIns.TryGetValue(language[..dash], out var languageOnly)
? languageOnly ? languageOnly
: []; : [];
} }
@@ -149,12 +173,12 @@ public static class ItemsProxySorting
_ => SortKind.Text _ => 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) var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true; .FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true;
var comparer = StringComparer.Create(culture, 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) => values.Sort((left, right) =>
{ {
@@ -197,14 +221,14 @@ public static class ItemsProxySorting
private readonly record struct SortValue(bool HasValue, string Text, double Number); 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(); prefix = (prefix ?? string.Empty).Trim();
if (prefix.Length == 0) if (prefix.Length == 0)
return items; return items;
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(); .ToList();
} }
@@ -227,8 +251,9 @@ public static class ItemsProxySorting
var word = article.Trim(); var word = article.Trim();
if (word.Length == 0 || text.Length <= word.Length) if (word.Length == 0 || text.Length <= word.Length)
continue; continue;
if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase)) var attachedArticle = word.EndsWith("'", StringComparison.Ordinal);
return text[(word.Length + 1)..].TrimStart(); if (text.StartsWith(word + (attachedArticle ? string.Empty : " "), StringComparison.OrdinalIgnoreCase))
return text[word.Length..].TrimStart();
} }
return text; return text;
@@ -13,7 +13,7 @@ namespace Jellyfin.Plugin.Multilang.Services;
public readonly record struct ItemsProxyTransformResult(string Body, string[] ItemIds); 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); public readonly record struct ItemsProxyResolvedAsset(bool Change, string? Value);
@@ -61,9 +61,9 @@ public sealed class ItemsProxyTransformer
if (ids.Length == 0) if (ids.Length == 0)
return new ItemsProxyTransformResult(body, ids); return new ItemsProxyTransformResult(body, ids);
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale); var sortLocale = ItemsProxySorting.ResolveSortLocale(rules.SortLocale, controls.ClientLocale);
var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale); var sortCulture = ItemsProxySorting.GetSortCulture(sortLocale);
var sortArticles = ItemsProxySorting.GetSortArticles(_articleCatalog, Plugin.Instance?.Configuration?.ArticleEntries ?? [], sortLocale);
var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : []; var localGenreIds = rules.Enabled ? ItemsProxyRequestBuilder.ParseLocalGenreIds(controls.GenreIds) : [];
if (rules.Enabled || localGenreIds.Length > 0) if (rules.Enabled || localGenreIds.Length > 0)
@@ -80,10 +80,19 @@ public sealed class ItemsProxyTransformer
ids = GetItemIds(items); ids = GetItemIds(items);
} }
if (rules.Enabled) var titleLanguages = rules.Enabled
ApplyRules(items, ids, rules, controls.ClientLocale, factsByItem); ? 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); return new ItemsProxyTransformResult(root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), ids);
} }
@@ -230,12 +239,12 @@ public sealed class ItemsProxyTransformer
if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) && if (field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) &&
action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase) && action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(facts.OriginalTitle)) !string.IsNullOrWhiteSpace(facts.OriginalTitle))
return new ItemsProxyResolvedText(true, facts.OriginalTitle); return new ItemsProxyResolvedText(true, facts.OriginalTitle, facts.OriginalLanguage);
if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase)) if (action.Equals(OriginalAction, StringComparison.OrdinalIgnoreCase))
{ {
var originalValue = GetTranslatedField(byLang, OriginalAction, field); var originalValue = GetTranslatedField(byLang, OriginalAction, field);
if (!string.IsNullOrWhiteSpace(originalValue)) if (!string.IsNullOrWhiteSpace(originalValue))
return new ItemsProxyResolvedText(true, originalValue); return new ItemsProxyResolvedText(true, originalValue, facts.OriginalLanguage);
continue; continue;
} }
@@ -244,7 +253,7 @@ public sealed class ItemsProxyTransformer
var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field); var value = GetTranslatedField(byLang, action[LanguagePrefix.Length..], field);
if (!string.IsNullOrWhiteSpace(value)) if (!string.IsNullOrWhiteSpace(value))
return new ItemsProxyResolvedText(true, value); return new ItemsProxyResolvedText(true, value, action[LanguagePrefix.Length..]);
} }
return field.Equals(TitleField, StringComparison.OrdinalIgnoreCase) return field.Equals(TitleField, StringComparison.OrdinalIgnoreCase)
@@ -347,7 +356,7 @@ public sealed class ItemsProxyTransformer
return string.Empty; return string.Empty;
} }
private void ApplyRules( private Dictionary<string, string> ApplyRules(
IEnumerable<JsonObject> items, IEnumerable<JsonObject> items,
string[] ids, string[] ids,
UserRulesDocument rules, UserRulesDocument rules,
@@ -357,6 +366,7 @@ public sealed class ItemsProxyTransformer
var langs = GetNeededLanguages(rules); var langs = GetNeededLanguages(rules);
var translations = _store.GetTranslations(ids, langs); var translations = _store.GetTranslations(ids, langs);
var assets = _store.GetAssets(ids, langs); var assets = _store.GetAssets(ids, langs);
var titleLanguages = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var movieGenreNames = _store.GetGenreNames("movie", clientLocale); var movieGenreNames = _store.GetGenreNames("movie", clientLocale);
var tvGenreNames = _store.GetGenreNames("tv", 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); var title = ResolveField(TitleField, GetActionList(rules, category, TitleField), facts, byLang);
if (title.Change) if (title.Change)
item["Name"] = title.Value ?? string.Empty; 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); var overview = ResolveField(OverviewField, GetActionList(rules, category, OverviewField), facts, byLang);
if (overview.Change) if (overview.Change)
@@ -395,6 +407,27 @@ public sealed class ItemsProxyTransformer
ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind)); ApplyBackdropAsset(item, ResolveAsset(BackdropKind, GetActionList(rules, category, BackdropKind), assetsByKind));
ApplyGenres(item, facts, movieGenreNames, tvGenreNames); 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) private static void ApplyImageAsset(JsonObject item, string imageType, ItemsProxyResolvedAsset asset)
@@ -8,7 +8,7 @@ namespace Jellyfin.Plugin.Multilang.Tests;
public sealed class ItemsProxySortingTests public sealed class ItemsProxySortingTests
{ {
[Fact] [Fact]
public void GetSortArticlesUsesBuiltInLocaleFallback() public void GetSortArticlesUsesBuiltInLanguageFallback()
{ {
var articles = ItemsProxySorting.GetSortArticles(new SortArticleCatalog(), [], "sv-FI"); var articles = ItemsProxySorting.GetSortArticles(new SortArticleCatalog(), [], "sv-FI");
@@ -27,7 +27,7 @@ public sealed class ItemsProxySortingTests
} }
[Fact] [Fact]
public void GetSortArticlesPrefersConfiguredExactLocale() public void GetSortArticlesPrefersConfiguredExactLanguage()
{ {
var articles = ItemsProxySorting.GetSortArticles( var articles = ItemsProxySorting.GetSortArticles(
new SortArticleCatalog(), new SortArticleCatalog(),
@@ -40,6 +40,32 @@ public sealed class ItemsProxySortingTests
Assert.Equal(["exact"], articles); 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] [Fact]
public void ApplySortsByTitleAfterLeadingArticles() public void ApplySortsByTitleAfterLeadingArticles()
{ {
@@ -54,6 +80,41 @@ public sealed class ItemsProxySortingTests
Assert.Equal(["Avatar", "A Beautiful Mind", "The Matrix"], ItemNames(root)); 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] [Fact]
public void ApplyPadsNumbersDuringTitleSort() public void ApplyPadsNumbersDuringTitleSort()
{ {
@@ -29,6 +29,7 @@ public sealed class ItemsProxyTransformerResolutionTests
Assert.True(result.Change); Assert.True(result.Change);
Assert.Equal("Finnish overview", result.Value); Assert.Equal("Finnish overview", result.Value);
Assert.Equal("fi", result.Language);
} }
[Fact] [Fact]
@@ -37,11 +38,12 @@ public sealed class ItemsProxyTransformerResolutionTests
var result = ItemsProxyTransformer.ResolveField( var result = ItemsProxyTransformer.ResolveField(
TitleField, TitleField,
[OriginalAction, LanguagePrefix + "en"], [OriginalAction, LanguagePrefix + "en"],
Facts(originalTitle: "Original title"), Facts(originalTitle: "Original title", originalLanguage: "en"),
TextTranslations(("en", TitleField, "English title"))); TextTranslations(("en", TitleField, "English title")));
Assert.True(result.Change); Assert.True(result.Change);
Assert.Equal("Original title", result.Value); Assert.Equal("Original title", result.Value);
Assert.Equal("en", result.Language);
} }
[Fact] [Fact]
@@ -55,6 +57,7 @@ public sealed class ItemsProxyTransformerResolutionTests
Assert.False(result.Change); Assert.False(result.Change);
Assert.Null(result.Value); Assert.Null(result.Value);
Assert.Null(result.Language);
} }
[Fact] [Fact]
@@ -145,13 +148,13 @@ public sealed class ItemsProxyTransformerResolutionTests
return result; return result;
} }
private static FactsData Facts(string originalTitle = "") private static FactsData Facts(string originalTitle = "", string originalLanguage = "")
=> new( => new(
"item", "item",
"tmdb", "tmdb",
"movie", "movie",
originalTitle, originalTitle,
"", originalLanguage,
"", "",
"[]", "[]",
"[]", "[]",