Add language-aware article sorting
This commit is contained in:
@@ -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)
|
||||
@@ -1432,6 +1445,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; }
|
||||
@@ -1453,6 +1470,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,
|
||||
@@ -1475,6 +1494,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,
|
||||
"",
|
||||
"[]",
|
||||
"[]",
|
||||
|
||||
Reference in New Issue
Block a user