Add Accept-Language header support and cleanup translations (#16488)

* Add Accept-Language header support for per-request localization

* Use native middleware

* Cleanup

* Add Fallback

* Build BCP47 map reflexively

* Address review comments
This commit is contained in:
Cody Robibero
2026-05-14 18:57:11 -04:00
committed by GitHub
28 changed files with 337 additions and 150 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Buffers;
using System.Globalization;
using System.IO.Pipelines;
using System.Net;
using System.Net.WebSockets;
@@ -69,6 +70,11 @@ namespace Emby.Server.Implementations.HttpServer
/// <inheritdoc />
public IPAddress? RemoteEndPoint { get; }
/// <summary>
/// Gets or initializes the UI culture captured from the upgrade request.
/// </summary>
public CultureInfo? RequestUICulture { get; init; }
/// <inheritdoc />
public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; }
@@ -81,6 +87,17 @@ namespace Emby.Server.Implementations.HttpServer
/// <inheritdoc />
public WebSocketState State => _socket.State;
/// <inheritdoc />
public void ApplyRequestCulture()
{
if (RequestUICulture is null)
{
return;
}
CultureInfo.CurrentUICulture = RequestUICulture;
}
/// <inheritdoc />
public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken)
{

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
@@ -47,14 +48,18 @@ namespace Emby.Server.Implementations.HttpServer
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
var connection = new WebSocketConnection(
_loggerFactory.CreateLogger<WebSocketConnection>(),
webSocket,
authorizationInfo,
context.GetNormalizedRemoteIP())
{
OnReceive = ProcessWebSocketMessageReceived
RequestUICulture = CultureInfo.CurrentUICulture
};
connection.OnReceive = result =>
{
connection.ApplyRequestCulture();
return ProcessWebSocketMessageReceived(result);
};
await using (connection.ConfigureAwait(false))
{

View File

@@ -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",

View File

@@ -3,6 +3,7 @@ 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;
@@ -26,6 +27,7 @@ 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"];
@@ -34,13 +36,21 @@ namespace Emby.Server.Implementations.Localization
private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new(StringComparer.OrdinalIgnoreCase);
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> 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;
private FrozenDictionary<string, string> _iso6392BtoT = null!;
/// <summary>
@@ -54,6 +64,59 @@ namespace Emby.Server.Implementations.Localization
{
_configurationManager = configurationManager;
_logger = logger;
_configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
}
/// <summary>
/// Gets the supported UI cultures.
/// </summary>
/// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns>
public static IList<CultureInfo> GetSupportedUICultures()
{
var cultures = new List<CultureInfo>();
foreach (var option in _localizationOptions)
{
// Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
if (TryGetCultureInfo(option.Value, out var cultureInfo))
{
cultures.Add(cultureInfo);
}
}
return cultures;
}
/// <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;
}
}
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 +482,12 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc />
public string GetLocalizedString(string phrase)
{
return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name);
}
/// <inheritdoc />
public string GetServerLocalizedString(string phrase)
{
return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture);
}
@@ -436,6 +505,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 +518,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 +534,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 +583,55 @@ 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> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) BuildLocalizationData()
{
var options = new List<LocalizationOption>();
var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
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];
// Record the BCP-47 → Jellyfin mapping for any resource file using underscores.
if (code.Contains('_', StringComparison.Ordinal))
{
bcp47Map[code.Replace('_', '-')] = code;
}
// 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, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
}
private static string GetDisplayName(string cultureCode)
{
// Custom/novelty codes like "pr" (Pirate) — fall back to code itself
return TryGetCultureInfo(cultureCode, out var cultureInfo)
? cultureInfo.NativeName
: cultureCode;
}
/// <inheritdoc />

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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),

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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))

View File

@@ -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);

View File

@@ -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))

View File

@@ -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))

View File

@@ -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)

View File

@@ -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))

View File

@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
@@ -6,6 +7,7 @@ using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;
using Emby.Server.Implementations.EntryPoints;
using Emby.Server.Implementations.Localization;
using Jellyfin.Api.Middleware;
using Jellyfin.Database.Implementations;
using Jellyfin.LiveTv.Extensions;
@@ -22,6 +24,7 @@ using MediaBrowser.Controller.Extensions;
using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -127,6 +130,25 @@ 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.Configure<RequestLocalizationOptions>(options =>
{
var supportedUICultures = LocalizationManager.GetSupportedUICultures();
options.SupportedCultures = supportedUICultures;
options.SupportedUICultures = supportedUICultures;
options.DefaultRequestCulture = new RequestCulture(serverUICulture);
options.ApplyCurrentCultureToResponseHeaders = true;
options.FallBackToParentCultures = true;
options.FallBackToParentUICultures = true;
});
services.AddHostedService<RecordingsHost>();
services.AddHostedService<AutoDiscoveryHost>();
services.AddHostedService<NfoUserDataSaver>();
@@ -168,6 +190,8 @@ namespace Jellyfin.Server
mainApp.UseCors();
mainApp.UseRequestLocalization();
if (config.RequireHttps && _serverApplicationHost.ListenWithHttps)
{
mainApp.UseHttpsRedirection();

View File

@@ -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)
{

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -40,10 +40,10 @@ namespace Jellyfin.LiveTv.Channels
}
/// <inheritdoc />
public string Name => _localization.GetLocalizedString("TasksRefreshChannels");
public string Name => _localization.GetLocalizedString("TaskRefreshChannels");
/// <inheritdoc />
public string Description => _localization.GetLocalizedString("TasksRefreshChannelsDescription");
public string Description => _localization.GetLocalizedString("TaskRefreshChannelsDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("TasksChannelsCategory");

View File

@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BitFaster.Caching;
@@ -305,6 +306,98 @@ 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_UsesCurrentUICulture()
{
var localizationManager = Setup(new ServerConfiguration
{
UICulture = "en-US"
});
var previousCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
var translated = localizationManager.GetLocalizedString("Artists");
Assert.Equal("Interpreten", translated);
}
finally
{
CultureInfo.CurrentUICulture = previousCulture;
}
}
[Fact]
public void GetSupportedUICultures_IncludesCommonCultures()
{
var supported = LocalizationManager.GetSupportedUICultures();
Assert.Contains(supported, c => c.Name.Equals("de", StringComparison.OrdinalIgnoreCase));
Assert.Contains(supported, c => c.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase));
Assert.Contains(supported, c => c.Name.Equals("fr", StringComparison.OrdinalIgnoreCase));
// Underscore variants get normalized to BCP-47 hyphen form for CultureInfo compatibility.
Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
}
private LocalizationManager Setup(ServerConfiguration config)
{
var mockConfiguration = new Mock<IServerConfigurationManager>();