< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Localization.LocalizationManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Localization/LocalizationManager.cs
Line coverage
91%
Covered lines: 225
Uncovered lines: 21
Coverable lines: 246
Total lines: 658
Line coverage: 91.4%
Branch coverage
77%
Covered branches: 123
Total branches: 158
Branch coverage: 77.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 2/13/2026 - 12:11:21 AM Line coverage: 95.7% (113/118) Branch coverage: 88.1% (67/76) Total lines: 5624/19/2026 - 12:14:27 AM Line coverage: 68.2% (170/249) Branch coverage: 88.3% (99/112) Total lines: 5625/7/2026 - 12:15:44 AM Line coverage: 67.2% (183/272) Branch coverage: 80.3% (106/132) Total lines: 6055/15/2026 - 12:15:55 AM Line coverage: 91.4% (225/246) Branch coverage: 82.9% (131/158) Total lines: 6585/20/2026 - 12:15:44 AM Line coverage: 91.4% (225/246) Branch coverage: 77.8% (123/158) Total lines: 658 2/13/2026 - 12:11:21 AM Line coverage: 95.7% (113/118) Branch coverage: 88.1% (67/76) Total lines: 5624/19/2026 - 12:14:27 AM Line coverage: 68.2% (170/249) Branch coverage: 88.3% (99/112) Total lines: 5625/7/2026 - 12:15:44 AM Line coverage: 67.2% (183/272) Branch coverage: 80.3% (106/132) Total lines: 6055/15/2026 - 12:15:55 AM Line coverage: 91.4% (225/246) Branch coverage: 82.9% (131/158) Total lines: 6585/20/2026 - 12:15:44 AM Line coverage: 91.4% (225/246) Branch coverage: 77.8% (123/158) Total lines: 658

Coverage delta

