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; }
}
}