From e123a13e3853087a87a845c7738a74c22ea9064f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 30 Jul 2026 09:53:44 +0200 Subject: [PATCH] Apply the by-name access exemption in the search candidate query --- .../Library/Search/SearchManager.cs | 13 +-- .../Library/Search/SearchQueryAccessFilter.cs | 38 +++++++ .../Library/Search/SqlSearchProvider.cs | 8 +- .../Item/BaseItemRepository.QueryBuilding.cs | 102 ++++++++++++++++-- .../Item/BaseItemRepository.TranslateQuery.cs | 74 +------------ 5 files changed, 146 insertions(+), 89 deletions(-) create mode 100644 Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index a5be3f07bd..01f9062734 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -118,7 +118,7 @@ public class SearchManager : ISearchManager var user = _userManager.GetUserById(query.UserId.Value); if (user is not null) { - results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); + results = await FilterByUserAccessAsync(results, user, query, cancellationToken).ConfigureAwait(false); } } @@ -128,13 +128,14 @@ public class SearchManager : ISearchManager private async Task> FilterByUserAccessAsync( IReadOnlyList candidates, User user, + SearchProviderQuery query, CancellationToken cancellationToken) { - // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates - // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds - // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty. - var accessFilter = new InternalItemsQuery(user); - _libraryManager.ConfigureUserAccess(accessFilter, user); + // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds + // for the user's accessible libraries. The candidate ids are applied to the query below + // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when + // ItemIds is non-empty. + var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager); Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; diff --git a/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs new file mode 100644 index 0000000000..6e3f01de13 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs @@ -0,0 +1,38 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Search; + +/// +/// Builds the access filter that decides which items a search may return for a user. +/// +internal static class SearchQueryAccessFilter +{ + /// + /// Builds an access filter carrying the search's library access and type filters. + /// + /// The user the search runs for. + /// The search query. + /// The library manager. + /// The access filter. + public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager) + { + // The type filters have to travel with the access filter: a by-name item belongs to no + // library, so it carries no TopParentId to match, and the library filter only knows to + // exempt it when the query says those types are wanted. A search scoped to a parent gets + // no exemption because a by-name item has no parent to descend from either. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = query.IncludeItemTypes, + ExcludeItemTypes = query.ExcludeItemTypes, + IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty() + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index bc766f1c8c..c4d3b249d5 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); dbQuery = ApplyParentFilter(dbQuery, query.ParentId); - dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId); + dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query); // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it @@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider private IQueryable ApplyUserAccessFilter( JellyfinDbContext dbContext, IQueryable query, - Guid? userId) + SearchProviderQuery searchQuery) { + var userId = searchQuery.UserId; if (!userId.HasValue || userId.Value.IsEmpty()) { return query; @@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider return query; } - var accessFilter = new InternalItemsQuery(user); - _libraryManager.ConfigureUserAccess(accessFilter, user); + var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager); return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 9d16f7976a..96a23d2d12 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -416,12 +416,7 @@ public sealed partial class BaseItemRepository IQueryable baseQuery, InternalItemsQuery filter) { - // Apply TopParentIds filtering (library folder access) - if (filter.TopParentIds.Length > 0) - { - var topParentIds = filter.TopParentIds; - baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value)); - } + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); baseQuery = ApplyParentalRestrictions(context, baseQuery, filter); @@ -435,6 +430,101 @@ public sealed partial class BaseItemRepository return baseQuery; } + /// + /// Restricts a query to the libraries the user may open, exempting requested by-name items. + /// + /// The database context. + /// The query to filter. + /// The query filter. + /// The filtered query. + private IQueryable ApplyTopParentFiltering( + JellyfinDbContext context, + IQueryable baseQuery, + InternalItemsQuery filter) + { + var queryTopParentIds = filter.TopParentIds; + if (queryTopParentIds.Length == 0) + { + return baseQuery; + } + + var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter); + if (exemptedItemByNameTypes.Count == 0) + { + return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); + } + + baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); + if (filter.UserHasContentRestrictions) + { + baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds); + } + + return baseQuery; + } + + /// + /// Returns the by-name types a query asks for, which carry no TopParentId to filter on. + /// + /// The query filter. + /// The type names exempt from library filtering. + private List GetExemptedItemByNameTypes(InternalItemsQuery filter) + { + var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); + if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0) + { + return includedItemByNameTypes; + } + + return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList(); + } + + /// + /// Keeps a by-name row only when at least one item behind its name is reachable for the user. + /// + /// The query to filter. + /// The database context. + /// The query filter. + /// The exempted by-name type names. + /// The libraries the user may open. + /// The filtered query. + private IQueryable ApplyItemByNameAccessFiltering( + IQueryable baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList itemByNameTypes, + Guid[] topParentIds) + { + // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts. + var accessibleItems = ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking(), + new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true }); + + var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + if (itemByNameTypes.Contains(personType)) + { + baseQuery = baseQuery.Where(e => e.Type != personType + || context.Peoples.Any(p => p.Name == e.Name + && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + foreach (var (kind, valueTypes) in _itemByNameValueTypes) + { + var typeName = _itemTypeLookup.BaseItemKindNames[kind]; + if (!itemByNameTypes.Contains(typeName)) + { + continue; + } + + baseQuery = baseQuery.Where(e => e.Type != typeName + || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName + && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + return baseQuery; + } + /// /// Applies the user's parental rating and tag restrictions to a query. /// diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 8b0b7f37f8..fb9bbf0d47 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1044,36 +1044,7 @@ public sealed partial class BaseItemRepository : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); } - var queryTopParentIds = filter.TopParentIds; - - if (queryTopParentIds.Length > 0) - { - var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); - var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; - - // A by-name item belongs to no library, so it has no TopParentId to test and the filter - // below would drop it. Items-by-name queries exempt the whole group; a query that names a - // by-name type explicitly gets the same exemption, since it is asking for those items. - var exemptedItemByNameTypes = enableItemsByName - ? includedItemByNameTypes - : _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList(); - - if (exemptedItemByNameTypes.Count > 0) - { - baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); - } - else - { - baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); - } - - // That exemption is what lets a by-name item from a library the user cannot open show up - // in search. Decide those on the items behind the name instead. - if (filter.UserHasContentRestrictions && exemptedItemByNameTypes.Count > 0) - { - baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds); - } - } + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); if (filter.AncestorIds.Length > 0) { @@ -1287,47 +1258,4 @@ public sealed partial class BaseItemRepository return baseQuery; } - - /// - /// Keeps a by-name row only when at least one item behind its name is reachable for the user. - /// - private IQueryable ApplyItemByNameAccessFiltering( - IQueryable baseQuery, - JellyfinDbContext context, - InternalItemsQuery filter, - IReadOnlyList itemByNameTypes, - Guid[] topParentIds) - { - // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts. - var accessibleItems = ApplyAccessFiltering( - context, - context.BaseItems.AsNoTracking(), - new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true }); - - // Each predicate is written outside-in - name row, then link table, then item - and with nested - // Any() rather than a Contains over the accessible ids, which would materialise all of them - // before the first row. That keeps every step an index seek. - var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; - if (itemByNameTypes.Contains(personType)) - { - baseQuery = baseQuery.Where(e => e.Type != personType - || context.Peoples.Any(p => p.Name == e.Name - && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId)))); - } - - foreach (var (kind, valueTypes) in _itemByNameValueTypes) - { - var typeName = _itemTypeLookup.BaseItemKindNames[kind]; - if (!itemByNameTypes.Contains(typeName)) - { - continue; - } - - baseQuery = baseQuery.Where(e => e.Type != typeName - || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName - && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId)))); - } - - return baseQuery; - } }