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 { /// /// Class LocalizationManager. /// 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 _bcp47ToJellyfinMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["es-419"] = "es_419", ["es-DO"] = "es_DO", ["ur-PK"] = "ur_PK" }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); private readonly IServerConfigurationManager _configurationManager; private readonly ILogger _logger; private readonly Dictionary> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly ConcurrentDictionary _cultureCache = new(StringComparer.OrdinalIgnoreCase); private List _cultures = []; private static readonly IReadOnlyList _localizationOptions = BuildLocalizationOptions(); private FrozenDictionary _iso6392BtoT = null!; /// /// Initializes a new instance of the class. /// /// The configuration manager. /// The logger. public LocalizationManager( IServerConfigurationManager configurationManager, ILogger logger) { _configurationManager = configurationManager; _logger = logger; _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; } /// /// Gets the supported UI cultures. /// /// A list of objects covering every embedded translation. public static IList GetSupportedUICultures() { var cultures = new List(); 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); } } } /// /// Loads all resources into memory. /// /// . 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(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); var dict = new Dictionary(); 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); } /// /// Gets the cultures. /// /// . public IEnumerable GetCultures() => _cultures; private async Task LoadCultures() { List list = []; Dictionary iso6392BtoTdict = new Dictionary(); 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); } } /// 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); } /// public IReadOnlyList GetCountries() { using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); return JsonSerializer.Deserialize>(stream, _jsonOptions) ?? []; } /// public IReadOnlyList 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)]; } /// /// Gets the parental ratings dictionary. /// /// The optional two letter ISO language string. /// . private Dictionary? 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; } /// 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; } /// public string GetLocalizedString(string phrase) { return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name); } /// public string GetServerLocalizedString(string phrase) { return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture); } /// 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 GetLocalizationDictionary(string culture) { ArgumentException.ThrowIfNullOrEmpty(culture); return _cultureOnlyDictionaries.GetOrAdd( culture, static (key, localizationManager) => { var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); var namespaceName = localizationManager.GetType().Namespace + ".Core"; localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter().GetResult(); return dictionary; }, this); } private async Task CopyInto(IDictionary 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>(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"; } /// public IEnumerable GetLocalizationOptions() { return _localizationOptions; } private static IReadOnlyList BuildLocalizationOptions() { var options = new List(); 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; } } /// 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; } } }