Coverage delta 28 -28

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetSupportedUICultures()100%44100%
TryGetCultureInfo(...)100%1150%
OnConfigurationUpdated(...)50%44100%
LoadAll()92.85%1414100%
GetCultures()100%11100%
LoadCultures()87.5%161693.1%
FindLanguageInfo(...)50%2295.45%
GetCountries()50%44100%
GetParentalRatings()65%202094.44%
GetParentalRatingsDictionary(...)75%44100%
GetRatingScore(...)68.42%633874.19%
TryGetRatingScoreBySeparator(...)92.85%141496%
GetLocalizedString(...)100%11100%
GetServerLocalizedString(...)100%11100%
GetLocalizedString(...)83.33%121285.71%
GetLocalizationDictionary(...)100%11100%
CopyInto()83.33%66100%
GetResourceFilename(...)100%22100%
GetLocalizationOptions()100%210%
BuildLocalizationData()90%1010100%
GetDisplayName(...)50%22100%
TryGetISO6392TFromB(...)83.33%7671.42%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/Localization/LocalizationManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Frozen;
 4using System.Collections.Generic;
 5using System.Diagnostics.CodeAnalysis;
 6using System.Globalization;
 7using System.IO;
 8using System.Linq;
 9using System.Reflection;
 10using System.Text.Json;
 11using System.Threading.Tasks;
 12using Jellyfin.Extensions;
 13using Jellyfin.Extensions.Json;
 14using MediaBrowser.Controller.Configuration;
 15using MediaBrowser.Model.Entities;
 16using MediaBrowser.Model.Globalization;
 17using Microsoft.Extensions.Logging;
 18
 19namespace Emby.Server.Implementations.Localization
 20{
 21    /// <summary>
 22    /// Class LocalizationManager.
 23    /// </summary>
 24    public class LocalizationManager : ILocalizationManager
 25    {
 26        private const string DefaultCulture = "en-US";
 27        private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings.";
 28        private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt";
 29        private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json";
 30        private const string CoreResourcePrefix = "Emby.Server.Implementations.Localization.Core.";
 231        private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly;
 232        private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"];
 33
 34        private readonly IServerConfigurationManager _configurationManager;
 35        private readonly ILogger<LocalizationManager> _logger;
 36
 7237        private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringCo
 38
 7239        private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringC
 40
 7241        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 42
 7243        private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase)
 7244        private List<CultureDto> _cultures = [];
 45
 246        private static readonly (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJell
 247        private static readonly IReadOnlyList<LocalizationOption> _localizationOptions = _localizationData.Options;
 48
 49        // Maps BCP-47 hyphenated culture codes (set by ASP.NET Core's RequestLocalizationMiddleware
 50        // and used as CurrentUICulture.Name) to Jellyfin's underscore-based resource file codes.
 51        // Built reflexively from the resource file scan so both directions stay in sync.
 252        private static readonly FrozenDictionary<string, string> _bcp47ToJellyfinMap = _localizationData.Bcp47ToJellyfin
 53
 54        private FrozenDictionary<string, string> _iso6392BtoT = null!;
 55
 56        /// <summary>
 57        /// Initializes a new instance of the <see cref="LocalizationManager" /> class.
 58        /// </summary>
 59        /// <param name="configurationManager">The configuration manager.</param>
 60        /// <param name="logger">The logger.</param>
 61        public LocalizationManager(
 62            IServerConfigurationManager configurationManager,
 63            ILogger<LocalizationManager> logger)
 64        {
 7265            _configurationManager = configurationManager;
 7266            _logger = logger;
 67
 7268            _configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
 7269        }
 70
 71        /// <summary>
 72        /// Gets the supported UI cultures.
 73        /// </summary>
 74        /// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns>
 75        public static IList<CultureInfo> GetSupportedUICultures()
 76        {
 2277            var cultures = new List<CultureInfo>();
 462078            foreach (var option in _localizationOptions)
 79            {
 80                // Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
 228881                if (TryGetCultureInfo(option.Value, out var cultureInfo))
 82                {
 228883                    cultures.Add(cultureInfo);
 84                }
 85            }
 86
 2287            return cultures;
 88        }
 89
 90        /// <summary>
 91        /// Resolves a Jellyfin resource culture code (which may use underscores, e.g. <c>es_419</c>)
 92        /// to a <see cref="CultureInfo"/>. Returns <see langword="false"/> for codes .NET cannot resolve.
 93        /// </summary>
 94        private static bool TryGetCultureInfo(string cultureCode, [NotNullWhen(true)] out CultureInfo? cultureInfo)
 95        {
 96            try
 97            {
 98                // Resource files use underscores for some variants (e.g. es_419);
 99                // CultureInfo only accepts hyphenated BCP-47 codes.
 2494100                cultureInfo = CultureInfo.GetCultureInfo(cultureCode.Replace('_', '-'));
 2494101                return true;
 102            }
 0103            catch (CultureNotFoundException)
 104            {
 0105                cultureInfo = null;
 0106                return false;
 107            }
 2494108        }
 109
 110        private static void OnConfigurationUpdated(object? sender, EventArgs e)
 111        {
 101112            if (sender is IServerConfigurationManager configManager)
 113            {
 101114                var uiCulture = configManager.Configuration.UICulture;
 101115                if (!string.IsNullOrEmpty(uiCulture))
 116                {
 101117                    CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture);
 118                }
 119            }
 101120        }
 121
 122        /// <summary>
 123        /// Loads all resources into memory.
 124        /// </summary>
 125        /// <returns><see cref="Task" />.</returns>
 126        public async Task LoadAll()
 127        {
 128            // Extract from the assembly
 19152129            foreach (var resource in _assembly.GetManifestResourceNames())
 130            {
 9513131                if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal))
 132                {
 133                    continue;
 134                }
 135
 2835136                using var stream = _assembly.GetManifestResourceStream(resource);
 2835137                if (stream is not null)
 138                {
 2835139                    var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions)
 2835140                                ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
 141
 2835142                    var dict = new Dictionary<string, ParentalRatingScore?>();
 2835143                    if (ratingSystem.Ratings is not null)
 144                    {
 44100145                        foreach (var ratingEntry in ratingSystem.Ratings)
 146                        {
 99036147                            foreach (var ratingString in ratingEntry.RatingStrings)
 148                            {
 30303149                                dict[ratingString] = ratingEntry.RatingScore;
 150                            }
 151                        }
 152
 2835153                        _allParentalRatings[ratingSystem.CountryCode] = dict;
 154                    }
 155                }
 2835156            }
 157
 63158            await LoadCultures().ConfigureAwait(false);
 63159        }
 160
 161        /// <summary>
 162        /// Gets the cultures.
 163        /// </summary>
 164        /// <returns><see cref="IEnumerable{CultureDto}" />.</returns>
 165        public IEnumerable<CultureDto> GetCultures()
 1166            => _cultures;
 167
 168        private async Task LoadCultures()
 169        {
 63170            List<CultureDto> list = [];
 63171            Dictionary<string, string> iso6392BtoTdict = new Dictionary<string, string>();
 172
 63173            using var stream = _assembly.GetManifestResourceStream(CulturesPath);
 63174            if (stream is null)
 175            {
 0176                throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'");
 177            }
 178            else
 179            {
 63180                using var reader = new StreamReader(stream);
 62622181                await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
 182                {
 31248183                    if (string.IsNullOrWhiteSpace(line))
 184                    {
 185                        continue;
 186                    }
 187
 31248188                    var parts = line.Split('|');
 31248189                    if (parts.Length != 5)
 190                    {
 0191                        throw new InvalidDataException($"Invalid culture data found at: '{line}'");
 192                    }
 193
 31248194                    string name = parts[3];
 31248195                    string displayname = parts[3];
 31248196                    if (string.IsNullOrWhiteSpace(displayname))
 197                    {
 198                        continue;
 199                    }
 200
 31248201                    string twoCharName = parts[2];
 31248202                    if (string.IsNullOrWhiteSpace(twoCharName))
 203                    {
 19026204                        twoCharName = string.Empty;
 205                    }
 12222206                    else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
 207                    {
 567208                        name = twoCharName;
 209                    }
 210
 211                    string[] threeLetterNames;
 31248212                    if (string.IsNullOrWhiteSpace(parts[1]))
 213                    {
 29610214                        threeLetterNames = [parts[0]];
 215                    }
 216                    else
 217                    {
 1638218                        threeLetterNames = [parts[0], parts[1]];
 219
 220                        // In cases where there are two TLN the first one is ISO 639-2/T and the second one is ISO 639-2
 221                        // We need ISO 639-2/T for the .NET cultures so we cultivate a dictionary for the translation B-
 1638222                        iso6392BtoTdict.TryAdd(parts[1], parts[0]);
 223                    }
 224
 31248225                    list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
 226                }
 227
 63228                _cultureCache.Clear();
 63229                _cultures = list;
 63230                _iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
 63231            }
 63232        }
 233
 234        /// <inheritdoc />
 235        public CultureDto? FindLanguageInfo(string language)
 236        {
 9237            if (string.IsNullOrEmpty(language))
 238            {
 0239                return null;
 240            }
 241
 9242            return _cultureCache.GetOrAdd(
 9243                language,
 9244                static (lang, cultures) =>
 9245                {
 9246                    // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
 9247                    for (var i = 0; i < cultures.Count; i++)
 9248                    {
 9249                        var culture = cultures[i];
 9250                        if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
 9251                            || lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
 9252                            || culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
 9253                            || lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
 9254                        {
 9255                            return culture;
 9256                        }
 9257                    }
 9258
 9259                    return null;
 9260                },
 9261                _cultures);
 262        }
 263
 264        /// <inheritdoc />
 265        public IReadOnlyList<CountryInfo> GetCountries()
 266        {
 1267            using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException
 268
 1269            return JsonSerializer.Deserialize<IReadOnlyList<CountryInfo>>(stream, _jsonOptions) ?? [];
 1270        }
 271
 272        /// <inheritdoc />
 273        public IReadOnlyList<ParentalRating> GetParentalRatings()
 274        {
 275            // Use server default language for ratings
 276            // Fall back to empty list if there are no parental ratings for that language
 2277            var ratings = GetParentalRatingsDictionary()?.Select(x => new ParentalRating(x.Key, x.Value)).ToList() ?? []
 278
 279            // Add common ratings to ensure them being available for selection
 280            // Based on the US rating system due to it being the main source of rating in the metadata providers
 281            // Unrated
 2282            if (!ratings.Any(x => x is null))
 283            {
 2284                ratings.Add(new("Unrated", null));
 285            }
 286
 287            // Minimum rating possible
 2288            if (ratings.All(x => x.RatingScore?.Score != 0))
 289            {
 0290                ratings.Add(new("Approved", new(0, null)));
 291            }
 292
 293            // Matches PG (this has different age restrictions depending on country)
 2294            if (ratings.All(x => x.RatingScore?.Score != 10))
 295            {
 1296                ratings.Add(new("10", new(10, null)));
 297            }
 298
 299            // Matches PG-13
 2300            if (ratings.All(x => x.RatingScore?.Score != 13))
 301            {
 1302                ratings.Add(new("13", new(13, null)));
 303            }
 304
 305            // Matches TV-14
 2306            if (ratings.All(x => x.RatingScore?.Score != 14))
 307            {
 1308                ratings.Add(new("14", new(14, null)));
 309            }
 310
 311            // Catchall if max rating of country is less than 21
 312            // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned
 2313            if (!ratings.Any(x => x.RatingScore?.Score >= 21))
 314            {
 2315                ratings.Add(new ParentalRating("21", new(21, null)));
 316            }
 317
 318            // A lot of countries don't explicitly have a separate rating for adult content
 2319            if (ratings.All(x => x.RatingScore?.Score != 1000))
 320            {
 2321                ratings.Add(new ParentalRating("XXX", new(1000, null)));
 322            }
 323
 324            // A lot of countries don't explicitly have a separate rating for banned content
 2325            if (ratings.All(x => x.RatingScore?.Score != 1001))
 326            {
 2327                ratings.Add(new ParentalRating("Banned", new(1001, null)));
 328            }
 329
 2330            return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)];
 331        }
 332
 333        /// <summary>
 334        /// Gets the parental ratings dictionary.
 335        /// </summary>
 336        /// <param name="countryCode">The optional two letter ISO language string.</param>
 337        /// <returns><see cref="Dictionary{String, ParentalRatingScore}" />.</returns>
 338        private Dictionary<string, ParentalRatingScore?>? GetParentalRatingsDictionary(string? countryCode = null)
 339        {
 340            // Fallback to server default if no country code is specified.
 26341            if (string.IsNullOrEmpty(countryCode))
 342            {
 26343                countryCode = _configurationManager.Configuration.MetadataCountryCode;
 344            }
 345
 26346            if (_allParentalRatings.TryGetValue(countryCode, out var countryValue))
 347            {
 25348                return countryValue;
 349            }
 350
 1351            return null;
 352        }
 353
 354        /// <inheritdoc />
 355        public ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null)
 356        {
 34357            ArgumentException.ThrowIfNullOrEmpty(rating);
 358
 359            // Handle unrated content
 34360            if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
 361            {
 4362                return null;
 363            }
 364
 365            // Convert ints directly
 366            // This may override some of the locale specific age ratings (but those always map to the same age)
 30367            if (int.TryParse(rating, out var ratingAge))
 368            {
 6369                return new(ratingAge, null);
 370            }
 371
 372            // Fairly common for some users to have "Rated R" in their rating field
 24373            rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase)
 24374                            .Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase)
 24375                            .Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase)
 24376                            .Trim();
 377
 378            // Use rating system matching the language
 24379            if (!string.IsNullOrEmpty(countryCode))
 380            {
 0381                var ratingsDictionary = GetParentalRatingsDictionary(countryCode);
 0382                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? valu
 383                {
 0384                    return value;
 385                }
 386
 0387                if (ratingsDictionary is not null && rating.Length > countryCode.Length
 0388                    && rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
 0389                    && (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
 0390                    && ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue)
 391                {
 0392                    return normalizedValue;
 393                }
 394            }
 395            else
 396            {
 397                // Fall back to server default language for ratings check
 24398                var ratingsDictionary = GetParentalRatingsDictionary();
 24399                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? valu
 400                {
 8401                    return value;
 402                }
 403            }
 404
 405            // If we don't find anything, check all ratings systems, starting with US
 16406            if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValu
 407            {
 3408                return usValue;
 409            }
 410
 1016411            foreach (var dictionary in _allParentalRatings.Values)
 412            {
 496413                if (dictionary.TryGetValue(rating, out var value))
 414                {
 2415                    return value;
 416                }
 417            }
 418
 419            // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
 11420            if (TryGetRatingScoreBySeparator(rating, ':', out var result)
 11421                || TryGetRatingScoreBySeparator(rating, '-', out result))
 422            {
 9423                return result;
 424            }
 425
 2426            return null;
 2427        }
 428
 429        private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
 430        {
 15431            result = null;
 432
 15433            if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
 434            {
 4435                return false;
 436            }
 437
 11438            var ratingSpan = rating.AsSpan();
 11439            var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
 11440            var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
 11441            if (ratingPart.Length == 0)
 442            {
 2443                return false;
 444            }
 445
 9446            string? resolvedCountryCode = null;
 447
 9448            if (_allParentalRatings.ContainsKey(countryPart))
 449            {
 8450                resolvedCountryCode = countryPart;
 451            }
 452            else
 453            {
 1454                var culture = FindLanguageInfo(countryPart);
 1455                if (culture is not null)
 456                {
 0457                    resolvedCountryCode = culture.TwoLetterISOLanguageName;
 458                }
 459            }
 460
 9461            if (resolvedCountryCode is not null
 9462                && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
 463            {
 8464                if (countryRatings.TryGetValue(ratingPart, out result))
 465                {
 4466                    return true;
 467                }
 468
 4469                _logger.LogWarning(
 4470                    "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
 4471                    rating,
 4472                    resolvedCountryCode);
 473
 4474                return true;
 475            }
 476
 477            // Country not identified or no rating data available, try recursive lookup
 1478            result = GetRatingScore(ratingPart, resolvedCountryCode);
 479
 1480            return true;
 481        }
 482
 483        /// <inheritdoc />
 484        public string GetLocalizedString(string phrase)
 485        {
 445486            return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
 487        }
 488
 489        /// <inheritdoc />
 490        public string GetServerLocalizedString(string phrase)
 491        {
 65492            return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
 493        }
 494
 495        /// <inheritdoc />
 496        public string GetLocalizedString(string phrase, string culture)
 497        {
 513498            if (string.IsNullOrEmpty(culture))
 499            {
 0500                culture = _configurationManager.Configuration.UICulture;
 501            }
 502
 513503            if (string.IsNullOrEmpty(culture))
 504            {
 0505                culture = DefaultCulture;
 506            }
 507
 508            // Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
 513509            if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
 510            {
 1511                culture = mapped;
 512            }
 513
 513514            var dictionary = GetLocalizationDictionary(culture);
 515
 513516            if (dictionary.TryGetValue(phrase, out var value))
 517            {
 504518                return value;
 519            }
 520
 9521            if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
 522            {
 8523                var fallback = GetLocalizationDictionary(DefaultCulture);
 8524                if (fallback.TryGetValue(phrase, out var fallbackValue))
 525                {
 8526                    return fallbackValue;
 527                }
 528            }
 529
 1530            return phrase;
 531        }
 532
 533        private Dictionary<string, string> GetLocalizationDictionary(string culture)
 534        {
 521535            ArgumentException.ThrowIfNullOrEmpty(culture);
 536
 521537            return _cultureOnlyDictionaries.GetOrAdd(
 521538                culture,
 521539                static (key, localizationManager) =>
 521540                {
 521541                    var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 521542                    var namespaceName = localizationManager.GetType().Namespace + ".Core";
 521543                    localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter(
 521544
 521545                    return dictionary;
 521546                },
 521547                this);
 548        }
 549
 550        private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
 551        {
 31552            using var stream = _assembly.GetManifestResourceStream(resourcePath);
 553            // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the c
 31554            if (stream is null)
 555            {
 2556                _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath);
 2557                return;
 558            }
 559
 29560            var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).Configure
 6434561            foreach (var key in dict.Keys)
 562            {
 3188563                dictionary[key] = dict[key];
 564            }
 31565        }
 566
 567        private static string GetResourceFilename(string culture)
 568        {
 31569            var parts = culture.Split('-');
 570
 31571            if (parts.Length == 2)
 572            {
 25573                culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
 574            }
 575            else
 576            {
 6577                culture = culture.ToLowerInvariant();
 578            }
 579
 31580            return culture + ".json";
 581        }
 582
 583        /// <inheritdoc />
 584        public IEnumerable<LocalizationOption> GetLocalizationOptions()
 585        {
 0586            return _localizationOptions;
 587        }
 588
 589        private static (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) 
 590        {
 2591            var options = new List<LocalizationOption>();
 2592            var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 2593            var prefix = CoreResourcePrefix;
 594
 608595            foreach (var resource in _assembly.GetManifestResourceNames())
 596            {
 302597                if (!resource.StartsWith(prefix, StringComparison.Ordinal)
 302598                    || !resource.EndsWith(".json", StringComparison.Ordinal))
 599                {
 600                    continue;
 601                }
 602
 603                // Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR"
 208604                var code = resource[prefix.Length..^5];
 605
 606                // Record the BCP-47 → Jellyfin mapping for any resource file using underscores.
 208607                if (code.Contains('_', StringComparison.Ordinal))
 608                {
 10609                    bcp47Map[code.Replace('_', '-')] = code;
 610                }
 611
 612                // Skip the base language file — en-US is added explicitly below
 208613                if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase))
 614                {
 615                    continue;
 616                }
 617
 206618                var displayName = GetDisplayName(code);
 206619                options.Add(new LocalizationOption(displayName, code));
 620            }
 621
 622            // Ensure en-US is always present
 2623            options.Add(new LocalizationOption("English", DefaultCulture));
 624
 2625            options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
 2626            return (options, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
 627        }
 628
 629        private static string GetDisplayName(string cultureCode)
 630        {
 631            // Custom/novelty codes like "pr" (Pirate) — fall back to code itself
 206632            return TryGetCultureInfo(cultureCode, out var cultureInfo)
 206633                ? cultureInfo.NativeName
 206634                : cultureCode;
 635        }
 636
 637        /// <inheritdoc />
 638        public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT)
 639        {
 640            // Unlikely case the dictionary is not (yet) initialized properly
 3641            if (_iso6392BtoT is null)
 642            {
 0643                isoT = null;
 0644                return false;
 645            }
 646
 3647            var result = _iso6392BtoT.TryGetValue(isoB, out isoT) && !string.IsNullOrEmpty(isoT);
 648
 649            // Ensure the ISO code being null if the result is false
 3650            if (!result)
 651            {
 1652                isoT = null;
 653            }
 654
 3655            return result;
 656        }
 657    }
 658}