mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-15 13:16:49 +01:00
Add Accept-Language header support for per-request localization
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
@@ -7,6 +9,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Localization;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Net.WebSocketMessages;
|
||||
@@ -69,6 +72,17 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
/// <inheritdoc />
|
||||
public IPAddress? RemoteEndPoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or initializes the culture fallback chain captured from the
|
||||
/// <c>Accept-Language</c> header of the upgrade request.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? RequestCultureFallback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or initializes the UI culture name captured from the upgrade request.
|
||||
/// </summary>
|
||||
public string? RequestUICulture { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; }
|
||||
|
||||
@@ -81,6 +95,28 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
/// <inheritdoc />
|
||||
public WebSocketState State => _socket.State;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ApplyRequestCulture()
|
||||
{
|
||||
if (RequestCultureFallback is not null)
|
||||
{
|
||||
LocalizationManager.RequestCultureFallback = RequestCultureFallback;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(RequestUICulture))
|
||||
{
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(RequestUICulture);
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
// Jellyfin culture codes (e.g. "es_419") aren't always valid .NET cultures —
|
||||
// skip setting CurrentUICulture; RequestCultureFallback above carries the chain.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Localization;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -48,13 +50,22 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
|
||||
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||
|
||||
// Capture the culture context set by AcceptLanguageMiddleware so it can be
|
||||
// restored both when processing incoming messages and when periodic
|
||||
// listeners produce server-initiated payloads on background tasks.
|
||||
var connection = new WebSocketConnection(
|
||||
_loggerFactory.CreateLogger<WebSocketConnection>(),
|
||||
webSocket,
|
||||
authorizationInfo,
|
||||
context.GetNormalizedRemoteIP())
|
||||
{
|
||||
OnReceive = ProcessWebSocketMessageReceived
|
||||
RequestCultureFallback = LocalizationManager.RequestCultureFallback,
|
||||
RequestUICulture = CultureInfo.CurrentUICulture.Name
|
||||
};
|
||||
connection.OnReceive = result =>
|
||||
{
|
||||
connection.ApplyRequestCulture();
|
||||
return ProcessWebSocketMessageReceived(result);
|
||||
};
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
{
|
||||
"Albums": "Albums",
|
||||
"AppDeviceValues": "App: {0}, Device: {1}",
|
||||
"Application": "Application",
|
||||
"Artists": "Artists",
|
||||
"AuthenticationSucceededWithUserName": "{0} successfully authenticated",
|
||||
"Books": "Books",
|
||||
"CameraImageUploadedFrom": "A new camera image has been uploaded from {0}",
|
||||
"Channels": "Channels",
|
||||
"ChapterNameValue": "Chapter {0}",
|
||||
"Collections": "Collections",
|
||||
"Default": "Default",
|
||||
"DeviceOfflineWithName": "{0} has disconnected",
|
||||
"DeviceOnlineWithName": "{0} is connected",
|
||||
"External": "External",
|
||||
"FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
|
||||
"Favorites": "Favorites",
|
||||
"Folders": "Folders",
|
||||
"Forced": "Forced",
|
||||
"Genres": "Genres",
|
||||
"HeaderAlbumArtists": "Album artists",
|
||||
"HeaderContinueWatching": "Continue Watching",
|
||||
"HeaderFavoriteAlbums": "Favorite Albums",
|
||||
"HeaderFavoriteArtists": "Favorite Artists",
|
||||
"HeaderFavoriteEpisodes": "Favorite Episodes",
|
||||
"HeaderFavoriteShows": "Favorite Shows",
|
||||
"HeaderFavoriteSongs": "Favorite Songs",
|
||||
"HeaderLiveTV": "Live TV",
|
||||
"HeaderNextUp": "Next Up",
|
||||
"HeaderRecordingGroups": "Recording Groups",
|
||||
"HearingImpaired": "Hearing Impaired",
|
||||
"HomeVideos": "Home Videos",
|
||||
"Inherit": "Inherit",
|
||||
"ItemAddedWithName": "{0} was added to the library",
|
||||
"ItemRemovedWithName": "{0} was removed from the library",
|
||||
"LabelIpAddressValue": "IP address: {0}",
|
||||
"LabelRunningTimeValue": "Running time: {0}",
|
||||
"Latest": "Latest",
|
||||
"MessageApplicationUpdated": "Jellyfin Server has been updated",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated",
|
||||
"MessageServerConfigurationUpdated": "Server configuration has been updated",
|
||||
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
|
||||
"MixedContent": "Mixed content",
|
||||
"Movies": "Movies",
|
||||
"Music": "Music",
|
||||
@@ -66,24 +50,15 @@
|
||||
"NotificationOptionVideoPlaybackStopped": "Video playback stopped",
|
||||
"Original": "Original",
|
||||
"Photos": "Photos",
|
||||
"Playlists": "Playlists",
|
||||
"Plugin": "Plugin",
|
||||
"PluginInstalledWithName": "{0} was installed",
|
||||
"PluginUninstalledWithName": "{0} was uninstalled",
|
||||
"PluginUpdatedWithName": "{0} was updated",
|
||||
"ProviderValue": "Provider: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} failed",
|
||||
"ScheduledTaskStartedWithName": "{0} started",
|
||||
"ServerNameNeedsToBeRestarted": "{0} needs to be restarted",
|
||||
"Shows": "Shows",
|
||||
"Songs": "Songs",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.",
|
||||
"SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}",
|
||||
"Sync": "Sync",
|
||||
"System": "System",
|
||||
"TvShows": "TV Shows",
|
||||
"Undefined": "Undefined",
|
||||
"User": "User",
|
||||
"UserCreatedWithName": "User {0} has been created",
|
||||
"UserDeletedWithName": "User {0} has been deleted",
|
||||
"UserDownloadingItemWithValues": "{0} is downloading {1}",
|
||||
@@ -91,11 +66,8 @@
|
||||
"UserOfflineFromDevice": "{0} has disconnected from {1}",
|
||||
"UserOnlineFromDevice": "{0} is online from {1}",
|
||||
"UserPasswordChangedWithName": "Password has been changed for user {0}",
|
||||
"UserPolicyUpdatedWithName": "User policy has been updated for {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} has been added to your media library",
|
||||
"ValueSpecialEpisodeName": "Special - {0}",
|
||||
"VersionNumber": "Version {0}",
|
||||
"TasksMaintenanceCategory": "Maintenance",
|
||||
"TasksLibraryCategory": "Library",
|
||||
@@ -121,8 +93,8 @@
|
||||
"TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.",
|
||||
"TaskCleanTranscode": "Clean Transcode Directory",
|
||||
"TaskCleanTranscodeDescription": "Deletes transcode files more than one day old.",
|
||||
"TaskRefreshChannels": "Refresh Channels",
|
||||
"TaskRefreshChannelsDescription": "Refreshes internet channel information.",
|
||||
"TasksRefreshChannels": "Refresh Channels",
|
||||
"TasksRefreshChannelsDescription": "Refreshes internet channel information.",
|
||||
"TaskDownloadMissingLyrics": "Download missing lyrics",
|
||||
"TaskDownloadMissingLyricsDescription": "Downloads lyrics for songs",
|
||||
"TaskDownloadMissingSubtitles": "Download missing subtitles",
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
@@ -26,21 +28,36 @@ namespace Emby.Server.Implementations.Localization
|
||||
private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings.";
|
||||
private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt";
|
||||
private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json";
|
||||
private const string CoreResourcePrefix = "Emby.Server.Implementations.Localization.Core.";
|
||||
private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly;
|
||||
private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mapping from BCP-47 hyphenated culture codes to Jellyfin's underscore-based codes.
|
||||
/// </summary>
|
||||
public static readonly FrozenDictionary<string, string> Bcp47ToJellyfinMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["es-419"] = "es_419",
|
||||
["es-DO"] = "es_DO",
|
||||
["ur-PK"] = "ur_PK"
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly ILogger<LocalizationManager> _logger;
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly AsyncLocal<IReadOnlyList<string>?> _requestCultureFallback = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private List<CultureDto> _cultures = [];
|
||||
|
||||
private static readonly IReadOnlyList<LocalizationOption> _localizationOptions = BuildLocalizationOptions();
|
||||
|
||||
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
||||
|
||||
/// <summary>
|
||||
@@ -54,6 +71,41 @@ namespace Emby.Server.Implementations.Localization
|
||||
{
|
||||
_configurationManager = configurationManager;
|
||||
_logger = logger;
|
||||
|
||||
_configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-request culture fallback chain resolved from Accept-Language.
|
||||
/// Each entry is a Jellyfin culture code (e.g. "de", "nl", "en-US") in priority order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string>? RequestCultureFallback
|
||||
{
|
||||
get => _requestCultureFallback.Value;
|
||||
set => _requestCultureFallback.Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a translation resource file exists for the given culture code.
|
||||
/// </summary>
|
||||
/// <param name="culture">The culture code to check (e.g. "de", "pt-BR", "es_419").</param>
|
||||
/// <returns><c>true</c> if an embedded translation resource exists for the culture.</returns>
|
||||
public static bool HasTranslation(string culture)
|
||||
{
|
||||
var resourceName = CoreResourcePrefix + GetResourceFilename(culture);
|
||||
return _assembly.GetManifestResourceInfo(resourceName) is not null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -419,6 +471,27 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetLocalizedString(string phrase)
|
||||
{
|
||||
var fallback = _requestCultureFallback.Value;
|
||||
if (fallback is not null)
|
||||
{
|
||||
foreach (var culture in fallback)
|
||||
{
|
||||
var dict = GetLocalizationDictionary(culture);
|
||||
if (dict.TryGetValue(phrase, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return phrase;
|
||||
}
|
||||
|
||||
return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetServerLocalizedString(string phrase)
|
||||
{
|
||||
return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
|
||||
}
|
||||
@@ -436,6 +509,12 @@ namespace Emby.Server.Implementations.Localization
|
||||
culture = DefaultCulture;
|
||||
}
|
||||
|
||||
// Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
|
||||
if (Bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
|
||||
{
|
||||
culture = mapped;
|
||||
}
|
||||
|
||||
var dictionary = GetLocalizationDictionary(culture);
|
||||
|
||||
if (dictionary.TryGetValue(phrase, out var value))
|
||||
@@ -443,6 +522,15 @@ namespace Emby.Server.Implementations.Localization
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fallback = GetLocalizationDictionary(DefaultCulture);
|
||||
if (fallback.TryGetValue(phrase, out var fallbackValue))
|
||||
{
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
return phrase;
|
||||
}
|
||||
|
||||
@@ -450,28 +538,19 @@ namespace Emby.Server.Implementations.Localization
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(culture);
|
||||
|
||||
const string Prefix = "Core";
|
||||
|
||||
return _dictionaries.GetOrAdd(
|
||||
return _cultureOnlyDictionaries.GetOrAdd(
|
||||
culture,
|
||||
static (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(),
|
||||
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();
|
||||
|
||||
return dictionary;
|
||||
},
|
||||
this);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(culture);
|
||||
|
||||
var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var namespaceName = GetType().Namespace + "." + prefix;
|
||||
|
||||
await CopyInto(dictionary, namespaceName + "." + baseFilename).ConfigureAwait(false);
|
||||
await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)).ConfigureAwait(false);
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath)
|
||||
{
|
||||
using var stream = _assembly.GetManifestResourceStream(resourcePath);
|
||||
@@ -508,77 +587,68 @@ namespace Emby.Server.Implementations.Localization
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<LocalizationOption> GetLocalizationOptions()
|
||||
{
|
||||
yield return new LocalizationOption("Afrikaans", "af");
|
||||
yield return new LocalizationOption("العربية", "ar");
|
||||
yield return new LocalizationOption("Беларуская", "be");
|
||||
yield return new LocalizationOption("Български", "bg-BG");
|
||||
yield return new LocalizationOption("বাংলা (বাংলাদেশ)", "bn");
|
||||
yield return new LocalizationOption("Català", "ca");
|
||||
yield return new LocalizationOption("Čeština", "cs");
|
||||
yield return new LocalizationOption("Cymraeg", "cy");
|
||||
yield return new LocalizationOption("Dansk", "da");
|
||||
yield return new LocalizationOption("Deutsch", "de");
|
||||
yield return new LocalizationOption("English (United Kingdom)", "en-GB");
|
||||
yield return new LocalizationOption("English", "en-US");
|
||||
yield return new LocalizationOption("Ελληνικά", "el");
|
||||
yield return new LocalizationOption("Esperanto", "eo");
|
||||
yield return new LocalizationOption("Español", "es");
|
||||
yield return new LocalizationOption("Español americano", "es_419");
|
||||
yield return new LocalizationOption("Español (Argentina)", "es-AR");
|
||||
yield return new LocalizationOption("Español (Dominicana)", "es_DO");
|
||||
yield return new LocalizationOption("Español (México)", "es-MX");
|
||||
yield return new LocalizationOption("Eesti", "et");
|
||||
yield return new LocalizationOption("Basque", "eu");
|
||||
yield return new LocalizationOption("فارسی", "fa");
|
||||
yield return new LocalizationOption("Suomi", "fi");
|
||||
yield return new LocalizationOption("Filipino", "fil");
|
||||
yield return new LocalizationOption("Français", "fr");
|
||||
yield return new LocalizationOption("Français (Canada)", "fr-CA");
|
||||
yield return new LocalizationOption("Galego", "gl");
|
||||
yield return new LocalizationOption("Schwiizerdütsch", "gsw");
|
||||
yield return new LocalizationOption("עִבְרִית", "he");
|
||||
yield return new LocalizationOption("हिन्दी", "hi");
|
||||
yield return new LocalizationOption("Hrvatski", "hr");
|
||||
yield return new LocalizationOption("Magyar", "hu");
|
||||
yield return new LocalizationOption("Bahasa Indonesia", "id");
|
||||
yield return new LocalizationOption("Íslenska", "is");
|
||||
yield return new LocalizationOption("Italiano", "it");
|
||||
yield return new LocalizationOption("日本語", "ja");
|
||||
yield return new LocalizationOption("Qazaqşa", "kk");
|
||||
yield return new LocalizationOption("한국어", "ko");
|
||||
yield return new LocalizationOption("Lietuvių", "lt");
|
||||
yield return new LocalizationOption("Latviešu", "lv");
|
||||
yield return new LocalizationOption("Македонски", "mk");
|
||||
yield return new LocalizationOption("മലയാളം", "ml");
|
||||
yield return new LocalizationOption("मराठी", "mr");
|
||||
yield return new LocalizationOption("Bahasa Melayu", "ms");
|
||||
yield return new LocalizationOption("Norsk bokmål", "nb");
|
||||
yield return new LocalizationOption("नेपाली", "ne");
|
||||
yield return new LocalizationOption("Nederlands", "nl");
|
||||
yield return new LocalizationOption("Norsk nynorsk", "nn");
|
||||
yield return new LocalizationOption("ਪੰਜਾਬੀ", "pa");
|
||||
yield return new LocalizationOption("Polski", "pl");
|
||||
yield return new LocalizationOption("Pirate", "pr");
|
||||
yield return new LocalizationOption("Português", "pt");
|
||||
yield return new LocalizationOption("Português (Brasil)", "pt-BR");
|
||||
yield return new LocalizationOption("Português (Portugal)", "pt-PT");
|
||||
yield return new LocalizationOption("Românește", "ro");
|
||||
yield return new LocalizationOption("Русский", "ru");
|
||||
yield return new LocalizationOption("Slovenčina", "sk");
|
||||
yield return new LocalizationOption("Slovenščina", "sl-SI");
|
||||
yield return new LocalizationOption("Shqip", "sq");
|
||||
yield return new LocalizationOption("Српски", "sr");
|
||||
yield return new LocalizationOption("Svenska", "sv");
|
||||
yield return new LocalizationOption("தமிழ்", "ta");
|
||||
yield return new LocalizationOption("తెలుగు", "te");
|
||||
yield return new LocalizationOption("ภาษาไทย", "th");
|
||||
yield return new LocalizationOption("Türkçe", "tr");
|
||||
yield return new LocalizationOption("Українська", "uk");
|
||||
yield return new LocalizationOption("اُردُو", "ur_PK");
|
||||
yield return new LocalizationOption("Tiếng Việt", "vi");
|
||||
yield return new LocalizationOption("汉语 (简体字)", "zh-CN");
|
||||
yield return new LocalizationOption("漢語 (繁體字)", "zh-TW");
|
||||
yield return new LocalizationOption("廣東話 (香港)", "zh-HK");
|
||||
return _localizationOptions;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LocalizationOption> BuildLocalizationOptions()
|
||||
{
|
||||
var options = new List<LocalizationOption>();
|
||||
var prefix = CoreResourcePrefix;
|
||||
|
||||
foreach (var resource in _assembly.GetManifestResourceNames())
|
||||
{
|
||||
if (!resource.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| !resource.EndsWith(".json", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR"
|
||||
var code = resource[prefix.Length..^5];
|
||||
|
||||
// Skip the base language file — en-US is added explicitly below
|
||||
if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var displayName = GetDisplayName(code);
|
||||
options.Add(new LocalizationOption(displayName, code));
|
||||
}
|
||||
|
||||
// Ensure en-US is always present
|
||||
options.Add(new LocalizationOption("English", DefaultCulture));
|
||||
|
||||
options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
return options;
|
||||
}
|
||||
|
||||
private static string GetDisplayName(string cultureCode)
|
||||
{
|
||||
// Handle Jellyfin-specific codes that aren't valid CultureInfo names
|
||||
if (Bcp47ToJellyfinMap.Values.Contains(cultureCode))
|
||||
{
|
||||
// Convert underscore to hyphen for CultureInfo lookup
|
||||
var normalized = cultureCode.Replace('_', '-');
|
||||
try
|
||||
{
|
||||
return CultureInfo.GetCultureInfo(normalized).NativeName;
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
return cultureCode;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return CultureInfo.GetCultureInfo(cultureCode).NativeName;
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
// Custom/novelty codes like "pr" (Pirate) — fall back to code itself
|
||||
return cultureCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -935,11 +935,11 @@ public class LibraryController : BaseJellyfinApiController
|
||||
try
|
||||
{
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
|
||||
string.Format(CultureInfo.InvariantCulture, _localization.GetServerLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
|
||||
"UserDownloadingContent",
|
||||
User.GetUserId())
|
||||
{
|
||||
ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()),
|
||||
ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetServerLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()),
|
||||
ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture)
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEve
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"),
|
||||
_localizationManager.GetServerLocalizedString("LyricDownloadFailureFromForItem"),
|
||||
eventArgs.Provider,
|
||||
GetItemName(eventArgs.Item)),
|
||||
"LyricDownloadFailure",
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Library
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("SubtitleDownloadFailureFromForItem"),
|
||||
_localizationManager.GetServerLocalizedString("SubtitleDownloadFailureFromForItem"),
|
||||
eventArgs.Provider,
|
||||
GetItemName(eventArgs.Item)),
|
||||
"SubtitleDownloadFailure",
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("FailedLoginAttemptWithUserName"),
|
||||
_localizationManager.GetServerLocalizedString("FailedLoginAttemptWithUserName"),
|
||||
eventArgs.Username),
|
||||
"AuthenticationFailed",
|
||||
Guid.Empty)
|
||||
@@ -43,7 +43,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security
|
||||
LogSeverity = LogLevel.Error,
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("LabelIpAddressValue"),
|
||||
_localizationManager.GetServerLocalizedString("LabelIpAddressValue"),
|
||||
eventArgs.RemoteEndPoint),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"),
|
||||
_localizationManager.GetServerLocalizedString("AuthenticationSucceededWithUserName"),
|
||||
eventArgs.User.Name),
|
||||
"AuthenticationSucceeded",
|
||||
eventArgs.User.Id)
|
||||
{
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("LabelIpAddressValue"),
|
||||
_localizationManager.GetServerLocalizedString("LabelIpAddressValue"),
|
||||
eventArgs.SessionInfo?.RemoteEndPoint),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserStartedPlayingItemWithValues"),
|
||||
_localizationManager.GetServerLocalizedString("UserStartedPlayingItemWithValues"),
|
||||
user.Username,
|
||||
GetItemName(eventArgs.MediaInfo),
|
||||
eventArgs.DeviceName),
|
||||
|
||||
@@ -69,7 +69,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserStoppedPlayingItemWithValues"),
|
||||
_localizationManager.GetServerLocalizedString("UserStoppedPlayingItemWithValues"),
|
||||
user.Username,
|
||||
GetItemName(item),
|
||||
eventArgs.DeviceName),
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserOfflineFromDevice"),
|
||||
_localizationManager.GetServerLocalizedString("UserOfflineFromDevice"),
|
||||
eventArgs.Argument.UserName,
|
||||
eventArgs.Argument.DeviceName),
|
||||
"SessionEnded",
|
||||
@@ -46,7 +46,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
|
||||
{
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("LabelIpAddressValue"),
|
||||
_localizationManager.GetServerLocalizedString("LabelIpAddressValue"),
|
||||
eventArgs.Argument.RemoteEndPoint),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserOnlineFromDevice"),
|
||||
_localizationManager.GetServerLocalizedString("UserOnlineFromDevice"),
|
||||
eventArgs.Argument.UserName,
|
||||
eventArgs.Argument.DeviceName),
|
||||
"SessionStarted",
|
||||
@@ -46,7 +46,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
|
||||
{
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("LabelIpAddressValue"),
|
||||
_localizationManager.GetServerLocalizedString("LabelIpAddressValue"),
|
||||
eventArgs.Argument.RemoteEndPoint)
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System
|
||||
var time = result.EndTimeUtc - result.StartTimeUtc;
|
||||
var runningTime = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("LabelRunningTimeValue"),
|
||||
_localizationManager.GetServerLocalizedString("LabelRunningTimeValue"),
|
||||
ToUserFriendlyString(time));
|
||||
|
||||
if (result.Status == TaskCompletionStatus.Failed)
|
||||
@@ -65,7 +65,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System
|
||||
}
|
||||
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name),
|
||||
string.Format(CultureInfo.InvariantCulture, _localizationManager.GetServerLocalizedString("ScheduledTaskFailedWithName"), task.Name),
|
||||
NotificationType.TaskFailed.ToString(),
|
||||
Guid.Empty)
|
||||
{
|
||||
|
||||
@@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("NameInstallFailed"),
|
||||
_localizationManager.GetServerLocalizedString("NameInstallFailed"),
|
||||
eventArgs.InstallationInfo.Name),
|
||||
NotificationType.InstallationFailed.ToString(),
|
||||
Guid.Empty)
|
||||
{
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("VersionNumber"),
|
||||
_localizationManager.GetServerLocalizedString("VersionNumber"),
|
||||
eventArgs.InstallationInfo.Version),
|
||||
Overview = eventArgs.Exception.Message
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
@@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("PluginInstalledWithName"),
|
||||
_localizationManager.GetServerLocalizedString("PluginInstalledWithName"),
|
||||
eventArgs.Argument.Name),
|
||||
NotificationType.PluginInstalled.ToString(),
|
||||
Guid.Empty)
|
||||
{
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("VersionNumber"),
|
||||
_localizationManager.GetServerLocalizedString("VersionNumber"),
|
||||
eventArgs.Argument.Version)
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("PluginUninstalledWithName"),
|
||||
_localizationManager.GetServerLocalizedString("PluginUninstalledWithName"),
|
||||
eventArgs.Argument.Name),
|
||||
NotificationType.PluginUninstalled.ToString(),
|
||||
Guid.Empty))
|
||||
|
||||
@@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("PluginUpdatedWithName"),
|
||||
_localizationManager.GetServerLocalizedString("PluginUpdatedWithName"),
|
||||
eventArgs.Argument.Name),
|
||||
NotificationType.PluginUpdateInstalled.ToString(),
|
||||
Guid.Empty)
|
||||
{
|
||||
ShortOverview = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("VersionNumber"),
|
||||
_localizationManager.GetServerLocalizedString("VersionNumber"),
|
||||
eventArgs.Argument.Version),
|
||||
Overview = eventArgs.Argument.Changelog
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserCreatedWithName"),
|
||||
_localizationManager.GetServerLocalizedString("UserCreatedWithName"),
|
||||
eventArgs.Argument.Username),
|
||||
"UserCreated",
|
||||
eventArgs.Argument.Id))
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserDeletedWithName"),
|
||||
_localizationManager.GetServerLocalizedString("UserDeletedWithName"),
|
||||
eventArgs.Argument.Username),
|
||||
"UserDeleted",
|
||||
Guid.Empty))
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserLockedOutWithName"),
|
||||
_localizationManager.GetServerLocalizedString("UserLockedOutWithName"),
|
||||
eventArgs.Argument.Username),
|
||||
NotificationType.UserLockedOut.ToString(),
|
||||
eventArgs.Argument.Id)
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users
|
||||
await _activityManager.CreateAsync(new ActivityLog(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localizationManager.GetLocalizedString("UserPasswordChangedWithName"),
|
||||
_localizationManager.GetServerLocalizedString("UserPasswordChangedWithName"),
|
||||
eventArgs.Argument.Username),
|
||||
"UserPasswordChanged",
|
||||
eventArgs.Argument.Id))
|
||||
|
||||
137
Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs
Normal file
137
Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Localization;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Server.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that resolves the <c>Accept-Language</c> request header
|
||||
/// to an ordered list of Jellyfin-supported cultures, sets the fallback chain
|
||||
/// on <see cref="LocalizationManager.RequestCultureFallback"/>, and writes
|
||||
/// the <c>Content-Language</c> response header.
|
||||
/// </summary>
|
||||
public class AcceptLanguageMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AcceptLanguageMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="next">Next request delegate.</param>
|
||||
public AcceptLanguageMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoke request.
|
||||
/// </summary>
|
||||
/// <param name="context">Request context.</param>
|
||||
/// <param name="configurationManager">The server configuration manager.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task Invoke(HttpContext context, IServerConfigurationManager configurationManager)
|
||||
{
|
||||
var chain = ResolveLanguages(context.Request, configurationManager);
|
||||
if (chain is not null)
|
||||
{
|
||||
LocalizationManager.RequestCultureFallback = chain;
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(chain[0]);
|
||||
}
|
||||
|
||||
context.Response.OnStarting(
|
||||
static state =>
|
||||
{
|
||||
var (ctx, languages) = ((HttpContext, IReadOnlyList<string>?))state;
|
||||
if (languages is not null)
|
||||
{
|
||||
ctx.Response.Headers.ContentLanguage = string.Join(", ", languages);
|
||||
}
|
||||
else
|
||||
{
|
||||
var culture = CultureInfo.CurrentUICulture.Name;
|
||||
if (!string.IsNullOrEmpty(culture))
|
||||
{
|
||||
ctx.Response.Headers.ContentLanguage = culture;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
(context, chain));
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalizationManager.RequestCultureFallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ResolveLanguages(HttpRequest request, IServerConfigurationManager configurationManager)
|
||||
{
|
||||
var acceptLanguageHeader = request.GetTypedHeaders().AcceptLanguage;
|
||||
if (acceptLanguageHeader is null || acceptLanguageHeader.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var languages = acceptLanguageHeader
|
||||
.OrderByDescending(h => h.Quality ?? 1)
|
||||
.Select(h => h.Value.ToString());
|
||||
|
||||
var chain = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var lang in languages)
|
||||
{
|
||||
TryAddCulture(lang, chain, seen);
|
||||
}
|
||||
|
||||
// Append server default culture if not already present
|
||||
var serverCulture = configurationManager.Configuration.UICulture;
|
||||
if (!string.IsNullOrEmpty(serverCulture))
|
||||
{
|
||||
TryAddCulture(serverCulture, chain, seen);
|
||||
}
|
||||
|
||||
// Ensure en-US is always the final fallback
|
||||
TryAddCulture("en-US", chain, seen);
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static void TryAddCulture(string lang, List<string> chain, HashSet<string> seen)
|
||||
{
|
||||
// Direct match
|
||||
if (LocalizationManager.HasTranslation(lang) && seen.Add(lang))
|
||||
{
|
||||
chain.Add(lang);
|
||||
return;
|
||||
}
|
||||
|
||||
// BCP-47 to Jellyfin underscore mapping (e.g. es-419 -> es_419)
|
||||
if (LocalizationManager.Bcp47ToJellyfinMap.TryGetValue(lang, out var mapped) && seen.Add(mapped))
|
||||
{
|
||||
chain.Add(mapped);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parent culture fallback (e.g. de-DE -> de)
|
||||
var dashIndex = lang.IndexOf('-', StringComparison.Ordinal);
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
var parent = lang[..dashIndex];
|
||||
if (LocalizationManager.HasTranslation(parent) && seen.Add(parent))
|
||||
{
|
||||
chain.Add(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@@ -16,6 +17,7 @@ using Jellyfin.Networking.HappyEyeballs;
|
||||
using Jellyfin.Server.Extensions;
|
||||
using Jellyfin.Server.HealthChecks;
|
||||
using Jellyfin.Server.Implementations.Extensions;
|
||||
using Jellyfin.Server.Middleware;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Extensions;
|
||||
@@ -127,6 +129,14 @@ namespace Jellyfin.Server
|
||||
services.AddHlsPlaylistGenerator();
|
||||
services.AddLiveTvServices();
|
||||
|
||||
var serverUICulture = _serverConfigurationManager.Configuration.UICulture;
|
||||
if (string.IsNullOrEmpty(serverUICulture))
|
||||
{
|
||||
serverUICulture = "en-US";
|
||||
}
|
||||
|
||||
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(serverUICulture);
|
||||
|
||||
services.AddHostedService<RecordingsHost>();
|
||||
services.AddHostedService<AutoDiscoveryHost>();
|
||||
services.AddHostedService<NfoUserDataSaver>();
|
||||
@@ -168,6 +178,8 @@ namespace Jellyfin.Server
|
||||
|
||||
mainApp.UseCors();
|
||||
|
||||
mainApp.UseMiddleware<AcceptLanguageMiddleware>();
|
||||
|
||||
if (config.RequireHttps && _serverApplicationHost.ListenWithHttps)
|
||||
{
|
||||
mainApp.UseHttpsRedirection();
|
||||
|
||||
@@ -209,6 +209,11 @@ namespace MediaBrowser.Controller.Net
|
||||
var (connection, cts, state) = tuple;
|
||||
var cancellationToken = cts.Token;
|
||||
|
||||
// Restore the culture context captured when the connection was established
|
||||
// so that GetDataToSendForConnection produces a localized payload matching
|
||||
// the client's Accept-Language preference rather than the server default.
|
||||
connection.ApplyRequestCulture();
|
||||
|
||||
var data = await GetDataToSendForConnection(connection).ConfigureAwait(false);
|
||||
if (data is null)
|
||||
{
|
||||
|
||||
@@ -77,5 +77,14 @@ namespace MediaBrowser.Controller.Net
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task ReceiveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the culture context captured when the connection was established
|
||||
/// (from the upgrade request's <c>Accept-Language</c> header) to the current
|
||||
/// async flow. Server-initiated message senders should call this before
|
||||
/// localising any payload so that the response uses the client's preferred
|
||||
/// language rather than the server default.
|
||||
/// </summary>
|
||||
void ApplyRequestCulture();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ public interface ILocalizationManager
|
||||
/// <returns>System.String.</returns>
|
||||
string GetLocalizedString(string phrase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized string using the server's configured UICulture,
|
||||
/// ignoring the current request's culture. Use this for data that is
|
||||
/// persisted (e.g. activity log entries) rather than returned per-request.
|
||||
/// </summary>
|
||||
/// <param name="phrase">The phrase.</param>
|
||||
/// <returns>System.String.</returns>
|
||||
string GetServerLocalizedString(string phrase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localization options.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BitFaster.Caching;
|
||||
@@ -305,6 +306,166 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
||||
Assert.Equal(key, translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_WithCulture_ReturnsTranslation()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
var translated = localizationManager.GetLocalizedString("Artists", "de");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_WithCulture_FallsBackToEnUs()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
// A culture with no translation file should fall back to en-US
|
||||
var translated = localizationManager.GetLocalizedString("Artists", "zz");
|
||||
Assert.Equal("Artists", translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_WithBcp47Normalization_ReturnsTranslation()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
// es-419 is stored as es_419 in Jellyfin
|
||||
var translated = localizationManager.GetLocalizedString("Default", "es-419");
|
||||
Assert.NotEqual("Default", translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServerLocalizedString_UsesServerCulture()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "de"
|
||||
});
|
||||
|
||||
// Even if CurrentUICulture is fr, GetServerLocalizedString should use the server's "de"
|
||||
var previousCulture = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
|
||||
var translated = localizationManager.GetServerLocalizedString("Artists");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = previousCulture;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_FallbackChain_UsesFirstAvailableCulture()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
// Set fallback chain: de -> fr -> en-US
|
||||
// "Artists" exists in de as "Interpreten", should use de (first in chain)
|
||||
LocalizationManager.RequestCultureFallback = new[] { "de", "fr", "en-US" };
|
||||
try
|
||||
{
|
||||
var translated = localizationManager.GetLocalizedString("Artists");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalizationManager.RequestCultureFallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_FallbackChain_SkipsMissingAndUsesNext()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
// "zz" has no translation file so the key won't be found there,
|
||||
// should fall through to de which has "Artists" as "Interpreten"
|
||||
LocalizationManager.RequestCultureFallback = new[] { "zz", "de", "en-US" };
|
||||
try
|
||||
{
|
||||
var translated = localizationManager.GetLocalizedString("Artists");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalizationManager.RequestCultureFallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_FallbackChain_ReturnsKeyWhenNoTranslation()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
var key = "CompletelyNonExistentKey";
|
||||
LocalizationManager.RequestCultureFallback = new[] { "de", "en-US" };
|
||||
try
|
||||
{
|
||||
var translated = localizationManager.GetLocalizedString(key);
|
||||
Assert.Equal(key, translated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalizationManager.RequestCultureFallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_NoFallbackChain_UsesCurrentUICulture()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
var previousCulture = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
|
||||
LocalizationManager.RequestCultureFallback = null;
|
||||
|
||||
var translated = localizationManager.GetLocalizedString("Artists");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = previousCulture;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("de", true)]
|
||||
[InlineData("en-US", true)]
|
||||
[InlineData("fr", true)]
|
||||
[InlineData("es_419", true)]
|
||||
[InlineData("nonexistent", false)]
|
||||
[InlineData("zz-ZZ", false)]
|
||||
public void HasTranslation_ReturnsExpected(string culture, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, LocalizationManager.HasTranslation(culture));
|
||||
}
|
||||
|
||||
private LocalizationManager Setup(ServerConfiguration config)
|
||||
{
|
||||
var mockConfiguration = new Mock<IServerConfigurationManager>();
|
||||
|
||||
Reference in New Issue
Block a user