diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index 6dc6d9d289..17070c39ba 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -1,6 +1,5 @@
using System;
using System.Buffers;
-using System.Collections.Generic;
using System.Globalization;
using System.IO.Pipelines;
using System.Net;
@@ -9,7 +8,6 @@ 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;
@@ -72,12 +70,6 @@ namespace Emby.Server.Implementations.HttpServer
///
public IPAddress? RemoteEndPoint { get; }
- ///
- /// Gets or initializes the culture fallback chain captured from the
- /// Accept-Language header of the upgrade request.
- ///
- public IReadOnlyList? RequestCultureFallback { get; init; }
-
///
/// Gets or initializes the UI culture name captured from the upgrade request.
///
@@ -98,22 +90,18 @@ namespace Emby.Server.Implementations.HttpServer
///
public void ApplyRequestCulture()
{
- if (RequestCultureFallback is not null)
+ if (string.IsNullOrEmpty(RequestUICulture))
{
- LocalizationManager.RequestCultureFallback = RequestCultureFallback;
+ return;
}
- if (!string.IsNullOrEmpty(RequestUICulture))
+ try
{
- 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.
- }
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(RequestUICulture);
+ }
+ catch (CultureNotFoundException)
+ {
+ // Codes that aren't valid .NET cultures are ignored.
}
}
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
index 3b5f6d1d09..dcdfda5472 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
@@ -8,7 +8,6 @@ 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;
@@ -50,7 +49,7 @@ namespace Emby.Server.Implementations.HttpServer
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
- // Capture the culture context set by AcceptLanguageMiddleware so it can be
+ // Capture the culture set by RequestLocalizationMiddleware 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(
@@ -59,7 +58,6 @@ namespace Emby.Server.Implementations.HttpServer
authorizationInfo,
context.GetNormalizedRemoteIP())
{
- RequestCultureFallback = LocalizationManager.RequestCultureFallback,
RequestUICulture = CultureInfo.CurrentUICulture.Name
};
connection.OnReceive = result =>
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 2a7a3388aa..5c2376ea00 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -8,7 +8,6 @@ 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;
@@ -32,10 +31,9 @@ namespace Emby.Server.Implementations.Localization
private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly;
private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"];
- ///
- /// Gets the mapping from BCP-47 hyphenated culture codes to Jellyfin's underscore-based codes.
- ///
- public static readonly FrozenDictionary Bcp47ToJellyfinMap = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ // Maps BCP-47 hyphenated culture codes (set by ASP.NET Core's RequestLocalizationMiddleware
+ // and used as CurrentUICulture.Name) to Jellyfin's underscore-based resource file codes.
+ private static readonly FrozenDictionary _bcp47ToJellyfinMap = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["es-419"] = "es_419",
["es-DO"] = "es_DO",
@@ -47,8 +45,6 @@ namespace Emby.Server.Implementations.Localization
private readonly Dictionary> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase);
- private static readonly AsyncLocal?> _requestCultureFallback = new();
-
private readonly ConcurrentDictionary> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase);
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
@@ -76,24 +72,28 @@ namespace Emby.Server.Implementations.Localization
}
///
- /// 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.
+ /// Gets the supported UI cultures.
///
- public static IReadOnlyList? RequestCultureFallback
+ /// A list of objects covering every embedded translation.
+ public static IList GetSupportedUICultures()
{
- get => _requestCultureFallback.Value;
- set => _requestCultureFallback.Value = value;
- }
+ var cultures = new List();
+ foreach (var option in _localizationOptions)
+ {
+ // Resource files use underscores for some variants (e.g. es_419);
+ // CultureInfo only accepts hyphenated BCP-47 codes.
+ var code = option.Value.Replace('_', '-');
+ try
+ {
+ cultures.Add(CultureInfo.GetCultureInfo(code));
+ }
+ catch (CultureNotFoundException)
+ {
+ // Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve.
+ }
+ }
- ///
- /// Checks whether a translation resource file exists for the given culture code.
- ///
- /// The culture code to check (e.g. "de", "pt-BR", "es_419").
- /// true if an embedded translation resource exists for the culture.
- public static bool HasTranslation(string culture)
- {
- var resourceName = CoreResourcePrefix + GetResourceFilename(culture);
- return _assembly.GetManifestResourceInfo(resourceName) is not null;
+ return cultures;
}
private static void OnConfigurationUpdated(object? sender, EventArgs e)
@@ -472,21 +472,6 @@ namespace Emby.Server.Implementations.Localization
///
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);
}
@@ -510,7 +495,7 @@ namespace Emby.Server.Implementations.Localization
}
// Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes
- if (Bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
+ if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped))
{
culture = mapped;
}
@@ -626,7 +611,7 @@ namespace Emby.Server.Implementations.Localization
private static string GetDisplayName(string cultureCode)
{
// Handle Jellyfin-specific codes that aren't valid CultureInfo names
- if (Bcp47ToJellyfinMap.Values.Contains(cultureCode))
+ if (_bcp47ToJellyfinMap.Values.Contains(cultureCode))
{
// Convert underscore to hyphen for CultureInfo lookup
var normalized = cultureCode.Replace('_', '-');
diff --git a/Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs b/Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs
deleted file mode 100644
index 57390ae005..0000000000
--- a/Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs
+++ /dev/null
@@ -1,137 +0,0 @@
-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;
-
-///
-/// Middleware that resolves the Accept-Language request header
-/// to an ordered list of Jellyfin-supported cultures, sets the fallback chain
-/// on , and writes
-/// the Content-Language response header.
-///
-public class AcceptLanguageMiddleware
-{
- private readonly RequestDelegate _next;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Next request delegate.
- public AcceptLanguageMiddleware(RequestDelegate next)
- {
- _next = next;
- }
-
- ///
- /// Invoke request.
- ///
- /// Request context.
- /// The server configuration manager.
- /// Task.
- 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?))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? 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();
- var seen = new HashSet(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 chain, HashSet 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 ea677cf6d9..f1c8b532aa 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -7,6 +7,7 @@ using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;
using Emby.Server.Implementations.EntryPoints;
+using Emby.Server.Implementations.Localization;
using Jellyfin.Api.Middleware;
using Jellyfin.Database.Implementations;
using Jellyfin.LiveTv.Extensions;
@@ -17,13 +18,13 @@ 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;
using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -137,6 +138,15 @@ namespace Jellyfin.Server
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(serverUICulture);
+ services.Configure(options =>
+ {
+ var supportedUICultures = LocalizationManager.GetSupportedUICultures();
+ options.SupportedCultures = supportedUICultures;
+ options.SupportedUICultures = supportedUICultures;
+ options.DefaultRequestCulture = new RequestCulture(serverUICulture);
+ options.ApplyCurrentCultureToResponseHeaders = true;
+ });
+
services.AddHostedService();
services.AddHostedService();
services.AddHostedService();
@@ -178,7 +188,7 @@ namespace Jellyfin.Server
mainApp.UseCors();
- mainApp.UseMiddleware();
+ mainApp.UseRequestLocalization();
if (config.RequireHttps && _serverApplicationHost.ListenWithHttps)
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 1c4d0d4a8a..3b8fe5ca60 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -367,72 +367,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[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()
+ public void GetLocalizedString_UsesCurrentUICulture()
{
var localizationManager = Setup(new ServerConfiguration
{
@@ -443,8 +378,6 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
- LocalizationManager.RequestCultureFallback = null;
-
var translated = localizationManager.GetLocalizedString("Artists");
Assert.Equal("Interpreten", translated);
}
@@ -454,16 +387,15 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
}
- [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)
+ [Fact]
+ public void GetSupportedUICultures_IncludesCommonCultures()
{
- Assert.Equal(expected, LocalizationManager.HasTranslation(culture));
+ var supported = LocalizationManager.GetSupportedUICultures();
+ Assert.Contains(supported, c => c.Name.Equals("de", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(supported, c => c.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(supported, c => c.Name.Equals("fr", StringComparison.OrdinalIgnoreCase));
+ // Underscore variants get normalized to BCP-47 hyphen form for CultureInfo compatibility.
+ Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
}
private LocalizationManager Setup(ServerConfiguration config)