Merge pull request #17466 from Shadowghost/fix-byname-queries

Improve People deduplication, fix search and restrict ItemByName responses
This commit is contained in:
Cody Robibero
2026-08-05 18:39:55 -04:00
committed by GitHub
20 changed files with 4520 additions and 117 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);
}
@@ -4,6 +4,7 @@ using System.Linq;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Dto;
@@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController
personTypes,
excludePersonTypes)
{
AccessFilter = BuildAccessFilter(user),
NameContains = searchTerm,
NameStartsWith = nameStartsWith,
NameLessThan = nameLessThan,
@@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController
.ToArray());
}
// People are not owned by a library, so nothing in the Peoples table says which of them a user is
// allowed to see; that only follows from the items they are credited on.
private InternalItemsQuery? BuildAccessFilter(User? user)
{
if (user is null || !user.HasContentRestrictions())
{
return null;
}
var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true };
_libraryManager.ConfigureUserAccess(accessFilter, user);
return accessFilter;
}
/// <summary>
/// Get person by name.
/// </summary>
+17
View File
@@ -162,6 +162,23 @@ public static class UserEntityExtensions
return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1;
}
/// <summary>
/// Checks whether any library, parental rating or tag rule keeps content from this user.
/// </summary>
/// <param name="entity">The user to check.</param>
/// <returns><c>True</c> if some content in the library is hidden from this user.</returns>
public static bool HasContentRestrictions(this User entity)
{
ArgumentNullException.ThrowIfNull(entity);
return !entity.HasPermission(PermissionKind.EnableAllFolders)
|| entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0
|| entity.MaxParentalRatingScore.HasValue
|| entity.GetPreference(PreferenceKind.BlockedTags).Length > 0
|| entity.GetPreference(PreferenceKind.AllowedTags).Length > 0
|| entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0;
}
/// <summary>
/// Initializes the default permissions for a user. Should only be called on user creation.
/// </summary>
@@ -428,6 +428,17 @@ public sealed partial class BaseItemRepository
return ApplyAccessFiltering(context, baseQuery, filter);
}
/// <summary>
/// Checks whether the user restricts access to items by parental rating or tags.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns><c>true</c> if the query carries parental restrictions.</returns>
private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
=> filter.IncludeInheritedTags.Length > 0
|| filter.ExcludeInheritedTags.Length > 0
|| filter.MaxParentalRating is not null
|| filter.BlockUnratedItems.Length > 0;
/// <summary>
/// Applies user access filtering to a query.
/// Includes TopParentIds, parental rating, and tag filtering.
@@ -438,13 +449,127 @@ public sealed partial class BaseItemRepository
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
// Apply TopParentIds filtering (library folder access)
if (filter.TopParentIds.Length > 0)
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
var topParentIds = filter.TopParentIds;
baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
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>
/// <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> ApplyParentalRestrictions(
JellyfinDbContext context,
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
// Apply parental rating filtering
if (filter.MaxParentalRating is not null)
{
@@ -495,13 +620,6 @@ public sealed partial class BaseItemRepository
|| e.Type == personTypeName);
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
return baseQuery;
}
@@ -169,7 +169,10 @@ public sealed partial class BaseItemRepository
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
}
var orderedAlbums = topAlbumsQuery
// The album is what gets returned, and neither branch above reads it through the
// user's filters, so its own parental restrictions have to be applied here: a
// matching track does not make an album the user may not see visible.
var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
.OrderByDescending(album => album.DateCreated)
.ThenByDescending(album => album.Id);
@@ -422,6 +425,40 @@ public sealed partial class BaseItemRepository
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
}
// Step 5b: A container is what gets returned, so it has to pass the user's access
// filters on its own - a matching episode does not make a Season or Series the user
// may not see visible. Containers that don't pass are replaced by their episode.
if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0)
{
var allowedContainerIds = ApplyParentalRestrictions(
context,
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
filter)
.Select(e => e.Id)
.ToHashSet();
for (var i = 0; i < seriesResults.Count; i++)
{
var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i];
if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value))
{
seasonId = null;
}
if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value))
{
seriesId = null;
}
if (seasonId is null && seriesId is null)
{
entitiesToFetch.Add(mostRecentEpisodeId);
}
seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId);
}
}
// Step 6: Fetch the Season/Series entities we decided to return
var entities = entitiesToFetch.Count > 0
? ApplyNavigations(
@@ -995,21 +995,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;
if (enableItemsByName && includedItemByNameTypes.Count > 0)
{
baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
}
else
{
baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
}
}
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
if (filter.AncestorIds.Length > 0)
{
@@ -46,6 +46,23 @@ public sealed partial class BaseItemRepository
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
private static readonly BaseItemKind[] _itemByNameKinds =
[
BaseItemKind.Person,
BaseItemKind.Genre,
BaseItemKind.MusicGenre,
BaseItemKind.MusicArtist,
BaseItemKind.Studio
];
private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
[
(BaseItemKind.Genre, _getGenreValueTypes),
(BaseItemKind.MusicGenre, _getGenreValueTypes),
(BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
(BaseItemKind.Studio, _getStudiosValueTypes)
];
// The only folder kinds whose children form a single viewing sequence, so playback progress on a
// child rolls up to them. Every other folder kind is a container that cannot be resumed.
private static readonly BaseItemKind[] _resumableFolderKinds =
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
/// </summary>
/// <param name="dbProvider">Efcore Factory.</param>
/// <param name="itemTypeLookup">Items lookup service.</param>
/// <param name="queryHelpers">Shared item query helpers.</param>
/// <remarks>
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
/// </remarks>
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
int? distinctNameCount = null;
// Include PeopleBaseItemMap
if (!filter.ItemId.IsEmpty())
{
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
.OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
.OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
.ThenBy(e => e.PersonType)
.ThenBy(e => e.Name);
}
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
// The Peoples table has one row per (Name, PersonType), so the same person can
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
// grouping key so case-only duplicates collapse together.
var representativeIds = dbQuery
.GroupBy(e => e.Name.ToLower())
.Select(g => g.Min(e => e.Id));
dbQuery = context.Peoples.AsNoTracking()
.Where(p => representativeIds.Contains(p.Id))
.OrderBy(e => e.Name);
// name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
// lowest id per lowercased name so case-only duplicates collapse together.
var candidates = dbQuery;
dbQuery = candidates
.Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
.OrderBy(e => e.Name.ToLower());
if (filter.EnableTotalRecordCount)
{
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
}
}
var count = 0;
if (filter.EnableTotalRecordCount)
{
count = distinctNameCount ?? dbQuery.Count();
}
var count = dbQuery.Count();
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
StartIndex = filter.StartIndex ?? 0,
TotalRecordCount = count,
Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
};
}
@@ -107,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
person.Role = person.Role?.Trim() ?? string.Empty;
}
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
// Project the values every comparison below needs once, so neither the case folding nor the
// enum formatting is repeated per candidate.
var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
// multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
// The role is part of the key because one person can hold several credits of the same type
// on an item, e.g. a Writer credited for both the Novel and the Screenplay.
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
@@ -122,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
.Select(f => f.item)
.ToArray();
var toAdd = people
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
.Select(Map);
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
var toAdd = distinctPersons
.Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
.Select(e => Map(e.Person))
.ToArray();
context.Peoples.AddRange(toAdd);
context.SaveChanges();
var personsEntities = toAdd.Concat(existingPersons).ToArray();
// The Peoples table can hold case-only duplicates, so keep the first match per key just as
// the previous First() lookup did.
var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
foreach (var entity in toAdd.Concat(existingPersons))
{
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
}
var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
foreach (var map in existingMaps)
{
existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
}
var listOrder = 0;
foreach (var person in people)
foreach (var credit in distinctCredits)
{
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = credit.Person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
else
{
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
{
@@ -147,18 +186,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
SortOrder = person.SortOrder,
Role = person.Role
SortOrder = credit.Person.SortOrder,
Role = credit.Person.Role
});
}
else
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
listOrder++;
}
@@ -205,9 +236,19 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
return result;
}
private PersonInfo Map(People people)
private IEnumerable<PersonInfo> MapCredits(People people)
{
var mappings = people.BaseItems;
if (mappings is null || mappings.Count == 0)
{
return [Map(people, null)];
}
return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
}
private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
{
var mapping = people.BaseItems?.FirstOrDefault();
var personInfo = new PersonInfo()
{
Id = people.Id,
@@ -240,13 +281,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var oldQuery = query;
var userId = filter.User.Id;
var isFavorite = filter.IsFavorite.Value;
var favoriteItemIds = context.UserData
.Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
.Select(u => u.ItemId);
query = context.UserData
.Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
.Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
.Distinct()
.AsNoTracking();
var favoriteNames = context.BaseItems
.Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
.Select(b => b.Name);
query = query.Where(e => favoriteNames.Contains(e.Name));
}
if (filter.AccessFilter is not null)
{
// Keep only people credited on at least one item the user can see.
var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
query = query.Where(e => context.PeopleBaseItemMap
.Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
}
if (!filter.ItemId.IsEmpty())
@@ -496,6 +496,12 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<string> SubtitleLanguages { get; set; }
/// <summary>
/// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>.
/// Filters that only exist to hide content can be skipped entirely when this is false.
/// </summary>
public bool UserHasContentRestrictions { get; private set; }
public void SetUser(User user)
{
var maxRating = user.MaxParentalRatingScore;
@@ -519,6 +525,7 @@ namespace MediaBrowser.Controller.Entities
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
.ToArray();
UserHasContentRestrictions = user.HasContentRestrictions();
User = user;
}
@@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities
{
PersonTypes = personTypes;
ExcludePersonTypes = excludePersonTypes;
EnableTotalRecordCount = true;
}
/// <summary>
/// Gets or sets a value indicating whether to count the matching people. Under an
/// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops
/// at the limit, the count has to check every person.
/// </summary>
public bool EnableTotalRecordCount { get; set; }
public int? StartIndex { get; set; }
/// <summary>
@@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities
public User User { get; set; }
public bool? IsFavorite { get; set; }
/// <summary>
/// Gets or sets the item query whose access settings (library access, parental rating, tags)
/// people must satisfy through at least one of the items they are credited on.
/// </summary>
public InternalItemsQuery AccessFilter { get; set; }
}
}
@@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities
person.Type = PersonKind.Writer;
}
// If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes
if (person.Type == PersonKind.GuestStar)
{
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor);
// Check for dupes based on the combination of Name, Type and Role.
var existing = people.FirstOrDefault(p => IsSameCredit(p, person)
&& string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
existing.Type = PersonKind.GuestStar;
MergeExisting(existing, person);
return;
}
}
if (person.Type == PersonKind.Actor)
if (existing is null)
{
// If the actor already exists without a role and we have one, fill it in
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar));
if (existing is null)
if (string.IsNullOrEmpty(person.Role))
{
// Wasn't there - add it
people.Add(person);
existing = people.FirstOrDefault(p => IsSameCredit(p, person));
}
else
{
// Was there, if no role and we have one - fill it in
if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role))
// If the person already exists without a role and we have one, fill it in
existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role));
if (existing is not null)
{
existing.Role = person.Role;
}
MergeExisting(existing, person);
}
}
else
if (existing is null)
{
var existing = people.FirstOrDefault(p =>
string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase)
&& p.Type == person.Type);
// Check for dupes based on the combination of Name and Type
if (existing is null)
{
people.Add(person);
}
else
{
MergeExisting(existing, person);
}
people.Add(person);
return;
}
// If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes
if (person.Type == PersonKind.GuestStar)
{
existing.Type = PersonKind.GuestStar;
}
MergeExisting(existing, person);
}
private static bool IsSameCredit(PersonInfo existing, PersonInfo person)
{
if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Actor and GuestStar describe the same credit, a guest star is just a promoted actor.
if (IsCastKind(existing.Type) && IsCastKind(person.Type))
{
return true;
}
return existing.Type == person.Type;
}
private static bool IsCastKind(PersonKind kind)
=> kind is PersonKind.Actor or PersonKind.GuestStar;
private static void MergeExisting(PersonInfo existing, PersonInfo person)
{
existing.SortOrder = person.SortOrder ?? existing.SortOrder;
@@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
builder.HasIndex(e => e.PeopleId);
builder.HasIndex(e => new { e.PeopleId, e.ItemId });
builder.HasOne(e => e.Item);
builder.HasOne(e => e.People);
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddPeopleNameLowerIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Expression index, so it cannot be declared on the entity type. /Persons collapses the
// one-row-per-(Name, PersonType) table to one row per lowercased name; without this index
// that dedup scans and groups the whole table on every request.
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS \"IX_Peoples_NameLower\" ON \"Peoples\" (lower(\"Name\"));");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_Peoples_NameLower\";");
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddPeopleItemMapCoveringIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PeopleBaseItemMap_PeopleId",
table: "PeopleBaseItemMap");
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
table: "PeopleBaseItemMap",
columns: new[] { "PeopleId", "ItemId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
table: "PeopleBaseItemMap");
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_PeopleId",
table: "PeopleBaseItemMap",
column: "PeopleId");
}
}
}
@@ -1058,12 +1058,12 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("ItemId", "PeopleId", "Role");
b.HasIndex("PeopleId");
b.HasIndex("ItemId", "ListOrder");
b.HasIndex("ItemId", "SortOrder");
b.HasIndex("PeopleId", "ItemId");
b.ToTable("PeopleBaseItemMap");
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
@@ -0,0 +1,186 @@
using System;
using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
{
private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly PeopleRepository _repository;
public PeopleRepositoryUpdatePeopleTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
var itemTypeLookup = new ItemTypeLookup();
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
ctx.BaseItems.Add(new BaseItemEntity
{
Id = _itemId,
Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie],
Name = "Movie",
MediaType = "Video",
IsMovie = true,
IsFolder = false,
IsVirtualItem = false
});
ctx.SaveChanges();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
_repository = new PeopleRepository(
factory.Object,
itemTypeLookup,
new Mock<IItemQueryHelpers>().Object);
}
public void Dispose()
{
_connection.Dispose();
}
[Fact]
public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
{
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Writer, "Novel"),
CreatePerson("Person A", PersonKind.Writer, "Screenplay")
]);
using var ctx = CreateDbContext();
Assert.Single(ctx.Peoples);
Assert.Equal(
["Novel", "Screenplay"],
ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
}
[Fact]
public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped()
{
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Actor, "Hero"),
CreatePerson("person a", PersonKind.Actor, "hero")
]);
using var ctx = CreateDbContext();
Assert.Single(ctx.Peoples);
var map = Assert.Single(ctx.PeopleBaseItemMap);
Assert.Equal("Hero", map.Role);
}
[Fact]
public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType()
{
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Actor, "Hero"),
CreatePerson("Person A", PersonKind.Director, string.Empty)
]);
using var ctx = CreateDbContext();
Assert.Equal(2, ctx.Peoples.Count());
Assert.Equal(2, ctx.PeopleBaseItemMap.Count());
}
[Fact]
public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder()
{
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Actor, "Hero"),
CreatePerson("Person B", PersonKind.Actor, "Sidekick")
]);
Guid[] peopleIdsBefore;
using (var ctx = CreateDbContext())
{
peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray();
}
// Reversed order, so the list order of both mappings has to be rewritten.
_repository.UpdatePeople(_itemId, [
CreatePerson("Person B", PersonKind.Actor, "Sidekick"),
CreatePerson("Person A", PersonKind.Actor, "Hero")
]);
using var after = CreateDbContext();
Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray());
Assert.Equal(
["Sidekick", "Hero"],
after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
}
[Fact]
public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping()
{
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Writer, "Novel"),
CreatePerson("Person A", PersonKind.Writer, "Screenplay")
]);
_repository.UpdatePeople(_itemId, [
CreatePerson("Person A", PersonKind.Writer, "Novel")
]);
using var ctx = CreateDbContext();
var map = Assert.Single(ctx.PeopleBaseItemMap);
Assert.Equal("Novel", map.Role);
}
[Fact]
public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping()
{
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
_repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]);
using var ctx = CreateDbContext();
var map = Assert.Single(ctx.PeopleBaseItemMap);
Assert.Equal("Hero", map.Role);
}
private static PersonInfo CreatePerson(string name, PersonKind type, string role)
{
return new PersonInfo
{
Name = name,
Type = type,
Role = role
};
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}