Add Accept-Language header support for per-request localization

This commit is contained in:
Shadowghost
2026-05-04 20:26:39 +02:00
parent e9942c3857
commit 4be3f5f1f9
28 changed files with 571 additions and 149 deletions

View File

@@ -3,10 +3,12 @@ using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
@@ -26,21 +28,36 @@ namespace Emby.Server.Implementations.Localization
private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings.";
private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt";
private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json";
private const string CoreResourcePrefix = "Emby.Server.Implementations.Localization.Core.";
private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly;
private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"];
/// <summary>
/// Gets the mapping from BCP-47 hyphenated culture codes to Jellyfin's underscore-based codes.
/// </summary>
public static readonly FrozenDictionary<string, string> Bcp47ToJellyfinMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["es-419"] = "es_419",
["es-DO"] = "es_DO",
["ur-PK"] = "ur_PK"
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
private readonly IServerConfigurationManager _configurationManager;
private readonly ILogger<LocalizationManager> _logger;
private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new(StringComparer.OrdinalIgnoreCase);
private static readonly AsyncLocal<IReadOnlyList<string>?> _requestCultureFallback = new();
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase);
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
private List<CultureDto> _cultures = [];
private static readonly IReadOnlyList<LocalizationOption> _localizationOptions = BuildLocalizationOptions();
private FrozenDictionary<string, string> _iso6392BtoT = null!;
/// <summary>
@@ -54,6 +71,41 @@ namespace Emby.Server.Implementations.Localization
{
_configurationManager = configurationManager;
_logger = logger;
_configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
}
/// <summary>
/// Gets or sets the per-request culture fallback chain resolved from Accept-Language.
/// Each entry is a Jellyfin culture code (e.g. "de", "nl", "en-US") in priority order.
/// </summary>
public static IReadOnlyList<string>? RequestCultureFallback
{
get => _requestCultureFallback.Value;
set => _requestCultureFallback.Value = value;
}
/// <summary>
/// Checks whether a translation resource file exists for the given culture code.
/// </summary>
/// <param name="culture">The culture code to check (e.g. "de", "pt-BR", "es_419").</param>
/// <returns><c>true</c> if an embedded translation resource exists for the culture.</returns>
public static bool HasTranslation(string culture)
{
var resourceName = CoreResourcePrefix + GetResourceFilename(culture);
return _assembly.GetManifestResourceInfo(resourceName) is not null;
}
private static void OnConfigurationUpdated(object? sender, EventArgs e)
{
if (sender is IServerConfigurationManager configManager)
{
var uiCulture = configManager.Configuration.UICulture;
if (!string.IsNullOrEmpty(uiCulture))
{
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture);
}
}
}
/// <summary>
@@ -419,6 +471,27 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc />
public string GetLocalizedString(string phrase)
{
var fallback = _requestCultureFallback.Value;
if (fallback is not null)
{
foreach (var culture in fallback)
{
var dict = GetLocalizationDictionary(culture);
if (dict.TryGetValue(phrase, out var value))
{
return value;
}
}
return phrase;
}
return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
}
/// <inheritdoc />
public string GetServerLocalizedString(string phrase)
{
return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
}
@@ -436,6 +509,12 @@ namespace Emby.Server.Implementations.Localization
culture = DefaultCulture;
}
// Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
if (Bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
{
culture = mapped;
}
var dictionary = GetLocalizationDictionary(culture);
if (dictionary.TryGetValue(phrase, out var value))
@@ -443,6 +522,15 @@ namespace Emby.Server.Implementations.Localization
return value;
}
if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
{
var fallback = GetLocalizationDictionary(DefaultCulture);
if (fallback.TryGetValue(phrase, out var fallbackValue))
{
return fallbackValue;
}
}
return phrase;
}
@@ -450,28 +538,19 @@ namespace Emby.Server.Implementations.Localization
{
ArgumentException.ThrowIfNullOrEmpty(culture);
const string Prefix = "Core";
return _dictionaries.GetOrAdd(
return _cultureOnlyDictionaries.GetOrAdd(
culture,
static (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(),
static (key, localizationManager) =>
{
var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var namespaceName = localizationManager.GetType().Namespace + ".Core";
localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter().GetResult();
return dictionary;
},
this);
}
private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename)
{
ArgumentException.ThrowIfNullOrEmpty(culture);
var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var namespaceName = GetType().Namespace + "." + prefix;
await CopyInto(dictionary, namespaceName + "." + baseFilename).ConfigureAwait(false);
await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)).ConfigureAwait(false);
return dictionary;
}
private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
{
using var stream = _assembly.GetManifestResourceStream(resourcePath);
@@ -508,77 +587,68 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc />
public IEnumerable<LocalizationOption> GetLocalizationOptions()
{
yield return new LocalizationOption("Afrikaans", "af");
yield return new LocalizationOption("العربية", "ar");
yield return new LocalizationOption("Беларуская", "be");
yield return new LocalizationOption("Български", "bg-BG");
yield return new LocalizationOption("বাংলা (বাংলাদেশ)", "bn");
yield return new LocalizationOption("Català", "ca");
yield return new LocalizationOption("Čeština", "cs");
yield return new LocalizationOption("Cymraeg", "cy");
yield return new LocalizationOption("Dansk", "da");
yield return new LocalizationOption("Deutsch", "de");
yield return new LocalizationOption("English (United Kingdom)", "en-GB");
yield return new LocalizationOption("English", "en-US");
yield return new LocalizationOption("Ελληνικά", "el");
yield return new LocalizationOption("Esperanto", "eo");
yield return new LocalizationOption("Español", "es");
yield return new LocalizationOption("Español americano", "es_419");
yield return new LocalizationOption("Español (Argentina)", "es-AR");
yield return new LocalizationOption("Español (Dominicana)", "es_DO");
yield return new LocalizationOption("Español (México)", "es-MX");
yield return new LocalizationOption("Eesti", "et");
yield return new LocalizationOption("Basque", "eu");
yield return new LocalizationOption("فارسی", "fa");
yield return new LocalizationOption("Suomi", "fi");
yield return new LocalizationOption("Filipino", "fil");
yield return new LocalizationOption("Français", "fr");
yield return new LocalizationOption("Français (Canada)", "fr-CA");
yield return new LocalizationOption("Galego", "gl");
yield return new LocalizationOption("Schwiizerdütsch", "gsw");
yield return new LocalizationOption("עִבְרִית", "he");
yield return new LocalizationOption("हिन्दी", "hi");
yield return new LocalizationOption("Hrvatski", "hr");
yield return new LocalizationOption("Magyar", "hu");
yield return new LocalizationOption("Bahasa Indonesia", "id");
yield return new LocalizationOption("Íslenska", "is");
yield return new LocalizationOption("Italiano", "it");
yield return new LocalizationOption("日本語", "ja");
yield return new LocalizationOption("Qazaqşa", "kk");
yield return new LocalizationOption("한국어", "ko");
yield return new LocalizationOption("Lietuvių", "lt");
yield return new LocalizationOption("Latviešu", "lv");
yield return new LocalizationOption("Македонски", "mk");
yield return new LocalizationOption("മലയാളം", "ml");
yield return new LocalizationOption("मराठी", "mr");
yield return new LocalizationOption("Bahasa Melayu", "ms");
yield return new LocalizationOption("Norsk bokmål", "nb");
yield return new LocalizationOption("नेपाली", "ne");
yield return new LocalizationOption("Nederlands", "nl");
yield return new LocalizationOption("Norsk nynorsk", "nn");
yield return new LocalizationOption("ਪੰਜਾਬੀ", "pa");
yield return new LocalizationOption("Polski", "pl");
yield return new LocalizationOption("Pirate", "pr");
yield return new LocalizationOption("Português", "pt");
yield return new LocalizationOption("Português (Brasil)", "pt-BR");
yield return new LocalizationOption("Português (Portugal)", "pt-PT");
yield return new LocalizationOption("Românește", "ro");
yield return new LocalizationOption("Русский", "ru");
yield return new LocalizationOption("Slovenčina", "sk");
yield return new LocalizationOption("Slovenščina", "sl-SI");
yield return new LocalizationOption("Shqip", "sq");
yield return new LocalizationOption("Српски", "sr");
yield return new LocalizationOption("Svenska", "sv");
yield return new LocalizationOption("தமிழ்", "ta");
yield return new LocalizationOption("తెలుగు", "te");
yield return new LocalizationOption("ภาษาไทย", "th");
yield return new LocalizationOption("Türkçe", "tr");
yield return new LocalizationOption("Українська", "uk");
yield return new LocalizationOption("اُردُو", "ur_PK");
yield return new LocalizationOption("Tiếng Việt", "vi");
yield return new LocalizationOption("汉语 (简体字)", "zh-CN");
yield return new LocalizationOption("漢語 (繁體字)", "zh-TW");
yield return new LocalizationOption("廣東話 (香港)", "zh-HK");
return _localizationOptions;
}
private static IReadOnlyList<LocalizationOption> BuildLocalizationOptions()
{
var options = new List<LocalizationOption>();
var prefix = CoreResourcePrefix;
foreach (var resource in _assembly.GetManifestResourceNames())
{
if (!resource.StartsWith(prefix, StringComparison.Ordinal)
|| !resource.EndsWith(".json", StringComparison.Ordinal))
{
continue;
}
// Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR"
var code = resource[prefix.Length..^5];
// Skip the base language file — en-US is added explicitly below
if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var displayName = GetDisplayName(code);
options.Add(new LocalizationOption(displayName, code));
}
// Ensure en-US is always present
options.Add(new LocalizationOption("English", DefaultCulture));
options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
return options;
}
private static string GetDisplayName(string cultureCode)
{
// Handle Jellyfin-specific codes that aren't valid CultureInfo names
if (Bcp47ToJellyfinMap.Values.Contains(cultureCode))
{
// Convert underscore to hyphen for CultureInfo lookup
var normalized = cultureCode.Replace('_', '-');
try
{
return CultureInfo.GetCultureInfo(normalized).NativeName;
}
catch (CultureNotFoundException)
{
return cultureCode;
}
}
try
{
return CultureInfo.GetCultureInfo(cultureCode).NativeName;
}
catch (CultureNotFoundException)
{
// Custom/novelty codes like "pr" (Pirate) — fall back to code itself
return cultureCode;
}
}
/// <inheritdoc />