Always apply recursive when filters are requested (#17088)

This commit is contained in:
Bond-009
2026-06-17 20:45:52 +02:00
committed by GitHub
3 changed files with 334 additions and 233 deletions

View File

@@ -321,24 +321,21 @@ public class ItemsController : BaseJellyfinApiController
recursive = true;
includeItemTypes = [BaseItemKind.Playlist];
}
else if (folder is ICollectionFolder)
else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
{
if (includeItemTypes.Length == 0)
includeItemTypes = collectionType switch
{
includeItemTypes = collectionType switch
{
CollectionType.boxsets => [BaseItemKind.BoxSet],
null => [BaseItemKind.Movie, BaseItemKind.Series],
_ => []
};
}
CollectionType.boxsets => [BaseItemKind.BoxSet],
null => [BaseItemKind.Movie, BaseItemKind.Series],
_ => []
};
}
// When the client doesn't specify recursive/includeItemTypes, force the query
// through the database path where all filters (IsHD, genres, etc.) are applied.
if (includeItemTypes.Length > 0)
{
recursive ??= true;
}
// includeItemTypes on a library lists its contents recursively rather than just its
// immediate children, so default to a recursive query when the client didn't choose.
if (folder is ICollectionFolder && includeItemTypes.Length > 0)
{
recursive ??= true;
}
if (item is not UserRootFolder
@@ -351,246 +348,248 @@ public class ItemsController : BaseJellyfinApiController
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
}
if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
// Build the query up front so the dispatch below can decide the path from it.
// Use search providers when searchTerm is provided. Providers return only IDs and scores;
// items are loaded server-side via folder.GetItems below, which applies user-access filtering.
Dictionary<Guid, float>? searchResultScores = null;
Guid[] itemIds = ids;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Use search providers when searchTerm is provided. Providers return only IDs and scores;
// items are loaded server-side via folder.GetItems below, which applies user-access filtering.
Dictionary<Guid, float>? searchResultScores = null;
Guid[] itemIds = ids;
if (!string.IsNullOrWhiteSpace(searchTerm))
var searchProviderQuery = new SearchProviderQuery
{
var searchProviderQuery = new SearchProviderQuery
{
SearchTerm = searchTerm,
UserId = userId,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
MediaTypes = mediaTypes,
Limit = limit.HasValue ? limit.Value * 3 : null,
ParentId = parentId
};
var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
if (searchResults.Count > 0)
{
searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
itemIds = ids.Length > 0
? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
: searchResultScores.Keys.ToArray();
}
}
var query = new InternalItemsQuery(user)
{
IsPlayed = isPlayed,
MediaTypes = mediaTypes,
SearchTerm = searchTerm,
UserId = userId,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
Limit = searchResultScores is null ? limit : null,
StartIndex = searchResultScores is null ? startIndex : null,
IsMissing = isMissing,
IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
HasImdbId = hasImdbId,
IsPlaceHolder = isPlaceHolder,
IsLocked = isLocked,
MinWidth = minWidth,
MinHeight = minHeight,
MaxWidth = maxWidth,
MaxHeight = maxHeight,
Is3D = is3D,
HasTvdbId = hasTvdbId,
HasTmdbId = hasTmdbId,
IsMovie = isMovie,
IsSeries = isSeries,
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
HasOverview = hasOverview,
HasOfficialRating = hasOfficialRating,
HasParentalRating = hasParentalRating,
HasSpecialFeature = hasSpecialFeature,
HasSubtitles = hasSubtitles,
HasThemeSong = hasThemeSong,
HasThemeVideo = hasThemeVideo,
HasTrailer = hasTrailer,
IsHD = isHd,
Is4K = is4K,
Tags = tags,
OfficialRatings = officialRatings,
Genres = genres,
ArtistIds = artistIds,
AlbumArtistIds = albumArtistIds,
ContributingArtistIds = contributingArtistIds,
GenreIds = genreIds,
StudioIds = studioIds,
Person = person,
PersonIds = personIds,
PersonTypes = personTypes,
Years = years,
ImageTypes = imageTypes,
VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
ItemIds = itemIds,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
IndexNumber = indexNumber,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchResultScores is null ? searchTerm : null,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
AudioLanguages = audioLanguages,
SubtitleLanguages = subtitleLanguages,
LinkedChildAncestorIds = linkedChildAncestorIds,
MediaTypes = mediaTypes,
Limit = limit.HasValue ? limit.Value * 3 : null,
ParentId = parentId
};
if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
if (searchResults.Count > 0)
{
query.CollapseBoxSetItems = false;
searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
itemIds = ids.Length > 0
? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
: searchResultScores.Keys.ToArray();
}
}
if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
var query = new InternalItemsQuery(user)
{
IsPlayed = isPlayed,
MediaTypes = mediaTypes,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
Limit = searchResultScores is null ? limit : null,
StartIndex = searchResultScores is null ? startIndex : null,
IsMissing = isMissing,
IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
HasImdbId = hasImdbId,
IsPlaceHolder = isPlaceHolder,
IsLocked = isLocked,
MinWidth = minWidth,
MinHeight = minHeight,
MaxWidth = maxWidth,
MaxHeight = maxHeight,
Is3D = is3D,
HasTvdbId = hasTvdbId,
HasTmdbId = hasTmdbId,
IsMovie = isMovie,
IsSeries = isSeries,
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
HasOverview = hasOverview,
HasOfficialRating = hasOfficialRating,
HasParentalRating = hasParentalRating,
HasSpecialFeature = hasSpecialFeature,
HasSubtitles = hasSubtitles,
HasThemeSong = hasThemeSong,
HasThemeVideo = hasThemeVideo,
HasTrailer = hasTrailer,
IsHD = isHd,
Is4K = is4K,
Tags = tags,
OfficialRatings = officialRatings,
Genres = genres,
ArtistIds = artistIds,
AlbumArtistIds = albumArtistIds,
ContributingArtistIds = contributingArtistIds,
GenreIds = genreIds,
StudioIds = studioIds,
Person = person,
PersonIds = personIds,
PersonTypes = personTypes,
Years = years,
ImageTypes = imageTypes,
VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
ItemIds = itemIds,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
IndexNumber = indexNumber,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchResultScores is null ? searchTerm : null,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
AudioLanguages = audioLanguages,
SubtitleLanguages = subtitleLanguages,
LinkedChildAncestorIds = linkedChildAncestorIds,
};
if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
{
query.CollapseBoxSetItems = false;
}
if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
{
if (query.HasSubtitles.Value)
{
if (query.HasSubtitles.Value)
// if we check for specific subtitles we don't need a separate check for subtitle existence
query.HasSubtitles = null;
}
else
{
// if we search for items without 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
if (seriesStatus.Length != 0)
{
query.SeriesStatuses = seriesStatus;
}
// Exclude Blocked Unrated Items
var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
if (blockedUnratedItems is not null)
{
query.BlockUnratedItems = blockedUnratedItems;
}
// ExcludeLocationTypes
if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
{
query.IsVirtualItem = false;
}
if (locationTypes.Length > 0 && locationTypes.Length < 4)
{
query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
}
// Min official rating
if (!string.IsNullOrWhiteSpace(minOfficialRating))
{
query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
}
// Max official rating
if (!string.IsNullOrWhiteSpace(maxOfficialRating))
{
query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
}
// Artists
if (artists.Length != 0)
{
query.ArtistIds = artists.Select(i =>
{
try
{
// if we check for specific subtitles we don't need a separate check for subtitle existence
query.HasSubtitles = null;
return _libraryManager.GetArtist(i, new DtoOptions(false));
}
else
catch
{
// if we search for items without subtitles, we don't need to check for subtitles of a specific language
query.SubtitleLanguages = [];
return null;
}
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// 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
)
// ExcludeArtistIds
if (excludeArtistIds.Length != 0)
{
query.ExcludeArtistIds = excludeArtistIds;
}
if (albumIds.Length != 0)
{
query.AlbumIds = albumIds;
}
// Albums
if (albums.Length != 0)
{
query.AlbumIds = albums.SelectMany(i =>
{
query.IncludeOwnedItems = true;
}
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
}).ToArray();
}
query.ApplyFilters(filters);
// Filter by Series Status
if (seriesStatus.Length != 0)
// Studios
if (studios.Length != 0)
{
query.StudioIds = studios.Select(i =>
{
query.SeriesStatuses = seriesStatus;
}
// Exclude Blocked Unrated Items
var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
if (blockedUnratedItems is not null)
{
query.BlockUnratedItems = blockedUnratedItems;
}
// ExcludeLocationTypes
if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
{
query.IsVirtualItem = false;
}
if (locationTypes.Length > 0 && locationTypes.Length < 4)
{
query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
}
// Min official rating
if (!string.IsNullOrWhiteSpace(minOfficialRating))
{
query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
}
// Max official rating
if (!string.IsNullOrWhiteSpace(maxOfficialRating))
{
query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
}
// Artists
if (artists.Length != 0)
{
query.ArtistIds = artists.Select(i =>
try
{
try
{
return _libraryManager.GetArtist(i, new DtoOptions(false));
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// ExcludeArtistIds
if (excludeArtistIds.Length != 0)
{
query.ExcludeArtistIds = excludeArtistIds;
}
if (albumIds.Length != 0)
{
query.AlbumIds = albumIds;
}
// Albums
if (albums.Length != 0)
{
query.AlbumIds = albums.SelectMany(i =>
{
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
}).ToArray();
}
// Studios
if (studios.Length != 0)
{
query.StudioIds = studios.Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// Apply default sorting if none requested
if (query.OrderBy.Count == 0)
{
// Albums by artist
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
{
query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
// Apply default sorting if none requested
if (query.OrderBy.Count == 0)
{
// Albums by artist
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
{
query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
}
}
query.Parent = null;
query.Parent = null;
// At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries
if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
{
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
result = folder.GetItems(query);
if (searchResultScores is not null && searchResultScores.Count > 0)

View File

@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
}
}
/// <summary>
/// Gets a value indicating whether the query carries any criteria that narrows the
/// result set, as opposed to user context, pagination, sorting or DTO options.
/// </summary>
public bool HasFilters =>
IncludeItemTypes.Length > 0
|| ExcludeItemTypes.Length > 0
|| Genres.Count > 0
|| GenreIds.Count > 0
|| Years.Length > 0
|| Tags.Length > 0
|| ExcludeTags.Length > 0
|| OfficialRatings.Length > 0
|| StudioIds.Length > 0
|| ArtistIds.Length > 0
|| AlbumArtistIds.Length > 0
|| ContributingArtistIds.Length > 0
|| ExcludeArtistIds.Length > 0
|| AlbumIds.Length > 0
|| PersonIds.Length > 0
|| PersonTypes.Length > 0
|| MediaTypes.Length > 0
|| VideoTypes.Length > 0
|| ImageTypes.Length > 0
|| SeriesStatuses.Length > 0
|| ItemIds.Length > 0
|| ExcludeItemIds.Length > 0
|| AudioLanguages.Count > 0
|| SubtitleLanguages.Count > 0
|| LinkedChildAncestorIds.Length > 0
|| AncestorIds.Length > 0
|| IsFavorite.HasValue
|| IsFavoriteOrLiked.HasValue
|| IsLiked.HasValue
|| IsPlayed.HasValue
|| IsResumable.HasValue
|| IsFolder.HasValue
|| IsMissing.HasValue
|| IsUnaired.HasValue
|| IsSpecialSeason.HasValue
|| Is3D.HasValue
|| IsHD.HasValue
|| Is4K.HasValue
|| IsLocked.HasValue
|| IsPlaceHolder.HasValue
|| IsMovie.HasValue
|| IsSports.HasValue
|| IsKids.HasValue
|| IsNews.HasValue
|| IsSeries.HasValue
|| IsAiring.HasValue
|| IsVirtualItem.HasValue
|| HasImdbId.HasValue
|| HasTmdbId.HasValue
|| HasTvdbId.HasValue
|| HasOverview.HasValue
|| HasOfficialRating.HasValue
|| HasParentalRating.HasValue
|| HasThemeSong.HasValue
|| HasThemeVideo.HasValue
|| HasSubtitles.HasValue
|| HasSpecialFeature.HasValue
|| HasTrailer.HasValue
|| HasChapterImages.HasValue
|| MinCriticRating.HasValue
|| MinCommunityRating.HasValue
|| MinParentalRating is not null
|| MinIndexNumber.HasValue
|| MinParentAndIndexNumber.HasValue
|| IndexNumber.HasValue
|| ParentIndexNumber.HasValue
|| AiredDuringSeason.HasValue
|| MinWidth.HasValue
|| MinHeight.HasValue
|| MaxWidth.HasValue
|| MaxHeight.HasValue
|| MinPremiereDate.HasValue
|| MaxPremiereDate.HasValue
|| MinStartDate.HasValue
|| MaxStartDate.HasValue
|| MinEndDate.HasValue
|| MaxEndDate.HasValue
|| MinDateCreated.HasValue
|| MinDateLastSaved.HasValue
|| MinDateLastSavedForUser.HasValue
|| AdjacentTo.HasValue
|| !string.IsNullOrEmpty(NameStartsWith)
|| !string.IsNullOrEmpty(NameStartsWithOrGreater)
|| !string.IsNullOrEmpty(NameLessThan)
|| !string.IsNullOrEmpty(NameContains)
|| !string.IsNullOrEmpty(MinSortName)
|| !string.IsNullOrEmpty(Name)
|| !string.IsNullOrEmpty(Person)
|| !string.IsNullOrEmpty(SearchTerm)
|| !string.IsNullOrEmpty(Path);
public bool Recursive { get; set; }
public int? StartIndex { get; set; }

View File

@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
{
if (query.Recursive)
// The user root holds no items of its own - a plain listing returns the user's
// views. But a request carrying any filter is a search across the libraries, so
// resolve it through the recursive query path even when Recursive wasn't set;
// otherwise the filters would be silently dropped. Recursive is set so the
// downstream query (ancestor/top-parent scoping) treats it as a recursive search.
if (query.Recursive || query.HasFilters)
{
query.Recursive = true;
return QueryRecursive(query);
}