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: [PATCH 1/6] feat: language filters for subtitles and audio --- Jellyfin.Api/Controllers/ItemsController.cs | 39 +++++++++++++++++++ .../Controllers/TrailersController.cs | 6 +++ .../Item/BaseItemRepository.Querying.cs | 18 ++++++++- .../Item/BaseItemRepository.TranslateQuery.cs | 20 ++++++++++ .../Entities/InternalItemsQuery.cs | 6 +++ .../Querying/QueryFiltersLegacy.cs | 6 +++ .../DescendantQueryHelper.cs | 4 +- .../MatchCriteria/HasMediaStreamType.cs | 23 +++++++++-- 8 files changed, 117 insertions(+), 5 deletions(-) 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) + { + } +} From a42956c18286e253e4d5dc3c64e39f47490b2b4f Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Sun, 10 May 2026 11:32:37 +0200 Subject: [PATCH 2/6] fix: filter for VideoTypes if Item is Iso file --- .../Item/BaseItemRepository.TranslateQuery.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 95c4d04adc..b58e7fffe3 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1076,8 +1076,11 @@ public sealed partial class BaseItemRepository if (filter.VideoTypes.Length > 0) { + // 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 var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); - Expression> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + 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)); baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } From 5701cdce684dbbcdfdd5cc4c79586fe623e9f2d0 Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Sun, 10 May 2026 21:40:41 +0200 Subject: [PATCH 3/6] fix: prevent language filters to load in non video libraries --- .../Item/BaseItemRepository.Querying.cs | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index d8fc87ec18..71b46b3cb5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -517,20 +517,6 @@ 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)) @@ -549,6 +535,28 @@ 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, @@ -559,4 +567,14 @@ public sealed partial class BaseItemRepository AudioLanguages = audioLanguages }; } + + 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); + } } 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: [PATCH 4/6] 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 ++++++ .../Library/ILibraryManager.cs | 7 ++++ .../Persistence/IMediaStreamRepository.cs | 7 ++++ MediaBrowser.Model/Querying/QueryFilters.cs | 6 +++ .../Querying/QueryFiltersLegacy.cs | 6 --- 10 files changed, 82 insertions(+), 44 deletions(-) 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; } } } From 068b3fd58d23a25c26b81b9511647c9abbfd798f Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Thu, 14 May 2026 01:01:56 +0200 Subject: [PATCH 5/6] remove language filters from old Items endpoint --- Jellyfin.Api/Controllers/ItemsController.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 8eca2787b1..e599aa3d34 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -158,7 +158,7 @@ public class ItemsController : BaseJellyfinApiController /// 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. If specified, results will be filtered based on subtitle language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the items. @@ -414,7 +414,7 @@ public class ItemsController : BaseJellyfinApiController if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) { - if (query.HasSubtitles.Value is true) + if (query.HasSubtitles.Value) { // if we check for specific subtitles we don't need a separate check for subtitle existence query.HasSubtitles = null; @@ -640,8 +640,6 @@ 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. @@ -733,8 +731,6 @@ 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( @@ -822,8 +818,8 @@ public class ItemsController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, - audioLanguages, - subtitleLanguages, + [], + [], enableTotalRecordCount, enableImages).ConfigureAwait(false); From fae4950ac2b5918081198ee5f876dd82ca81ae5d Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Fri, 15 May 2026 11:12:08 +0200 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Bond-009 --- Jellyfin.Api/Controllers/FilterController.cs | 4 ++-- MediaBrowser.Model/Querying/QueryFilters.cs | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 740423ef04..cfc8be28ae 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -197,7 +197,7 @@ public class FilterController : BaseJellyfinApiController var culture = _localization.FindLanguageInfo(language); return new NameValuePair { - Name = culture != null ? $"{culture.DisplayName} ({language})" : language, + Name = culture is null ? language : $"{culture.DisplayName} ({language})", Value = language }; }) @@ -210,7 +210,7 @@ public class FilterController : BaseJellyfinApiController var culture = _localization.FindLanguageInfo(language); return new NameValuePair { - Name = culture != null ? $"{culture.DisplayName} ({language})" : language, + Name = culture is null ? language : $"{culture.DisplayName} ({language})", Value = language }; }) diff --git a/MediaBrowser.Model/Querying/QueryFilters.cs b/MediaBrowser.Model/Querying/QueryFilters.cs index b877af71c6..095f460923 100644 --- a/MediaBrowser.Model/Querying/QueryFilters.cs +++ b/MediaBrowser.Model/Querying/QueryFilters.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using MediaBrowser.Model.Dto; namespace MediaBrowser.Model.Querying @@ -16,12 +17,12 @@ namespace MediaBrowser.Model.Querying SubtitleLanguages = Array.Empty(); } - public NameGuidPair[] Genres { get; set; } + public IReadOnlyList Genres { get; set; } - public string[] Tags { get; set; } + public IReadOnlyList Tags { get; set; } - public NameValuePair[] AudioLanguages { get; set; } + public IReadOnlyList AudioLanguages { get; set; } - public NameValuePair[] SubtitleLanguages { get; set; } + public IReadOnlyList SubtitleLanguages { get; set; } } }