2013-06-10 13:46:11 -04:00
|
|
|
using System;
|
2013-06-10 22:34:55 -04:00
|
|
|
using System.Collections.Concurrent;
|
2025-04-08 05:29:12 +02:00
|
|
|
using System.Collections.Frozen;
|
2013-06-06 10:33:11 -04:00
|
|
|
using System.Collections.Generic;
|
2025-04-08 05:29:12 +02:00
|
|
|
using System.Diagnostics.CodeAnalysis;
|
2026-05-04 20:26:39 +02:00
|
|
|
using System.Globalization;
|
2013-06-10 13:46:11 -04:00
|
|
|
using System.IO;
|
2022-10-09 22:56:23 +02:00
|
|
|
using System.Linq;
|
2019-01-27 10:20:05 +01:00
|
|
|
using System.Reflection;
|
2020-12-23 13:12:40 +01:00
|
|
|
using System.Text.Json;
|
2019-01-27 10:20:05 +01:00
|
|
|
using System.Threading.Tasks;
|
2021-06-19 18:02:33 +02:00
|
|
|
using Jellyfin.Extensions;
|
|
|
|
|
using Jellyfin.Extensions.Json;
|
2019-01-13 20:22:00 +01:00
|
|
|
using MediaBrowser.Controller.Configuration;
|
|
|
|
|
using MediaBrowser.Model.Entities;
|
|
|
|
|
using MediaBrowser.Model.Globalization;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
2013-06-06 10:33:11 -04:00
|
|
|
|
2016-11-04 22:17:18 -04:00
|
|
|
namespace Emby.Server.Implementations.Localization
|
2013-06-06 10:33:11 -04:00
|
|
|
{
|
|
|
|
|
/// <summary>
|
2019-08-16 17:31:47 +02:00
|
|
|
/// Class LocalizationManager.
|
2013-06-06 10:33:11 -04:00
|
|
|
/// </summary>
|
|
|
|
|
public class LocalizationManager : ILocalizationManager
|
|
|
|
|
{
|
2019-08-16 17:31:47 +02:00
|
|
|
private const string DefaultCulture = "en-US";
|
2021-08-10 14:03:15 +02:00
|
|
|
private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings.";
|
|
|
|
|
private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt";
|
|
|
|
|
private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json";
|
2026-05-04 20:26:39 +02:00
|
|
|
private const string CoreResourcePrefix = "Emby.Server.Implementations.Localization.Core.";
|
2019-08-16 17:31:47 +02:00
|
|
|
private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly;
|
2025-03-31 05:51:54 +02:00
|
|
|
private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"];
|
2013-06-10 13:46:11 -04:00
|
|
|
|
2021-08-10 13:39:51 +02:00
|
|
|
private readonly IServerConfigurationManager _configurationManager;
|
|
|
|
|
private readonly ILogger<LocalizationManager> _logger;
|
|
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase);
|
2013-06-10 22:34:55 -04:00
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase);
|
2019-08-16 17:31:47 +02:00
|
|
|
|
2021-08-10 13:39:51 +02:00
|
|
|
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
|
|
|
|
|
2025-12-28 07:22:33 -05:00
|
|
|
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
2025-03-31 05:51:54 +02:00
|
|
|
private List<CultureDto> _cultures = [];
|
2021-08-15 17:20:07 +02:00
|
|
|
|
2026-05-13 21:23:53 +02:00
|
|
|
private static readonly (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) _localizationData = BuildLocalizationData();
|
|
|
|
|
private static readonly IReadOnlyList<LocalizationOption> _localizationOptions = _localizationData.Options;
|
|
|
|
|
|
|
|
|
|
// Maps BCP-47 hyphenated culture codes (set by ASP.NET Core's RequestLocalizationMiddleware
|
|
|
|
|
// and used as CurrentUICulture.Name) to Jellyfin's underscore-based resource file codes.
|
|
|
|
|
// Built reflexively from the resource file scan so both directions stay in sync.
|
|
|
|
|
private static readonly FrozenDictionary<string, string> _bcp47ToJellyfinMap = _localizationData.Bcp47ToJellyfinMap;
|
2026-05-04 20:26:39 +02:00
|
|
|
|
2025-04-08 05:29:12 +02:00
|
|
|
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
|
|
|
|
|
2013-06-10 13:46:11 -04:00
|
|
|
/// <summary>
|
2014-06-04 22:32:40 -04:00
|
|
|
/// Initializes a new instance of the <see cref="LocalizationManager" /> class.
|
2013-06-10 13:46:11 -04:00
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="configurationManager">The configuration manager.</param>
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <param name="logger">The logger.</param>
|
2019-01-17 22:55:05 +00:00
|
|
|
public LocalizationManager(
|
|
|
|
|
IServerConfigurationManager configurationManager,
|
2019-08-16 17:31:47 +02:00
|
|
|
ILogger<LocalizationManager> logger)
|
2013-06-10 13:46:11 -04:00
|
|
|
{
|
|
|
|
|
_configurationManager = configurationManager;
|
2019-08-16 17:31:47 +02:00
|
|
|
_logger = logger;
|
2026-05-04 20:26:39 +02:00
|
|
|
|
|
|
|
|
_configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-05-04 21:57:11 +02:00
|
|
|
/// Gets the supported UI cultures.
|
2026-05-04 20:26:39 +02:00
|
|
|
/// </summary>
|
2026-05-04 21:57:11 +02:00
|
|
|
/// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns>
|
|
|
|
|
public static IList<CultureInfo> GetSupportedUICultures()
|
2026-05-04 20:26:39 +02:00
|
|
|
{
|
2026-05-04 21:57:11 +02:00
|
|
|
var cultures = new List<CultureInfo>();
|
|
|
|
|
foreach (var option in _localizationOptions)
|
|
|
|
|
{
|
2026-05-14 07:46:43 +02:00
|
|
|
// Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
|
|
|
|
|
if (TryGetCultureInfo(option.Value, out var cultureInfo))
|
2026-05-04 21:57:11 +02:00
|
|
|
{
|
2026-05-14 07:46:43 +02:00
|
|
|
cultures.Add(cultureInfo);
|
2026-05-04 21:57:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-04 20:26:39 +02:00
|
|
|
|
2026-05-04 21:57:11 +02:00
|
|
|
return cultures;
|
2026-05-04 20:26:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 07:46:43 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves a Jellyfin resource culture code (which may use underscores, e.g. <c>es_419</c>)
|
|
|
|
|
/// to a <see cref="CultureInfo"/>. Returns <see langword="false"/> for codes .NET cannot resolve.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static bool TryGetCultureInfo(string cultureCode, [NotNullWhen(true)] out CultureInfo? cultureInfo)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Resource files use underscores for some variants (e.g. es_419);
|
|
|
|
|
// CultureInfo only accepts hyphenated BCP-47 codes.
|
|
|
|
|
cultureInfo = CultureInfo.GetCultureInfo(cultureCode.Replace('_', '-'));
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
catch (CultureNotFoundException)
|
|
|
|
|
{
|
|
|
|
|
cultureInfo = null;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
private static void OnConfigurationUpdated(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (sender is IServerConfigurationManager configManager)
|
|
|
|
|
{
|
|
|
|
|
var uiCulture = configManager.Configuration.UICulture;
|
|
|
|
|
if (!string.IsNullOrEmpty(uiCulture))
|
|
|
|
|
{
|
|
|
|
|
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture);
|
|
|
|
|
}
|
|
|
|
|
}
|
2013-06-18 16:54:32 -04:00
|
|
|
}
|
|
|
|
|
|
2021-08-10 13:39:51 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Loads all resources into memory.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <returns><see cref="Task" />.</returns>
|
|
|
|
|
public async Task LoadAll()
|
|
|
|
|
{
|
|
|
|
|
// Extract from the assembly
|
|
|
|
|
foreach (var resource in _assembly.GetManifestResourceNames())
|
|
|
|
|
{
|
2021-08-10 14:03:15 +02:00
|
|
|
if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal))
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
using var stream = _assembly.GetManifestResourceStream(resource);
|
|
|
|
|
if (stream is not null)
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions).ConfigureAwait(false)
|
|
|
|
|
?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
|
|
|
|
|
|
|
|
|
|
var dict = new Dictionary<string, ParentalRatingScore?>();
|
|
|
|
|
if (ratingSystem.Ratings is not null)
|
2021-08-10 14:03:15 +02:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
foreach (var ratingEntry in ratingSystem.Ratings)
|
2023-10-11 18:32:57 +02:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
foreach (var ratingString in ratingEntry.RatingStrings)
|
|
|
|
|
{
|
|
|
|
|
dict[ratingString] = ratingEntry.RatingScore;
|
|
|
|
|
}
|
2023-10-11 18:32:57 +02:00
|
|
|
}
|
2021-08-10 14:03:15 +02:00
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
_allParentalRatings[ratingSystem.CountryCode] = dict;
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await LoadCultures().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
2013-06-06 10:33:11 -04:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the cultures.
|
|
|
|
|
/// </summary>
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <returns><see cref="IEnumerable{CultureDto}" />.</returns>
|
|
|
|
|
public IEnumerable<CultureDto> GetCultures()
|
2019-01-27 10:20:05 +01:00
|
|
|
=> _cultures;
|
2017-11-05 16:51:23 -05:00
|
|
|
|
2021-08-10 13:39:51 +02:00
|
|
|
private async Task LoadCultures()
|
|
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
List<CultureDto> list = [];
|
2025-04-08 05:29:12 +02:00
|
|
|
Dictionary<string, string> iso6392BtoTdict = new Dictionary<string, string>();
|
2021-08-10 13:39:51 +02:00
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
using var stream = _assembly.GetManifestResourceStream(CulturesPath);
|
|
|
|
|
if (stream is null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'");
|
|
|
|
|
}
|
|
|
|
|
else
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
using var reader = new StreamReader(stream);
|
|
|
|
|
await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2021-08-10 14:03:15 +02:00
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
var parts = line.Split('|');
|
|
|
|
|
if (parts.Length != 5)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidDataException($"Invalid culture data found at: '{line}'");
|
|
|
|
|
}
|
2021-08-10 14:03:15 +02:00
|
|
|
|
|
|
|
|
string name = parts[3];
|
2025-07-30 22:15:37 +08:00
|
|
|
string displayname = parts[3];
|
|
|
|
|
if (string.IsNullOrWhiteSpace(displayname))
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2021-08-10 14:03:15 +02:00
|
|
|
string twoCharName = parts[2];
|
|
|
|
|
if (string.IsNullOrWhiteSpace(twoCharName))
|
|
|
|
|
{
|
Fix language display for ISO 639-2-only codes (e.g. mul, und)
LoadCultures() in LocalizationManager skipped all iso6392.txt entries
without a two-letter ISO 639-1 code, dropping 302 of 496 languages
including mul (Multiple languages), und (Undetermined), mis (Uncoded
languages), zxx, and many real languages like Achinese, Akkadian, etc.
This caused FindLanguageInfo() to return null for these codes, which
meant:
- ExternalPathParser could not recognize them as valid language codes
in subtitle filenames, so the Language field was never set
- DisplayTitle fell back to the raw code string (e.g. "Mul")
Fix by allowing entries without two-letter codes to be loaded with an
empty TwoLetterISOLanguageName. Also set LocalizedLanguage in
ProbeResultNormalizer for ffprobe-detected streams (the DB repository
path was already handled on master).
2026-04-04 16:10:07 +00:00
|
|
|
twoCharName = string.Empty;
|
2021-08-10 14:03:15 +02:00
|
|
|
}
|
2025-07-30 22:15:37 +08:00
|
|
|
else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
name = twoCharName;
|
|
|
|
|
}
|
2021-08-10 13:39:51 +02:00
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
string[] threeLetterNames;
|
2021-08-10 14:03:15 +02:00
|
|
|
if (string.IsNullOrWhiteSpace(parts[1]))
|
|
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
threeLetterNames = [parts[0]];
|
2021-08-10 14:03:15 +02:00
|
|
|
}
|
|
|
|
|
else
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
threeLetterNames = [parts[0], parts[1]];
|
2025-04-08 05:29:12 +02:00
|
|
|
|
|
|
|
|
// In cases where there are two TLN the first one is ISO 639-2/T and the second one is ISO 639-2/B
|
|
|
|
|
// We need ISO 639-2/T for the .NET cultures so we cultivate a dictionary for the translation B->T
|
|
|
|
|
iso6392BtoTdict.TryAdd(parts[1], parts[0]);
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
2021-08-10 14:03:15 +02:00
|
|
|
|
2025-07-30 22:15:37 +08:00
|
|
|
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 07:22:33 -05:00
|
|
|
_cultureCache.Clear();
|
2025-03-31 05:51:54 +02:00
|
|
|
_cultures = list;
|
2025-04-08 05:29:12 +02:00
|
|
|
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
2025-03-31 05:51:54 +02:00
|
|
|
}
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
|
|
|
|
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <inheritdoc />
|
2021-08-15 17:20:07 +02:00
|
|
|
public CultureDto? FindLanguageInfo(string language)
|
2021-05-24 00:30:41 +02:00
|
|
|
{
|
2025-12-28 07:22:33 -05:00
|
|
|
if (string.IsNullOrEmpty(language))
|
2021-05-24 00:30:41 +02:00
|
|
|
{
|
2025-12-28 07:22:33 -05:00
|
|
|
return null;
|
2021-05-24 00:30:41 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 07:22:33 -05:00
|
|
|
return _cultureCache.GetOrAdd(
|
|
|
|
|
language,
|
|
|
|
|
static (lang, cultures) =>
|
|
|
|
|
{
|
|
|
|
|
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
|
|
|
|
for (var i = 0; i < cultures.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
var culture = cultures[i];
|
|
|
|
|
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return culture;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
_cultures);
|
2021-05-24 00:30:41 +02:00
|
|
|
}
|
2018-09-12 19:26:21 +02:00
|
|
|
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <inheritdoc />
|
2025-03-31 05:51:54 +02:00
|
|
|
public IReadOnlyList<CountryInfo> GetCountries()
|
2020-12-23 13:12:40 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
|
|
|
|
|
|
|
|
|
|
return JsonSerializer.Deserialize<IReadOnlyList<CountryInfo>>(stream, _jsonOptions) ?? [];
|
2020-12-23 13:12:40 +01:00
|
|
|
}
|
2013-06-06 10:33:11 -04:00
|
|
|
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <inheritdoc />
|
2025-03-31 05:51:54 +02:00
|
|
|
public IReadOnlyList<ParentalRating> GetParentalRatings()
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2023-03-09 14:13:57 +01:00
|
|
|
// Use server default language for ratings
|
|
|
|
|
// Fall back to empty list if there are no parental ratings for that language
|
2025-03-31 05:51:54 +02:00
|
|
|
var ratings = GetParentalRatingsDictionary()?.Select(x => new ParentalRating(x.Key, x.Value)).ToList() ?? [];
|
2022-11-29 22:04:28 +01:00
|
|
|
|
2023-03-09 14:13:57 +01:00
|
|
|
// Add common ratings to ensure them being available for selection
|
2022-11-29 22:04:28 +01:00
|
|
|
// Based on the US rating system due to it being the main source of rating in the metadata providers
|
2023-03-09 14:13:57 +01:00
|
|
|
// Unrated
|
2025-03-31 05:51:54 +02:00
|
|
|
if (!ratings.Any(x => x is null))
|
2023-03-09 14:13:57 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
ratings.Add(new("Unrated", null));
|
2023-03-09 14:13:57 +01:00
|
|
|
}
|
|
|
|
|
|
2022-11-29 22:04:28 +01:00
|
|
|
// Minimum rating possible
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratings.All(x => x.RatingScore?.Score != 0))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
ratings.Add(new("Approved", new(0, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
2022-12-08 16:15:26 +01:00
|
|
|
// Matches PG (this has different age restrictions depending on country)
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratings.All(x => x.RatingScore?.Score != 10))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
ratings.Add(new("10", new(10, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Matches PG-13
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratings.All(x => x.RatingScore?.Score != 13))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
ratings.Add(new("13", new(13, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Matches TV-14
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratings.All(x => x.RatingScore?.Score != 14))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
ratings.Add(new("14", new(14, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Catchall if max rating of country is less than 21
|
|
|
|
|
// Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned
|
2025-03-31 05:51:54 +02:00
|
|
|
if (!ratings.Any(x => x.RatingScore?.Score >= 21))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
ratings.Add(new ParentalRating("21", new(21, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
2024-12-07 21:52:54 -10:00
|
|
|
// A lot of countries don't explicitly have a separate rating for adult content
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratings.All(x => x.RatingScore?.Score != 1000))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2026-04-16 11:38:01 +02:00
|
|
|
ratings.Add(new ParentalRating("XXX", new(1000, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
2024-12-07 21:52:54 -10:00
|
|
|
// A lot of countries don't explicitly have a separate rating for banned content
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratings.All(x => x.RatingScore?.Score != 1001))
|
2022-11-29 22:04:28 +01:00
|
|
|
{
|
2026-04-16 11:38:01 +02:00
|
|
|
ratings.Add(new ParentalRating("Banned", new(1001, null)));
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
|
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)];
|
2022-11-29 22:04:28 +01:00
|
|
|
}
|
2013-06-10 22:34:55 -04:00
|
|
|
|
2021-08-10 13:39:51 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the parental ratings dictionary.
|
|
|
|
|
/// </summary>
|
2023-03-09 14:13:57 +01:00
|
|
|
/// <param name="countryCode">The optional two letter ISO language string.</param>
|
2025-03-31 05:51:54 +02:00
|
|
|
/// <returns><see cref="Dictionary{String, ParentalRatingScore}" />.</returns>
|
|
|
|
|
private Dictionary<string, ParentalRatingScore?>? GetParentalRatingsDictionary(string? countryCode = null)
|
2021-08-10 13:39:51 +02:00
|
|
|
{
|
2023-03-09 14:13:57 +01:00
|
|
|
// Fallback to server default if no country code is specified.
|
2021-08-10 13:39:51 +02:00
|
|
|
if (string.IsNullOrEmpty(countryCode))
|
|
|
|
|
{
|
2023-03-09 14:13:57 +01:00
|
|
|
countryCode = _configurationManager.Configuration.MetadataCountryCode;
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
|
|
|
|
|
2023-03-09 14:13:57 +01:00
|
|
|
if (_allParentalRatings.TryGetValue(countryCode, out var countryValue))
|
|
|
|
|
{
|
|
|
|
|
return countryValue;
|
|
|
|
|
}
|
2021-08-10 13:39:51 +02:00
|
|
|
|
2023-03-09 14:13:57 +01:00
|
|
|
return null;
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
|
|
|
|
|
2019-03-13 22:32:52 +01:00
|
|
|
/// <inheritdoc />
|
2025-03-31 05:51:54 +02:00
|
|
|
public ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null)
|
2013-06-10 13:46:11 -04:00
|
|
|
{
|
2022-10-13 19:08:00 +02:00
|
|
|
ArgumentException.ThrowIfNullOrEmpty(rating);
|
2013-06-10 13:46:11 -04:00
|
|
|
|
2022-10-09 22:56:23 +02:00
|
|
|
// Handle unrated content
|
2021-05-24 00:30:41 +02:00
|
|
|
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
|
2015-11-06 10:02:22 -05:00
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
// Convert ints directly
|
2024-04-17 18:44:30 +02:00
|
|
|
// This may override some of the locale specific age ratings (but those always map to the same age)
|
|
|
|
|
if (int.TryParse(rating, out var ratingAge))
|
|
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
return new(ratingAge, null);
|
2024-04-17 18:44:30 +02:00
|
|
|
}
|
|
|
|
|
|
2015-05-11 12:32:15 -04:00
|
|
|
// Fairly common for some users to have "Rated R" in their rating field
|
2025-03-02 11:23:02 -05:00
|
|
|
rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
.Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
.Trim();
|
2015-05-11 12:32:15 -04:00
|
|
|
|
2023-03-09 14:13:57 +01:00
|
|
|
// Use rating system matching the language
|
|
|
|
|
if (!string.IsNullOrEmpty(countryCode))
|
2013-06-10 22:34:55 -04:00
|
|
|
{
|
2023-03-09 14:13:57 +01:00
|
|
|
var ratingsDictionary = GetParentalRatingsDictionary(countryCode);
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
2023-03-09 14:13:57 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
return value;
|
2023-03-09 14:13:57 +01:00
|
|
|
}
|
2026-03-26 10:06:50 +01:00
|
|
|
|
|
|
|
|
if (ratingsDictionary is not null && rating.Length > countryCode.Length
|
|
|
|
|
&& rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
&& (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
|
|
|
|
|
&& ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue))
|
|
|
|
|
{
|
|
|
|
|
return normalizedValue;
|
2026-03-29 10:28:15 +02:00
|
|
|
}
|
2023-03-09 14:13:57 +01:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Fall back to server default language for ratings check
|
2025-12-28 07:22:30 -05:00
|
|
|
var ratingsDictionary = GetParentalRatingsDictionary();
|
2025-03-31 05:51:54 +02:00
|
|
|
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
2023-03-09 14:13:57 +01:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
return value;
|
2023-03-09 14:13:57 +01:00
|
|
|
}
|
2018-09-12 19:26:21 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 07:22:30 -05:00
|
|
|
// If we don't find anything, check all ratings systems, starting with US
|
|
|
|
|
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
|
|
|
|
|
{
|
|
|
|
|
return usValue;
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-12 19:26:21 +02:00
|
|
|
foreach (var dictionary in _allParentalRatings.Values)
|
|
|
|
|
{
|
2023-03-09 14:13:57 +01:00
|
|
|
if (dictionary.TryGetValue(rating, out var value))
|
2013-06-18 16:54:32 -04:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
return value;
|
2018-09-12 19:26:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 09:14:23 +01:00
|
|
|
// Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
|
|
|
|
|
if (TryGetRatingScoreBySeparator(rating, ':', out var result)
|
|
|
|
|
|| TryGetRatingScoreBySeparator(rating, '-', out result))
|
2018-09-12 19:26:21 +02:00
|
|
|
{
|
2026-03-02 09:14:23 +01:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
|
|
|
|
|
{
|
|
|
|
|
result = null;
|
|
|
|
|
|
|
|
|
|
if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
2013-06-10 22:34:55 -04:00
|
|
|
}
|
2013-06-10 13:46:11 -04:00
|
|
|
|
2026-03-02 09:14:23 +01:00
|
|
|
var ratingSpan = rating.AsSpan();
|
|
|
|
|
var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
|
|
|
|
|
var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
|
|
|
|
|
if (ratingPart.Length == 0)
|
2022-10-09 22:56:23 +02:00
|
|
|
{
|
2026-03-02 09:14:23 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
2023-03-09 14:13:57 +01:00
|
|
|
|
2026-03-02 09:14:23 +01:00
|
|
|
string? resolvedCountryCode = null;
|
2023-03-09 14:13:57 +01:00
|
|
|
|
2026-03-02 09:14:23 +01:00
|
|
|
if (_allParentalRatings.ContainsKey(countryPart))
|
|
|
|
|
{
|
|
|
|
|
resolvedCountryCode = countryPart;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
var culture = FindLanguageInfo(countryPart);
|
|
|
|
|
if (culture is not null)
|
2024-05-13 12:47:32 -04:00
|
|
|
{
|
2026-03-02 09:14:23 +01:00
|
|
|
resolvedCountryCode = culture.TwoLetterISOLanguageName;
|
2024-05-13 12:47:32 -04:00
|
|
|
}
|
2022-10-09 22:56:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 09:14:23 +01:00
|
|
|
if (resolvedCountryCode is not null
|
|
|
|
|
&& _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
|
|
|
|
|
{
|
|
|
|
|
if (countryRatings.TryGetValue(ratingPart, out result))
|
2024-05-13 12:47:32 -04:00
|
|
|
{
|
2026-03-02 09:14:23 +01:00
|
|
|
return true;
|
2024-05-13 12:47:32 -04:00
|
|
|
}
|
2026-03-02 09:14:23 +01:00
|
|
|
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
|
|
|
|
|
rating,
|
|
|
|
|
resolvedCountryCode);
|
|
|
|
|
|
|
|
|
|
return true;
|
2022-10-09 22:56:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 09:14:23 +01:00
|
|
|
// Country not identified or no rating data available, try recursive lookup
|
|
|
|
|
result = GetRatingScore(ratingPart, resolvedCountryCode);
|
|
|
|
|
|
|
|
|
|
return true;
|
2013-06-10 13:46:11 -04:00
|
|
|
}
|
2014-03-30 21:00:47 -04:00
|
|
|
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <inheritdoc />
|
2014-03-30 21:00:47 -04:00
|
|
|
public string GetLocalizedString(string phrase)
|
2026-05-04 20:26:39 +02:00
|
|
|
{
|
|
|
|
|
return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
|
|
public string GetServerLocalizedString(string phrase)
|
2014-03-30 21:00:47 -04:00
|
|
|
{
|
|
|
|
|
return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
|
|
|
|
|
}
|
|
|
|
|
|
2019-08-16 17:31:47 +02:00
|
|
|
/// <inheritdoc />
|
2014-03-30 21:00:47 -04:00
|
|
|
public string GetLocalizedString(string phrase, string culture)
|
|
|
|
|
{
|
2018-09-12 19:26:21 +02:00
|
|
|
if (string.IsNullOrEmpty(culture))
|
2017-10-20 12:16:56 -04:00
|
|
|
{
|
|
|
|
|
culture = _configurationManager.Configuration.UICulture;
|
|
|
|
|
}
|
2019-03-13 22:32:52 +01:00
|
|
|
|
2018-09-12 19:26:21 +02:00
|
|
|
if (string.IsNullOrEmpty(culture))
|
2017-10-21 12:39:52 -04:00
|
|
|
{
|
|
|
|
|
culture = DefaultCulture;
|
|
|
|
|
}
|
2017-10-20 12:16:56 -04:00
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
// Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
|
2026-05-04 21:57:11 +02:00
|
|
|
if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
|
2026-05-04 20:26:39 +02:00
|
|
|
{
|
|
|
|
|
culture = mapped;
|
|
|
|
|
}
|
|
|
|
|
|
2014-03-30 21:00:47 -04:00
|
|
|
var dictionary = GetLocalizationDictionary(culture);
|
|
|
|
|
|
2019-01-13 21:46:33 +01:00
|
|
|
if (dictionary.TryGetValue(phrase, out var value))
|
2014-03-30 21:00:47 -04:00
|
|
|
{
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
var fallback = GetLocalizationDictionary(DefaultCulture);
|
|
|
|
|
if (fallback.TryGetValue(phrase, out var fallbackValue))
|
|
|
|
|
{
|
|
|
|
|
return fallbackValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2014-03-30 21:00:47 -04:00
|
|
|
return phrase;
|
|
|
|
|
}
|
|
|
|
|
|
2019-08-16 17:31:47 +02:00
|
|
|
private Dictionary<string, string> GetLocalizationDictionary(string culture)
|
2014-03-30 21:00:47 -04:00
|
|
|
{
|
2022-10-13 19:08:00 +02:00
|
|
|
ArgumentException.ThrowIfNullOrEmpty(culture);
|
2017-10-21 12:39:52 -04:00
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
return _cultureOnlyDictionaries.GetOrAdd(
|
2021-04-30 15:09:36 +02:00
|
|
|
culture,
|
2026-05-04 20:26:39 +02:00
|
|
|
static (key, localizationManager) =>
|
|
|
|
|
{
|
|
|
|
|
var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
var namespaceName = localizationManager.GetType().Namespace + ".Core";
|
|
|
|
|
localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter().GetResult();
|
2014-03-30 21:00:47 -04:00
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
return dictionary;
|
|
|
|
|
},
|
|
|
|
|
this);
|
2014-03-30 21:00:47 -04:00
|
|
|
}
|
|
|
|
|
|
2019-01-27 10:20:05 +01:00
|
|
|
private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
|
2014-03-30 21:00:47 -04:00
|
|
|
{
|
2025-03-31 05:51:54 +02:00
|
|
|
using var stream = _assembly.GetManifestResourceStream(resourcePath);
|
2021-08-10 14:03:15 +02:00
|
|
|
// If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain
|
2022-12-05 15:00:20 +01:00
|
|
|
if (stream is null)
|
2014-03-30 21:00:47 -04:00
|
|
|
{
|
2021-08-15 17:20:07 +02:00
|
|
|
_logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2019-02-08 10:03:38 +01:00
|
|
|
|
2025-03-31 05:51:54 +02:00
|
|
|
var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Resource contains invalid data: '{stream}'");
|
2021-08-15 17:20:07 +02:00
|
|
|
foreach (var key in dict.Keys)
|
2021-08-10 14:03:15 +02:00
|
|
|
{
|
2021-08-15 17:20:07 +02:00
|
|
|
dictionary[key] = dict[key];
|
2021-08-10 14:03:15 +02:00
|
|
|
}
|
2014-03-30 21:00:47 -04:00
|
|
|
}
|
|
|
|
|
|
2019-01-06 21:50:43 +01:00
|
|
|
private static string GetResourceFilename(string culture)
|
2014-03-30 21:00:47 -04:00
|
|
|
{
|
|
|
|
|
var parts = culture.Split('-');
|
|
|
|
|
|
|
|
|
|
if (parts.Length == 2)
|
|
|
|
|
{
|
2019-01-27 12:03:43 +01:00
|
|
|
culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
|
2014-03-30 21:00:47 -04:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2019-01-27 12:03:43 +01:00
|
|
|
culture = culture.ToLowerInvariant();
|
2014-03-30 21:00:47 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return culture + ".json";
|
|
|
|
|
}
|
2021-08-10 13:39:51 +02:00
|
|
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
|
|
public IEnumerable<LocalizationOption> GetLocalizationOptions()
|
|
|
|
|
{
|
2026-05-04 20:26:39 +02:00
|
|
|
return _localizationOptions;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 21:23:53 +02:00
|
|
|
private static (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) BuildLocalizationData()
|
2026-05-04 20:26:39 +02:00
|
|
|
{
|
|
|
|
|
var options = new List<LocalizationOption>();
|
2026-05-13 21:23:53 +02:00
|
|
|
var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
2026-05-04 20:26:39 +02:00
|
|
|
var prefix = CoreResourcePrefix;
|
|
|
|
|
|
|
|
|
|
foreach (var resource in _assembly.GetManifestResourceNames())
|
|
|
|
|
{
|
|
|
|
|
if (!resource.StartsWith(prefix, StringComparison.Ordinal)
|
|
|
|
|
|| !resource.EndsWith(".json", StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR"
|
|
|
|
|
var code = resource[prefix.Length..^5];
|
|
|
|
|
|
2026-05-13 21:23:53 +02:00
|
|
|
// Record the BCP-47 → Jellyfin mapping for any resource file using underscores.
|
|
|
|
|
if (code.Contains('_', StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
bcp47Map[code.Replace('_', '-')] = code;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:26:39 +02:00
|
|
|
// Skip the base language file — en-US is added explicitly below
|
|
|
|
|
if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var displayName = GetDisplayName(code);
|
|
|
|
|
options.Add(new LocalizationOption(displayName, code));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure en-US is always present
|
|
|
|
|
options.Add(new LocalizationOption("English", DefaultCulture));
|
|
|
|
|
|
|
|
|
|
options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
2026-05-13 21:23:53 +02:00
|
|
|
return (options, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
2026-05-04 20:26:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetDisplayName(string cultureCode)
|
|
|
|
|
{
|
2026-05-14 07:46:43 +02:00
|
|
|
// Custom/novelty codes like "pr" (Pirate) — fall back to code itself
|
|
|
|
|
return TryGetCultureInfo(cultureCode, out var cultureInfo)
|
|
|
|
|
? cultureInfo.NativeName
|
2026-05-13 21:23:53 +02:00
|
|
|
: cultureCode;
|
2021-08-10 13:39:51 +02:00
|
|
|
}
|
2025-04-08 05:29:12 +02:00
|
|
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
|
|
public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT)
|
|
|
|
|
{
|
|
|
|
|
// Unlikely case the dictionary is not (yet) initialized properly
|
2025-05-04 16:40:34 +02:00
|
|
|
if (_iso6392BtoT is null)
|
2025-04-08 05:29:12 +02:00
|
|
|
{
|
|
|
|
|
isoT = null;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result = _iso6392BtoT.TryGetValue(isoB, out isoT) && !string.IsNullOrEmpty(isoT);
|
|
|
|
|
|
|
|
|
|
// Ensure the ISO code being null if the result is false
|
|
|
|
|
if (!result)
|
|
|
|
|
{
|
|
|
|
|
isoT = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
2013-06-06 10:33:11 -04:00
|
|
|
}
|
|
|
|
|
}
|