From 4ebce3907062ade1937440628eebd665440b338d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:43:01 +0200 Subject: Implement Similarity providers --- .../Entities/InternalItemsQuery.cs | 2 + .../Library/ILocalSimilarItemsProvider.cs | 27 ++++++++++++ .../Library/IRemoteSimilarItemsProvider.cs | 26 +++++++++++ .../Library/ISimilarItemsManager.cs | 50 ++++++++++++++++++++++ .../Library/ISimilarItemsProvider.cs | 26 +++++++++++ .../Library/SimilarItemReference.cs | 22 ++++++++++ .../Library/SimilarItemsQuery.cs | 37 ++++++++++++++++ .../Providers/IProviderManager.cs | 11 +++++ 8 files changed, 201 insertions(+) create mode 100644 MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/ISimilarItemsManager.cs create mode 100644 MediaBrowser.Controller/Library/ISimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/SimilarItemReference.cs create mode 100644 MediaBrowser.Controller/Library/SimilarItemsQuery.cs (limited to 'MediaBrowser.Controller') diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..8ae578b228 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -351,6 +351,8 @@ namespace MediaBrowser.Controller.Entities public Dictionary? HasAnyProviderId { get; set; } + public Dictionary? HasAnyProviderIds { get; set; } + public Guid[] AlbumArtistIds { get; set; } public Guid[] BoxSetLibraryFolders { get; set; } diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..9bf0121f5f --- /dev/null +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// Provides similar items from the local library for a specific item type. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// +/// The type of item this provider handles. +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider + where TItemType : BaseItem +{ + /// + /// Gets similar items from the local library. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions, etc.). + /// Cancellation token. + /// The list of similar items from the library. + Task> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs new file mode 100644 index 0000000000..a77b6628d9 --- /dev/null +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// Provides similar item references from remote/external sources for a specific item type. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// +/// The type of item this provider handles. +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider + where TItemType : BaseItem +{ + /// + /// Gets similar item references from an external source as an async stream. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions). + /// Cancellation token. + /// An async enumerable of similar item references. + IAsyncEnumerable GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs new file mode 100644 index 0000000000..0ced6f71ee --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for managing similar items providers and operations. +/// +public interface ISimilarItemsManager +{ + /// + /// Registers similar items providers discovered through dependency injection. + /// + /// The similar items providers to register. + void AddParts(IEnumerable providers); + + /// + /// Gets the similar items providers for a specific item type. + /// + /// The item type. + /// The list of similar items providers for that type. + IReadOnlyList GetSimilarItemsProviders() + where T : BaseItem; + + /// + /// Gets similar items for the specified item. + /// + /// The source item to find similar items for. + /// Artist IDs to exclude from results. + /// The user context. + /// The DTO options. + /// Maximum number of results. + /// The library options for provider configuration. + /// The cancellation token. + /// The list of similar items. + Task> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs new file mode 100644 index 0000000000..0d089369a8 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Base marker interface for similar items providers. +/// +public interface ISimilarItemsProvider +{ + /// + /// Gets the name of the provider. + /// + string Name { get; } + + /// + /// Gets the type of the provider. + /// + MetadataPluginType Type { get; } + + /// + /// Gets the cache duration for results from this provider. + /// If null, results will not be cached. + /// + TimeSpan? CacheDuration => null; +} diff --git a/MediaBrowser.Controller/Library/SimilarItemReference.cs b/MediaBrowser.Controller/Library/SimilarItemReference.cs new file mode 100644 index 0000000000..2a40c93bdd --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemReference.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Controller.Library; + +/// +/// A reference to a similar item by provider ID with a similarity score. +/// +public class SimilarItemReference +{ + /// + /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist"). + /// + public required string ProviderName { get; set; } + + /// + /// Gets or sets the provider ID value. + /// + public required string ProviderId { get; set; } + + /// + /// Gets or sets the similarity score (0.0 to 1.0). + /// + public float? Score { get; set; } +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsQuery.cs b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs new file mode 100644 index 0000000000..1ed3ceec16 --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; + +namespace MediaBrowser.Controller.Library; + +/// +/// Query options for similar items requests. +/// +public class SimilarItemsQuery +{ + /// + /// Gets or sets the user context. + /// + public User? User { get; set; } + + /// + /// Gets or sets the maximum number of results. + /// + public int? Limit { get; set; } + + /// + /// Gets or sets the DTO options. + /// + public DtoOptions? DtoOptions { get; set; } + + /// + /// Gets or sets the item IDs to exclude from results. + /// + public IReadOnlyList ExcludeItemIds { get; set; } = []; + + /// + /// Gets or sets the artist IDs to exclude from results. + /// + public IReadOnlyList ExcludeArtistIds { get; set; } = []; +} diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 0d3a334dfb..c87f09a117 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -143,6 +143,17 @@ namespace MediaBrowser.Controller.Providers IEnumerable> GetMetadataProviders(BaseItem item, LibraryOptions libraryOptions) where T : BaseItem; + /// + /// Gets the metadata providers for the provided item. + /// + /// The item. + /// The library options. + /// Whether to include disabled providers. + /// The type of metadata provider. + /// The metadata providers. + IEnumerable> GetMetadataProviders(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem; + /// /// Gets the metadata savers for the provided item. /// -- cgit v1.2.3 From 2b7f64116309c7a33611334c1d08745c6c50d537 Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Sun, 10 May 2026 11:10:56 +0200 Subject: feat: language filters for subtitles and audio --- Jellyfin.Api/Controllers/ItemsController.cs | 39 ++++++++++++++++++++++ Jellyfin.Api/Controllers/TrailersController.cs | 6 ++++ .../Item/BaseItemRepository.Querying.cs | 18 +++++++++- .../Item/BaseItemRepository.TranslateQuery.cs | 20 +++++++++++ .../Entities/InternalItemsQuery.cs | 6 ++++ MediaBrowser.Model/Querying/QueryFiltersLegacy.cs | 6 ++++ .../DescendantQueryHelper.cs | 4 ++- .../MatchCriteria/HasMediaStreamType.cs | 23 +++++++++++-- 8 files changed, 117 insertions(+), 5 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 53656186c8..a813109c96 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -157,6 +157,8 @@ public class ItemsController : BaseJellyfinApiController /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values. + /// Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the items. @@ -247,6 +249,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -399,6 +403,8 @@ public class ItemsController : BaseJellyfinApiController MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), MinPremiereDate = minPremiereDate?.ToUniversalTime(), MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + AudioLanguages = audioLanguages, + SubtitleLanguages = subtitleLanguages, }; if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) @@ -406,6 +412,33 @@ public class ItemsController : BaseJellyfinApiController query.CollapseBoxSetItems = false; } + if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + { + if (query.HasSubtitles.Value is true) + { + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; + } + else + { + // if we want to know if an item has no subtitles we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; + } + } + + // for filter values that rely on media streams, we need to include alternative and linked versions + if (query.HasSubtitles.HasValue + || query.SubtitleLanguages.Count > 0 + || query.AudioLanguages.Count > 0 + || query.Is3D.HasValue + || query.IsHD.HasValue + || query.Is4K.HasValue + || query.VideoTypes.Length > 0 + ) + { + query.IncludeOwnedItems = true; + } + query.ApplyFilters(filters); // Filter by Series Status @@ -607,6 +640,8 @@ public class ItemsController : BaseJellyfinApiController /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values. + /// Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the items. @@ -698,6 +733,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) => await GetItems( @@ -785,6 +822,8 @@ public class ItemsController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + audioLanguages, + subtitleLanguages, enableTotalRecordCount, enableImages).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index e2075c2b8d..121db66858 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -115,6 +115,8 @@ public class TrailersController : BaseJellyfinApiController /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values. + /// Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the trailers. @@ -203,6 +205,8 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -294,6 +298,8 @@ public class TrailersController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + audioLanguages, + subtitleLanguages, enableTotalRecordCount, enableImages).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index dc16c3b1b3..d8fc87ec18 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -517,6 +517,20 @@ public sealed partial class BaseItemRepository .OrderBy(r => r) .ToArray(); + var subtitleLanguages = context.MediaStreamInfos + .Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .OrderBy(l => l) + .ToArray(); + + var audioLanguages = context.MediaStreamInfos + .Where(s => s.StreamType == MediaStreamTypeEntity.Audio) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .OrderBy(l => l) + .ToArray(); + var tags = context.ItemValuesMap .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) @@ -540,7 +554,9 @@ public sealed partial class BaseItemRepository Years = years, OfficialRatings = officialRatings, Tags = tags, - Genres = genres + Genres = genres, + SubtitleLanguages = subtitleLanguages, + AudioLanguages = audioLanguages }; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 0abe981af8..95c4d04adc 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -823,6 +823,26 @@ public sealed partial class BaseItemRepository } } + if (filter.SubtitleLanguages.Count > 0) + { + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.AudioLanguages.Count > 0) + { + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + || (e.IsFolder && foldersWithAudio.Contains(e.Id))); + } + if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..e520ffd179 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -58,6 +58,8 @@ namespace MediaBrowser.Controller.Entities VideoTypes = []; Years = []; SkipDeserialization = false; + AudioLanguages = []; + SubtitleLanguages = []; } public InternalItemsQuery(User? user) @@ -385,6 +387,10 @@ namespace MediaBrowser.Controller.Entities public bool IncludeExtras { get; set; } + public IReadOnlyList AudioLanguages { get; set; } + + public IReadOnlyList SubtitleLanguages { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; diff --git a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs index fcb450ed30..aa1ca85cad 100644 --- a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs +++ b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs @@ -13,6 +13,8 @@ namespace MediaBrowser.Model.Querying Tags = Array.Empty(); OfficialRatings = Array.Empty(); Years = Array.Empty(); + AudioLanguages = Array.Empty(); + SubtitleLanguages = Array.Empty(); } public string[] Genres { get; set; } @@ -22,5 +24,9 @@ namespace MediaBrowser.Model.Querying public string[] OfficialRatings { get; set; } public int[] Years { get; set; } + + public string[] AudioLanguages { get; set; } + + public string[] SubtitleLanguages { get; set; } } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 43e6a8bc00..88a2c684ff 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -111,7 +111,9 @@ public static class DescendantQueryHelper private static HashSet GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos - .Where(ms => ms.StreamType == criteria.StreamType && ms.Language == criteria.Language); + .Where(ms => ms.StreamType == criteria.StreamType + && (criteria.Language.Contains(ms.Language) + || (criteria.Language.Contains("und") && string.IsNullOrEmpty(ms.Language)))); // und = undetermined if (criteria.IsExternal.HasValue) { diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs index 68f2ca2786..c1f6ab16a9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs @@ -1,3 +1,6 @@ +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +using System.Collections.Generic; using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Database.Implementations.MatchCriteria; @@ -6,9 +9,23 @@ namespace Jellyfin.Database.Implementations.MatchCriteria; /// Matches folders containing descendants with a specific media stream type and language. /// /// The type of media stream to match (Audio, Subtitle, etc.). -/// The language to match. +/// List of languages to match. /// If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles. public sealed record HasMediaStreamType( MediaStreamTypeEntity StreamType, - string Language, - bool? IsExternal = null) : FolderMatchCriteria; + IReadOnlyCollection Language, + bool? IsExternal = null) : FolderMatchCriteria +{ + /// + /// Initializes a new instance of the class. + /// + /// The type of media stream to match (Audio, Subtitle, etc.). + /// The language to match. + /// If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles. + public HasMediaStreamType( + MediaStreamTypeEntity StreamType, + string Language, + bool? IsExternal = null) : this(StreamType, [Language], IsExternal) + { + } +} -- cgit v1.2.3 From 39049a726e1a88e8acf1d8cc5c217bc8d86be9ae Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Tue, 12 May 2026 01:47:07 +0200 Subject: move language filters from QueryFiltersLegacy to QueryFilters --- .../Library/LibraryManager.cs | 13 +++++++- Jellyfin.Api/Controllers/FilterController.cs | 37 +++++++++++++++++++++- Jellyfin.Api/Controllers/ItemsController.cs | 2 +- .../Item/BaseItemRepository.Querying.cs | 36 +-------------------- .../Item/BaseItemRepository.TranslateQuery.cs | 1 + .../Item/MediaStreamRepository.cs | 11 +++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 7 ++++ .../Persistence/IMediaStreamRepository.cs | 7 ++++ MediaBrowser.Model/Querying/QueryFilters.cs | 6 ++++ MediaBrowser.Model/Querying/QueryFiltersLegacy.cs | 6 ---- 10 files changed, 82 insertions(+), 44 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 11f1496086..15d51cf35f 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -87,6 +87,7 @@ namespace Emby.Server.Implementations.Library private readonly IPathManager _pathManager; private readonly FastConcurrentLru _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; + private readonly IMediaStreamRepository _mediaStreamRepository; /// /// The _root folder sync lock. @@ -129,6 +130,7 @@ namespace Emby.Server.Implementations.Library /// The people repository. /// The path manager. /// The .ignore rule handler. + /// The media stream repository. public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -151,7 +153,8 @@ namespace Emby.Server.Implementations.Library IDirectoryService directoryService, IPeopleRepository peopleRepository, IPathManager pathManager, - DotIgnoreIgnoreRule dotIgnoreIgnoreRule) + DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + IMediaStreamRepository mediaStreamRepository) { _appHost = appHost; _logger = loggerFactory.CreateLogger(); @@ -181,6 +184,8 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; + _mediaStreamRepository = mediaStreamRepository; + RecordConfigurationValues(_configurationManager.Configuration); } @@ -3800,5 +3805,11 @@ namespace Emby.Server.Implementations.Library SetTopParentOrAncestorIds(query); return _itemRepository.GetQueryFiltersLegacy(query); } + + /// + public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); + } } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 2f53784db1..740423ef04 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -8,6 +8,8 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -24,16 +26,19 @@ public class FilterController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ILocalizationManager _localization; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + /// Instance of the interface. + public FilterController(ILibraryManager libraryManager, IUserManager userManager, ILocalizationManager localization) { _libraryManager = libraryManager; _userManager = userManager; + _localization = localization; } /// @@ -183,6 +188,36 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + { + filters.AudioLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Audio) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture != null ? $"{culture.DisplayName} ({language})" : language, + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + filters.SubtitleLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture != null ? $"{culture.DisplayName} ({language})" : language, + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + } + return filters; } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index a813109c96..8eca2787b1 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -421,7 +421,7 @@ public class ItemsController : BaseJellyfinApiController } else { - // if we want to know if an item has no subtitles we don't need to check for subtitles of a specific language + // if we search for items without subtitles, we don't need to check for subtitles of a specific language query.SubtitleLanguages = []; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 71b46b3cb5..dc16c3b1b3 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -535,46 +535,12 @@ public sealed partial class BaseItemRepository .OrderBy(g => g) .ToArray(); - // At the moment language filters are only available for video types (Movie and Series libraries). - // They are fetched directly from the MediaStreamInfos table and only filtered by StreamType. - // This is the fastest and most perfomant way to get the list of available languages, - // but the filter values can include language tags that are not linked to any item in the current library. - var subtitleLanguages = IncludesVideoTypes(filter) - ? context.MediaStreamInfos - .Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined - .Distinct() - .OrderBy(l => l) - .ToArray() - : []; - - var audioLanguages = IncludesVideoTypes(filter) - ? context.MediaStreamInfos - .Where(s => s.StreamType == MediaStreamTypeEntity.Audio) - .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined - .Distinct() - .OrderBy(l => l) - .ToArray() - : []; - return new QueryFiltersLegacy { Years = years, OfficialRatings = officialRatings, Tags = tags, - Genres = genres, - SubtitleLanguages = subtitleLanguages, - AudioLanguages = audioLanguages + Genres = genres }; } - - private bool IncludesVideoTypes(InternalItemsQuery filter) - { - return filter.IncludeItemTypes.Contains(BaseItemKind.Movie) - || filter.IncludeItemTypes.Contains(BaseItemKind.Video) - || filter.IncludeItemTypes.Contains(BaseItemKind.Series) - || filter.IncludeItemTypes.Contains(BaseItemKind.Season) - || filter.IncludeItemTypes.Contains(BaseItemKind.Episode) - || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer); - } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index b58e7fffe3..3d1aafd72e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1078,6 +1078,7 @@ public sealed partial class BaseItemRepository { // Dvds and Blu-rays can either be stored in a folder structure or as an iso file // => to find all matches we need to check both: VideoType and IsoType + // alternatively, we could provide specific IsoType filters var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); Expression> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index dd0446f49a..7fa33c8639 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -55,6 +55,17 @@ public class MediaStreamRepository : IMediaStreamRepository return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); } + /// + public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + using var context = _dbProvider.CreateDbContext(); + return context.MediaStreamInfos + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string? GetPathToSave(string? path) { if (path is null) diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f5e3d7034e..f4c2196400 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -784,5 +784,12 @@ namespace MediaBrowser.Controller.Library /// The query filter. /// Aggregated filter values. QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); + + /// + /// Gets a list of all language codes of the provided stream type. + /// + /// The stream type. + /// List of language codes. + IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType); } } diff --git a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs index 665129eafd..de04ff021d 100644 --- a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs +++ b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs @@ -21,6 +21,13 @@ public interface IMediaStreamRepository /// IEnumerable{MediaStream}. IReadOnlyList GetMediaStreams(MediaStreamQuery filter); + /// + /// Gets all language codes of the provided stream type. + /// + /// The type of the media stream. + /// IEnumerable{string}. + IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType); + /// /// Saves the media streams. /// diff --git a/MediaBrowser.Model/Querying/QueryFilters.cs b/MediaBrowser.Model/Querying/QueryFilters.cs index 73b27a7b06..b877af71c6 100644 --- a/MediaBrowser.Model/Querying/QueryFilters.cs +++ b/MediaBrowser.Model/Querying/QueryFilters.cs @@ -12,10 +12,16 @@ namespace MediaBrowser.Model.Querying { Tags = Array.Empty(); Genres = Array.Empty(); + AudioLanguages = Array.Empty(); + SubtitleLanguages = Array.Empty(); } public NameGuidPair[] Genres { get; set; } public string[] Tags { get; set; } + + public NameValuePair[] AudioLanguages { get; set; } + + public NameValuePair[] SubtitleLanguages { get; set; } } } diff --git a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs index aa1ca85cad..fcb450ed30 100644 --- a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs +++ b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs @@ -13,8 +13,6 @@ namespace MediaBrowser.Model.Querying Tags = Array.Empty(); OfficialRatings = Array.Empty(); Years = Array.Empty(); - AudioLanguages = Array.Empty(); - SubtitleLanguages = Array.Empty(); } public string[] Genres { get; set; } @@ -24,9 +22,5 @@ namespace MediaBrowser.Model.Querying public string[] OfficialRatings { get; set; } public int[] Years { get; set; } - - public string[] AudioLanguages { get; set; } - - public string[] SubtitleLanguages { get; set; } } } -- cgit v1.2.3 From 4be3f5f1f9ff8bd0333033d6ad9c99711da03f96 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 4 May 2026 20:26:39 +0200 Subject: Add Accept-Language header support for per-request localization --- .../HttpServer/WebSocketConnection.cs | 36 +++ .../HttpServer/WebSocketManager.cs | 13 +- .../Localization/Core/en-US.json | 34 +-- .../Localization/LocalizationManager.cs | 250 +++++++++++++-------- Jellyfin.Api/Controllers/LibraryController.cs | 4 +- .../Library/LyricDownloadFailureLogger.cs | 2 +- .../Library/SubtitleDownloadFailureLogger.cs | 2 +- .../Security/AuthenticationFailedLogger.cs | 4 +- .../Security/AuthenticationSucceededLogger.cs | 4 +- .../Consumers/Session/PlaybackStartLogger.cs | 2 +- .../Events/Consumers/Session/PlaybackStopLogger.cs | 2 +- .../Events/Consumers/Session/SessionEndedLogger.cs | 4 +- .../Consumers/Session/SessionStartedLogger.cs | 4 +- .../Events/Consumers/System/TaskCompletedLogger.cs | 4 +- .../Updates/PluginInstallationFailedLogger.cs | 4 +- .../Consumers/Updates/PluginInstalledLogger.cs | 4 +- .../Consumers/Updates/PluginUninstalledLogger.cs | 2 +- .../Consumers/Updates/PluginUpdatedLogger.cs | 4 +- .../Events/Consumers/Users/UserCreatedLogger.cs | 2 +- .../Events/Consumers/Users/UserDeletedLogger.cs | 2 +- .../Events/Consumers/Users/UserLockedOutLogger.cs | 2 +- .../Consumers/Users/UserPasswordChangedLogger.cs | 2 +- .../Middleware/AcceptLanguageMiddleware.cs | 137 +++++++++++ Jellyfin.Server/Startup.cs | 12 + .../Net/BasePeriodicWebSocketListener.cs | 5 + .../Net/IWebSocketConnection.cs | 9 + .../Globalization/ILocalizationManager.cs | 9 + .../Localization/LocalizationManagerTests.cs | 161 +++++++++++++ 28 files changed, 571 insertions(+), 149 deletions(-) create mode 100644 Jellyfin.Server/Middleware/AcceptLanguageMiddleware.cs (limited to 'MediaBrowser.Controller') 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 /// 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. + /// + public string? RequestUICulture { get; init; } + /// public Func? OnReceive { get; set; } @@ -81,6 +95,28 @@ namespace Emby.Server.Implementations.HttpServer /// public WebSocketState State => _socket.State; + /// + 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. + } + } + } + /// public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) { 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(), 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"]; + /// + /// Gets the mapping from BCP-47 hyphenated culture codes to Jellyfin's underscore-based codes. + /// + public static readonly FrozenDictionary Bcp47ToJellyfinMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["es-419"] = "es_419", + ["es-DO"] = "es_DO", + ["ur-PK"] = "ur_PK" + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + private readonly IServerConfigurationManager _configurationManager; private readonly ILogger _logger; private readonly Dictionary> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> _dictionaries = new(StringComparer.OrdinalIgnoreCase); + private static readonly AsyncLocal?> _requestCultureFallback = new(); + + private readonly ConcurrentDictionary> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly ConcurrentDictionary _cultureCache = new(StringComparer.OrdinalIgnoreCase); private List _cultures = []; + private static readonly IReadOnlyList _localizationOptions = BuildLocalizationOptions(); + private FrozenDictionary _iso6392BtoT = null!; /// @@ -54,6 +71,41 @@ namespace Emby.Server.Implementations.Localization { _configurationManager = configurationManager; _logger = logger; + + _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; + } + + /// + /// 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. + /// + public static IReadOnlyList? RequestCultureFallback + { + get => _requestCultureFallback.Value; + set => _requestCultureFallback.Value = value; + } + + /// + /// 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; + } + + 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); + } + } } /// @@ -419,6 +471,27 @@ 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); + } + + /// + 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> GetDictionary(string prefix, string culture, string baseFilename) - { - ArgumentException.ThrowIfNullOrEmpty(culture); - - var dictionary = new Dictionary(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(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 dictionary, string resourcePath) @@ -508,77 +587,68 @@ namespace Emby.Server.Implementations.Localization /// public IEnumerable 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 BuildLocalizationOptions() + { + var options = new List(); + 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; + } } /// 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 +/// 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 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(); services.AddHostedService(); services.AddHostedService(); @@ -168,6 +178,8 @@ namespace Jellyfin.Server mainApp.UseCors(); + mainApp.UseMiddleware(); + 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 /// The cancellation token. /// Task. Task ReceiveAsync(CancellationToken cancellationToken = default); + + /// + /// Applies the culture context captured when the connection was established + /// (from the upgrade request's Accept-Language 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. + /// + 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 @@ -50,6 +50,15 @@ public interface ILocalizationManager /// System.String. string GetLocalizedString(string phrase); + /// + /// 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. + /// + /// The phrase. + /// System.String. + string GetServerLocalizedString(string phrase); + /// /// Gets the localization options. /// 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(); -- cgit v1.2.3 From b1b45199444e369b12844661f09d1cd0830d25f7 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 13 May 2026 00:58:03 +0200 Subject: Apply review suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 14 ++++++- .../Library/SimilarItems/SimilarItemsManager.cs | 45 +++++++--------------- .../Library/ILocalSimilarItemsProvider.cs | 38 +++++++++++++++++- .../Library/IRemoteSimilarItemsProvider.cs | 38 +++++++++++++++++- .../ListenBrainz/Api/ListenBrainzLabsClient.cs | 36 ++++++++++++++--- 5 files changed, 131 insertions(+), 40 deletions(-) (limited to 'MediaBrowser.Controller') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index f9547c2c38..93aa0574c0 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -15,7 +16,7 @@ namespace Emby.Server.Implementations.Library.SimilarItems; /// /// Provides similar items for movies and trailers. /// -public class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider { private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _serverConfigurationManager; @@ -51,6 +52,17 @@ public class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILoc return Task.FromResult(GetSimilarMovieItems(item, query)); } + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken) + => item switch + { + Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken), + Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken), + _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) + }; + private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) { var includeItemTypes = new List { BaseItemKind.Movie }; diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index ddafed3d67..b56779cf3f 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -28,10 +26,6 @@ namespace Emby.Server.Implementations.Library.SimilarItems; /// public class SimilarItemsManager : ISimilarItemsManager { - private static readonly ConcurrentDictionary _genericMethodCache = new(); - private static readonly MethodInfo _getSimilarItemsInternalMethod = typeof(SimilarItemsManager) - .GetMethod(nameof(GetSimilarItemsInternalAsync), BindingFlags.NonPublic | BindingFlags.Instance)!; - private readonly ILogger _logger; private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; @@ -67,10 +61,10 @@ public class SimilarItemsManager : ISimilarItemsManager public IReadOnlyList GetSimilarItemsProviders() where T : BaseItem { + var itemType = typeof(T); return _similarItemsProviders - .OfType>() - .Cast() - .Concat(_similarItemsProviders.OfType>()) + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) .ToList(); } @@ -88,22 +82,6 @@ public class SimilarItemsManager : ISimilarItemsManager ArgumentNullException.ThrowIfNull(excludeArtistIds); var itemType = item.GetType(); - var method = _genericMethodCache.GetOrAdd(itemType, static type => _getSimilarItemsInternalMethod.MakeGenericMethod(type)); - - var task = (Task>)method.Invoke(this, [item, excludeArtistIds, user, dtoOptions, limit, libraryOptions, cancellationToken])!; - return await task.ConfigureAwait(false); - } - - private async Task> GetSimilarItemsInternalAsync( - T item, - IReadOnlyList excludeArtistIds, - User? user, - DtoOptions dtoOptions, - int? limit, - LibraryOptions? libraryOptions, - CancellationToken cancellationToken) - where T : BaseItem - { var requestedLimit = limit ?? 50; var itemKind = item.GetBaseItemKind(); @@ -114,11 +92,16 @@ public class SimilarItemsManager : ISimilarItemsManager } // Local providers are always enabled. Remote providers must be explicitly enabled. - var localProviders = _similarItemsProviders.OfType>().Cast().ToList(); - var remoteProviders = _similarItemsProviders.OfType>().Cast(); + var localProviders = _similarItemsProviders + .OfType() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType() + .Where(p => p.Supports(itemType)); var matchingProviders = new List(localProviders); - var typeOptions = libraryOptions?.GetTypeOptions(typeof(T).Name); + var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name); if (typeOptions?.SimilarItemProviders?.Length > 0) { matchingProviders.AddRange(remoteProviders @@ -143,7 +126,7 @@ public class SimilarItemsManager : ISimilarItemsManager try { - if (provider is ILocalSimilarItemsProvider localProvider) + if (provider is ILocalSimilarItemsProvider localProvider) { var query = new SimilarItemsQuery { @@ -165,9 +148,9 @@ public class SimilarItemsManager : ISimilarItemsManager } } } - else if (provider is IRemoteSimilarItemsProvider remoteProvider) + else if (provider is IRemoteSimilarItemsProvider remoteProvider) { - var cachePath = GetSimilarItemsCachePath(provider.Name, typeof(T).Name, item.Id); + var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id); var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); if (cachedReferences is not null) diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs index 9bf0121f5f..b8e41ec810 100644 --- a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -5,12 +6,38 @@ using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Library; +/// +/// Provides similar items from the local library. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Determines whether the provider can handle items of the specified type. + /// + /// The item type. + /// true if the provider handles this item type; otherwise false. + bool Supports(Type itemType); + + /// + /// Gets similar items from the local library. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions, etc.). + /// Cancellation token. + /// The list of similar items from the library. + Task> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + /// /// Provides similar items from the local library for a specific item type. /// Returns fully resolved BaseItems directly - no additional resolution needed. /// /// The type of item this provider handles. -public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +public interface ILocalSimilarItemsProvider : ILocalSimilarItemsProvider where TItemType : BaseItem { /// @@ -24,4 +51,13 @@ public interface ILocalSimilarItemsProvider : ISimilarItemsProvider TItemType item, SimilarItemsQuery query, CancellationToken cancellationToken); + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + Task> ILocalSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); } diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs index a77b6628d9..3803e51769 100644 --- a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -1,15 +1,42 @@ +using System; using System.Collections.Generic; using System.Threading; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Library; +/// +/// Provides similar item references from remote/external sources. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Determines whether the provider can handle items of the specified type. + /// + /// The item type. + /// true if the provider handles this item type; otherwise false. + bool Supports(Type itemType); + + /// + /// Gets similar item references from an external source as an async stream. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions). + /// Cancellation token. + /// An async enumerable of similar item references. + IAsyncEnumerable GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + /// /// Provides similar item references from remote/external sources for a specific item type. /// Returns lightweight references with ProviderIds that the manager resolves to library items. /// /// The type of item this provider handles. -public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +public interface IRemoteSimilarItemsProvider : IRemoteSimilarItemsProvider where TItemType : BaseItem { /// @@ -23,4 +50,13 @@ public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider TItemType item, SimilarItemsQuery query, CancellationToken cancellationToken); + + bool IRemoteSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + IAsyncEnumerable IRemoteSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); } diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs index e57aa3ed1d..e080370b8c 100644 --- a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs @@ -15,11 +15,11 @@ namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api; /// /// Client for the ListenBrainz Labs API. /// -public class ListenBrainzLabsClient +public class ListenBrainzLabsClient : IDisposable { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - private readonly Lock _rateLimitLock = new(); + private readonly SemaphoreSlim _rateLimitLock = new(1, 1); private DateTime _lastRequestTime = DateTime.MinValue; @@ -52,7 +52,7 @@ public class ListenBrainzLabsClient var rateLimit = config?.RateLimit ?? PluginConfiguration.DefaultRateLimit; // Enforce rate limit - EnforceRateLimit(rateLimit); + await EnforceRateLimitAsync(rateLimit, cancellationToken).ConfigureAwait(false); var url = $"{baseUrl}/similar-artists/json?artist_mbids={artistMbid}&algorithm={algorithm}"; @@ -86,19 +86,43 @@ public class ListenBrainzLabsClient } } - private void EnforceRateLimit(double rateLimitSeconds) + /// + public void Dispose() { - lock (_rateLimitLock) + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _rateLimitLock.Dispose(); + } + } + + private async Task EnforceRateLimitAsync(double rateLimitSeconds, CancellationToken cancellationToken) + { + await _rateLimitLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime; var requiredDelay = TimeSpan.FromSeconds(rateLimitSeconds) - timeSinceLastRequest; if (requiredDelay > TimeSpan.Zero) { - Thread.Sleep(requiredDelay); + await Task.Delay(requiredDelay, cancellationToken).ConfigureAwait(false); } _lastRequestTime = DateTime.UtcNow; } + finally + { + _rateLimitLock.Release(); + } } } -- cgit v1.2.3