< 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: 242
Uncovered lines: 22
Coverable lines: 264
Total lines: 720
Line coverage: 91.6%
Branch coverage
78%
Covered branches: 134
Total branches: 170
Branch coverage: 78.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/15/2026 - 12:14:34 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: 6585/27/2026 - 12:15:38 AM Line coverage: 91.8% (226/246) Branch coverage: 77.8% (123/158) Total lines: 6586/28/2026 - 12:15:35 AM Line coverage: 91.9% (227/247) Branch coverage: 77.8% (123/158) Total lines: 6627/6/2026 - 12:16:28 AM Line coverage: 92% (233/253) Branch coverage: 78.3% (127/162) Total lines: 6837/14/2026 - 12:13:58 AM Line coverage: 91.4% (236/258) Branch coverage: 78% (128/164) Total lines: 7027/22/2026 - 12:16:22 AM Line coverage: 91.6% (242/264) Branch coverage: 78.8% (134/170) Total lines: 720 4/15/2026 - 12:14:34 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: 6585/27/2026 - 12:15:38 AM Line coverage: 91.8% (226/246) Branch coverage: 77.8% (123/158) Total lines: 6586/28/2026 - 12:15:35 AM Line coverage: 91.9% (227/247) Branch coverage: 77.8% (123/158) Total lines: 6627/6/2026 - 12:16:28 AM Line coverage: 92% (233/253) Branch coverage: 78.3% (127/162) Total lines: 6837/14/2026 - 12:13:58 AM Line coverage: 91.4% (236/258) Branch coverage: 78% (128/164) Total lines: 7027/22/2026 - 12:16:22 AM Line coverage: 91.6% (242/264) Branch coverage: 78.8% (134/170) Total lines: 720

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%
GetLanguageDisplayName(...)100%66100%
GetCountries()50%44100%
GetParentalRatings()65%202094.44%
GetParentalRatingsDictionary(...)75%44100%
GetRatingScore(...)100%44100%
GetSingleRatingScore(...)68.42%653873.33%
TryGetRatingScoreBySeparator(...)87.5%161689.28%
TryParseRatingAsScore(...)100%11100%
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
 8237        private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringCo
 38
 8239        private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringC
 40
 8241        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 42
 8243        private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase)
 8244        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        {
 8265            _configurationManager = configurationManager;
 8266            _logger = logger;
 67
 8268            _configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
 8269        }
 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        {
 2377            var cultures = new List<CultureInfo>();
 487678            foreach (var option in _localizationOptions)
 79            {
 80                // Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
 241581                if (TryGetCultureInfo(option.Value, out var cultureInfo))
 82                {
 241583                    cultures.Add(cultureInfo);
 84                }
 85            }
 86
 2387            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.
 2623100                cultureInfo = CultureInfo.GetCultureInfo(cultureCode.Replace('_', '-'));
 2623101                return true;
 102            }
 0103            catch (CultureNotFoundException)
 104            {
 0105                cultureInfo = null;
 0106                return false;
 107            }
 2623108        }
 109
 110        private static void OnConfigurationUpdated(object? sender, EventArgs e)
 111        {
 106112            if (sender is IServerConfigurationManager configManager)
 113            {
 106114                var uiCulture = configManager.Configuration.UICulture;
 106115                if (!string.IsNullOrEmpty(uiCulture))
 116                {
 106117                    CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture);
 118                }
 119            }
 106120        }
 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
 22032129            foreach (var resource in _assembly.GetManifestResourceNames())
 130            {
 10944131                if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal))
 132                {
 133                    continue;
 134                }
 135
 3240136                using var stream = _assembly.GetManifestResourceStream(resource);
 3240137                if (stream is not null)
 138                {
 3240139                    var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions)
 3240140                                ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
 141
 3240142                    var dict = new Dictionary<string, ParentalRatingScore?>();
 3240143                    if (ratingSystem.Ratings is not null)
 144                    {
 50976145                        foreach (var ratingEntry in ratingSystem.Ratings)
 146                        {
 117936147                            foreach (var ratingString in ratingEntry.RatingStrings)
 148                            {
 36720149                                dict[ratingString] = ratingEntry.RatingScore;
 150                            }
 151                        }
 152
 3240153                        _allParentalRatings[ratingSystem.CountryCode] = dict;
 154                    }
 155                }
 3240156            }
 157
 72158            await LoadCultures().ConfigureAwait(false);
 72159        }
 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        {
 72170            List<CultureDto> list = [];
 72171            Dictionary<string, string> iso6392BtoTdict = new Dictionary<string, string>();
 172
 72173            using var stream = _assembly.GetManifestResourceStream(CulturesPath);
 72174            if (stream is null)
 175            {
 0176                throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'");
 177            }
 178            else
 179            {
 72180                using var reader = new StreamReader(stream);
 71568181                await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
 182                {
 35712183                    if (string.IsNullOrWhiteSpace(line))
 184                    {
 185                        continue;
 186                    }
 187
 35712188                    var parts = line.Split('|');
 35712189                    if (parts.Length != 5)
 190                    {
 0191                        throw new InvalidDataException($"Invalid culture data found at: '{line}'");
 192                    }
 193
 35712194                    string name = parts[3];
 35712195                    string displayname = parts[3];
 35712196                    if (string.IsNullOrWhiteSpace(displayname))
 197                    {
 198                        continue;
 199                    }
 200
 35712201                    string twoCharName = parts[2];
 35712202                    if (string.IsNullOrWhiteSpace(twoCharName))
 203                    {
 21744204                        twoCharName = string.Empty;
 205                    }
 13968206                    else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
 207                    {
 648208                        name = twoCharName;
 209                    }
 210
 211                    string[] threeLetterNames;
 35712212                    if (string.IsNullOrWhiteSpace(parts[1]))
 213                    {
 33840214                        threeLetterNames = [parts[0]];
 215                    }
 216                    else
 217                    {
 1872218                        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-
 1872222                        iso6392BtoTdict.TryAdd(parts[1], parts[0]);
 223                    }
 224
 35712225                    list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
 226                }
 227
 72228                _cultureCache.Clear();
 72229                _cultures = list;
 72230                _iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
 72231            }
 72232        }
 233
 234        /// <inheritdoc />
 235        public CultureDto? FindLanguageInfo(string language)
 236        {
 90237            if (string.IsNullOrEmpty(language))
 238            {
 75239                return null;
 240            }
 241
 15242            return _cultureCache.GetOrAdd(
 15243                language,
 15244                static (lang, cultures) =>
 15245                {
 15246                    // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
 15247                    for (var i = 0; i < cultures.Count; i++)
 15248                    {
 15249                        var culture = cultures[i];
 15250                        if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
 15251                            || lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
 15252                            || culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
 15253                            || lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
 15254                        {
 15255                            return culture;
 15256                        }
 15257                    }
 15258
 15259                    return null;
 15260                },
 15261                _cultures);
 262        }
 263
 264        /// <inheritdoc />
 265        public string? GetLanguageDisplayName(string language)
 266        {
 8267            if (string.IsNullOrEmpty(language))
 268            {
 2269                return null;
 270            }
 271
 6272            var displayName = FindLanguageInfo(language)?.DisplayName;
 6273            if (displayName is null)
 274            {
 1275                return null;
 276            }
 277
 278            // Truncate at the first delimiter to avoid cluttered display names
 5279            return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim();
 280        }
 281
 282        /// <inheritdoc />
 283        public IReadOnlyList<CountryInfo> GetCountries()
 284        {
 1285            using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException
 286
 1287            return JsonSerializer.Deserialize<IReadOnlyList<CountryInfo>>(stream, _jsonOptions) ?? [];
 1288        }
 289
 290        /// <inheritdoc />
 291        public IReadOnlyList<ParentalRating> GetParentalRatings()
 292        {
 293            // Use server default language for ratings
 294            // Fall back to empty list if there are no parental ratings for that language
 2295            var ratings = GetParentalRatingsDictionary()?.Select(x => new ParentalRating(x.Key, x.Value)).ToList() ?? []
 296
 297            // Add common ratings to ensure them being available for selection
 298            // Based on the US rating system due to it being the main source of rating in the metadata providers
 299            // Unrated
 2300            if (!ratings.Any(x => x is null))
 301            {
 2302                ratings.Add(new("Unrated", null));
 303            }
 304
 305            // Minimum rating possible
 2306            if (ratings.All(x => x.RatingScore?.Score != 0))
 307            {
 0308                ratings.Add(new("Approved", new(0, null)));
 309            }
 310
 311            // Matches PG (this has different age restrictions depending on country)
 2312            if (ratings.All(x => x.RatingScore?.Score != 10))
 313            {
 1314                ratings.Add(new("10", new(10, null)));
 315            }
 316
 317            // Matches PG-13
 2318            if (ratings.All(x => x.RatingScore?.Score != 13))
 319            {
 1320                ratings.Add(new("13", new(13, null)));
 321            }
 322
 323            // Matches TV-14
 2324            if (ratings.All(x => x.RatingScore?.Score != 14))
 325            {
 1326                ratings.Add(new("14", new(14, null)));
 327            }
 328
 329            // Catchall if max rating of country is less than 21
 330            // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned
 2331            if (!ratings.Any(x => x.RatingScore?.Score >= 21))
 332            {
 2333                ratings.Add(new ParentalRating("21", new(21, null)));
 334            }
 335
 336            // A lot of countries don't explicitly have a separate rating for adult content
 2337            if (ratings.All(x => x.RatingScore?.Score != 1000))
 338            {
 2339                ratings.Add(new ParentalRating("XXX", new(1000, null)));
 340            }
 341
 342            // A lot of countries don't explicitly have a separate rating for banned content
 2343            if (ratings.All(x => x.RatingScore?.Score != 1001))
 344            {
 2345                ratings.Add(new ParentalRating("Banned", new(1001, null)));
 346            }
 347
 2348            return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)];
 349        }
 350
 351        /// <summary>
 352        /// Gets the parental ratings dictionary.
 353        /// </summary>
 354        /// <param name="countryCode">The optional two letter ISO language string.</param>
 355        /// <returns><see cref="Dictionary{String, ParentalRatingScore}" />.</returns>
 356        private Dictionary<string, ParentalRatingScore?>? GetParentalRatingsDictionary(string? countryCode = null)
 357        {
 358            // Fallback to server default if no country code is specified.
 28359            if (string.IsNullOrEmpty(countryCode))
 360            {
 28361                countryCode = _configurationManager.Configuration.MetadataCountryCode;
 362            }
 363
 28364            if (_allParentalRatings.TryGetValue(countryCode, out var countryValue))
 365            {
 27366                return countryValue;
 367            }
 368
 1369            return null;
 370        }
 371
 372        /// <inheritdoc />
 373        public ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null)
 374        {
 34375            ArgumentException.ThrowIfNullOrEmpty(rating);
 376
 377            // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
 378            // Try each one in order and use the first that resolves.
 34379            var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 380
 114381            foreach (var ratingValue in ratingValues)
 382            {
 35383                var score = GetSingleRatingScore(ratingValue, countryCode);
 35384                if (score is not null)
 385                {
 24386                    return score;
 387                }
 388            }
 389
 10390            return null;
 391        }
 392
 393        /// <summary>
 394        /// Resolves a single rating value to a score.
 395        /// </summary>
 396        private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
 397        {
 398            // Handle unrated content
 35399            if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
 400            {
 3401                return null;
 402            }
 403
 404            // Convert ints directly
 405            // This may override some of the locale specific age ratings (but those always map to the same age)
 32406            if (TryParseRatingAsScore(rating, out var ratingAge))
 407            {
 6408                return new(ratingAge, null);
 409            }
 410
 411            // Fairly common for some users to have "Rated R" in their rating field
 26412            rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase)
 26413                            .Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase)
 26414                            .Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase)
 26415                            .Trim();
 416
 417            // Use rating system matching the language
 26418            if (!string.IsNullOrEmpty(countryCode))
 419            {
 0420                var ratingsDictionary = GetParentalRatingsDictionary(countryCode);
 0421                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? valu
 422                {
 0423                    return value;
 424                }
 425
 0426                if (ratingsDictionary is not null && rating.Length > countryCode.Length
 0427                    && rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
 0428                    && (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
 0429                    && ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue)
 430                {
 0431                    return normalizedValue;
 432                }
 433            }
 434            else
 435            {
 436                // Fall back to server default language for ratings check
 26437                var ratingsDictionary = GetParentalRatingsDictionary();
 26438                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? valu
 439                {
 8440                    return value;
 441                }
 442            }
 443
 444            // If we don't find anything, check all ratings systems, starting with US
 18445            if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValu
 446            {
 3447                return usValue;
 448            }
 449
 1196450            foreach (var dictionary in _allParentalRatings.Values)
 451            {
 584452                if (dictionary.TryGetValue(rating, out var value))
 453                {
 2454                    return value;
 455                }
 456            }
 457
 458            // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
 13459            if (TryGetRatingScoreBySeparator(rating, ':', out var result)
 13460                || TryGetRatingScoreBySeparator(rating, '-', out result))
 461            {
 9462                return result;
 463            }
 464
 4465            return null;
 2466        }
 467
 468        private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
 469        {
 19470            result = null;
 471
 19472            if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
 473            {
 8474                return false;
 475            }
 476
 11477            var ratingSpan = rating.AsSpan();
 11478            var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
 11479            var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
 11480            if (ratingPart.Length == 0)
 481            {
 2482                return false;
 483            }
 484
 9485            string? resolvedCountryCode = null;
 486
 9487            if (_allParentalRatings.ContainsKey(countryPart))
 488            {
 8489                resolvedCountryCode = countryPart;
 490            }
 491            else
 492            {
 1493                var culture = FindLanguageInfo(countryPart);
 1494                if (culture is not null)
 495                {
 0496                    resolvedCountryCode = culture.TwoLetterISOLanguageName;
 497                }
 498            }
 499
 9500            if (resolvedCountryCode is not null
 9501                && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
 502            {
 8503                if (countryRatings.TryGetValue(ratingPart, out result))
 504                {
 4505                    return true;
 506                }
 507
 508                // If it's not a recognized rating string, fall back to using the number as the score
 4509                if (TryParseRatingAsScore(ratingPart, out var numericScore))
 510                {
 0511                    result = new ParentalRatingScore(numericScore, null);
 0512                    return true;
 513                }
 514
 4515                _logger.LogWarning(
 4516                    "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
 4517                    rating,
 4518                    resolvedCountryCode);
 519
 4520                return true;
 521            }
 522
 523            // Country not identified or no rating data available, try recursive lookup
 1524            result = GetRatingScore(ratingPart, resolvedCountryCode);
 525
 1526            return true;
 527        }
 528
 529        /// <summary>
 530        /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
 531        /// </summary>
 532        /// <param name="ratingValue">Rating value to parse.</param>
 533        /// <param name="score">Parsed score.</param>
 534        /// <returns>Returns true if parsing was successful.</returns>
 535        private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
 536        {
 36537            var trimmed = ratingValue.TrimEnd('+');
 36538            return int.TryParse(trimmed, out score);
 539        }
 540
 541        /// <inheritdoc />
 542        public string GetLocalizedString(string phrase)
 543        {
 464544            return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
 545        }
 546
 547        /// <inheritdoc />
 548        public string GetServerLocalizedString(string phrase)
 549        {
 73550            return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
 551        }
 552
 553        /// <inheritdoc />
 554        public string GetLocalizedString(string phrase, string culture)
 555        {
 541556            if (string.IsNullOrEmpty(culture))
 557            {
 0558                culture = _configurationManager.Configuration.UICulture;
 559            }
 560
 541561            if (string.IsNullOrEmpty(culture))
 562            {
 0563                culture = DefaultCulture;
 564            }
 565
 566            // Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
 541567            if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
 568            {
 2569                culture = mapped;
 570            }
 571
 541572            var dictionary = GetLocalizationDictionary(culture);
 573
 541574            if (dictionary.TryGetValue(phrase, out var value))
 575            {
 532576                return value;
 577            }
 578
 9579            if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
 580            {
 8581                var fallback = GetLocalizationDictionary(DefaultCulture);
 8582                if (fallback.TryGetValue(phrase, out var fallbackValue))
 583                {
 8584                    return fallbackValue;
 585                }
 586            }
 587
 1588            return phrase;
 589        }
 590
 591        private Dictionary<string, string> GetLocalizationDictionary(string culture)
 592        {
 549593            ArgumentException.ThrowIfNullOrEmpty(culture);
 594
 549595            return _cultureOnlyDictionaries.GetOrAdd(
 549596                culture,
 549597                static (key, localizationManager) =>
 549598                {
 549599                    var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 549600                    var namespaceName = localizationManager.GetType().Namespace + ".Core";
 549601                    localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter(
 549602
 549603                    return dictionary;
 549604                },
 549605                this);
 606        }
 607
 608        private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
 609        {
 33610            using var stream = _assembly.GetManifestResourceStream(resourcePath);
 611            // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the c
 33612            if (stream is null)
 613            {
 2614                _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath);
 2615                return;
 616            }
 617
 31618            var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).Configure
 6882619            foreach (var key in dict.Keys)
 620            {
 3410621                dictionary[key] = dict[key];
 622            }
 33623        }
 624
 625        private static string GetResourceFilename(string culture)
 626        {
 627            // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
 628            // Normalize the casing (lower-case language, upper-case region) while preserving the separator
 629            // so the result matches the embedded resource file name, which is case-sensitive.
 33630            var separatorIndex = culture.IndexOfAny(['-', '_']);
 631
 33632            if (separatorIndex > 0)
 633            {
 28634                var separator = culture[separatorIndex];
 28635                culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToU
 636            }
 637            else
 638            {
 5639                culture = culture.ToLowerInvariant();
 640            }
 641
 33642            return culture + ".json";
 643        }
 644
 645        /// <inheritdoc />
 646        public IEnumerable<LocalizationOption> GetLocalizationOptions()
 647        {
 0648            return _localizationOptions;
 649        }
 650
 651        private static (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) 
 652        {
 2653            var options = new List<LocalizationOption>();
 2654            var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 2655            var prefix = CoreResourcePrefix;
 656
 612657            foreach (var resource in _assembly.GetManifestResourceNames())
 658            {
 304659                if (!resource.StartsWith(prefix, StringComparison.Ordinal)
 304660                    || !resource.EndsWith(".json", StringComparison.Ordinal))
 661                {
 662                    continue;
 663                }
 664
 665                // Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR"
 210666                var code = resource[prefix.Length..^5];
 667
 668                // Record the BCP-47 → Jellyfin mapping for any resource file using underscores.
 210669                if (code.Contains('_', StringComparison.Ordinal))
 670                {
 10671                    bcp47Map[code.Replace('_', '-')] = code;
 672                }
 673
 674                // Skip the base language file — en-US is added explicitly below
 210675                if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase))
 676                {
 677                    continue;
 678                }
 679
 208680                var displayName = GetDisplayName(code);
 208681                options.Add(new LocalizationOption(displayName, code));
 682            }
 683
 684            // Ensure en-US is always present
 2685            options.Add(new LocalizationOption("English", DefaultCulture));
 686
 2687            options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
 2688            return (options, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
 689        }
 690
 691        private static string GetDisplayName(string cultureCode)
 692        {
 693            // Custom/novelty codes like "pr" (Pirate) — fall back to code itself
 208694            return TryGetCultureInfo(cultureCode, out var cultureInfo)
 208695                ? cultureInfo.NativeName
 208696                : cultureCode;
 697        }
 698
 699        /// <inheritdoc />
 700        public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT)
 701        {
 702            // Unlikely case the dictionary is not (yet) initialized properly
 3703            if (_iso6392BtoT is null)
 704            {
 0705                isoT = null;
 0706                return false;
 707            }
 708
 3709            var result = _iso6392BtoT.TryGetValue(isoB, out isoT) && !string.IsNullOrEmpty(isoT);
 710
 711            // Ensure the ISO code being null if the result is false
 3712            if (!result)
 713            {
 1714                isoT = null;
 715            }
 716
 3717            return result;
 718        }
 719    }
 720}