Files
jellyfin/Emby.Server.Implementations/Localization/LocalizationManager.cs
2026-05-12 23:18:38 +02:00

661 lines
25 KiB
C#

using System;
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.Tasks;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Localization
{
/// <summary>
/// Class LocalizationManager.
/// </summary>
public class LocalizationManager : ILocalizationManager
{
private const string DefaultCulture = "en-US";
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"];
// Maps BCP-47 hyphenated culture codes (set by ASP.NET Core's RequestLocalizationMiddleware
// and used as CurrentUICulture.Name) to Jellyfin's underscore-based resource file codes.
private 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>> _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>
/// Initializes a new instance of the <see cref="LocalizationManager" /> class.
/// </summary>
/// <param name="configurationManager">The configuration manager.</param>
/// <param name="logger">The logger.</param>
public LocalizationManager(
IServerConfigurationManager configurationManager,
ILogger<LocalizationManager> logger)
{
_configurationManager = configurationManager;
_logger = logger;
_configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
}
/// <summary>
/// Gets the supported UI cultures.
/// </summary>
/// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns>
public static IList<CultureInfo> GetSupportedUICultures()
{
var cultures = new List<CultureInfo>();
foreach (var option in _localizationOptions)
{
// Resource files use underscores for some variants (e.g. es_419);
// CultureInfo only accepts hyphenated BCP-47 codes.
var code = option.Value.Replace('_', '-');
try
{
cultures.Add(CultureInfo.GetCultureInfo(code));
}
catch (CultureNotFoundException)
{
// Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
}
}
return cultures;
}
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>
/// Loads all resources into memory.
/// </summary>
/// <returns><see cref="Task" />.</returns>
public async Task LoadAll()
{
// Extract from the assembly
foreach (var resource in _assembly.GetManifestResourceNames())
{
if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal))
{
continue;
}
using var stream = _assembly.GetManifestResourceStream(resource);
if (stream is not null)
{
var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions).ConfigureAwait(false)
?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
var dict = new Dictionary<string, ParentalRatingScore?>();
if (ratingSystem.Ratings is not null)
{
foreach (var ratingEntry in ratingSystem.Ratings)
{
foreach (var ratingString in ratingEntry.RatingStrings)
{
dict[ratingString] = ratingEntry.RatingScore;
}
}
_allParentalRatings[ratingSystem.CountryCode] = dict;
}
}
}
await LoadCultures().ConfigureAwait(false);
}
/// <summary>
/// Gets the cultures.
/// </summary>
/// <returns><see cref="IEnumerable{CultureDto}" />.</returns>
public IEnumerable<CultureDto> GetCultures()
=> _cultures;
private async Task LoadCultures()
{
List<CultureDto> list = [];
Dictionary<string, string> iso6392BtoTdict = new Dictionary<string, string>();
using var stream = _assembly.GetManifestResourceStream(CulturesPath);
if (stream is null)
{
throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'");
}
else
{
using var reader = new StreamReader(stream);
await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var parts = line.Split('|');
if (parts.Length != 5)
{
throw new InvalidDataException($"Invalid culture data found at: '{line}'");
}
string name = parts[3];
string displayname = parts[3];
if (string.IsNullOrWhiteSpace(displayname))
{
continue;
}
string twoCharName = parts[2];
if (string.IsNullOrWhiteSpace(twoCharName))
{
twoCharName = string.Empty;
}
else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
{
name = twoCharName;
}
string[] threeLetterNames;
if (string.IsNullOrWhiteSpace(parts[1]))
{
threeLetterNames = [parts[0]];
}
else
{
threeLetterNames = [parts[0], parts[1]];
// In cases where there are two TLN the first one is ISO 639-2/T and the second one is ISO 639-2/B
// We need ISO 639-2/T for the .NET cultures so we cultivate a dictionary for the translation B->T
iso6392BtoTdict.TryAdd(parts[1], parts[0]);
}
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
}
_cultureCache.Clear();
_cultures = list;
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
}
/// <inheritdoc />
public CultureDto? FindLanguageInfo(string language)
{
if (string.IsNullOrEmpty(language))
{
return null;
}
return _cultureCache.GetOrAdd(
language,
static (lang, cultures) =>
{
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
for (var i = 0; i < cultures.Count; i++)
{
var culture = cultures[i];
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{
return culture;
}
}
return null;
},
_cultures);
}
/// <inheritdoc />
public IReadOnlyList<CountryInfo> GetCountries()
{
using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
return JsonSerializer.Deserialize<IReadOnlyList<CountryInfo>>(stream, _jsonOptions) ?? [];
}
/// <inheritdoc />
public IReadOnlyList<ParentalRating> GetParentalRatings()
{
// Use server default language for ratings
// Fall back to empty list if there are no parental ratings for that language
var ratings = GetParentalRatingsDictionary()?.Select(x => new ParentalRating(x.Key, x.Value)).ToList() ?? [];
// Add common ratings to ensure them being available for selection
// Based on the US rating system due to it being the main source of rating in the metadata providers
// Unrated
if (!ratings.Any(x => x is null))
{
ratings.Add(new("Unrated", null));
}
// Minimum rating possible
if (ratings.All(x => x.RatingScore?.Score != 0))
{
ratings.Add(new("Approved", new(0, null)));
}
// Matches PG (this has different age restrictions depending on country)
if (ratings.All(x => x.RatingScore?.Score != 10))
{
ratings.Add(new("10", new(10, null)));
}
// Matches PG-13
if (ratings.All(x => x.RatingScore?.Score != 13))
{
ratings.Add(new("13", new(13, null)));
}
// Matches TV-14
if (ratings.All(x => x.RatingScore?.Score != 14))
{
ratings.Add(new("14", new(14, null)));
}
// Catchall if max rating of country is less than 21
// Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned
if (!ratings.Any(x => x.RatingScore?.Score >= 21))
{
ratings.Add(new ParentalRating("21", new(21, null)));
}
// A lot of countries don't explicitly have a separate rating for adult content
if (ratings.All(x => x.RatingScore?.Score != 1000))
{
ratings.Add(new ParentalRating("XXX", new(1000, null)));
}
// A lot of countries don't explicitly have a separate rating for banned content
if (ratings.All(x => x.RatingScore?.Score != 1001))
{
ratings.Add(new ParentalRating("Banned", new(1001, null)));
}
return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)];
}
/// <summary>
/// Gets the parental ratings dictionary.
/// </summary>
/// <param name="countryCode">The optional two letter ISO language string.</param>
/// <returns><see cref="Dictionary{String, ParentalRatingScore}" />.</returns>
private Dictionary<string, ParentalRatingScore?>? GetParentalRatingsDictionary(string? countryCode = null)
{
// Fallback to server default if no country code is specified.
if (string.IsNullOrEmpty(countryCode))
{
countryCode = _configurationManager.Configuration.MetadataCountryCode;
}
if (_allParentalRatings.TryGetValue(countryCode, out var countryValue))
{
return countryValue;
}
return null;
}
/// <inheritdoc />
public ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null)
{
ArgumentException.ThrowIfNullOrEmpty(rating);
// Handle unrated content
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age)
if (int.TryParse(rating, out var ratingAge))
{
return new(ratingAge, null);
}
// Fairly common for some users to have "Rated R" in their rating field
rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
// Use rating system matching the language
if (!string.IsNullOrEmpty(countryCode))
{
var ratingsDictionary = GetParentalRatingsDictionary(countryCode);
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
{
return value;
}
if (ratingsDictionary is not null && rating.Length > countryCode.Length
&& rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
&& (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
&& ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue))
{
return normalizedValue;
}
}
else
{
// Fall back to server default language for ratings check
var ratingsDictionary = GetParentalRatingsDictionary();
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
{
return value;
}
}
// If we don't find anything, check all ratings systems, starting with US
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
{
return usValue;
}
foreach (var dictionary in _allParentalRatings.Values)
{
if (dictionary.TryGetValue(rating, out var value))
{
return value;
}
}
// Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
if (TryGetRatingScoreBySeparator(rating, ':', out var result)
|| TryGetRatingScoreBySeparator(rating, '-', out result))
{
return result;
}
return null;
}
private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
{
result = null;
if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
{
return false;
}
var ratingSpan = rating.AsSpan();
var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
if (ratingPart.Length == 0)
{
return false;
}
string? resolvedCountryCode = null;
if (_allParentalRatings.ContainsKey(countryPart))
{
resolvedCountryCode = countryPart;
}
else
{
var culture = FindLanguageInfo(countryPart);
if (culture is not null)
{
resolvedCountryCode = culture.TwoLetterISOLanguageName;
}
}
if (resolvedCountryCode is not null
&& _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
{
if (countryRatings.TryGetValue(ratingPart, out result))
{
return true;
}
_logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating,
resolvedCountryCode);
return true;
}
// Country not identified or no rating data available, try recursive lookup
result = GetRatingScore(ratingPart, resolvedCountryCode);
return true;
}
/// <inheritdoc />
public string GetLocalizedString(string phrase)
{
return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
}
/// <inheritdoc />
public string GetServerLocalizedString(string phrase)
{
return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
}
/// <inheritdoc />
public string GetLocalizedString(string phrase, string culture)
{
if (string.IsNullOrEmpty(culture))
{
culture = _configurationManager.Configuration.UICulture;
}
if (string.IsNullOrEmpty(culture))
{
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))
{
return value;
}
if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
{
var fallback = GetLocalizationDictionary(DefaultCulture);
if (fallback.TryGetValue(phrase, out var fallbackValue))
{
return fallbackValue;
}
}
return phrase;
}
private Dictionary<string, string> GetLocalizationDictionary(string culture)
{
ArgumentException.ThrowIfNullOrEmpty(culture);
return _cultureOnlyDictionaries.GetOrAdd(
culture,
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 CopyInto(IDictionary<string, string> dictionary, string resourcePath)
{
using var stream = _assembly.GetManifestResourceStream(resourcePath);
// If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain
if (stream is null)
{
_logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath);
return;
}
var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Resource contains invalid data: '{stream}'");
foreach (var key in dict.Keys)
{
dictionary[key] = dict[key];
}
}
private static string GetResourceFilename(string culture)
{
var parts = culture.Split('-');
if (parts.Length == 2)
{
culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
}
else
{
culture = culture.ToLowerInvariant();
}
return culture + ".json";
}
/// <inheritdoc />
public IEnumerable<LocalizationOption> GetLocalizationOptions()
{
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 />
public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT)
{
// Unlikely case the dictionary is not (yet) initialized properly
if (_iso6392BtoT is null)
{
isoT = null;
return false;
}
var result = _iso6392BtoT.TryGetValue(isoB, out isoT) && !string.IsNullOrEmpty(isoT);
// Ensure the ISO code being null if the result is false
if (!result)
{
isoT = null;
}
return result;
}
}
}