diff options
28 files changed, 571 insertions, 149 deletions
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 373b0994a6..6dc6d9d289 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -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; } @@ -82,6 +96,28 @@ namespace Emby.Server.Implementations.HttpServer 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) { var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index cb5b3993b8..3b5f6d1d09 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -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)) { diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 9b5049c8c7..ff674bd0d0 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -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", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index d8797e612b..2a7a3388aa 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -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> @@ -420,6 +472,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,26 +538,17 @@ 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(), - 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); + 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; + return dictionary; + }, + this); } private async Task CopyInto(IDictionary<string, string> dictionary, string 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 /> diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 69c17f2486..0839d62a5c 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs index 5f4864e953..cfd1cbe05b 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs @@ -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", diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs index 8fe380e4f4..24146210c6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs @@ -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", diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs index 1a8931a6dc..df526977a2 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index 584d559e44..fa9ce21170 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index 73323acb37..8f71966b83 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -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), diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index b75567539c..09d68e4451 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -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), diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs index b90708a2f2..74dfeebba6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs index 139c2e2acb..4028522838 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs index da82a3b30f..1e3dc7c92e 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs @@ -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) { diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs index 632f30c7ad..9fb007aca7 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs @@ -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); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs index 4b49b714cf..2aa738c153 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs index 2d24de7fc6..f7e651173d 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs @@ -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)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs index e892d3dd9a..bca9662839 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs @@ -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); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs index 4f063f6a1b..cf5c81b981 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs @@ -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)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs index ba4a072e84..720480c28f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs @@ -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)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs index bbc00567d1..efaf19397f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs @@ -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) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs index 7219704ec6..cc9efa7061 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs @@ -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)) diff --git a/Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs b/Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs new file mode 100644 index 0000000000..57390ae005 --- /dev/null +++ b/Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs @@ -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); + } + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index f6a4ae7d6e..ea677cf6d9 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -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(); diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 6b1eac8047..2bcce168cf 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -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) { diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index bdc0f9a10f..48431e75c3 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -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(); } } diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index f6e65028e4..7ad240abfb 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -51,6 +51,15 @@ public interface ILocalizationManager 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> /// <returns><see cref="IEnumerable{LocalizationOption}" />.</returns> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index acabaf3acb..1c4d0d4a8a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -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>(); |
