feat: language filters for subtitles and audio

This commit is contained in:
TheMelmacian
2026-05-10 11:10:56 +02:00
parent 82120732ca
commit 2b7f641163
8 changed files with 117 additions and 5 deletions

View File

@@ -157,6 +157,8 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param>
/// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
@@ -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
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param>
/// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
@@ -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);

View File

@@ -115,6 +115,8 @@ public class TrailersController : BaseJellyfinApiController
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param>
/// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
@@ -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);
}

View File

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

View File

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

View File

@@ -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<string> AudioLanguages { get; set; }
public IReadOnlyList<string> SubtitleLanguages { get; set; }
public void SetUser(User user)
{
var maxRating = user.MaxParentalRatingScore;

View File

@@ -13,6 +13,8 @@ namespace MediaBrowser.Model.Querying
Tags = Array.Empty<string>();
OfficialRatings = Array.Empty<string>();
Years = Array.Empty<int>();
AudioLanguages = Array.Empty<string>();
SubtitleLanguages = Array.Empty<string>();
}
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; }
}
}

View File

@@ -111,7 +111,9 @@ public static class DescendantQueryHelper
private static HashSet<Guid> 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)
{

View File

@@ -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.
/// </summary>
/// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param>
/// <param name="Language">The language to match.</param>
/// <param name="Language">List of languages to match.</param>
/// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param>
public sealed record HasMediaStreamType(
MediaStreamTypeEntity StreamType,
string Language,
bool? IsExternal = null) : FolderMatchCriteria;
IReadOnlyCollection<string> Language,
bool? IsExternal = null) : FolderMatchCriteria
{
/// <summary>
/// Initializes a new instance of the <see cref="HasMediaStreamType"/> class.
/// </summary>
/// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param>
/// <param name="Language">The language to match.</param>
/// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param>
public HasMediaStreamType(
MediaStreamTypeEntity StreamType,
string Language,
bool? IsExternal = null) : this(StreamType, [Language], IsExternal)
{
}
}