diff options
Diffstat (limited to 'Jellyfin.Server.Implementations/Item/PeopleRepository.cs')
| -rw-r--r-- | Jellyfin.Server.Implementations/Item/PeopleRepository.cs | 194 |
1 files changed, 151 insertions, 43 deletions
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 9611c5c13a..aaa363b046 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -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,66 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I return result; } - private PersonInfo Map(People people) + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + using var context = _dbProvider.CreateDbContext(); + var rows = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)) + .OrderBy(m => m.ListOrder) + .Select(m => new + { + m.ItemId, + m.Role, + m.SortOrder, + m.People.Id, + m.People.Name, + m.People.PersonType + }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var people = new List<PersonInfo>(); + foreach (var row in group) + { + var personInfo = new PersonInfo + { + ItemId = row.ItemId, + Id = row.Id, + Name = row.Name, + Role = row.Role, + SortOrder = row.SortOrder + }; + if (Enum.TryParse<PersonKind>(row.PersonType, out var kind)) + { + personInfo.Type = kind; + } + + people.Add(personInfo); + } + + result[group.Key] = people; + } + + return result; + } + + 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,18 +328,34 @@ 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()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId))); + var itemId = filter.ItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(itemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } if (filter.ParentId != null) @@ -261,7 +365,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (!filter.AppearsInItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId))); + var appearsInItemId = filter.AppearsInItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(appearsInItemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList(); |
