3 Commits
Author SHA1 Message Date
ajp_anton a97e2bb37f Release version 0.2.2 2026-07-19 01:40:29 +00:00
ajp_anton 6a81debc8c Add language-aware article sorting 2026-07-19 01:35:02 +00:00
ajp_anton 6c33dc1d69 Release version 0.2.1 2026-07-16 02:00:57 +00:00
10 changed files with 243 additions and 41 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
Multilang is a Jellyfin plugin that lets different users see different metadata Multilang is a Jellyfin plugin that lets different users see different metadata
languages without changing Jellyfin's stored metadata. languages without changing Jellyfin's stored metadata.
The plugin is built for Jellyfin 10.11.9 and is currently early beta software. The plugin is built for the Jellyfin 10.11.x stable series on Linux x64 and is
currently early beta software.
## What It Does ## What It Does
@@ -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;
} }
@@ -3,12 +3,13 @@
<PropertyGroup> <PropertyGroup>
<RootNamespace>Jellyfin.Plugin.Multilang</RootNamespace> <RootNamespace>Jellyfin.Plugin.Multilang</RootNamespace>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<Version>0.2.0</Version> <Version>0.2.2</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion> <AssemblyVersion>0.2.2.0</AssemblyVersion>
<FileVersion>0.2.0.0</FileVersion> <FileVersion>0.2.2.0</FileVersion>
<Authors>ajp_anton</Authors> <Authors>ajp_anton</Authors>
</PropertyGroup> </PropertyGroup>
@@ -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,
"", "",
"[]", "[]",
"[]", "[]",