mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-15 13:16:49 +01:00
Use native middleware
This commit is contained in:
@@ -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
|
||||
/// <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>
|
||||
@@ -98,22 +90,18 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
/// <inheritdoc />
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
/// <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)
|
||||
// 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<string, string> _bcp47ToJellyfinMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["es-419"] = "es_419",
|
||||
["es-DO"] = "es_DO",
|
||||
@@ -47,8 +45,6 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = 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;
|
||||
@@ -76,24 +72,28 @@ namespace Emby.Server.Implementations.Localization
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Gets the supported UI cultures.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string>? RequestCultureFallback
|
||||
/// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns>
|
||||
public static IList<CultureInfo> GetSupportedUICultures()
|
||||
{
|
||||
get => _requestCultureFallback.Value;
|
||||
set => _requestCultureFallback.Value = value;
|
||||
}
|
||||
var cultures = new List<CultureInfo>();
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
return cultures;
|
||||
}
|
||||
|
||||
private static void OnConfigurationUpdated(object? sender, EventArgs e)
|
||||
@@ -472,21 +472,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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('_', '-');
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RequestLocalizationOptions>(options =>
|
||||
{
|
||||
var supportedUICultures = LocalizationManager.GetSupportedUICultures();
|
||||
options.SupportedCultures = supportedUICultures;
|
||||
options.SupportedUICultures = supportedUICultures;
|
||||
options.DefaultRequestCulture = new RequestCulture(serverUICulture);
|
||||
options.ApplyCurrentCultureToResponseHeaders = true;
|
||||
});
|
||||
|
||||
services.AddHostedService<RecordingsHost>();
|
||||
services.AddHostedService<AutoDiscoveryHost>();
|
||||
services.AddHostedService<NfoUserDataSaver>();
|
||||
@@ -178,7 +188,7 @@ namespace Jellyfin.Server
|
||||
|
||||
mainApp.UseCors();
|
||||
|
||||
mainApp.UseMiddleware<AcceptLanguageMiddleware>();
|
||||
mainApp.UseRequestLocalization();
|
||||
|
||||
if (config.RequireHttps && _serverApplicationHost.ListenWithHttps)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user