< 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: 258
Uncovered lines: 23
Coverable lines: 281
Total lines: 772
Line coverage: 91.8%
Branch coverage
79%
Covered branches: 146
Total branches: 184
Branch coverage: 79.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/12/2026 - 12:15:57 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: 72010/29/2026 - 12:17:11 AM Line coverage: 91.8% (258/281) Branch coverage: 79.3% (146/184) Total lines: 772 5/12/2026 - 12:15:57 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: 72010/29/2026 - 12:17:11 AM Line coverage: 91.8% (258/281) Branch coverage: 79.3% (146/184) Total lines: 772

Coverage delta

Coverage delta 25 -25

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(...)92.85%1414100%
IsUnrated(...)100%11100%
GetSingleRatingScore(...)68.42%683872.41%
TryGetRatingScoreBySeparator(...)85%212087.09%
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
 15637        private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringCo
 38
 15639        private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringC
 40
 15641        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 42
 15643        private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase)
 15644        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        {
 15665            _configurationManager = configurationManager;
 15666            _logger = logger;
 67
 15668            _configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
 15669        }
 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        {
 2877            var cultures = new List<CultureInfo>();
 593678            foreach (var option in _localizationOptions)
 79            {
 80                // Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
 294081                if (TryGetCultureInfo(option.Value, out var cultureInfo))
 82                {
 294083                    cultures.Add(cultureInfo);
 84                }
 85            }
 86
 2887            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.
 3148100                cultureInfo = CultureInfo.GetCultureInfo(cultureCode.Replace('_', '-'));
 3148101                return true;
 102            }
 0103            catch (CultureNotFoundException)
 104            {
 0105                cultureInfo = null;
 0106                return false;
 107            }
 3148108        }
 109
 110        private static void OnConfigurationUpdated(object? sender, EventArgs e)
 111        {
 125112            if (sender is IServerConfigurationManager configManager)
 113            {
 125114                var uiCulture = configManager.Configuration.UICulture;
 125115                if (!string.IsNullOrEmpty(uiCulture))
 116                {
 125117                    CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture);
 118                }
 119            }
 125120        }
 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
 44676129            foreach (var resource in _assembly.GetManifestResourceNames())
 130            {
 22192131                if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal))
 132                {
 133                    continue;
 134                }
 135
 6570136                using var stream = _assembly.GetManifestResourceStream(resource);
 6570137                if (stream is not null)
 138                {
 6570139                    var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions)
 6570140                                ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
 141
 142                    // Rating strings are compared case insensitively, providers are not consistent about casing (e.g. "
 6570143                    var dict = new Dictionary<string, ParentalRatingScore?>(StringComparer.OrdinalIgnoreCase);
 6570144                    if (ratingSystem.Ratings is not null)
 145                    {
 105412146                        foreach (var ratingEntry in ratingSystem.Ratings)
 147                        {
 268640148                            foreach (var ratingString in ratingEntry.RatingStrings)
 149                            {
 88184150                                dict[ratingString] = ratingEntry.RatingScore;
 151                            }
 152                        }
 153
 6570154                        _allParentalRatings[ratingSystem.CountryCode] = dict;
 155                    }
 156                }
 6570157            }
 158
 146159            await LoadCultures().ConfigureAwait(false);
 146160        }
 161
 162        /// <summary>
 163        /// Gets the cultures.
 164        /// </summary>
 165        /// <returns><see cref="IEnumerable{CultureDto}" />.</returns>
 166        public IEnumerable<CultureDto> GetCultures()
 1167            => _cultures;
 168
 169        private async Task LoadCultures()
 170        {
 146171            List<CultureDto> list = [];
 146172            Dictionary<string, string> iso6392BtoTdict = new Dictionary<string, string>();
 173
 146174            using var stream = _assembly.GetManifestResourceStream(CulturesPath);
 146175            if (stream is null)
 176            {
 0177                throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'");
 178            }
 179            else
 180            {
 146181                using var reader = new StreamReader(stream);
 145124182                await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
 183                {
 72416184                    if (string.IsNullOrWhiteSpace(line))
 185                    {
 186                        continue;
 187                    }
 188
 72416189                    var parts = line.Split('|');
 72416190                    if (parts.Length != 5)
 191                    {
 0192                        throw new InvalidDataException($"Invalid culture data found at: '{line}'");
 193                    }
 194
 72416195                    string name = parts[3];
 72416196                    string displayname = parts[3];
 72416197                    if (string.IsNullOrWhiteSpace(displayname))
 198                    {
 199                        continue;
 200                    }
 201
 72416202                    string twoCharName = parts[2];
 72416203                    if (string.IsNullOrWhiteSpace(twoCharName))
 204                    {
 44092205                        twoCharName = string.Empty;
 206                    }
 28324207                    else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
 208                    {
 1314209                        name = twoCharName;
 210                    }
 211
 212                    string[] threeLetterNames;
 72416213                    if (string.IsNullOrWhiteSpace(parts[1]))
 214                    {
 68620215                        threeLetterNames = [parts[0]];
 216                    }
 217                    else
 218                    {
 3796219                        threeLetterNames = [parts[0], parts[1]];
 220
 221                        // In cases where there are two TLN the first one is ISO 639-2/T and the second one is ISO 639-2
 222                        // We need ISO 639-2/T for the .NET cultures so we cultivate a dictionary for the translation B-
 3796223                        iso6392BtoTdict.TryAdd(parts[1], parts[0]);
 224                    }
 225
 72416226                    list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
 227                }
 228
 146229                _cultureCache.Clear();
 146230                _cultures = list;
 146231                _iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
 146232            }
 146233        }
 234
 235        /// <inheritdoc />
 236        public CultureDto? FindLanguageInfo(string language)
 237        {
 180238            if (string.IsNullOrEmpty(language))
 239            {
 165240                return null;
 241            }
 242
 15243            return _cultureCache.GetOrAdd(
 15244                language,
 15245                static (lang, cultures) =>
 15246                {
 15247                    // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
 15248                    for (var i = 0; i < cultures.Count; i++)
 15249                    {
 15250                        var culture = cultures[i];
 15251                        if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
 15252                            || lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
 15253                            || culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
 15254                            || lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
 15255                        {
 15256                            return culture;
 15257                        }
 15258                    }
 15259
 15260                    return null;
 15261                },
 15262                _cultures);
 263        }
 264
 265        /// <inheritdoc />
 266        public string? GetLanguageDisplayName(string language)
 267        {
 8268            if (string.IsNullOrEmpty(language))
 269            {
 2270                return null;
 271            }
 272
 6273            var displayName = FindLanguageInfo(language)?.DisplayName;
 6274            if (displayName is null)
 275            {
 1276                return null;
 277            }
 278
 279            // Truncate at the first delimiter to avoid cluttered display names
 5280            return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim();
 281        }
 282
 283        /// <inheritdoc />
 284        public IReadOnlyList<CountryInfo> GetCountries()
 285        {
 1286            using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException
 287
 1288            return JsonSerializer.Deserialize<IReadOnlyList<CountryInfo>>(stream, _jsonOptions) ?? [];
 1289        }
 290
 291        /// <inheritdoc />
 292        public IReadOnlyList<ParentalRating> GetParentalRatings()
 293        {
 294            // Use server default language for ratings
 295            // Fall back to empty list if there are no parental ratings for that language
 2296            var ratings = GetParentalRatingsDictionary()?.Select(x => new ParentalRating(x.Key, x.Value)).ToList() ?? []
 297
 298            // Add common ratings to ensure them being available for selection
 299            // Based on the US rating system due to it being the main source of rating in the metadata providers
 300            // Unrated
 2301            if (!ratings.Any(x => x is null))
 302            {
 2303                ratings.Add(new("Unrated", null));
 304            }
 305
 306            // Minimum rating possible
 2307            if (ratings.All(x => x.RatingScore?.Score != 0))
 308            {
 0309                ratings.Add(new("Approved", new(0, null)));
 310            }
 311
 312            // Matches PG (this has different age restrictions depending on country)
 2313            if (ratings.All(x => x.RatingScore?.Score != 10))
 314            {
 1315                ratings.Add(new("10", new(10, null)));
 316            }
 317
 318            // Matches PG-13
 2319            if (ratings.All(x => x.RatingScore?.Score != 13))
 320            {
 1321                ratings.Add(new("13", new(13, null)));
 322            }
 323
 324            // Matches TV-14
 2325            if (ratings.All(x => x.RatingScore?.Score != 14))
 326            {
 1327                ratings.Add(new("14", new(14, null)));
 328            }
 329
 330            // Catchall if max rating of country is less than 21
 331            // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned
 2332            if (!ratings.Any(x => x.RatingScore?.Score >= 21))
 333            {
 2334                ratings.Add(new ParentalRating("21", new(21, null)));
 335            }
 336
 337            // A lot of countries don't explicitly have a separate rating for adult content
 2338            if (ratings.All(x => x.RatingScore?.Score != 1000))
 339            {
 2340                ratings.Add(new ParentalRating("XXX", new(1000, null)));
 341            }
 342
 343            // A lot of countries don't explicitly have a separate rating for banned content
 2344            if (ratings.All(x => x.RatingScore?.Score != 1001))
 345            {
 2346                ratings.Add(new ParentalRating("Banned", new(1001, null)));
 347            }
 348
 2349            return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)];
 350        }
 351
 352        /// <summary>
 353        /// Gets the parental ratings dictionary.
 354        /// </summary>
 355        /// <param name="countryCode">The optional two letter ISO language string.</param>
 356        /// <returns><see cref="Dictionary{String, ParentalRatingScore}" />.</returns>
 357        private Dictionary<string, ParentalRatingScore?>? GetParentalRatingsDictionary(string? countryCode = null)
 358        {
 359            // Fallback to server default if no country code is specified.
 101360            if (string.IsNullOrEmpty(countryCode))
 361            {
 101362                countryCode = _configurationManager.Configuration.MetadataCountryCode;
 363            }
 364
 101365            if (_allParentalRatings.TryGetValue(countryCode, out var countryValue))
 366            {
 100367                return countryValue;
 368            }
 369
 1370            return null;
 371        }
 372
 373        /// <inheritdoc />
 374        public ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null)
 375        {
 105376            ArgumentException.ThrowIfNullOrEmpty(rating);
 377
 378            // Handle unrated content. This has to happen before the split below,
 379            // because some of the unrated values contain a '/' themselves (e.g. "n/a").
 105380            if (IsUnrated(rating))
 381            {
 6382                return null;
 383            }
 384
 385            // Several rating systems contain a '/' inside a single rating (e.g. "M/12" in PT,
 386            // "U/A 13+" in IN, "7/i/fig" in ES), so the value as a whole always wins over the split below.
 99387            var wholeValueScore = GetSingleRatingScore(rating, countryCode);
 99388            if (wholeValueScore is not null)
 389            {
 84390                return wholeValueScore;
 391            }
 392
 393            // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
 394            // Try each one in order and use the first that resolves.
 15395            var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 15396            if (ratingValues.Length == 1)
 397            {
 6398                return null;
 399            }
 400
 9401            var hasUnknownRating = false;
 36402            foreach (var ratingValue in ratingValues)
 403            {
 404                // A single entry of such a list may be unrated while a later one still resolves
 13405                if (IsUnrated(ratingValue))
 406                {
 407                    continue;
 408                }
 409
 410                // An unknown entry is not worth a warning of its own as long as another entry still resolves
 11411                var score = GetSingleRatingScore(ratingValue, countryCode, logUnknownRating: false);
 11412                if (score is not null)
 413                {
 8414                    return score;
 415                }
 416
 3417                hasUnknownRating = true;
 418            }
 419
 1420            if (hasUnknownRating)
 421            {
 1422                _logger.LogWarning(
 1423                    "None of the ratings in '{Rating}' were found in a known rating system, treating as unrated",
 1424                    rating);
 425            }
 426
 1427            return null;
 428        }
 429
 430        /// <summary>
 431        /// Checks whether a rating value marks the content as unrated.
 432        /// </summary>
 433        /// <param name="rating">Rating value to check.</param>
 434        /// <returns>Returns true if the value is an unrated marker.</returns>
 435        private static bool IsUnrated(ReadOnlySpan<char> rating)
 125436            => _unratedValues.Contains(rating.Trim(), StringComparison.OrdinalIgnoreCase);
 437
 438        /// <summary>
 439        /// Resolves a single rating value to a score.
 440        /// </summary>
 441        private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode, bool logUnknownRating = tr
 442        {
 443            // Convert ints directly
 444            // This may override some of the locale specific age ratings (but those always map to the same age)
 110445            if (TryParseRatingAsScore(rating, out var ratingAge))
 446            {
 11447                return new(ratingAge, null);
 448            }
 449
 450            // Fairly common for some users to have "Rated R" in their rating field
 99451            rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase)
 99452                            .Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase)
 99453                            .Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase)
 99454                            .Trim();
 455
 456            // Use rating system matching the language
 99457            if (!string.IsNullOrEmpty(countryCode))
 458            {
 0459                var ratingsDictionary = GetParentalRatingsDictionary(countryCode);
 0460                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? valu
 461                {
 0462                    return value;
 463                }
 464
 0465                if (ratingsDictionary is not null && rating.Length > countryCode.Length
 0466                    && rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
 0467                    && (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
 0468                    && ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue)
 469                {
 0470                    return normalizedValue;
 471                }
 472            }
 473            else
 474            {
 475                // Fall back to server default language for ratings check
 99476                var ratingsDictionary = GetParentalRatingsDictionary();
 99477                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? valu
 478                {
 52479                    return value;
 480                }
 481            }
 482
 483            // If we don't find anything, check all ratings systems, starting with US
 47484            if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValu
 485            {
 3486                return usValue;
 487            }
 488
 3696489            foreach (var dictionary in _allParentalRatings.Values)
 490            {
 1806491                if (dictionary.TryGetValue(rating, out var value))
 492                {
 4493                    return value;
 494                }
 495            }
 496
 497            // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18".
 498            // A '/' marks a list of ratings, which this would split into the first entry's country and the last entry's
 40499            if (!rating.Contains('/', StringComparison.Ordinal)
 40500                && (TryGetRatingScoreBySeparator(rating, ':', logUnknownRating, out var result)
 40501                    || TryGetRatingScoreBySeparator(rating, '-', logUnknownRating, out result)))
 502            {
 29503                return result;
 504            }
 505
 11506            return null;
 4507        }
 508
 509        private bool TryGetRatingScoreBySeparator(string rating, char separator, bool logUnknownRating, out ParentalRati
 510        {
 48511            result = null;
 512
 48513            if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
 514            {
 17515                return false;
 516            }
 517
 31518            var ratingSpan = rating.AsSpan();
 31519            var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
 31520            var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
 31521            if (ratingPart.Length == 0)
 522            {
 2523                return false;
 524            }
 525
 29526            string? resolvedCountryCode = null;
 527
 29528            if (_allParentalRatings.ContainsKey(countryPart))
 529            {
 28530                resolvedCountryCode = countryPart;
 531            }
 532            else
 533            {
 1534                var culture = FindLanguageInfo(countryPart);
 1535                if (culture is not null)
 536                {
 0537                    resolvedCountryCode = culture.TwoLetterISOLanguageName;
 538                }
 539            }
 540
 29541            if (resolvedCountryCode is not null
 29542                && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
 543            {
 28544                if (countryRatings.TryGetValue(ratingPart, out result))
 545                {
 21546                    return true;
 547                }
 548
 549                // If it's not a recognized rating string, fall back to using the number as the score
 7550                if (TryParseRatingAsScore(ratingPart, out var numericScore))
 551                {
 0552                    result = new ParentalRatingScore(numericScore, null);
 0553                    return true;
 554                }
 555
 556                // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure
 7557                if (IsUnrated(ratingPart))
 558                {
 0559                    return true;
 560                }
 561
 7562                if (logUnknownRating)
 563                {
 4564                    _logger.LogWarning(
 4565                        "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
 4566                        rating,
 4567                        resolvedCountryCode);
 568                }
 569
 7570                return true;
 571            }
 572
 573            // Country not identified or no rating data available, try recursive lookup
 1574            result = GetRatingScore(ratingPart, resolvedCountryCode);
 575
 1576            return true;
 577        }
 578
 579        /// <summary>
 580        /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+")
 581        /// or a leading '-' (e.g. the French "-12").
 582        /// </summary>
 583        /// <param name="ratingValue">Rating value to parse.</param>
 584        /// <param name="score">Parsed score.</param>
 585        /// <returns>Returns true if parsing was successful.</returns>
 586        private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
 587        {
 588            // A leading '-' marks a minimum age ("-12" is French for "not for under 12s"), never a negative score
 117589            var trimmed = ratingValue.TrimStart('-').TrimEnd('+');
 117590            return int.TryParse(trimmed, out score);
 591        }
 592
 593        /// <inheritdoc />
 594        public string GetLocalizedString(string phrase)
 595        {
 553596            return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
 597        }
 598
 599        /// <inheritdoc />
 600        public string GetServerLocalizedString(string phrase)
 601        {
 77602            return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
 603        }
 604
 605        /// <inheritdoc />
 606        public string GetLocalizedString(string phrase, string culture)
 607        {
 634608            if (string.IsNullOrEmpty(culture))
 609            {
 0610                culture = _configurationManager.Configuration.UICulture;
 611            }
 612
 634613            if (string.IsNullOrEmpty(culture))
 614            {
 0615                culture = DefaultCulture;
 616            }
 617
 618            // Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
 634619            if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
 620            {
 2621                culture = mapped;
 622            }
 623
 634624            var dictionary = GetLocalizationDictionary(culture);
 625
 634626            if (dictionary.TryGetValue(phrase, out var value))
 627            {
 625628                return value;
 629            }
 630
 9631            if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
 632            {
 8633                var fallback = GetLocalizationDictionary(DefaultCulture);
 8634                if (fallback.TryGetValue(phrase, out var fallbackValue))
 635                {
 8636                    return fallbackValue;
 637                }
 638            }
 639
 1640            return phrase;
 641        }
 642
 643        private Dictionary<string, string> GetLocalizationDictionary(string culture)
 644        {
 642645            ArgumentException.ThrowIfNullOrEmpty(culture);
 646
 642647            return _cultureOnlyDictionaries.GetOrAdd(
 642648                culture,
 642649                static (key, localizationManager) =>
 642650                {
 642651                    var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 642652                    var namespaceName = localizationManager.GetType().Namespace + ".Core";
 642653                    localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter(
 642654
 642655                    return dictionary;
 642656                },
 642657                this);
 658        }
 659
 660        private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
 661        {
 38662            using var stream = _assembly.GetManifestResourceStream(resourcePath);
 663            // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the c
 38664            if (stream is null)
 665            {
 2666                _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath);
 2667                return;
 668            }
 669
 36670            var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).Configure
 8902671            foreach (var key in dict.Keys)
 672            {
 4415673                dictionary[key] = dict[key];
 674            }
 38675        }
 676
 677        private static string GetResourceFilename(string culture)
 678        {
 679            // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
 680            // Normalize the casing (lower-case language, upper-case region) while preserving the separator
 681            // so the result matches the embedded resource file name, which is case-sensitive.
 38682            var separatorIndex = culture.IndexOfAny(['-', '_']);
 683
 38684            if (separatorIndex > 0)
 685            {
 33686                var separator = culture[separatorIndex];
 33687                culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToU
 688            }
 689            else
 690            {
 5691                culture = culture.ToLowerInvariant();
 692            }
 693
 38694            return culture + ".json";
 695        }
 696
 697        /// <inheritdoc />
 698        public IEnumerable<LocalizationOption> GetLocalizationOptions()
 699        {
 0700            return _localizationOptions;
 701        }
 702
 703        private static (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) 
 704        {
 2705            var options = new List<LocalizationOption>();
 2706            var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 2707            var prefix = CoreResourcePrefix;
 708
 612709            foreach (var resource in _assembly.GetManifestResourceNames())
 710            {
 304711                if (!resource.StartsWith(prefix, StringComparison.Ordinal)
 304712                    || !resource.EndsWith(".json", StringComparison.Ordinal))
 713                {
 714                    continue;
 715                }
 716
 717                // Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR"
 210718                var code = resource[prefix.Length..^5];
 719
 720                // Record the BCP-47 → Jellyfin mapping for any resource file using underscores.
 210721                if (code.Contains('_', StringComparison.Ordinal))
 722                {
 10723                    bcp47Map[code.Replace('_', '-')] = code;
 724                }
 725
 726                // Skip the base language file — en-US is added explicitly below
 210727                if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase))
 728                {
 729                    continue;
 730                }
 731
 208732                var displayName = GetDisplayName(code);
 208733                options.Add(new LocalizationOption(displayName, code));
 734            }
 735
 736            // Ensure en-US is always present
 2737            options.Add(new LocalizationOption("English", DefaultCulture));
 738
 2739            options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
 2740            return (options, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
 741        }
 742
 743        private static string GetDisplayName(string cultureCode)
 744        {
 745            // Custom/novelty codes like "pr" (Pirate) — fall back to code itself
 208746            return TryGetCultureInfo(cultureCode, out var cultureInfo)
 208747                ? cultureInfo.NativeName
 208748                : cultureCode;
 749        }
 750
 751        /// <inheritdoc />
 752        public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT)
 753        {
 754            // Unlikely case the dictionary is not (yet) initialized properly
 3755            if (_iso6392BtoT is null)
 756            {
 0757                isoT = null;
 0758                return false;
 759            }
 760
 3761            var result = _iso6392BtoT.TryGetValue(isoB, out isoT) && !string.IsNullOrEmpty(isoT);
 762
 763            // Ensure the ISO code being null if the result is false
 3764            if (!result)
 765            {
 1766                isoT = null;
 767            }
 768
 3769            return result;
 770        }
 771    }
 772}