aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs36
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketManager.cs13
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-US.json34
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs250
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs4
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs2
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs2
-rw-r--r--Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs137
-rw-r--r--Jellyfin.Server/Startup.cs12
-rw-r--r--MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs5
-rw-r--r--MediaBrowser.Controller/Net/IWebSocketConnection.cs9
-rw-r--r--MediaBrowser.Model/Globalization/ILocalizationManager.cs9
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs161
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>();