Apply the by-name access exemption in the search candidate query

This commit is contained in:
Shadowghost
2026-07-30 09:53:44 +02:00
parent a94588497c
commit e123a13e38
5 changed files with 146 additions and 89 deletions
@@ -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<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
IReadOnlyList<SearchResult> 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)];
@@ -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;
/// <summary>
/// Builds the access filter that decides which items a search may return for a user.
/// </summary>
internal static class SearchQueryAccessFilter
{
/// <summary>
/// Builds an access filter carrying the search's library access and type filters.
/// </summary>
/// <param name="user">The user the search runs for.</param>
/// <param name="query">The search query.</param>
/// <param name="libraryManager">The library manager.</param>
/// <returns>The access filter.</returns>
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;
}
}
@@ -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<BaseItemEntity> ApplyUserAccessFilter(
JellyfinDbContext dbContext,
IQueryable<BaseItemEntity> 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);
}
@@ -416,12 +416,7 @@ public sealed partial class BaseItemRepository
IQueryable<BaseItemEntity> 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;
}
/// <summary>
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The query to filter.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The filtered query.</returns>
private IQueryable<BaseItemEntity> ApplyTopParentFiltering(
JellyfinDbContext context,
IQueryable<BaseItemEntity> 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;
}
/// <summary>
/// Returns the by-name types a query asks for, which carry no TopParentId to filter on.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The type names exempt from library filtering.</returns>
private List<string> 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();
}
/// <summary>
/// Keeps a by-name row only when at least one item behind its name is reachable for the user.
/// </summary>
/// <param name="baseQuery">The query to filter.</param>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <param name="itemByNameTypes">The exempted by-name type names.</param>
/// <param name="topParentIds">The libraries the user may open.</param>
/// <returns>The filtered query.</returns>
private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
InternalItemsQuery filter,
IReadOnlyList<string> 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;
}
/// <summary>
/// Applies the user's parental rating and tag restrictions to a query.
/// </summary>
@@ -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;
}
/// <summary>
/// Keeps a by-name row only when at least one item behind its name is reachable for the user.
/// </summary>
private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
InternalItemsQuery filter,
IReadOnlyList<string> 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;
}
}